缓存的收益人人会说——快,但真正的第一课是:缓存失效的那一瞬间,系统会不会被打垮。本文先讲清缓存在存储体系中的位置,再逐一拆解穿透、击穿、雪崩三大经典故障的成因与解法,最后引出缓存与数据库一致性这个更长期的话题。

它解决什么问题

不同存储介质的访问延迟差着好几个数量级,业界常画成一座金字塔:顶端是 CPU 寄存器与 L1/L2/L3 缓存,往下依次是内存、SSD、机械盘,最底端是网络另一端的数据库。CPU 访问内存约百纳秒级,访问一次远端 MySQL 则是毫秒级,中间隔着四到五个数量级。数据库还要把有限的磁盘 I/O 与连接数留给事务和写入,面对每秒几万次的商品详情查询,硬扛数据库等于把最贵的资源花在重复的读上。

缓存的思路是在离请求更近、介质更快的地方放一份副本,用相对便宜的内存挡掉绝大多数读流量。这本质上是”空间换时间”——索引、CDN、操作系统的页缓存,都是同一思想在不同层级的落地。一台普通配置的 Redis 单机就能支撑十万级 QPS 的读,延迟稳定在亚毫秒,既降低了响应时间,也把数据库从读流量洪峰下解放出来。代价随之而来:数据现在有了两份,副本会过期、会缺失、会与主库不一致。三大经典问题,全部诞生于”缓存里没有,或者缓存不可信”的时刻。

核心原理

读路径的骨架只有四步:先查缓存,命中直接返回;未命中则查数据库,把结果写回缓存,再返回。所有事故都发生在”未命中”这个分支上。按未命中的成因,可以把故障分成三类:

  • 穿透:查询的数据在数据库里压根不存在,缓存永远无法被回填,每一次请求都穿透到数据库。多为恶意攻击或非法参数。
  • 击穿:某一个被高并发访问的热点 key 恰好在某一刻过期,请求在同一瞬间集体 miss,全部涌向数据库去重建这份缓存。
  • 雪崩:大量 key 在同一时间段集中过期,或者缓存服务本身宕机,miss 从单点扩散成面,数据库被全量读流量淹没。

三者的共性是”缓存层失去了挡流量的能力”,区别在于影响范围:穿透针对不存在的 key,击穿针对单个热点,雪崩是大面积失效。理解这个分类,后面的方案选型就水到渠成。

深入机制

穿透:拦截不存在的查询

布隆过滤器是最常用的前置防线。它用一个位数组加多个哈希函数记录”哪些 key 可能存在”:把数据库中全部合法 key 预先布隆化,请求来了先过过滤器,被判定不存在的直接拒绝,根本到不了缓存和数据库。它空间极省、查询 O(k),但有两个必须知道的特性:一是存在误判率,会把少量不存在的 key 误判为存在(正好被缓存兜底,无害);二是标准结构不支持删除,数据集合变化大时要定期重建,或改用布谷鸟过滤器。

更轻量的做法是空值缓存:数据库查不到时,仍然把一个空标记(如 NULL 占位符)写进缓存,配上很短的 TTL(几十秒到几分钟)。同一个非法 key 的后续请求直接命中空标记返回。代价是短时会占用一些内存存放无效 key,且攻击者如果每次换随机 key,空值缓存形同虚设——所以实战中常与布隆过滤器叠加,入口再做一层参数合法性校验,三层各挡一部分。

击穿:热点 key 失效瞬间的并发重建

假设秒杀商品的缓存 key 在零点过期,零点零分一万个请求同时 miss。如果放任不管,一万个线程会各自查库、各自回填,数据库在同一行上承受一万次重复读。互斥重建的思路是只放一个线程进去干活:

请求线程:
1. 查缓存 miss
2. 尝试获取该 key 的互斥锁(SET key lock NX EX 10)
3. 拿到锁 -> 查库 -> 回填缓存 -> 释放锁
4. 没拿到锁 -> 短暂休眠后重试查缓存(此时多半已被回填)

锁必须带超时,防止持锁线程宕机造成死锁。另一个思路是逻辑过期:不给 key 设物理 TTL,把过期时间塞进 value 里。线程读到已过逻辑期的数据时,先尝试抢锁,抢到就开异步线程去重建,本次直接返回旧值;没抢到也直接返回旧值。它不阻塞用户请求、彻底避免惊群,代价是在重建完成前大家读到的是旧数据,且 key 永不物理过期、占用更多内存。选哪个取决于业务能不能接受短暂旧值。

雪崩:从集中过期到服务不可用

最简单的诱因是批量写入缓存时给了相同的 TTL——比如凌晨定时任务预热了一万个 key,全部 24 小时后到期,第二天凌晨集体失效。解法是给每个 key 的 TTL 叠加一个随机抖动,比如 3600 + random(600) 秒,把失效时间点打散到一个区间内,数据库压力曲线随之摊平。

更严重的雪崩是 Redis 本身宕机。这要靠高可用架构兜底:主从加哨兵或 Cluster 保证故障自动切换;事前对核心链路做降级预案,缓存不可用时由限流组件(令牌桶、熔断器)把超出数据库承受能力的请求直接拒绝或返回兜底数据,宁可部分请求体验降级,也不能让数据库被打死后拖垮整个系统。多级缓存也是有效缓冲:本地缓存(Caffeine)挡一层、Redis 挡一层,即使 Redis 整体不可用,本地缓存的短 TTL 数据仍能扛住最热的头部流量。

工程实践

  • 监控先行:盯缓存命中率、Redis 内存与连接数、数据库慢查询。命中率突然下跌往往先于故障爆发,是最有价值的预警信号。
  • 大促前预热:把可预见的热点数据在流量到来前写入缓存,避免冷启动的首次 miss 风暴。
  • TTL 治理:基础 TTL 加随机抖动是默认动作;逻辑过期的 key 要有独立的刷新任务与淘汰策略,防止内存只进不出。
  • 压测验证:上线前模拟热点 key 失效、Redis 节点宕机两类故障,观察数据库 QPS 峰值是否在安全水位内,方案靠压测背书而不是靠推理。
  • 降级预案文档化:缓存故障时哪些接口返回兜底数据、哪些直接限流,提前写清楚并演练,事故时没有人会现场设计。

对比与权衡

问题 方案 优点 代价与局限
穿透 布隆过滤器 空间省、查询快,入口拦截 有误判率;不支持删除,需定期重建
穿透 空值缓存 实现零成本 占内存;随机 key 攻击下失效;短 TTL 仍有一致性窗口
击穿 互斥重建 保证只有一个重建者,数据新 未抢到锁的线程自旋等待,延迟抖动
击穿 逻辑过期 无阻塞、无惊群 重建完成前返回旧值;key 常驻内存
雪崩 TTL 随机抖动 改动极小,立竿见影 只解决集中过期,不解决服务宕机
雪崩 高可用 + 熔断降级 能扛住 Redis 整体故障 架构复杂度高;降级意味着部分请求有损
雪崩 多级缓存 极致的头部流量承载 本地缓存间的数据一致性难保证,只适合低频变更数据

没有银弹:读多写少且容忍短暂旧值的业务用逻辑过期体验最好;要求读到的尽量新就用互斥重建;两者经常按 key 的热度分级混用。

面试视角

穿透、击穿、雪崩的区别是什么? 三者的共同点都是缓存 miss 导致流量打到数据库,区别在于 miss 的成因与范围:穿透是查询根本不存在的数据,缓存永远填不上;击穿是单个热点 key 过期引发的并发重建;雪崩是大面积 key 同时失效或缓存服务宕机。答出”成因不同所以解法不同”是加分点。

布隆过滤器为什么会误判,能删元素吗? 多个 key 的哈希位可能恰好覆盖另一个不存在 key 的全部位,所以只能说”不存在一定准确,存在可能误判”。标准结构只置位不清零,删除一个元素会误伤共享哈希位的其他元素,因此数据频繁变动的场景要定期重建,或换支持删除的布谷鸟过滤器。

热点 key 失效,怎么保证只有一个线程去查库? 用带超时的互斥锁(如 SET NX EX)圈出重建临界区,抢到锁的线程查库回填,其余线程短暂等待后重试缓存。锁必须设过期时间防止持锁者宕机死锁。如果业务能接受旧值,逻辑过期加异步重建是更平滑的选择。

一万个 key 同一时刻过期怎么防? 写入时给基础 TTL 叠加随机抖动,把失效时刻摊到一个时间窗里,数据库压力峰值随之削平。同时反问一句”是否预热任务统一 TTL 导致的”,体现定位问题的思路,比单背方案更得分。

Redis 整个挂了怎么办? 事前靠主从哨兵或 Cluster 自动切换,靠多级缓存削顶;事中靠限流与熔断把流量压到数据库能承受的水位,非核心接口返回兜底数据;事后靠预热快速重建。核心思想是缓存故障要演练,而不是祈祷它不发生。

小结

  • 缓存的价值是用内存挡掉重复读流量,代价是数据有了两份,三大经典问题全部源于”缓存失去挡流量的能力”。
  • 穿透用布隆过滤器加空值缓存分层拦截;击穿在互斥重建与逻辑过期之间按一致性要求选型;雪崩用 TTL 抖动防集中过期,用高可用与熔断防服务宕机。
  • 方案没有绝对优劣,判断标准是业务对旧值的容忍度、热点集中度与架构预算。
  • 三大问题解决的是”缓存里没有”;更难的下集是”缓存里的是错的”——缓存与数据库的读写顺序、双写一致性,需要单独一篇深入展开。