上个月帮一个团队排查线上问题,服务压测时内存一直往上顶,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,虽然缓存抽象本身没有大改,但依赖坐标、自动配置行为上有一些细节很容易被忽略。这一章列几个我在实践中反复遇到的配置问题,每一个都值得对照自查。
