Java

缓存一抖就删 key、扩线程、加重试,通常会把事故推得更大

缓存故障刚开始时,最先要判断的不是名词,而是系统有没有进入回源放大。先看命中率、回源量、线程占用和数据库压力是否连成一条线,再决定保护动作,往往比急着删 key、扩线程、加重试更重要。

  • 缓存
  • Redis
  • 故障排查
  • 稳定性治理
  • 故障复盘
12 分钟阅读

很多缓存事故,真正开始失控,不是在 Redis 已经完全不可用的时候,而是在它只是先抖了一下的时候。

现场通常是这样起的头:

  • 某几个接口 RT 先抬起来
  • Redis timeout 开始增多
  • 命中率掉了几个点
  • 数据库 QPS 还没爆,但已经明显往上拱
  • 应用线程池里的慢请求越来越多

这时候最危险的,往往不是缓存本身,而是现场有人马上做出三类“看起来很积极”的动作:

  • 删 key,想把脏数据或旧数据赶紧清掉
  • 扩线程,觉得多放一点并发就能把堆积顶过去
  • 加重试,想把超时请求捞回来

这三件事的问题不是永远不能做,而是如果系统已经开始进入回源放大,它们很容易把局部抖动推成整条链路的拥塞。

所以缓存一抖,前几分钟最该做的事,不是先给故障命名,也不是先做补救动作,而是先判断一句话:

现在只是缓存层短暂抖动,还是已经开始把请求批量压回下游。

这条判断线拉清楚,后面的动作才不会越救越乱。

先看的是回源有没有开始放大,不是先争这算击穿还是雪崩

我更习惯先把名词放一边,先盯四组直接信号:

  • Redis 自己是不是在抖:RT、timeout、连接错误有没有明显抬头
  • 命中率是不是在持续掉:不是瞬时点,而是几分钟内有没有往下滑
  • 回源量是不是在加速:数据库或下游服务的 QPS、并发、慢查询是不是一起上来
  • 应用层是不是已经被拖住:线程池排队、连接池等待、接口超时是不是同步变坏

如果这四组信号开始连成一条线,基本就可以按“回源放大已开始”来处理了。

这里最容易误判的,是只看到 Redis 指标波动,还把它当成单层故障。

实际上,缓存事故最麻烦的地方正是:

  • Redis 只是先抖一下
  • 命中率掉一点
  • 回源立刻把数据库推高
  • 数据库一慢,线程占用变长
  • 线程占用一长,请求排队和超时变多
  • 超时一多,客户端和服务端重试再把流量加一轮

走到这一步,系统最贵的已经不是“缓存 miss 了一次”,而是一次 miss 后面带出的一整串排队、等待和重试。

所以第一轮判断不要问“这更像哪种缓存故障”,而要问“每一次 miss 现在会不会把下游再拖慢一截”。

为什么这时候不能先删 key

缓存抖动时,删 key 是一种很常见的冲动。

做这件事的人通常在想两件事之一:

  • 反正这批 key 可能已经不稳定,不如删掉重新回填
  • 某个热点数据可能坏了,删了就能让它重新刷出来

但如果现场已经进入回源放大,删 key 往往等于主动扩大 miss 面积。

原来只是部分 key 因为超时、失效或抖动没命中;你一删,更多原本还能命中的请求也会一起回源。

结果通常不是“更快恢复”,而是:

  • 数据库瞬时压力再被推高一段
  • 热点对象同时重建,放大并发竞争
  • 应用里等待缓存回填的请求一起堆住
  • Redis 自己恢复后,还要承接一波更集中的回填写入

所以在缓存刚抖、回源已经变多的时候,删 key 不是修复动作,常常是放大动作

除非你已经明确定位到极小范围的异常 key,且确认回填链路扛得住,否则不该在事故前段把删除当成默认操作。

为什么这时候不能先扩线程

另一个很常见的现场动作,是看到线程池排队,就想把线程数调大。

这件事的直觉也不难理解:

  • 既然请求在等,那就多放一点工作线程
  • 让更多请求同时跑,也许堆积就下去了

问题在于,缓存故障带来的慢,不是 CPU 型慢,往往是等待型慢:

  • 等 Redis
  • 等数据库
  • 等下游 RPC
  • 等连接池

这时线程开得更大,通常不是把系统“做得更快”,而是把更多请求同时送去等待同一批已经变慢的资源。

于是你看到的往往不是恢复,而是:

  • 数据库并发更高
  • 连接池更快打满
  • 上下游超时更多
  • 应用内存占用和上下文切换更重

表面上是线程更多了,实际上是把阻塞请求摊得更广,最后把整条链拖得更紧。

所以当缓存抖动已经引发回源时,线程池排队通常不是“线程不够”的问题,而是“下游已经开始扛不住”的结果。先扩线程,常常是在掩盖判断,而不是解决问题。

为什么这时候不能先加重试

缓存相关故障里,重试特别容易让现场失真。

因为一开始看上去它确实像能救回一部分请求:

  • Redis timeout 了一次,第二次也许成功
  • 数据库偶尔慢一下,再试一次似乎能过
  • 某个下游瞬时抖动,再补一枪可能就好了

但只要问题已经不是单点偶发,而是回源放大在形成,重试基本就是给拥塞再叠一层流量。

原来一万个请求里有一部分 miss;加了重试之后,系统看到的可能就不是一万个,而是一万二、一万五,甚至更多次真实访问。

更麻烦的是,重试会把事故的观察面搅乱:

  • 成功率看起来没有立刻塌
  • 但 RT 被拉长
  • 线程占用更久
  • 下游实际承受的请求数被抬高

最后现场容易出现一种错觉:

“怎么指标看起来只是慢一点,资源却突然全紧了?”

因为多出来的不是业务流量,而是故障里自己制造出来的补偿流量。

所以缓存抖动后,如果已经看到回源、排队和超时连在一起,优先级通常应该是收紧重试,而不是再把 retry 打大。

现场前几分钟,更实际的处理顺序是什么

如果要把第一轮动作压成一条顺序,我会更建议这样处理。

1. 先确认有没有进入回源放大

把 Redis、命中率、数据库 / 下游 QPS、线程池等待放到同一时间窗里看。

如果只是 Redis RT 短时抖了一下,但命中率没持续掉、数据库也没明显抬头,那还可以按局部抖动观察。

如果几条线已经一起变坏,就别再按“单层缓存问题”处理了,直接按回源链路保护来做。

2. 先保护下游,不要先追命中率

这一步最重要的一句话是:

第一目标不是立刻把缓存命中率拉回原样,而是别让数据库、下游服务和线程池先被打穿。

所以更优先的,通常是:

  • 压无效流量
  • 收紧重试
  • 必要时限流或降级
  • 控制新的回源扩散面

这时候如果只想着“赶紧把缓存补满”,很容易把恢复动作本身做成新的冲击。

3. 先把不能抢跑的动作按住

在事故前段,下面这几类动作最好明确成“先别动”:

  • 批量删 key
  • 直接把线程池或连接池开大
  • 临时加重试次数或放宽超时
  • 大规模预热或大面积回填

原因不是这些动作永远不能做,而是回源已经起来时,谁先抢跑,谁就可能把下游再往前推一截。

更实际的顺序通常是:先看回源是不是还在抬、数据库和线程池是不是还在吃紧,再决定能不能小范围回填、能不能逐步撤保护,而不是先把“恢复动作”打出去再看结果。

4. 恢复也按链路恢复,不按单点恢复

缓存事故常见的错觉是:Redis 指标先好一点,大家就觉得可以撤保护了。

但真实情况往往是:

  • Redis 先稳住
  • 命中率慢慢回来
  • 数据库和线程池里还压着一批旧请求
  • 前面产生的重试和回填流量还在往后走

所以恢复阶段也要继续盯同一条线:

  • 回源量是不是回落了
  • 线程和连接池等待是不是出清了
  • 下游是否脱离高压区
  • 保护动作能不能按顺序慢慢撤

如果只看缓存层先恢复,就急着放开流量,系统很容易再被压回去一次。

最后压成一句更实在的话

缓存抖动后的前几分钟,决定事故会不会被放大。

这时候最该先判断的,不是故障名词,而是系统有没有开始批量回源;最该先避免的,也不是“动作太少”,而是删 key、扩线程、加重试这些会把回源继续推高的动作。

先把动作顺序守住,再谈后面的配合和恢复,现场通常会稳得多。