那天早上 9 点 05 分,商品详情接口的 5xx 率从 0.02% 直接冲到 34%,数据库 CPU 打满,QPS 从平时的 3000 涨到 4 万多。我在地铁上,用手机看着监控图从绿变红,那种感觉挺难忘的。
事后复盘,这次事故其实是「击穿」和「雪崩」同时发生。趁着记忆还新,把这三个词的区别和我们的应对方案都写下来。
三个词的区别
这三个概念经常被混着说,但根因完全不同:
- 穿透:查一个根本不存在的数据。缓存里没有,数据库里也没有,所以永远无法缓存,每次请求都打到数据库。
- 击穿:某一个热点 key 过期的瞬间,大量并发请求同时发现缓存没了,一起去查数据库、一起去重建缓存。
- 雪崩:大批 key 在同一时刻集体失效,或者缓存服务本身挂了,所有流量瞬间转移到数据库。
我们那天到底发生了什么
根因有点尴尬。我们有个凌晨 3 点跑的缓存预热任务,把当天的热门商品全量刷进 Redis,TTL 统一设成 24 小时。
你可能已经看出来了:所有 key 的过期时间精确对齐在第二天凌晨 3 点。
但事故为什么发生在早上 9 点?因为预热任务本身要跑六个小时,从 3 点跑到 9 点,最后一批(也是访问量最大的那批热门商品)大约在 9 点写入。于是第二天早上 9 点整,几万个最热的 key 同时失效——而 9 点正好是流量高峰的起点。
雪崩之后,每个失效的热 key 又各自遭遇击穿:单个爆款商品的详情页每秒有 800 多次请求,缓存一空,这 800 个请求全部涌向数据库执行同一条 SQL。
当时的止血
9:12 我远程连上去,做了三件事:
- 把详情接口的降级开关打开,返回一个精简版的静态兜底数据。这个开关是半年前做的,第一次派上用场。
- 手工触发预热脚本的一个子集,先把访问量 top 500 的商品灌回缓存。
- 数据库层面临时把慢查询的超时从 30 秒降到 3 秒,避免连接被长时间占用。
9:31 恢复正常。整个过程 26 分钟。
之后做的改造
1. TTL 加随机抖动。 这是最简单也最有效的一步。原来是 SETEX key 86400 value,改成基础 TTL 加上一个随机偏移:
ttl = 86400 + random.randint(-3600, 3600)两小时的窗口内均匀分布,几万个 key 的失效被摊平了。就这一行代码,解决了雪崩问题的 90%。
2. 缓存重建加互斥锁。 针对击穿。缓存未命中时,先用 SET key_lock 1 NX EX 10 抢锁,抢到的那个请求去查数据库并回填,没抢到的等 50 毫秒后重试读缓存。这样 800 个并发请求最终只有 1 个打到数据库。
这里有个细节坑:锁 TTL 设短了会重复重建,设长了万一持锁进程挂了别人要干等。我们设 10 秒,同时给等待方加了最多重试 3 次的上限,超过就直接查库。
3. 逻辑过期。 对最核心的几百个 key,我们干脆不设物理 TTL,而是在 value 里存一个 expire_at 字段。读到过期数据时,先把旧数据返回给用户(保证不阻塞),同时异步触发一次刷新。用户拿到的可能是几秒前的旧数据,但绝不会等待,也绝不会击穿。
4. 空值缓存。 针对穿透。查不到的时候也往缓存里写一个空标记,TTL 设短一点(我们用 60 秒)。要注意的是空值和「值本身是空字符串」得能区分开,我们用了一个特殊的哨兵字符串。
布隆过滤器我们评估过,但没上。我们的商品 id 是连续自增的,参数校验阶段做个范围判断就能挡掉绝大多数恶意请求,成本低得多。不要为了用某个技术而用它。
留下的两条经验
第一,任何批量写入缓存的地方,都要检查 TTL 是不是同一个值。这个模式在预热、批量导入、定时刷新里反复出现,我后来在代码检查里加了关键词扫描。
第二,降级开关要平时就做好,并且定期演练。我们那个开关能救场,纯属半年前的自己给现在的自己留了条后路。现在我们每季度做一次演练,主动在低峰期打开降级看效果——第二次演练就发现开关本身有个 bug,兜底数据的字段少了一个,前端会白屏。