1. 事故现场还原:当Caffeine缓存成为性能杀手
那天下午4点37分,监控大屏突然爆出十几条红色告警。核心交易接口的响应时间从平均200ms飙升到8秒以上,错误率突破30%。更糟糕的是——这个故障发生在月底业务高峰时段,直接影响商户结算流程。
通过日志快速定位,问题出在一个看似无害的Caffeine缓存配置上。我们为商户资质查询接口添加了本地缓存,原始配置是这样的:
java复制Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
表面看这是个标准配置:最大缓存1万条,5分钟过期。但实际运行中出现了三个致命问题:
- 缓存键设计不合理,导致键数量爆炸(实际达到200万+)
- 未设置权重限制,单个缓存条目占用内存过大
- 高并发下触发了Caffeine的异步清理机制阻塞
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Caffeine缓存机制深度解析
2.1 内存管理底层原理
Caffeine采用Window-TinyLFU算法,其内存结构分为:
- 准入窗口(20%空间):新条目首先进入这里
- 主区域(80%空间):通过频率统计决定保留
当缓存超过容量限制时,会触发以下清理流程:
- 合并窗口区和主区的访问频率统计
- 比较新老条目的访问频率
- 淘汰频率较低的条目
问题在于:这个清理过程虽然是非阻塞的,但在极端情况下(比如我们遇到的键数量超标时),后台线程的清理速度可能跟不上新条目插入速度。
2.2 过期策略的隐藏陷阱
expireAfterWrite的实现在高并发场景下有个重要特性:过期条目不会立即清除,而是在以下时机清理:
- 写入新条目时
- 定期维护线程扫描时(默认每60秒)
这意味着如果突然有大量写入,可能触发连锁反应:
mermaid复制graph TD
A[大量新缓存写入] --> B[触发过期检查]
B --> C[清理线程占用CPU]
C --> D[处理请求的线程等待]
D --> E[整体响应变慢]
3. 实战修复方案与验证
3.1 紧急止血措施
我们立即实施了三级修复:
- 动态降级:在网关层添加开关,强制跳过缓存
- 内存限制:增加JVM堆内存(临时方案)
- 监控增强:添加缓存命中率/内存占用监控项
关键修复代码如下:
java复制// 修复后的配置
Caffeine.newBuilder()
.maximumSize(1_000)
.weigher((String key, byte[] value) -> value.length)
.maximumWeight(50 * 1024 * 1024) // 50MB
.expireAfterWrite(1, TimeUnit.MINUTES)
.scheduler(Scheduler.systemScheduler())
.executor(Executors.newSingleThreadExecutor())
.build();
3.2 长期优化方案
-
键设计规范:
- 采用MD5哈希压缩键长度
- 添加业务前缀隔离(如"BIZ_TYPE:ID")
-
分级缓存策略:
java复制// 一级缓存(热点数据) Cache<String, Object> L1 = Caffeine.newBuilder() .maximumSize(100) .expireAfterAccess(10, TimeUnit.SECONDS) .build(); // 二级缓存(全量数据) Cache<String, Object> L2 = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build(); -
监控指标体系:
- 缓存命中率(分L1/L2)
- 平均加载时间
- 内存占用百分比
- 淘汰队列长度
4. 血泪经验总结
4.1 必做的压测场景
在引入本地缓存前,必须模拟以下场景:
- 键数量爆炸测试:以10倍于配置的键数量冲击
- 值体积测试:构造1MB以上的大对象
- 并发清除测试:同时触发size和weight限制
4.2 配置检查清单
每次使用Caffeine时,请核对:
- [ ] 是否设置了合理的maximumSize/Weight?
- [ ] 是否配置了独立的清理线程池?
- [ ] 是否有监控能发现键数量异常?
- [ ] 是否考虑了缓存穿透/雪崩保护?
4.3 高级技巧
-
预热优化:
java复制// 启动时异步加载热点数据 cache.asMap().computeIfAbsent(key, k -> loadFromDB(k)); -
淘汰事件监听:
java复制.removalListener((key, value, cause) -> { if (cause == RemovalCause.SIZE) { alert("缓存容量告警"); } }) -
TTL动态调整:
java复制.expireAfter(new Expiry<String, Object>() { public long expireAfterCreate(String key, Object value, long currentTime) { return isHotKey(key) ? TimeUnit.MINUTES.toNanos(10) : TimeUnit.MINUTES.toNanos(1); } })
这次事故给我们的启示是:即使是看似简单的本地缓存,在高并发场景下也会变成系统的不稳定因素。建议所有使用Caffeine的团队建立三道防线:
- 代码层面的合理配置
- 发布前的全场景压测
- 运行时的实时监控
