十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot缓存实战:从Caffeine到Redis多级缓存方案详解

SpringBoot缓存实战:从Caffeine到Redis多级缓存方案详解 在SpringBoot里做缓存很多人第一反应是“加个注解就完事了”但真正上线后遇到缓存穿透、缓存一致性问题又不知道怎么排查。这篇文章我就结合自己做过的一些项目把常见的缓存方案从原理到整合实践完整拆一遍重点是讲清楚每一步为什么这么做而不是只给配置。1. 缓存的本质一次内存访问为什么能救活一个接口1.1 先搞懂缓存到底在解决什么问题任何一个接口的响应时间都由两部分组成网络传输时间和业务处理时间。业务处理时间里数据库查询往往是大头。一次普通的MySQL查询走B树索引单行数据在毫秒级返回但是当并发量上来或者查询需要做多表关联、复杂聚合数据库的CPU和磁盘IO就会迅速成为瓶颈。缓存做的事情本质上是把那些“被反复读取的热点数据”提前放到访问速度更快的存储里。内存的随机访问延迟在纳秒级比磁盘的毫秒级快了四五个数量级。你用一个Redis或者Caffeine挡住大部分请求数据库的实际压力会断崖式下降。举个例子我之前维护过一个商品详情接口每天被调用几千万次。商品信息来自MySQL一张表几十万行加了索引的情况下单次查询在5ms左右。听起来不快但接口被大量刷的时候数据库的连接池很快被打满接口延迟从5ms飙升到800ms。后来在接口前面加了一层Spring Cache读缓存命中后响应时间稳定在1ms左右数据库QPS从峰值2万降到了3000。这个效果就是缓存最直观的收益。1.2 缓存方案怎么分类本地缓存、分布式缓存、多级缓存SpringBoot生态里最常见的缓存方案有三类本地缓存数据存在应用进程内存里比如Caffeine、Guava Cache、ConcurrentHashMap自己维护。优点是访问极快零网络开销缺点是多实例部署下每个节点的缓存是独立的数据不一致问题天然存在。分布式缓存数据存在独立服务里比如Redis、Memcached。优点是所有应用节点共享同一份缓存数据一致性好支持集群扩展缺点是多了一次网络IO极端情况下比本地缓存慢一个数量级。多级缓存把本地缓存和分布式缓存组合起来本地缓存作为第一级Redis作为第二级业务侧代码按“本地优先Redis兜底数据库兜底”的顺序查找。典型的Caffeine Redis组合后面我专门展开讲。选择方案之前先问自己几个问题这个接口的并发量是多少数据更新频率高不高允许出现短暂的数据不一致吗单实例部署还是多实例这些问题想清楚了方案基本就定下来了。2. Spring Cache抽象层从EnableCaching到缓存注解的原理2.1 注解背后的动态代理机制Spring Cache是Spring Framework提供的一层抽象它不是某个具体的缓存实现而是定义了一套统一的编程模型。你通过注解声明缓存行为然后通过CacheManager接入具体的缓存实现比如RedisCacheManager、CaffeineCacheManager。用起来很简单第一步加依赖第二步在启动类上加EnableCaching第三步在方法上打注解。但这里面有个隐藏的原理你必须知道Spring Cache基于AOP面向切面编程实现。容器启动时Spring会扫描带有缓存注解的Bean通过动态代理生成一个代理对象代理对象在执行目标方法前后拦截方法调用并执行缓存逻辑。这就是为什么缓存注解必须打在public方法上而且不能在同一个类里通过this调用。因为this指向的是原始对象不是代理对象拦截逻辑根本不生效。这个坑我在项目里见过太多次了很多人排查半天发现缓存没生效问题就出在这里。2.2 三个核心注解的使用姿势Cacheable先查缓存缓存命中直接返回不执行方法体缓存未命中才执行方法并把返回值存入缓存。CachePut执行方法体然后把返回值更新到缓存常用于新增或更新操作。CacheEvict执行方法体然后删除缓存。常用于删除操作或者数据更新后主动清掉旧缓存。我给出一个典型的业务代码示例Service public class ProductService { Autowired private ProductMapper productMapper; Cacheable(cacheNames product, key #id) public Product getById(Long id) { return productMapper.selectById(id); } CachePut(cacheNames product, key #product.id) public Product update(Product product) { productMapper.updateById(product); return product; } CacheEvict(cacheNames product, key #id) public void delete(Long id) { productMapper.deleteById(id); } }key的写法有个细节默认的key生成策略是多个参数拼接但推荐显式指定key。如果参数是对象要用#对象属性的方式比如#product.id。如果参数是Map可以#requestMap.get(id)但这些写法可读性都一般我更推荐在传入参数前先抽取出缓存key然后直接用key #id。条件缓存也是常用场景比如Cacheable(cacheNames product, key #id, condition #id 100) public Product getById(Long id) { return productMapper.selectById(id); }condition在方法执行前判断只有满足条件才走缓存逻辑。unless则在方法执行后判断比如结果为空时不缓存Cacheable(cacheNames product, key #id, unless #result null) public Product getById(Long id) { return productMapper.selectById(id); }2.3 自定义CacheManager接入自己的缓存底层Spring Cache默认情况下不启用你必须提供一个CacheManager实例。假如你只想用一个简单的ConcurrentHashMap做演示可以这样Configuration EnableCaching public class CacheConfig { Bean public CacheManager cacheManager() { ConcurrentMapCacheManager cacheManager new ConcurrentMapCacheManager(); cacheManager.setAllowNullValues(false); return cacheManager; } }但真实项目里不会这么干一般直接上Caffeine或者Redis。这里有个小经验如果项目里用Spring Cache抽象层那CacheManager的选择会影响所有Cacheable的行为缓存失效策略、序列化方式都要在CacheManager里统一配置不要在每个Service里各搞一套。3. 本地缓存Caffeine内存换延迟的典型玩法3.1 什么时候必须上本地缓存Caffeine是目前Java生态里性能最优秀的本地缓存库底层用了类似ConcurrentHashMap的结构但做了更精细的淘汰策略W-TinyLFU算法兼顾访问频率和新鲜度。它的性能比Guava Cache快了不少我实测过单线程读写几百万次Caffeine的耗时大约是Guava的一半。什么场景适合用本地缓存判断标准很简单单机接口的并发QPS非常高比如超过几千数据量不大比如配置项、字典表几百几千条允许短时间内的数据不一致因为本地缓存无法做到跨节点实时同步你不想为了每秒钟几十次的读请求去多维护一套Redis集群。我做过一个典型的案例一个白名单配置接口配置数据存储在MySQL每隔5分钟刷新一次。这个接口被网关层调用一天几千万次。直接查数据库或者查Redis都会有网络开销本地缓存几乎零成本地解决了问题。配置项只有几百条每条不超过1KB缓存在内存里占用不到几MB却能扛住每秒上万次的读请求。3.2 Caffeine核心参数配置Caffeine初始化参数不多但每个参数背后的逻辑值得细说。Configuration public class CaffeineConfig { Bean public CacheManager caffeineCacheManager() { CaffeineObject, Object caffeine Caffeine.newBuilder() .initialCapacity(1000) .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats(); CaffeineCacheManager cacheManager new CaffeineCacheManager(); cacheManager.setCaffeine(caffeine); return cacheManager; } }initialCapacity初始容量预分配多少桶空间。如果预估缓存条目在千级别先给1000避免后续扩容时的性能损耗。maximumSize最大条目数超过之后触发淘汰。注意这个按条目数算不是按内存大小。如果你的缓存值很大比如单个对象几百KB就要按maximumWeight来限制。expireAfterWrite写入多少时间后过期。这是最常用的策略缓存写入后固定时间后失效。expireAfterAccess最后一次访问之后多久过期。适合“热数据一直留冷数据尽快清”的场景。recordStats开启统计信息方便后续通过CaffeineCache获取命中率。关于过期时间不同缓存类型建议分开设置。比如字典缓存可以设置10分钟过期用户基本资料可以设置5分钟商品信息可能只需要1分钟。不要所有缓存统一用同一个CacheManager我一般会建多个CacheManager按业务维度分开配。3.3 Caffeine实战中的几个坑第一个坑缓存击穿发生在本地缓存同步加载时。Caffeine的get(key, Function)方法Redis化加载逻辑时要注意当多个线程同时请求同一个未命中的key时Caffeine默认只允许一个线程去执行加载函数其他线程阻塞等待。如果加载函数里查数据库很慢这段时间内所有请求都会被阻塞。可以用expireAfterWrite结合get里的刷新机制规避或者把数据提前预热。第二个坑缓存值不能太大。本地缓存本身在JVM堆内堆内存是有上限的。如果你把几MB的对象放进本地缓存一个几百MB的堆很快就被打满然后频繁Full GC接口延迟反而飙升。所以本地缓存只适合放小对象。第三个坑多实例部署下更新缓存要格外小心。本地缓存各自为政A节点更新了缓存B节点还是旧的。如果这个数据要求强一致就不要用本地缓存。4. Redis做分布式缓存缓存一致性、穿透、雪崩的根因梳理4.1 缓存失效是怎么发生的Redis缓存最常见的三类问题缓存穿透、缓存击穿、缓存雪崩。面试里经常问项目里也是真正的痛点。穿透查询一个一定不存在的数据缓存储存null如果没做空值缓存请求直接打到数据库。如果这种请求被恶意刷数据库瞬间可能被打垮。解决办法对未命中的key也缓存空值并设置较短的过期时间比如30秒或者用布隆过滤器在进入缓存前先判断数据是否可能存在。击穿某个热点key正好到了过期时间同一时刻大量请求打到它缓存没命中全部落到数据库。解决办法用互斥锁只允许一个线程去查数据库并重建缓存其他线程等待或者对热点key设置逻辑过期时间后台线程异步刷新。雪崩大量key在同一时间段集中失效或者Redis服务宕机所有请求直接打到数据库数据库无法承受。解决办法过期时间加随机值打散Redis做高可用集群在业务侧做熔断降级。我用一个代码片段演示互斥锁解决击穿public Product getProductById(Long id) { Product product redisTemplate.opsForValue().get(product: id); if (product ! null) { return product; } // 加锁失败说明其他线程正在重建缓存先返回旧数据或空 String lockKey lock:product: id; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (Boolean.TRUE.equals(locked)) { try { product productMapper.selectById(id); if (product null) { redisTemplate.opsForValue().set(product: id, new Product(), Duration.ofSeconds(30)); } else { redisTemplate.opsForValue().set(product: id, product, Duration.ofMinutes(10)); } return product; } finally { redisTemplate.delete(lockKey); } } else { // 锁被其他线程持有先短暂自旋重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductById(id); } }这种写法简单直接但要注意锁的粒度。锁的粒度越细并发能力越强。你甚至可以只对product:id这个key加锁而不是对全局加一个锁。4.2 缓存一致性双写还是延迟双删现实项目中数据库和缓存做双写关键要解决的是“更新数据库后缓存里的旧值怎么处理”。走CacheEvict删除缓存和先更新数据库再删除缓存顺序不同方案也不同。最常用的方案是延迟双删先删除缓存再更新数据库短暂延迟后再次删除缓存。这个延迟是为了处理并发场景下的一个经典问题线程A删缓存线程B读缓存发现不存在于是去查库拿到旧值写回缓存线程A更新数据库然后第二次删缓存把那个旧值删掉。第二次删除的时间点要晚于线程B写缓存的时间点。理论上这个延迟时间必须大于线程B从查库到写入缓存的总耗时实际项目中很多人设置500ms左右但这个值依赖具体业务环境需要自己权衡。如果不想用延迟双删也可以用监听Binlog的方式让缓存同步的职责从业务侧剥离出去。数据库变更后通过Canal等组件监听Binlog异步地把变更同步到缓存。这个方案适合对一致性要求高的场景但引入额外组件复杂度更高。4.3 SpringBoot整合Redis时的序列化问题SpringBoot整合Redis默认用的序列化器是JdkSerializationRedisSerializer你写进去的对象直接变成一串不可读的二进制。这在开发中很不友好而且跨语言读取会出问题。我习惯把key和value的序列化都改成StringRedisSerializer或GenericJackson2JsonRedisSerializerConfiguration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }有一个经验是用GenericJackson2JsonRedisSerializer做value序列化时对象必须有无参构造函数否则反序列化会报错。如果你的对象没有无参构造函数或者带了复杂的泛型建议直接存JSON字符串读取时手动转对象。5. MyBatis一级缓存和二级缓存的边界与权衡5.1 一级缓存作用域陷阱说到SpringBoot项目里的缓存MyBatis自带的缓存机制也经常被人提起但很多人其实没搞懂它和Spring Cache的边界。MyBatis一级缓存默认开启作用域是SqlSession。在Spring整合环境下SqlSession的生命周期是由MyBatis-Spring管理的默认情况下每个DAO操作会使用独立的SqlSession所以一级缓存实际作用在一个方法调用内部跨方法不生效跨事务不生效。这意味着你以为一级缓存能帮你省掉查询实际上在SpringBoot里作用非常有限。更加需要注意的是一级缓存默认是本地Map如果两个方法在同一个事务里执行相同查询第一个查询结果会缓存起来。如果中间有另外一个线程更新了数据库当前事务里再次查询拿到的还是旧数据这就是著名的“一级缓存脏数据”问题。好在MyBatis-Spring的默认机制下一级缓存作用范围被限制得很窄这个问题出现的概率大大降低但了解它仍然很重要。5.2 二级缓存的安全设置MyBatis二级缓存默认关闭开启后允许多个SqlSession共享缓存。在集群环境下如果某个节点更新了数据库其他节点的二级缓存还是旧数据会引发一致性问题。而且二级缓存默认不区分“查询了但数据不存在”会缓存空结果。我在实际项目中基本不用MyBatis二级缓存。道理很简单如果要做缓存就通过Spring Cache统一管理方便控制过期时间、淘汰策略、命中统计。MyBatis二级缓存能做的Spring Cache Redis都能做得更好。为了避免踩坑我只会在Mapper.xml里显式关闭二级缓存mapper namespacecom.example.mapper.ProductMapper cache typeorg.mybatis.caches.caffeine.CaffeineCache evictionFIFO flushInterval60000 size1024 readOnlytrue/ /mapper这个配置意思是每60秒刷新一次最多缓存1024条只读。readOnlytrue很重要否则MyBatis会把返回对象序列化后缓存性能反而更差。6. Spring的“三级缓存”循环依赖的真面目6.1 SpringBoot面试里那个绕不开的话题Spring三级缓存和业务缓存关系不大但既然搜缓存会经常带上“Spring三级缓存原理”而且SpringBoot项目里循环依赖是绕不开的实践问题这里顺便梳理一下。Spring容器在创建Bean时如果A依赖BB又依赖A就会出现循环依赖。Spring主要通过三级缓存解决单例Bean的循环依赖问题一级缓存singletonObjects存放已经完整创建好的单例Bean二级缓存earlySingletonObjects存放早期暴露的Bean即已经实例化但还没有完成属性填充和初始化的对象三级缓存singletonFactories存放单例Bean工厂用于解决代理对象的提前创建问题。整个流程大概是A实例化后发现自己需要B于是把A的工厂放入三级缓存开始创建BB实例化后发现自己需要A此时从三级缓存里拿到A的工厂生成一个早期引用过早暴露的A注入给BB创建完成A继续完成自己的初始化。这样循环依赖就解决了。6.2 实际生产中的循环依赖处理原则虽然Spring能处理循环依赖但我在实际开发中强烈建议能避开就避开。项目里出现大量循环依赖往往是设计出了问题比如两个Service层次边界没划清或者把依赖关系搞成了网状。Spring三级缓存只是一个兜底机制不是让你滥用设计缺陷的。如果确实遇到了循环依赖有几个处理方向用构造器注入替代字段注入构造器注入天然不支持循环依赖会在启动时报错逼你去理清依赖关系把公共逻辑抽取到单独Service或者工具类里打破循环使用Lazy注解延迟加载其中一个依赖让被依赖的Bean在真正调用时才初始化重新思考依赖方向看是否能调整成单向依赖。有一点值得注意SpringBoot 2.6版本开始默认禁止了循环依赖如果还在用旧版本项目的代码直接升级启动会直接报错。这其实是个好机制逼着开发者在设计阶段就规避掉循环依赖问题。7. 多级缓存整合实践一套可以照抄的配置方案7.1 为什么需要多级缓存把Caffeine和Redis组合起来核心动机是性能与成本之间的平衡。Redis虽然很快但每访问一次Redis都是一次网络往返。在一个延迟敏感的服务里这个网络开销依然比本地内存访问慢几十倍。于是我们把最热的数据放在本地次热的数据放在Redis冷数据落到数据库。多级缓存的读取顺序是本地缓存 → Redis → 数据库。每命中一级就返回每查完数据库后把数据回填到各级缓存。更新顺序则是先更新数据库再删除Redis缓存同时清掉本地缓存。7.2 自定义CacheManager实现两级缓存Spring Cache本身不支持多级缓存但你可以通过自定义CacheManager实现。这里我给大家一个减少代码侵入的落地思路用AbstractValueAdaptingCache或直接实现Cache接口。如果不想自己重写整个CacheManager还有个更简单的方法用Cacheable的unless做单级缓存然后在Service方法内部手动添加第二层缓存。比如你可以在方法里先查Caffeine没命中再走Cacheable拿Redis或查库。这样代码是可读的也容易排查问题。不过既然要有完整的可复现方案我给出一个简化版两级缓存管理器的代码示例。public class CompositeCacheManager implements CacheManager { private final CacheManager localCacheManager; private final CacheManager remoteCacheManager; public CompositeCacheManager(CacheManager localCacheManager, CacheManager remoteCacheManager) { this.localCacheManager localCacheManager; this.remoteCacheManager remoteCacheManager; } Override public Cache getCache(String name) { return new CompositeCache( name, localCacheManager.getCache(name), remoteCacheManager.getCache(name) ); } Override public CollectionString getCacheNames() { return localCacheManager.getCacheNames(); } }CompositeCache的核心逻辑在于get方法先查本地本地没有再去远程找远程命中后回填本地。执行put方法时两个缓存同时写入执行evict时两个缓存同时删除。这个方案的优点是业务侧只依赖Spring Cache抽象不需要感知底层是本地还是远程缺点是数据一致性仍然需要业务代码保证尤其是在多实例场景。7.3 缓存预热与监控缓存上线之后最怕的是冷启动。Redis挂了重来、应用重启、缓存整体被刷新都有可能导致缓存为空。如果接口并发量很大冷启动瞬间所有请求直接打数据库数据库很容易被打垮。我自己的做法是写一个缓存预热组件在应用启动后把热点数据从数据库批量加载进缓存。批量加载的思路Component public class CacheWarmer implements ApplicationRunner { Autowired private ProductService productService; Override public void run(ApplicationArguments args) { ListProduct hotProducts productService.listHotProducts(); for (Product product : hotProducts) { productService.updateCache(product); } } }注意预热加载时要控制速率不要一次性把几百万条数据全塞进RedisRedis的CPU和内存都受不了。按批处理每批500条间隔几十毫秒再继续。监控方面如果用的是Caffeine可以暴露命中率指标然后再暴露到PrometheusRestController public class CacheMonitorController { Autowired private CacheManager cacheManager; GetMapping(/cache/stats) public MapString, CacheStats listStats() { MapString, CacheStats stats new HashMap(); CompositeCacheManager compositeManager (CompositeCacheManager) cacheManager; for (String name : compositeManager.getCacheNames()) { Cache cache compositeManager.getCache(name); if (cache instanceof CompositeCache) { stats.put(name, ((CompositeCache) cache).stats()); } } return stats; } }这里的CacheStats取自Caffeine的CaffeineCache.getNativeCache().stats()。核心指标就三个命中率、加载耗时、淘汰条目数。命中率低于50%的缓存基本等于白挂不如直接省掉。7.4 缓存版本号解决上线时的脏缓存问题最后分享一个线上经验。业务代码升级后缓存里的旧结构对象可能无法被新代码反序列化这时候所有缓存都成了脏缓存。最简单的办法是给缓存key加一个版本号前缀product:v1:123每次发版涉及缓存结构变化时把前缀从v1改成v2旧缓存自然就访问不到了。这个方法比上线前手动清理Redis要可靠得多因为新版代码第一次访问新前缀的缓存时缓存是空的直接从数据库加载新结构数据旧前缀的缓存会逐渐被淘汰不会对数据库产生突发压力。这个做法投入成本极低却能在关键时刻避免一次线上事故。我现在每做一个需要缓存结构的迭代都会习惯性地把版本号带上。8. SpringBoot缓存实战里的其他零散坑8.1 SpringBoot版本升级对缓存配置的影响SpringBoot 2.x升级到3.x很多缓存相关的配置都变了。举几个实际遇到的spring.redis.*配置前缀改成了spring.data.redis.*原来是spring.redis.host升级后必须改成spring.data.redis.hostJedis被移除了默认支持强制使用Lettuce作为Redis客户端Spring Framework 6里对CacheManager的默认配置也做了调整以前某些自动配置在升级后会失效。升级之前先去官方迁移文档里过一遍缓存相关的变更点。很多人升级后缓存失效排查半天发现是配置文件前缀改了这个坑几乎每个升级项目都会踩一次。8.2 Cacheable注解的失效场景排查清单如果你发现缓存注解不生效按这个清单逐个排查启动类是否加了EnableCaching注解是否打在public方法上是否通过this调用方法代理不生效是否有多个CacheManager且bean名称冲突key是否相同、是否包含可变参数缓存对象是否实现序列化接口Redis模式下项目是否使用了AspectJ模式而不是代理模式导致切入点不匹配。还有一个容易被忽略的点如果同一个类里有两个方法都用了Cacheable且它们有调用关系比如A调用BB的注解可能不会生效因为调用发生在本类内部没有经过代理对象。解决方案是把B拆分到独立的Service里或者自己注入代理对象再调用。8.3 正确看待缓存命中率很多人把缓存命中率当作唯一指标其实不然。命中率高了说明热点数据缓存得好但如果缓存的数据很少被更新命中率高也不代表缓存策略好。反过来命中率低但数据库也没有压力说明接口本身性能就不错不一定非要上缓存。更有意义的指标是“缓存节省的数据库查询次数”和“缓存加载耗时”。如果一个接口QPS是2000命中率95%意味着每秒只放过去100个查询到数据库这个效果是立竿见影的。如果命中率是50%每秒放过去1000个查询那就需要评估数据库能不能扛得住。我在实际项目中会结合这两个指标判断是否该调大缓存容量、延长过期时间或者增加预热逻辑。9. 最后再分享一个整合思路缓存方案没有银弹无非是在一致性、性能、复杂度三者之间做取舍。如果你的系统是单机部署Caffeine就够如果是分布式部署Redis是底线如果并发量极高再考虑多级缓存。Spring Cache抽象层让你可以低成本地在不同缓存实现间切换但切换之前一定要把缓存一致性、失效策略、序列化这些底层的规则搞清楚。我个人的建议是不要一上来就上最复杂的方案。先用Spring Cache Caffeine把单机场景跑顺然后根据监控数据判断是否需要引入Redis。缓存这东西引入了就是一套长期的治理责任要在维护成本和收益之间找到自己项目最舒服的那个点。
返回列表