Caffeine缓存过期参数用错,一次会员等级事故的排查与修复 1. 事故背景周五下班前告警来了下午四点五十七分我刚刚把最后一段代码提交进仓库茶都凉了一半正准备关掉 IDE 收拾东西下班。结果运维群先炸了客诉量从 16:40 开始快速往上爬用户反馈高度一致——“我的会员等级没变”“下单价格不对”。看到“等级”两个字我心里咯噔一下。这个活动上线前我们刚给用户权益查询链路加了本地缓存而本地缓存组件正是 Caffeine。先把这个服务的定位讲清楚。它是一个用户权益服务不直接面对用户而是被订单、营销、App 网关一堆上游调用。接口做的事情很简单根据 userId 返回用户的会员等级、积分、可用优惠券等权益信息。读多写少非常典型的“读多写少型”查询服务。原先每次请求都实时查 MySQL压测时单表已经到了瓶颈于是按惯例引入本地缓存。本地缓存选 Caffeine 是很自然的事。进程内访问没有网络开销不需要额外部署一套 RedisCaffeine 在吞吐、命中率、内存控制上又比老牌的 Guava Cache 好不少。问题从来不在“要不要用”而在“怎么配置”。这个事故的根子就是我们把 Caffeine 的一个过期参数用错了错得还挺隐蔽。1.1 为什么当时选 Caffeine 而不是 Redis本地缓存和集中式缓存的差别本质上是“一致性”和“性能”的权衡。当时我们评估的是用户等级信息变更频率很低一个用户一个月可能只变一两次读请求又极高很多接口一次请求要查好几个用户的权益。如果用 Redis每次读取都是一次网络往返延迟和数据量上来之后成本并不低。而本地缓存命中后是纯内存访问单次查询可以压到纳秒级。所以“本地缓存是不是合适”这个决策直到现在我也认为是正确的。真正有问题的是我们用了一个和业务语义不匹配的过期策略而且没有给缓存加任何可观测性。两个错误叠在一起事故自然就来了。1.2 事故时间线50 分钟从告警到救赎整个事故从告警到恢复前后差不多 50 分钟。我把核心节点列出来大家对照着看时间动作结果16:57运维群里贴客诉截图客诉量抬头17:02查 MySQL 用户表等级、积分均已正确更新DB 无异常17:10查服务日志和接口返回服务无报错但接口返回的等级是旧值17:18用诊断工具查看缓存 key 的存活时长发现该 key 在缓存里存活了很久远超 2 小时17:27翻缓存构建代码定位到 expireAfterAccess(2h) 的问题17:41批量失效问题 key滚动发布新配置数据恢复客诉回落17:58我终于关掉 IDE救赎完成注意一个细节“发现该 key 在缓存里存活了很久”非常关键。按我们配置的 2 小时过期即便 key 一直没更新也应该在写入 2 小时后消失。为什么它没消失这就是本次事故的全部谜底。2. 排查过程从数据库翻到缓存最后咬住过期参数很多人排查生产事故有个习惯先怀疑数据库、再怀疑代码、最后才怀疑缓存。这个顺序不算错但容易绕远路。效率高的方式是先在脑子里过一遍完整数据链路然后逐层“打点”数据库是不是对的接口返回的值是哪一层给的缓存里存的究竟是不是旧数据2.1 第一板斧确认数据源没坏客诉出来后第一件事永远是把数据库状态查清楚。我直接到 MySQL 里查了用户主表和变更流水主表里该用户的 level 字段是 2VIP变更时间在半小时前流水表也有一条“普通 → VIP”的升级记录。一切正常。这一步的意义是排除“写链路坏掉”的可能。如果数据库里就是旧值问题可能在订单回调、状态机、事务提交这些环节现在数据库是对的问题必然出在读链路。2.2 第二板斧看接口响应里的时间戳权益接口返回的对象里带了一个字段叫 levelUpdateTime也就是等级最后更新时间。我把问题用户的请求日志翻出来接口返回的等级确实是 VIP但 levelUpdateTime 是三天前。数据库里是半小时前接口里是三天前——这就非常实锤了接口返回的是一份“旧数据”。难道接口读了另一张表我快速扫了一遍代码接口走的就是 userLevelCache 这个 Caffeine 缓存没有查其他数据源于是把注意力完全放到缓存上。2.3 第三板斧用诊断工具扒开缓存看一眼我用诊断工具连上了一个实例找到缓存对象通过 Caffeine 的 policy() 视图查看 key 的存活状态。这里多说一句很多人不知道 Caffeine 自带这种运行期视图它可以在运行时查看每个 key 的年龄、剩余过期时间、淘汰权重等信息排查时非常有用。结果很有意思出问题的这个用户key 存在于缓存中value 是三天前的旧对象查到的 key 存活时长是 1 小时 20 分。也就是说这个 key 在缓存里待了超过三天但它的“最近一次访问时间”始终在滚动导致过期时间被不停往后推。2.4 为什么告警没有更早拦住排查到这里我顺手查了一下缓存统计发现命中率、加载耗时这些数据全是零。原因很直白构建 Caffeine 时没有开启 recordStats()服务侧也没有把 Caffeine 的指标接到监控系统里。等于这个缓存组件上线后一直是个“黑盒”我们只知道它“看起来很快”完全不知道它里面存了多久、命中率多少、淘汰是否正常。如果一开始就暴露命中率和 key 年龄指标这类“数据不更新”的问题会在 10 分钟内被定位根本不用等客诉上来。这是我在这个事故里学到的第一个、也是最重要的教训。缓存组件一旦上线必须同步配上可观测性否则它就是一个随时会引爆的定时炸弹。3. 根因剖析expireAfterAccess 为什么在这里是错的等到证据链完整根因已经很清楚了我们在缓存里用了 expireAfterAccess(Duration.ofHours(2))而业务要求的语义是“数据变更后缓存要在可接受的时间内失效”。这是两个完全不同的东西。3.1 Caffeine 三个过期参数到底差在哪Caffeine 提供三个表面相似、语义完全不同的时间参数我用一个表格直接对比参数触发条件行为典型效果expireAfterWrite(n)自写入/更新后经过 n 时间条目过期下次读重新加载硬性 TTL无论有没有人访问expireAfterAccess(n)自最近一次读或写后经过 n 时间条目过期只淘汰“长时间没人碰”的条目refreshAfterWrite(n)自写入后经过 n 时间且下一次被读取触发异步刷新读取时仍返回旧值平滑刷新避免过期时阻塞一句话概括expireAfterWrite 是“这个 Key 最多活多久”expireAfterAccess 是“这个 Key 多长时间没人碰就滚蛋”。我们的场景是用户等级。业务上最重要的要求是用户升级或降级后后续查询必须尽快看到新值。要满足这个要求key 必须有一个从写入那一刻算起的硬性存活上限——不管有没有人访问到了时间就失效并重新加载。这正是 expireAfterWrite 的语义。3.2 高频 Key 为什么不失效把 expireAfterAccess 想象成一个倒计时闹钟只要有人来看一眼这个闹钟就把倒计时重置回 2 小时。那么一个每天被访问几百次的用户他的缓存条目永远不可能倒计时归零。也就是说这个 key 的过期机制在它成为热 key 的那一刻就“失效”了。出了事的用户恰好是平台 VIP权益服务每天被上游调用几十次。三天过去这个 key 一直处于“刚被访问过”的状态过期判定永远无法成立。更麻烦的是用户升级这个动作发生在缓存写入之后而缓存根本不会触发重新加载它返回的就是内存里那份旧对象。哪怕数据库已经更新了一万次Caffeine 也不知道因为没有人告诉它“这个 key 该作废了”。说到底这不是 Caffeine 的 bug而是我们拿“空闲清理”的机制去做了“数据保鲜”的活儿。3.3 顺手盘点Caffeine 还有哪些常见误用借着这次事故我把团队里能想到的 Caffeine 误用场景全翻出来整理了一遍。如果你也要用这个库建议对照着自查不设置 maximumSize。本地缓存一旦没有容量上限遇到频繁写入新 key 的场景堆内存会被无限蚕食最后就是 OOM 或频繁 Full GC。过期时间一刀切。所有缓存共用 2 小时、24 小时完全不看业务对新鲜度的容忍度。用 expireAfterAccess 来实现“数据不会无限陈旧”。这是本次事故的直接原因它只能淘汰“没人碰”的 key拦不住“老被碰”的旧值。多实例部署却把本地缓存当成分布式缓存。三个实例各存各的一个请求打到 A 实例返回新值下一个请求打到 B 实例返回旧值用户完全无法理解。在 CacheLoader 里做远程调用或者重型计算。Caffeine 刷新虽然是异步的但首次 miss 时 get 是同步阻塞的加载器太重接口延迟会瞬间拉高。不打开 recordStats也没有指标暴露。缓存命中率、加载耗时、淘汰数全是黑盒。忽略发布后的冷启动预热。实例刚重启完缓存是空的第一波流量会产生大量 cache miss 直接打到数据库。3.4 Caffeine 的过期实现为什么“到点”也不一定立刻清还有一个细节值得说。Caffeine 的过期任务不是靠一个后台线程每秒扫描整个缓存来实现的它内部用的是时间轮timing wheel加惰性删除写入时把过期任务挂到时间轮上读取时会再次校验 key 是否已过期真正的物理清理会在维护操作里批量执行。这意味着在高并发写入的情况下清理动作可能被延后某些“已经到过期时间”的 key 不会在那一毫秒立刻消失。但对于 expireAfterWrite 来说这并不影响正确性因为读取路径上会校验过期时间读的时候该失效就会失效。真正会被这个机制放大的是 expireAfterAccess 这类依赖“最后一次访问时间”的策略——一旦 key 被频繁访问它连“到点”的机会都没有。4. 修正方案从止血到落地临下班的救赎实操搞清楚了根因接下来就是怎么改。临下班遇到生产事故心态一定要稳住先止血再根治不要一上来就大重构。4.1 紧急止血先让问题 Key 出缓存Caffeine 是进程内缓存最快最直接的办法是重启实例但重启有代价所有实例一起冷启动数据库会迎来一大波 cache miss 流量。所以我没有选择重启而是走了一条更克制的路径。第一步利用服务里已有的运维通道按 userId 批量执行 invalidate(key)。我们把客诉涉及的用户 id 汇总后直接清掉了这些 key。第二步写了一个一次性预热脚本模拟请求把这些用户的新等级读回缓存。这样做的好处是既清掉了旧值又不会在下一次真实读取时造成数据库被打。第三步滚动发布新配置。因为构建 Caffeine 时参数已经固化在代码里运行期无法动态把 expireAfterAccess 改成 expireAfterWrite所以必须走一次发布。我这里选择在客诉低峰期完成每实例依次发布发布过程中新旧配置并存老的失效策略还能充当一层兜底。提示清缓存不是终点务必同时确认下游数据库能扛住瞬时流量。如果没有预热条件宁可分批次清也不要一把全清。4.2 代码修正换成 expireAfterWrite refreshAfterWrite 组合代码修正分成两层。第一层把过期策略改掉。修正前LoadingCacheLong, UserLevel userLevelCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterAccess(Duration.ofHours(2)) .build(this::loadUserLevel);修正后LoadingCacheLong, UserLevel userLevelCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .refreshAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(this::loadUserLevel);两层策略各干一件事。expireAfterWrite(30 分钟) 保证任何 key 最多存活 30 分钟30 分钟后必然失效并重新加载这是数据新鲜度的底线。refreshAfterWrite(10 分钟) 保证热门 key 在真正过期前就触发异步刷新读取时返回旧值刷新成功后再更新避免大量 key 在同一时刻一起过期、把数据库压垮。需要强调的是refreshAfterWrite 要生效必须配合 CacheLoader也就是通过 LoadingCache 的 get 方法加载数据。它做的是“后台刷新”不是“阻塞等待新值”如果刷新失败旧值会继续保留等待下一次刷新机会。它和 expireAfterWrite 是互补关系不是替代关系。我们之所以两个都配就是既要“保鲜底线”也要“平滑过渡”。上面这两层合起来的效果是热点数据每 10 分钟左右刷新一次但即使刷新不成功30 分钟硬 TTL 也会兜底。常见的“缓存击穿、缓存雪崩”问题就被拆成了平滑刷新。4.3 数据变更时主动失效别只靠 TTL 兜底第二层在数据变更链路里主动失效缓存。用户升级或降级这类事件不能只靠 TTL 兜底。我们在用户等级变更的服务里增加了一个缓存失效动作变更成功后构造 cacheKey调用 invalidate(key) 删掉旧缓存。上游查询下一次进来时就会 miss 并重新加载新值立即可见。代码里我建议把缓存构建放在独立的配置类里key 的序列化规则统一定义避免散落各处拼错字符串Configuration public class UserLevelCacheConfig { Bean public LoadingCacheLong, UserLevel userLevelCache(UserLevelService service) { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .refreshAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(service::loadUserLevel); } }4.4 多实例缓存失效别只在一个 JVM 里清上面提到 invalidate有一个很容易踩的坑Caffeine 是进程内缓存服务有三个实例你在一个实例里 invalidate另外两个实例的旧值还在。如果业务能容忍最多 30 分钟的不一致只靠 TTL 兜底也没问题但如果要求秒级可见就得把失效事件广播出去。我们在项目里用的方式是用户等级变更后向消息队列发一条“缓存失效”事件三个实例各自消费这条消息再分别 invalidate 本地 key。因为事件里带的是 userId消费端做一次幂等判断即可逻辑非常简单。注意本地缓存无论如何做不到强一致最多做到“最终一致”的窗口可控。如果业务要求多副本强一致应该考虑统一的集中式缓存或者直接走数据库。4.5 可观测性补齐让缓存不再是黑盒这次事故让我把“缓存也要有监控”当成一条强制规范。Caffeine 开放了 recordStats() 和 policy() 两个关键入口前者提供命中率、加载耗时、淘汰数等统计后者提供每个 key 的年龄、权重、过期策略等运行期信息。我在改造里做了三件事。第一开启 recordStats()。第二通过 Micrometer 把 CacheStats 暴露成监控指标上报到监控系统重点盯命中率、缓存大小、加载耗时。第三加了一个小运维接口可以按 key 查缓存状态也可以手动清除指定 key。以后再有类似的“数据没更新”客诉第一反应可以直接查指标而不是继续猜。5. 排查经验汇总缓存事故的四板斧与速查表如果你也被缓存事故折腾过下面这份沉淀下来的排查套路可以直接拿走。5.1 判断数据链路问题的四板斧第一板斧查数据库把主表和流水表都看一遍确认“该更新的数据到底有没有更新”。这一步排除写链路问题。第二板斧查接口输出与时间戳对比接口返回的值、时间字段与数据库里的最新值。能找到“旧值”就说明数据被某个中间层拦截了。第三板斧查缓存内实际内容用诊断工具或运维接口直接看缓存 key 是否存在、value 是不是旧的、key 年龄有多长。这是定位本地缓存问题的关键一步。第四板斧查缓存配置与统计看 Caffeine 构建参数、有没有 recordStats、命中率曲线。很多问题在配置对比之后立刻现形。这套流程最快的一次十多分钟就把问题定位到 expireAfterAccess 上。5.2 常见缓存误用速查表现象可能原因排查与修正方向高频 key 的数据不更新像被“冻住”expireAfterAccess 让热 key 永不失效改为 expireAfterWrite变更时主动 invalidate实例内存暴涨出现 OOM 前兆未设置 maximumSizekey 无限增长设置容量上限观察淘汰数缓存过期瞬间数据库被打垮大量 key 同时过期refreshAfterWrite 平滑刷新 预热多实例返回不一致本地缓存无失效广播MQ 广播 invalidate或缩短 TTL接口延迟偶尔飙高加载器里有远程调用或重计算加载器保持轻量暴露加载耗时缓存占用内存但用的人不多过期时间过长 淘汰策略缺失按热度设置 TTL 和容量5.3 团队规范里我新加的三条第一代码评审看到 expireAfterAccess 必须追问这个 key 是不是高频访问它有没有业务保鲜需求如果没有合理解释默认用 expireAfterWrite。第二本地缓存的 TTL 上限写死在规范里默认 30 分钟特殊业务要走评审。过期时间越长出问题后“数据是旧的”这个结论就越难被接受。第三任何缓存组件上线必须同步提供命中率、key 年龄、缓存大小三类指标。没有指标的缓存不许上生产。这个要求看上去苛刻但它直接决定了下次事故发生时你是花 10 分钟定位还是花两小时怀疑人生。6. 写在最后一点亲身体会每次临近下班出现生产告警人都会有一种“整个晚上要泡汤”的绝望感。但事后回头看这个 Caffeine 事故并不是什么高深的技术难题它就是一个典型的“参数语义理解不到位 可观测性缺失”的组合拳。Caffeine 本身很稳并发和内存上都没有出过问题出问题的永远是使用者对语义的误解。我个人现在有个习惯任何新引入的组件先花半小时把核心配置项和文档过一遍尤其是那些“看起来差不多”的参数一定要落到业务语义上重新想一遍。Caffeine 的 expireAfterWrite、expireAfterAccess、refreshAfterWrite 这三个参数就是最好的例子——它们长得像三胞胎用错了就是一次客诉事故。多一句嘴排查缓存问题永远先看数据源再看接口再看缓存内部最后看配置。顺序对了救赎就在眼前顺序乱了下班就真的遥遥无期了。