Java

缓存回源流量突然放大怎么排查?从一次商品详情页被热点 key 打穿说起

一次大促里,商品详情页先慢下来的不是数据库,而是一个热点 key 过期后的并发回源。真正有用的排查,不是先分型讲课,而是先证明压力是不是集中在同一批 sku、同一条 SQL 和同一个失效窗口里。

  • Redis
  • 缓存
  • 回源
  • MySQL
  • 性能排查
10 分钟阅读

周六晚上做大促压测回放时,最先响起来的是商品详情页的 p99,从 180ms 抬到 1.8s。群里第一反应很熟:有人盯数据库 QPS,有人盯 Redis 命中率,还有人怀疑活动流量本来就把系统推高了。

但这次真正把方向钉住的,不是某一张总览图,而是一条很窄的事实:数据库新增的查询几乎都在打同一批 skuId,而这批 skuId 对应的缓存 key,恰好在 20:03 前后一起失效。

所以这篇不想再把“回源放大”拆成一排概念。更贴近现场的问法其实是:数据库突然被打高,到底是不是少数热点对象被打穿了? 如果答案是,那后面的动作和“整层缓存吸收能力变差”完全不是一回事。

先把事故现场钉死

那次值班先看到的是三件同时发生的事:

  • /product/detail 的 p99 在 20:03 开始跳高
  • 数据库里 select ... from product_snapshot where sku_id = ? 的调用量 4 分钟内翻了 9 倍
  • Redis 整体命中率只从 96.8% 掉到 94.9%,看起来并不吓人

如果只看平均命中率,很容易得出一个错结论:缓存虽然有波动,但不像主因。

真正把误判掰回来的是 Top miss 样本。那 5 分钟里新增的 miss,72% 集中在 38 个热门 skuId 上;数据库新增查询里,前 20 条 skuId 占了 61%。这不是面状漏流,更像同一批热点对象在同一时间窗被放下去。

为什么先怀疑热点击穿,而不是数据库自己变慢

因为时间顺序不对。

那天数据库慢 SQL 确实在 20:04 之后开始抬头,但 20:03:11 到 20:03:40 这一小段里,最早变差的是:

  • Redis 对应 key 的 miss 数陡增
  • 同批 skuId 的详情请求并发突然堆在一起
  • 应用里商品详情回填线程数打满

数据库只是第二段才被压上去。如果一开始就把锅扣给数据库,后面很可能只会做扩容、加连接、杀慢 SQL 这种结果层动作。那天也确实有人这么做了:先把只读库连接池从 120 提到 180,但 p99 几乎没回落。

原因很简单:真正放大的不是“数据库不够快”,而是同一个空窗里有太多人同时去回填同一批热点数据。

首证据不是总命中率,而是 miss 集中度

那次最值钱的一张表,不是总览,而是按 skuId 拉出来的 miss TopN:

时间窗新增 miss前 20 个 sku 占比数据库详情 SQL 占新增查询比例
19:55 - 20:001.2 万18%24%
20:00 - 20:059.6 万61%68%
20:05 - 20:104.1 万43%49%

这张表把问题说得很直接:回源不是均匀变多,而是先集中到少数热点商品上。只要这个事实成立,排查顺序就该完全换掉。

这时候最该看的不是“还有哪三类可能”,而是两件事:

  1. 这些热点 key 为什么会在同一时间段失效
  2. 失效之后有没有单飞 / 互斥回填保护

最后收敛到的根因,其实是两个小问题叠在一起

先查 TTL,发现商品详情缓存为了方便运维,统一设成了 30 分钟,批量预热又是在 19:33 做的。于是大促入口流量刚上来,第一批热点商品恰好开始成片过期。

再查代码,发现详情回填虽然做了异步线程池,但没有真正的单飞保护。一个热门商品 key 过期后,同一时间进来的几十个请求,都会穿过去打数据库。

这就解释了为什么现场看起来像数据库突然被打高:

  • 第一层是热点 key 同窗失效
  • 第二层是失效后没有把并发回填并成一次
  • 第三层才是数据库查询和连接池被一起拉高

如果少掉其中任何一层,事故都不会有这么重。

这类回源放大,验证动作应该怎么做

那次没有先动大范围配置,而是只挑了 50 个最热商品做两个动作:

  • 把这批 key 临时续期 40 分钟
  • 给详情回填加本地单飞保护,同一 skuId 同时只允许一个请求回源

动作下去以后,变化非常干净:

  • 20:11 起数据库详情 SQL 每秒查询数先回落
  • 20:12 起应用回填线程池 active 降下来
  • 20:13 起 /product/detail 的 p99 从 1.4s 回到 320ms

这里最关键的不是“指标降了”,而是回落顺序证明了我们按对了主线:先止住热点 key 的并发穿透,数据库压力才会跟着掉,而不是反过来。

这种现场最容易错在哪

最容易错的地方,就是拿平均值给自己壮胆。

那天 Redis 总命中率只掉了不到 2 个点,很像“缓存有点波动,但主问题不在这里”。可真正危险的并不是平均值,而是高并发请求是不是在同一批高价值对象上同时 miss。

只要是这种结构,哪怕整体命中率还算体面,数据库也一样会被打疼。

另一个常见误判,是看到数据库 RT 上升就先扩库、加连接、调 SQL。那不是完全没用,但如果回源还在源源不断地从同一批热点 key 上往下漏,这些动作大多只能把事故摊平一点,止不住根。

什么时候这篇思路不适用

如果你的回源新增不是集中在少数对象,而是多个接口、多个 key 前缀一起变差,那这篇的主线就不够用了。那种更像面状漏流、TTL 集中过期或者调用路径绕过缓存。

但只要你看到的是下面这种形状:

  • 总命中率没崩
  • 数据库新增查询却很集中
  • 新增 miss 主要落在同一批热点对象

就别再从全局平均值开始聊了,直接先证明是不是热点击穿。

回到这次事故真正教会人的一点

缓存回源流量突然放大,并不一定意味着整层缓存都坏了。很多时候,只是最贵、最热、最容易放大并发的那一小撮对象,先一起掉了下去。

一旦现场长成这样,第一张该看的图不是总命中率,而是 miss 到底集中在谁身上。这一步看清了,后面的 TTL、单飞保护、回填窗口,才有地方落。