Spring Boot 3.x中Caffeine缓存大小策略失效排查与解决 📅 发布时间:2026/9/10 3:01:45 👁 浏览次数: 做Spring Boot 3.x的缓存改造时我遇到过不止一次这样的场面配置里明明写了maximumSize500测试时往缓存里塞了两千条数据estimatedSize()却一路涨到两千多就好像根本没设上限。后来排查才知道问题不在Caffeine本身而在Spring Boot对CaffeineCacheManager的初始化时机以及缓存大小策略到底有没有被正确注入。这篇文章会从这类“配置不生效”的场景出发把Caffeine的大小策略、淘汰机制、正确配置方式、常见坑和监控手段完整拆一遍。无论你是刚接触Spring Cache还是在3.x上被缓存容量搞到内存吃紧都可以对照着用。1. 项目概述遇到“缓存最大容量不生效”怎么查1.1 场景还原看起来不起作用的maximumSize先说一个我实际见过的配置。项目用的是Spring Boot 3.1pom里加了spring-boot-starter-cache和com.github.ben-manes.caffeine:caffeine然后在application.yml里写了spring: cache: type: caffeine caffeine: spec: maximumSize100,expireAfterWrite10m业务代码里用Cacheable(cacheNames userCache)缓存用户信息。启动之后做压力测试往缓存里写入大量不同用户的数据。结果通过cacheManager.getCache(userCache).getNativeCache().estimatedSize()去观察发现条数早就超过100了甚至在两百、三百的级别继续增长。第一反应当然是怀疑maximumSize写错了或者是Caffeine版本的问题。但实际去翻CaffeineCacheManager的源码就会发现真正的罪魁祸首是“缓存创建时机”。Spring Boot通过spring.cache.caffeine.spec解析出的CaffeineSpec本质上是需要注入到CaffeineCacheManager里的而CaffeineCacheManager并不是在Bean初始化阶段就把所有缓存建好它是在第一次调用getCache(name)的时候才真正创建对应名称的CaffeineCache。也就是说谁先触发getCache(userCache)谁就决定了这个缓存是用什么配置创建的。如果此时spec还没被设置进去系统就会用默认的空配置建出一个“无大小限制”的缓存之后你就算再改spec已经创建的缓存也不会重新生成。1.2 为什么“配置不生效”CaffeineCacheManager的延迟创建机制这里值得多说一句核心机制。CaffeineCacheManager继承自AbstractCacheManager内部维护了一个ConcurrentHashMap来存放已经创建的缓存。调用getCache(String name)时如果缓存不存在并且dynamic模式是开启的它就会用当前持有的Caffeine构建器或者CaffeineSpec去创建一个全新的CaffeineCache。这个设计的本意是让开发者可以“随用随建”不用把所有缓存名提前声明。但副作用就是配置的注入时机必须严格早于第一次访问。你可以把这几个组件串起来理解CaffeineObject, Object构建器由代码编程式定义可以包含maximumSize、maximumWeight、expireAfterWrite、recordStats等全部参数。CaffeineSpec由spring.cache.caffeine.spec配置解析而来支持一部分常用参数适合简单场景。CaffeineCacheManager持有以上配置并在getCache时创建CaffeineCache。我在给团队讲这个问题的时候喜欢打一个比方办健身卡的时候要录入身高体重和体测数据录入之后卡片就印出来了。如果这时候你想改卡上的数据得重新办卡而不是把卡片拿回来让前台用笔改一下。getCache(userCache)就是“印卡”的瞬间之后无论你是改CaffeineSpec还是setCaffeine对这张已经存在的卡都没有任何影响。大家看到这里应该明白了如果你在PostConstruct、ApplicationRunner、CommandLineRunner甚至某个Bean方法里调用了cacheManager.getCache(xxx)那你已经把缓存“提前印”出来了。后续再登录系统看到“配置没生效”其实只是配置没赶上创建时机。1.3 结论先行配置大小策略的三个原则结合上面这个案例我在实操中总结出三条原则建议你先把这三条刻在脑子里再去写代码配置注入必须先于缓存创建。如果是用配置文件方式要确保CaffeineCacheManager是从Spring容器里那一个而不是自己new出来的如果是编程式配置setCaffeine和setCacheNames要放在任何可能触发getCache的调用之前。所有缓存名尽量提前声明。把项目里的Cacheable(cacheNames...)收敛一遍在CaffeineCacheManager里用setCacheNames一次性列出来这样启动阶段就会预建缓存运行期不会因为动态创建而抓到错误配置。不同缓存需要不同容量时不要图省事用统一spec。热门数据可以给大容量冷数据给个小容量用registerCustomCache分别注册否则一个maximumSize100会让所有缓存都受限或让某些缓存过大。2. 核心细节解析Caffeine大小策略到底在管什么2.1 maximumSize和maximumWeight两种“大小”语义很多同学以为Caffeine的大小策略就是“最多放多少条”其实Caffeine给了两种不同的“大小”语义。maximumSize是最常用的一种它限制的是缓存中条目的数量。比如maximumSize(10_000)表示最多保留1万个键值对当超过这个数量时会按照W-TinyLFU淘汰算法把一些条目逐出。它适合“每条数据大小差不多”的场景比如用户信息对象、商品对象这类结构相对统一的缓存。maximumWeight则是基于权重的限制。你需要先定义一个weigher告诉Caffeine每个键值对“重”多少。例如你想把缓存的总大小控制在1MB以内可以按字符串字节数计算权重Caffeine.newBuilder() .maximumWeight(1_000_000) .weigher((key, value) - value.toString().getBytes(StandardCharsets.UTF_8).length) .build();关于这两者的区别和限制我用一个表格来对照参数语义适用场景注意事项maximumSize条目数量上限缓存条目大小相对均匀时不能与maximumWeight同时使用maximumWeight总权重上限需要配合weigher缓存条目大小差异很大时设置weigher后必须同时设置maximumWeight否则缓存会无限增长initialCapacity初始化容量预估缓存规模时减少自动扩容的开销不是大小限制Caffeine的校验逻辑很严格maximumSize和maximumWeight同时设置会直接抛异常因为这是两种互斥的淘汰触发方式。源码里requireWeightWithMaximumWeight这类强校验会保证你配置的一致性所以如果你用了weigher却忘了maximumWeight启动阶段就可能直接报错这不算坏事至少问题暴露得早。2.2 W-TinyLFU淘汰算法为什么不是普通LRUCaffeine默认使用W-TinyLFU算法来做缓存淘汰理解它能帮你少走很多弯路。传统的LRULeast Recently Used简单粗暴最近没被访问的数据会被淘汰。但它的弱点是如果一个冷门数据在某个时刻突然被批量扫描了一次它就会进入“热区”把真正高频访问的数据挤出去。而LFULeast Frequently Used虽然能记住长期频率但对于刚进来的突发热点很不友好新数据还没来得及积累频率就被淘汰了。W-TinyLFU在两者之间做了一个折中。它把缓存空间拆成两部分一个很小的Window区域和一个Main区域。Main区域内部又分为Probation和Protected两个区域。简单理解Window区域用来接纳新写入的条目让突发流量有一个保护空间。Main区域里真正长期高频的数据会进入Protected区不太活跃的放在Probation区。当缓存满的时候候选条目会和Probation区的驱逐候选者“比频率”谁的访问频率低谁就走人。这个算法带来的直观效果就是缓存命中率通常比单纯的LRU更高尤其在热点数据突发的场景下更加明显。为什么要在这里讲这个因为“大小策略生效”并不等于“缓存永远保持在一个固定数量”。Caffeine的淘汰是异步执行的默认使用ForkJoinPool.commonPool(),当条目数超过上限后不会马上同步地把数量压回去会有一个短暂的时间窗口。如果你像刚才那个场景一样刚塞了一堆数据就立刻estimatedSize()看到的往往是“超限”的中间状态而不是最终稳定值。我在排查问题时见过不少人被这个现象误导以为maximumSize完全失效其实等一两秒再观察数量就慢慢回落了。2.3 大小策略与过期策略如何配合大小策略解决的是“缓存能占多少空间”过期策略解决的是“数据在什么时间内有效”。两者经常一起用但很多人容易混淆。Caffeine中常见的过期策略有expireAfterWrite(Duration)写入后经过指定时间过期。expireAfterAccess(Duration)最后一次访问后经过指定时间过期。expireAfter(Expiry)自定义过期时间可以根据key和value决定。如果只设置maximumSize而不设置过期策略请求峰值高的时候确实能控制总量但缓存里会出现大量“很久没更新、业务上已经不需要”的脏数据。它们会一直占着名额直到因为容量压力被淘汰。如果数据本身是会变化的那最好加上expireAfterWrite作为兜底保证即使没被淘汰也会在固定时间后失效重新从数据库加载。这里还有一个隐藏细节Caffeine的过期清理不是有单独线程定时扫描的它是在缓存读写操作时触发的属于“惰性删除”加“定期清理”的结合。所以你可能会发现某条数据明明到了过期时间但在下一次访问之前它仍然占着内存这并不代表过期配置失效。定期清理线程会分批处理过期条目只是这个动作不是每毫秒都在跑。3. 实操配置Spring Boot 3.x中正确的缓存大小策略写法3.1 方式一spring.cache.caffeine.spec简单配置如果项目里缓存场景比较单一所有缓存可以使用同样的大小和过期时间那么最简单的方式就是用配置文件spring: cache: type: caffeine caffeine: spec: maximumSize500,expireAfterWrite10m这种方式背后Spring Boot会自动创建一个CaffeineCacheManager并把CaffeineSpec设置进去。CaffeineSpec支持不少参数比如initialCapacity、maximumSize、maximumWeight、expireAfterAccess、expireAfterWrite、refreshAfterWrite、weakKeys、weakValues、softValues、recordStats。但它有几个天然限制一是weigher无法在CaffeineSpec中声明所以如果你要按字节数做权重限制就不能只靠配置文件。二是所有缓存共用同一个spec。假如hotCache你想给1万条configCache你只想给500条用配置文件就没有办法分别定制除非你拆成多个CacheManager那又太绕了。所以我的建议是配置文件的方式适合“项目很小、缓存规则统一、先跑起来再说”的阶段。一旦缓存变多、业务规则差异化尽早切换到代码配置。3.2 方式二自定义CacheManager Bean把配置权拿回来更推荐的做法是自定义一个CacheManager的Bean显式声明Caffeine构建器并提前把缓存名固定住。这样配置可见、可控排查问题时一眼就能看出每个缓存的容量是多少。Configuration public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(100) .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats()); cacheManager.setCacheNames(Arrays.asList(userCache, orderCache)); return cacheManager; } }这里最关键的一点是setCaffeine和setCacheNames必须发生在这个Bean返回之前而且不要在Bean方法内部调用cacheManager.getCache(userCache)。如果你手贱在Bean方法里先调了一次getCache就又把“提前创建”的问题引回来了。使用setCacheNames的另一个好处是缓存名被固定了。这样如果你在代码里敲错了一个缓存名比如把orderCache写成ordrCache启动时就会因为找不到声明而直接报错而不是运行期静默创建一个用默认配置的缓存让问题潜伏很久。3.3 方式三不同缓存名使用不同大小策略当项目里缓存的数据差别很大时统一配置一个maximumSize往往不够用。比如热搜商品数据量大、访问频繁可以给大容量系统配置项少但重要给小容量、长过期时间即可。CaffeineCacheManager提供了registerCustomCache方法可以为指定缓存名注册独立的Caffeine实例Configuration public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(); cacheManager.registerCustomCache(hotProductCache, Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats() .build()); cacheManager.registerCustomCache(configCache, Caffeine.newBuilder() .maximumSize(1_000) .expireAfterWrite(Duration.ofHours(1)) .recordStats() .build()); return cacheManager; } }注意用registerCustomCache注册的时候要保证这个方法在getCache之前执行。通常放在Bean方法里是安全的但如果你后续有其他代码在启动阶段提前触发了缓存创建那注册时机还是会乱掉。所以我习惯在配置类里同时给setCacheNames列出一个完整清单既不担心缓存被提前创建又能让所有缓存名在启动阶段就被验证一遍。3.4 补充用CacheManagerCustomizer做统一兜底Spring Boot提供了一种比较优雅的扩展入口CacheManagerCustomizer。如果你不想全部推翻Spring Boot的自动配置只想在自动配置的CaffeineCacheManager基础上做额外定制可以定义一个CacheManagerCustomizerCaffeineCacheManager的Bean。Component public class CaffeineCacheManagerCustomizer implements CacheManagerCustomizerCaffeineCacheManager { Override public void customize(CaffeineCacheManager cacheManager) { cacheManager.setAllowNullValues(false); cacheManager.setCacheNames(Arrays.asList(userCache, orderCache, configCache)); } }这个方式的优点是不会影响Spring Boot自动配置的spring.cache.caffeine.spec解析你仍然可以在配置文件中维护spec同时在代码里补充一些spec无法表达的东西比如固定缓存名列表、禁止缓存null值等。如果你的项目里有很多组员同时开发这种定制入口比每个人各写一套CacheManager更不容易冲突。4. 常见问题与排查技巧实录4.1 配置没生效先检查有没有人提前碰过缓存这个问题出现的概率非常高典型的排查路径是这样的先在CaffeineCacheManager.getCache(String name)方法入口打一个断点应用启动后看第一次执行getCache的调用栈是从哪里进来的。如果调用栈里出现了PostConstruct、ApplicationRunner、CommandLineRunner、EventListener(ApplicationReadyEvent.class)等方法那就是有代码在启动阶段提前创建了缓存导致后续的spec设置根本来不及生效。还有一类情况是项目里同时存在多个CacheManager。比如有人为了集成Redis定义了一个RedisCacheManager又定义了CaffeineCacheManager结果某些代码拿到了另一个实例配置自然对不上。排查时建议直接输出所有CacheManager的类型和实例确认你操作的确实是Spring Boot为Caffeine创建的那一个。解决这个问题的根治办法就是把缓存名全部提前声明并在配置类里完成所有Caffeine构建器的注入不依赖运行期的动态创建。4.2 maximumSize和maximumWeight同时设置启动直接报错这是Caffeine比较刚硬的一面。如果你在配置里既写了maximumSize又写了maximumWeight启动时会抛出类似IllegalStateException的异常提示不能同时设置两种淘汰条件。如果是从配置文件用CaffeineSpec写的也可能会在解析阶段就失败。遇到这个报错先确定你到底想按“条数”还是按“权重”限制缓存。我遇到过一些同学用maximumWeight但忘了写weigher结果缓存不淘汰内存持续增长这也是非常危险的。记住Caffeine的规则使用weigher就必须同时指定maximumWeight使用maximumWeight就必须准备一个weigher。4.3 动态缓存名泛滥缓存数量无限增长有些同学写Cacheable的时候喜欢把动态参数拼进cacheNames里比如Cacheable(cacheNames order_ orderId) public Order getOrder(Long orderId) { ... }这不是不行但要明白CaffeineCacheManager会为每一个不存在的缓存名创建一个新的缓存实例。如果订单总量有100万个那就会创建上百万个独立的Caffeine缓存每个缓存都有自己的W-TinyLFU结构和内部HashMap。这种情况下你就算给每个缓存设置maximumSize100总的内存占用也会因为缓存实例数量太多而爆炸。正确的做法是缓存名固定为orderCache把orderId作为缓存的key参数Cacheable(cacheNames orderCache, key #orderId) public Order getOrder(Long orderId) { ... }这类问题比单个缓存超过大小限制可怕得多因为它不仔细看监控很难发现。我的经验是在项目初期就约定好所有缓存名必须收敛成固定集合并且用setCacheNames固定下来从根上杜绝动态缓存名泛滥。4.4 同时存在Redis等其它缓存实现导致Caffeine压根没生效Spring Boot的缓存自动探测机制有个特点当classpath下同时存在Redis和Caffeine时如果spring.cache.type没有显式指定Boot会选择一个优先级更高的缓存实现来创建CacheManager。这个优先级往往会落在Redis上。表现就是你配置了spring.cache.caffeine.spec接口一直正常但缓存的淘汰行为完全不对甚至内存中根本没有Caffeine实例。查看CacheManager类型就会发现你拿到的其实是RedisCacheManager。解决办法很简单在配置里强制指定类型spring: cache: type: caffeine这个坑不算Caffeine的特殊问题但一旦踩到很容易让人怀疑自己的大小策略写错了浪费好几个小时排查。建议一上来就显式声明。4.5 问题速查表现象可能原因处理方法maximumSize看起来没生效spec/构建器在缓存创建后才设置提前声明缓存名或在Bean方法中完成配置缓存数量持续增长内存告警weigher设置了但没设maximumWeight必须成对设置启动报错maximumSize和maximumWeight冲突二选一动态缓存名非常多cacheNames拼接了动态参数改为固定缓存名 key参数Caffeine缓存完全没创建和Redis冲突自动探测选中了Redis显式配置spring.cache.type: caffeine缓存满了但数量没马上下降淘汰是异步执行稍等片刻再观察或减少entry超限测试幅度5. 监控与调优如何判断缓存大小配置是否合理5.1 开启recordStats获得Caffeine真实统计数据光配置完还不行你要能证明它有效。Caffeine默认不统计访问数据cache.stats()返回的都是空值。想拿到命中率、淘汰数等指标必须在构建Caffeine时开启recordStats()。如果是编程式配置直接加一个方法调用即可Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build();如果你用的是spring.cache.caffeine.spec也可以直接在spec里加上recordStatsspring: cache: caffeine: spec: maximumSize500,expireAfterWrite10m,recordStats拿到CacheManager之后可以这样获取缓存统计信息CaffeineCache caffeineCache (CaffeineCache) cacheManager.getCache(userCache); com.github.benmanes.caffeine.cache.CacheObject, Object nativeCache caffeineCache.getNativeCache(); CacheStats stats nativeCache.stats(); System.out.println(命中率: stats.hitRate()); System.out.println(被淘汰条数: stats.evictionCount()); System.out.println(请求总数: stats.requestCount()); System.out.println(加载成功次数: stats.loadSuccessCount());CacheStats返回的是一个快照对象多次读取结果一致不会实时变化所以适合定时采样或者对比两个时间点之间的差值。5.2 用Actuator暴露缓存指标并在请求中观察Spring Boot Actuator默认会暴露一部分Cache相关的指标比如cache.gets、cache.puts、cache.evictions等。如果你配置了management.endpoints.web.exposure.includemetrics就可以直接访问/actuator/metrics/cache.gets查看数据。但要注意这些指标走的是Spring Cache抽象层粒度是缓存名和CacheManager并不能完整替代Caffeine自身的CacheStats。如果你想知道Caffeine内部的命中率、加载时间、淘汰原因还是要靠recordStats()。我一般建议的监控方案是写一个定时任务每隔几十秒采集一次所有缓存实例的CacheStats快照把命中率、淘汰数、估算大小输出到日志或者上报到监控平台。这样当缓存容量设置不合理时你能在出现OOM之前看到淘汰数异常飙升。5.3 调整容量时的经验公式与实操心得很多同学会问“缓存容量到底设置多少合适”这个问题没有标准答案但有个经验公式可以帮你在初期给一个合理起点预估容量 高峰期每秒请求数 × 单次请求对应的缓存数据平均活跃时长 × 重复访问比例系数举个例子假设某个接口高峰期QPS是2000缓存数据在30秒内会有较高的重复访问概率那么这30秒内最多可能出现60000次不同的数据请求命中考虑重复访问比例系数0.3缓存容量可以起步设在60000 × 0.3 18000条左右。再结合单条数据大小估算出内存占用留出30%以上余量然后观察实际命中率和淘汰率逐步调整。调整的时候我习惯关注两个关键信号如果命中率长期在95%以上说明容量比较充裕可以考虑适当降低容量省内存。如果命中率经常低于80%且淘汰数很高说明容量偏小或者过期时间太短导致数据被过早逐出。如果命中率不低、淘汰数也不高但内存增长明显那就要检查是不是缓存名泛滥、数据对象本身太大、或者有没有未设置过期策略。另外initialCapacity这个参数很好用但很多人会忽略。它不限制缓存大小只是给内部的哈希表一个预分配空间。如果你预估容量在1万条左右设置initialCapacity(1000)或initialCapacity(10000)可以减少扩容带来的性能损耗。注意不要设得太夸张否则启动时就会占用较多内存。根据我个人的经验Caffeine的大小策略出问题绝大多数不是Caffeine本身的问题而是使用方把它的生命周期和Spring管理对象的生命周期搞混了。所以我现在接手一个新项目第一件事就是先看CacheManager是怎么创建的、缓存名是否收敛这比调maximumSize本身更重要。建议你也把缓存配置收敛到一个配置类里列出所有缓存名和容量规划再谈监控和调优。这步做完后面会省心很多。