上个月帮一个团队排查线上问题,服务压测时内存一直往上顶,GC日志显示老年代被Caffeine缓存填满,整整清出七八个G。开发很委屈:“我明明在Spring Boot 3.x里配置了maximumSize=500,为什么缓存还能无限膨胀?”我让他把@Cacheable注解的cacheNames和CacheManager的Bean定义贴出来,问题一目了然——他只写了注解,没有定义CacheManager,项目用的是Spring Boot自动配置的动态CaffeineCacheManager,而这个动态行为,跟很多人理解的“全局上限”根本不是一回事。
Caffeine是Java生态里目前综合表现最好的本地缓存库,Spring Boot 3.x时代,基于Caffeine做进程内缓存已经是标准方案。但越是常用,越容易在最基础的“大小策略”上翻车。这篇文章不打算讲Caffeine的所有API,而是把大小策略从原理到排查、从配置到调优,结合我在实际项目里踩过的坑,一次性讲清楚。
1. 大小策略不只是“能存多少条”:先理解Caffeine的淘汰机制
很多人在配置Caffeine时,第一个问题就是:“我设置maximumSize=500,是不是缓存到500条就不写了?”答案是不对。Caffeine不是“满了就拒收”,而是“满了之后用淘汰策略腾地方”。所以真正要理解的是:它驱逐哪些数据、依据什么驱逐。
1.1 三种大小约束:maximumSize、maximumWeight、expireAfter
Caffeine在配置维度上,和“大小”相关的约束主要有三类:
| 配置项 | 约束维度 | 典型使用场景 |
|---|---|---|
maximumSize(long) |
条目数量上限 | 大部分业务场景,限制最多缓存多少key |
maximumWeight(long) |
权重上限,需要配weigher |
key/value大小差异极大,需要按内存占用控制 |
expireAfterWrite/expireAfterAccess(Duration) |
时间维度 | 数据有时效性,时间到了主动失效 |
很多人把maximumSize和expireAfterWrite放在一起用,这是正确的,两者维度不同,可以叠加。但有一个容易混淆的点:maximumSize和maximumWeight是不能同时使用的,同时配置会直接抛出IllegalStateException。Caffeine的Builder里写得很明确:maximumSize和maximumWeight互斥,因为语义上一个是按条数控制、一个是按权重控制,两者同时启用会让淘汰判断失去基准。
expireAfter虽然属于时间维度,但它同样会影响缓存的驻留数量。如果一个缓存设置了expireAfterWrite=1m,哪怕maximumSize设得很大,大部分条目也会在一分钟后失效,实际占用也会收敛。所以,在做容量规划时,大小策略和时间策略必须一起考虑,不能只看条数。
1.2 基于频率的淘汰:W-TinyLFU是Caffeine的“内功”
Caffeine默认采用的淘汰算法叫W-TinyLFU(Window TinyLFU),这不是普通的LRU或LFU,理解它,你才能明白为什么Caffeine的淘汰“看起来不太一样”。
传统LRU(Least Recently Used)的问题是:一次全表扫描或批量查询,很容易把缓存里本来高频访问的数据全部挤出去,造成所谓的“缓存污染”。传统LFU(Least Frequently Used)的问题是:曾经热的数据永远热,无法适应热点转移。
W-TinyLFU把入口分成两部分:
- 窗口区(Window):新数据先进窗口区,按LRU管理。窗口区是给新数据一个“观察期”,让短时突发的热点有机会进入主缓存区。
- 主区(Main):大部分缓存容量在这里,内部分为淘汰段和保护段。主区使用频率信息决定谁的优先级更低,而频率信息通过一个紧凑的计数结构(Count-Min Sketch)记录,不是真实精确计数,而是一个带误差的概率计数。
这就像一个社区商店:窗口区是“新客试用区”,主区是“老客区”。偶尔来一个一次买一百单的大客户(突发流量),不会因为买完这一次就把天天来买菜的常客挤走。只有当高频数据和更高频数据竞争时,低频者才被淘汰。这个特性,决定了Caffeine非常适合应对访问热度分布极不均匀的业务。
另外,Caffeine会用定期衰减机制,让计数随时间“变淡”,避免陈旧的热点数据永远占据缓存。这也是为什么在线业务里,Caffeine的命中率通常比Caffeine出现之前的纯LRU实现要好几个百分点。
1.3 大小策略真正影响的是什么
大小策略的本质,是在“命中率”和“内存占用”之间做权衡。
如果你把maximumSize设置得过小,比如一个日活很大的用户系统只给用户缓存设置1000条,那热点数据可能刚缓存进去就被挤出去,命中率低到没法看,缓存形同虚设。如果设置得过大,又会让老年代膨胀,触发Full GC。
实际操作中,我不是先看“缓存多大合适”,而是先反问几个问题:
- 这个缓存的key规模是多少?全量数据有多少?
- 单个value对象占用内存多大?
- 业务能接受缓存失效后穿透到数据库的频率吗?
- 服务实例的堆内存多大?其他模块占用多少?
回答完这几个问题,maximumSize的取值范围基本就出来了。然后再根据监控数据做微调,一步到位往往不现实。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring Boot 3.x 集成Caffeine时最容易踩的配置坑
Spring Boot 3.x把javax名空间迁到了jakarta,虽然缓存抽象本身没有大改,但依赖坐标、自动配置行为上有一些细节很容易被忽略。这一章列几个我在实践中反复遇到的配置问题,每一个都值得对照自查。
2.1 依赖和自动配置:加了这个依赖,不代表缓存就被管理了
标准的Spring Boot 3.x集成方案是:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
然后在application.yml里加上:
yaml复制spring:
cache:
type: caffeine
到这里,Spring Boot会自动装配一个CaffeineCacheManager。注意,spring-boot-starter-cache本身不带本地缓存实现,如果不加Caffeine依赖,Spring Boot会尝试找其他缓存provider,找不到就默认用ConcurrentMapCacheManager。我用一个线上项目做过测试:开发在CacheConfig类里配了Caffeine的Caffeine.newBuilder(),但忘了引入Caffeine依赖,结果项目跑起来用的是JVM的ConcurrentHashMap缓存——对象永不过期、没有淘汰。这个问题非常隐蔽,因为@Cacheable照常工作,缓存的“读写”都正常,只是行为和预期完全不一样。
排查办法很简单,启动时打印一下CacheManager的类型:
java复制@Autowired
private CacheManager cacheManager;
@PostConstruct
public void printCacheType() {
System.out.println(cacheManager.getClass().getName());
}
如果输出com.github.benmanes.caffeine.cache.CaffeineCacheManager,说明Caffeine接管成功了。如果输出org.springframework.cache.concurrent.ConcurrentMapCacheManager,说明你的Caffeine配置没有被加载,依赖或自动配置上一定有环节出了问题。
2.2 用Spring的Spec配置,还是直接用Caffeine对象?
Spring Boot提供了两种配置Caffeine的方式。
第一种,通过配置文件:
yaml复制spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=500,expireAfterWrite=10m
第二种,在配置类中创建CaffeineCacheManager:
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return manager;
}
}
两种方式的核心区别是:Spec方式只能配置Caffeine内置支持的属性,且不能配置自定义的weigher、removalListener、ticker这些高级功能。如果你只是简单限制大小和过期时间,Spec够用;如果要做权重控制、统计、监听淘汰事件,必须用Caffeine.newBuilder()手动构建。
还有一个很反直觉的坑:Spec方式里的字符串是有严格格式的,maximumSize=500,expireAfterWrite=10m之间不能有空格、不能有其他非法字符。一旦写错,Spring Boot启动时不会给你一个清晰的中文报错,而是抛一个IllegalArgumentException: Unknown configuration key之类的异常。我见过同事把maximumSize = 500写进配置,中间多了一个空格,项目直接启动失败。这个坑非常好排查,但也很容易复犯。
2.3 Caffeine和Spring Cache语义差异:大小是“每个缓存”的,不是“所有缓存”的
这是我在这篇文章开头提到的那个线上问题的核心,也是Caffeine大小策略配置中最容易产生误解的地方。
CaffeineCacheManager创建的CaffeineCache,是按缓存名区分的一个个独立缓存实例。maximumSize=500的含义是:每个缓存名下,最多保留500条数据,而不是所有缓存加起来500条。
举一个真实的例子:
java复制@Service
public class UserService {
@Cacheable(cacheNames = "userCache", key = "#userId")
public User getUser(Long userId) {
return userMapper.selectById(userId);
}
@Cacheable(cacheNames = "userDetailCache", key = "#userId")
public UserDetail getUserDetail(Long userId) {
return userDetailMapper.selectByUserId(userId);
}
}
这段代码会创建一个userCache和一个userDetailCache,每个cache各自有500条的配额。如果代码里还有动态拼接的缓存名,那问题就更大了:
java复制@Cacheable(cacheNames = "user:" + "#userId", key = "#userId")
public User getUser(Long userId) {
return userMapper.selectById(userId);
}
这里cacheNames是动态拼接的,意味着每个userId都会生成一个全新的缓存实例,每个缓存实例又都有500条配额。并发一上来,缓存实例数量直接失控,老年代被几十万个Caffeine实例吃光都不奇怪。
这个动态命名场景,生产环境里真的会有人写。所以排查“maximumSize不生效”时,第一步永远不是检查配置数字对不对,而是确认:你配置的配额,到底作用在哪个粒度上。
3. maximumSize配置不生效:一次完整的问题排查链路
前面说的都是原理和背景,这里我把前面提到的那个线上问题,完整拆一遍排查过程。这个过程可以当模板用,下次遇到类似问题,照着这个路径一步步排查,大部分情况都能定位到根因。
3.1 现象描述与最初的怀疑
背景是某服务有多个维度缓存:用户信息、订单信息、商品信息,全部通过@Cacheable注解访问。开发在配置类中明确定义了CacheManager,设置了maximumSize(1000)。
压测跑了几分钟后,内存监控显示堆内存持续上涨,老年代从2G涨到了5G,GC日志显示多次Full GC,且回收后内存很快又涨回去。初步怀疑是缓存容量过大,于是加日志打印每个缓存的条目数,发现有一个缓存名userCache的条目数超过了3万,并且还在持续增长,完全无视maximumSize=1000的限制。
3.2 排查第一步:确认配置的CacheManager是否真的生效
我们用代码打印了CacheManager的实际类型,和实际创建的缓存列表。为什么先做这一步?因为Spring Boot自动配置和自定义配置之间存在覆盖关系:如果项目里存在多个CacheManager类型的Bean,或者@Primary标注不确定,Spring容器可能注入的并不是你配置的那个。这里有一种隐蔽情况:@ComponentScan扫描到的某个配置类里,可能悄悄定义了一个CaffeineCacheManager,并且没有设置maximumSize,导致容器里出现两个CacheManager,而你又没加@Primary,注入时Spring会按名称匹配,如果注入字段名和某个bean名一致,就可能拿到错误实例。
在这个案例中,打印结果显示注入的是我们自定义的CacheManager,maximumSize配置也正确加载了。说明问题不在“配置没生效”这一层。
3.3 排查第二步:确认配额作用是“每个缓存”,还是“所有缓存”
打印cacheManager.getCacheNames(),发现缓存的个数非常夸张,有几百个。继续打印缓存名,发现里面出现了userCache、userDetailCache、productCache:1001、productCache:1002这样带前缀后缀的动态缓存名。
到这里就明白了:业务代码里有一部分缓存是动态命名生成的。每个缓存独立拥有1000条配额,而动态缓存名的数量又没有限制,所以总内存被撑爆。
这种问题在“静止”的配置里看不出来,只有在运行期打印出全部缓存名后才能暴露。所以排查缓存问题时,我习惯在启动完成后拉一份完整的缓存名清单,然后检查有没有明显的动态命名模式,这比对着配置看半天有效得多。
3.4 排查第三步:淘汰是不是“延迟的”
排查到这里,有人会问:既然每个缓存只有1000条配额,为什么打印出来某个缓存有3万条?这不合理。
这里涉及Caffeine一个底层特性:驱逐操作不是读写请求同步执行的,而是异步基于维护时机触发。
Caffeine的维护逻辑(maintenance方法)会在每次读写操作后触发,但它是分批执行的,不会因为“超过容量”就立刻把所有超限条目全部清除。也就是说,在高并发写入的场景下,某个瞬间缓存实际条目数会显著超过maximumSize,然后由维护任务逐步收敛到上限以内。这个“超限-收敛”的动态过程是正常的。
你可能会问,那这个“瞬时超限”会超多少?取决于写入并发度和维护任务的执行时机。Caffeine内部有drainStatus和Executor机制,一般不会出现永久性超限,但如果业务吞吐极高,瞬时超限几百上千条是可能的。真正的“永久性超限”不是Caffeine的淘汰逻辑失效,而是上面第二步说的“缓存实例数量无限增长”导致的。
这一步的排查方法很简单,等压测流量暂停几十秒,再打印一次缓存条目数。如果条目数回落到1000条以内,说明淘汰逻辑正常;如果仍然有几万条,说明缓存实例的创建逻辑有问题,而不是淘汰逻辑有问题。
3.5 根因确认与修复方案
根因是两部分:动态缓存名导致缓存实例数量失控;每个缓存实例独立拥有1000条配额,叠加起来总内存不受控。
修复方式:
-
把动态缓存名改掉。缓存名应当是一个有限的、可枚举的集合。如果业务必须按某个维度做不同缓存实例,可以限制该维度的取值数量,比如按userId取模,拆成固定数量的分片。
-
用
CaffeineCacheManager.setCacheNames固定缓存集合。
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCacheNames(List.of(
"userCache",
"userDetailCache",
"productCache"
));
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10)));
return manager;
}
}
设置setCacheNames后,只有列表内的缓存名会被创建。业务代码里出现列表之外的缓存名时,Spring Cache会直接报错,从这个错误报警里你就能第一时间发现代码里有动态缓存名,而不是等内存爆了才回头查。
这是我在实际项目里的推荐做法:宁可启动时把缓存集合固定死,也不要用默认的动态创建模式。动态创建虽然方便,但它是生产环境的不定时炸弹。
4. maximumWeight与Weigher:需要更精细控制时再上这个
maximumSize按“条数”控制,够用大多数场景。但有些情况下,条数不能代表真实内存占用。比如一个缓存里同时存在几KB的String和几MB的JSON对象,如果只按条数限制,很可能出现“条数没满、内存已经爆了”的尴尬局面。此时需要用maximumWeight配合weigher按权重控制。
4.1 什么情况下必须用maximumWeight
典型的例子是:缓存商户的配置数据,其中大部分是几十字节的小对象,但有少数商户上传了很大的商品介绍文档,单条value就有几MB。如果按条数上限控制,比如上限5000条,当这5000条里混入几百条大的文档对象,总内存可能达到几个G,远超预期。
用权重控制就灵活很多:小对象权重小、大对象权重大,最终限制的是“总权重”,也就是总内存大小。
java复制@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCacheNames(List.of("merchantCache"));
manager.setCaffeine(Caffeine.newBuilder()
.maximumWeight(100 * 1024 * 1024) // 总权重不超过100MB
.weigher((String key, Object value) -> {
if (value instanceof byte[] bytes) {
return bytes.length;
}
// 没有现成大小,就做一次序列化估算
return getApproximateSize(value);
})
.expireAfterWrite(Duration.ofMinutes(5))
.build());
return manager;
}
}
这里weigher返回的是每个条目的权重,maximumWeight限制的是所有条目权重之和。它们的关系就是:所有存活条目的权重总和必须不超过maximumWeight。
4.2 Weigher的返回值陷阱:正数、零值、异常
weigher的实现有几个容易被忽视的边界条件:
- 返回值必须是正整数。官方要求weigher返回一个非负整数,如果返回0,那这条目几乎不会被淘汰,因为权重为0意味着“这条不占空间”,多个权重为0的条目堆积起来,一样会造成内存膨胀。所以,权重计算不能偷懒返回0。
- weigher异常会中断淘汰计算。如果weigher里做了序列化、IO、或者网络调用,一旦抛出异常,Caffeine的维护逻辑会被破坏,导致缓存功能异常。权重计算逻辑必须做到“非常快、无外部依赖、不抛异常”。
- 权重总和不要超过
Integer.MAX_VALUE。Caffeine内部维护权重用的是long,但如果weigher返回的权重值没有上限约束,极端情况下累加溢出是比较难排查的问题。实测建议权重设计尽量把单条上限控制在可管理范围,比如单条不超过几十MB。
4.3 权重计算本身也有开销,别每访问一次都做序列化
这一点是我踩过坑之后才注意到的。weigher会被Caffeine在写入、淘汰、维护时频繁调用。如果weigher内部每次都对value做一次完整序列化来计算字节数,那CPU开销会非常可观,反而拖垮接口性能。
更合理的做法是:在写入时就知道value的大致体积,而不是等weigher回调时才去量。比如可以在业务层把“大小估算值”作为元数据一起写入,或者对value对象做一次粗略的toString().length()估算,不要做强序列化。权重计算本来就是一个“够用就行”的近似控制,不需要做到字节级精确。
5. 从配置到治理:生产环境下Caffeine大小策略的调整思路
能配置正确、能排查问题,这些都算“从0到1”。真正让Caffeine在线上稳定运行,还需要一套基于监控和数据反馈的调整方法。
5.1 拍脑袋设500还是1000?先用数据估算
我见过不少团队对maximumSize的取值是“看着差不多就行”的。上线之后要么内存紧张,要么命中率低到怀疑人生。比较科学的做法是先做一次粗估算:
- 统计业务里该缓存的key规模。比如用户缓存,日活用户是10万,那key的规模不会超过10万。
- 估算单个entry的大小。取一个典型value,序列化后看一下字节数,再乘以1.5作为膨胀系数(对象头、引用、Map结构等)。
- 决定缓存占堆内存的比例。假设JVM堆是4G,给缓存预留20%,那就是800MB。
- 用800MB除以单个entry大小,得到理论条数上限,再把上限打7折作为
maximumSize,留出余量。
这样算出来的值,比拍脑袋设一个500要靠谱得多。而且这个过程本身会逼着你搞清楚“缓存的真实成本”。
5.2 用recordStats打开命中率监控
很多人不知道Caffeine自带一个简单实用的统计功能,只要在构建时加上recordStats(),就能通过cache.stats()拿到命中率、加载耗时、淘汰数量等指标。Spring Boot中可以通过缓存暴露或定期打印来观察。
java复制Caffeine.newBuilder()
.maximumSize(1000)
.recordStats()
.build();
然后在代码里定时获取:
java复制CaffeineCache caffeineCache = (CaffeineCache) cacheManager.getCache("userCache");
Cache<Object, Object> nativeCache = caffeineCache.getNativeCache();
CacheStats stats = nativeCache.stats();
log.info("hitCount={}, missCount={}, hitRate={}", stats.hitCount(), stats.missCount(), stats.hitRate());
命中率数据是最直接的调参依据。如果命中率长时间低于50%,说明缓存容量太小或者过期策略太激进,需要上调maximumSize或延长过期时间。如果命中率一直在95%以上,且内存占用偏高,可以适当下调maximumSize,把内存让给更需要的地方。
5.3 缓存大小、GC和本地内存的平衡
Caffeine缓存在堆内,对象都在老年代,太多缓存会让老年代膨胀,触发Full GC的概率直线上升。我实际测试过一个极端案例:堆内存4G,Caffeine缓存设置了3G,压测跑十分钟后平均每半分钟一次Full GC,接口RT直接被拉到几秒。后来把maximumSize降到1G以内,Full GC频率才降下来。
所以,不要抱着“缓存越大越好”的心态配大小。缓存大小的上限,是“不能影响JVM的正常GC行为”。预留多少堆内存给Caffeine,取决于应用本身的对象分配压力,长期运行的应用建议Caffeine占用控制在堆的20%-30%以内。
5.4 多级缓存:别让Caffeine单打独斗
如果业务规模大,单纯靠进程内缓存往往扛不住。我经常在项目里用“Caffeine + Redis”的组合:Caffeine做一级缓存,Redis做二级缓存,数据库在最底层。请求先查Caffeine,不中再查Redis,再不中才查数据库。这个模式下,Caffeine的大小策略依然重要,因为它决定了进程内能扛住多少热点流量;但它的压力会小很多,因为Redis是全局共享的,Caffeine只服务单个实例。
多级缓存的核心痛点不在“大小配置”,而在“一致性问题”。比如更新一条数据,你要同时失效Redis和Caffeine里的旧值,否则会出现数据不一致。Caffeine的expireAfterWrite时间一般设置得比Redis短,因为本地缓存实例多,一致性难度大,短过期时间可以当作一种兜底的自愈机制。这一点,也是我在设计Caffeine容量时,会额外考虑的因素——不一致窗口越短,expireAfterWrite越小,缓存容量可以适当调大;反过来,能接受短暂不一致,过期时间可以拉长,容量也可以相应收紧。
5.5 大小策略调整的“最小改动”原则
最后分享一个我在生产环境调整缓存参数时的习惯:不要一次性把maximumSize和过期时间都改掉。
每次只调一个变量,观察指标变化,确认有效后再调下一个。比如压测时发现内存偏高,先把maximumSize下调30%,观察命中率和接口RT的变化;如果命中率下降不明显,说明缓存容量冗余,可以继续下调。如果命中率骤降,说明容量已经逼近业务需求下限,需要换一个方向调整,比如延长过期时间而不是继续缩容量。
这种“单变量控制法”虽然慢,但反馈链路清晰,不会出现“改了缓存参数,出了问题却不知道是哪一项引起的”的窘境。
我在实际项目里,会把Caffeine的配置做成可配置项,maximumSize、expireAfterWrite这些参数全部通过配置中心下发,不写死在代码里。这样上线后调整大小策略,不需要重新发版,改个配置即可生效。配合命中率监控,基本上一天内就能找到当前业务规模下的最优配置组合。这才是从“能跑”到“跑得好”应该走的路。
