缓存几乎是后端系统里性价比最高的一个组件:加一层 Redis,数据库压力常常能降一个数量级。但它同时也是最容易把单点问题放大成系统性故障的地方——缓存失效的那一刻,压力会原封不动地回到数据库,而数据库通常并没有为这种瞬时冲击做好准备。
社区里关于缓存穿透、击穿、雪崩的讨论很多,但一个反复出现的现象是:这三个词被当成同一类问题,方案被混在一起使用——所有 key 加随机 TTL、所有查询加互斥锁、所有接口套布隆过滤器。结果是延迟上升、代码复杂度上升,故障该出现时仍然出现。这篇文章想回答一个具体问题:这三种异常是不是同一类问题?如果不是,处置顺序应该怎么排?
信息边界说明:本次没有提供具体站内讨论样本,下文分析基于通用工程机制,不引用具体帖子、数据或案例。
先分清故障类型,再谈方案
核心判断:穿透、击穿、雪崩不是同一类问题的三个变种,而是三个不同层面上的故障,混用方案会让延迟和复杂度同时上升,收益却不叠加。
三种异常的名字很像,但触发条件其实指向三个不同的层面。穿透是“请求合法,数据不存在”,攻击者或爬虫用随机 ID 反复查询一个库里根本没有的记录,缓存永远不会命中,每次都打到数据库。击穿是“数据存在,但热点 key 在失效的瞬间被大量并发同时打到数据库”。雪崩是“大量 key 在同一时间窗口集中失效”,或者缓存集群整体不可用。
这三者的差别不只是措辞,而是责任层不同。穿透属于输入校验和数据建模问题,它关心的是“这个 ID 有没有可能存在”;击穿属于并发控制问题,它关心的是“同一个 key 的重建能不能收敛成一次”;雪崩属于容量与时间分布问题,它关心的是“失效能不能被摊平、数据库有没有兜底”。用错层面的方案,效果往往接近于零。
| 异常 | 触发条件 | 数据库压力形态 | 主要责任层 | 典型误配 |
|---|---|---|---|---|
| 穿透 | 查询不存在的数据 | 低价值查询持续打满 | 参数校验、数据建模 | 空值缓存不设容量上限和独立短 TTL |
| 击穿 | 单个热点 key 失效 | 短时高并发集中于一行 | 并发控制、逻辑过期 | 给热点 key 加随机 TTL,反而增加重建次数 |
| 雪崩 | 大量 key 同时失效 | 全表级压力、连接数打满 | 容量规划、多级缓存 | 只靠随机 TTL,没有降级和限流 |
一个容易被忽略的细节是:空值缓存本身是穿透方案的一部分,但它需要独立的短 TTL 和容量上限。如果空值 key 与正常 key 混用同一套 TTL 策略,一次爬虫扫描就可能把 Redis 内存写满,反而制造出一次雪崩。
机制:为什么“加个随机 TTL”解决不了全部
先看一个基础的量化关系。设总请求量为 ,缓存命中率为 ,则落到数据库的请求量近似为:
命中率 99% 时,数据库只承担 1% 的流量,看起来很安全。但这个公式的前提是“命中率对每个 key 都成立”。击穿打破的正是这个前提:热点 key 失效的瞬间,该 key 的命中率归零,数据库承受的是这个 key 的全部并发,而不是全站平均值的 1%。这就是为什么平均值看起来很健康的系统,会在某个商品详情页过期时突然抖动。
穿透打破的是另一个前提——“未命中意味着数据存在,只是还没被缓存”。当查询的 ID 在业务上根本不可能存在时,回源查库的结果永远是空,缓存也永远不会被填充,于是每一次请求都是有效穿透。随机 TTL 在这类请求上完全无效,因为它根本没有可失效的缓存条目。
流程图
渲染中...
雪崩则是第三种机制:它不是单点问题,而是时间相关性问题。如果缓存是在某个整点批量预热、TTL 统一设为 30 分钟,那么 30 分钟后它们会一起失效。随机 TTL 在这个场景里是有效的,因为它打散了失效时间点;但它对击穿的作用恰恰相反——给一个热点 key 反复刷新随机 TTL,只会让它在一天内失效更多次。
取舍:多级缓存带来的新问题
本地缓存是近几年讨论度明显上升的一环。Caffeine 这类进程内缓存的优势很直接:没有网络往返,单次读取可以做到微秒级,而且天然不受 Redis 集群抖动影响。在极热点场景下,把最上面那几十个 key 放进本地缓存,能显著削掉 Redis 的带宽和连接压力。
但代价是一致性窗口从“Redis 与 DB 之间”变成了“多个实例与 DB 之间”。假设有 20 个实例,每个实例的本地缓存 TTL 是 5 分钟,那么一次数据更新后,最坏情况下会有 5 分钟的不一致窗口,而且不同实例返回的结果可能不同。对于商品标题这类弱一致字段可以接受,对于库存、余额这类字段则完全不可接受。更麻烦的是,雪崩的形态也变了:本地缓存失效不再是“Redis 挂了”,而是“20 个实例的本地缓存同时过期,一起回源 Redis”,压力会以实例数倍数放大。
| 方案 | 优点 | 代价 | 适用场景 |
|---|---|---|---|
| 仅 Redis | 实现简单,一致性窗口小 | 每次读都有网络开销 | 大多数业务读场景 |
| Caffeine + Redis | 极低延迟,抗 Redis 抖动 | 多实例不一致窗口,失效放大 | 读多写少、可容忍短暂不一致 |
| 先更新 DB 再删缓存 | 逻辑简单,覆盖多数场景 | 并发下仍有短暂脏读 | 常规业务默认选择 |
| 延迟双删 | 缓解并发脏读 | 延迟时间难定,代码侵入 | 写少读多、一致性要求高 |
| 订阅 binlog 失效 | 解耦、可靠 | 引入额外组件与运维成本 | 缓存量大、多语言消费方 |
这里有一个常被高估的选择:很多团队一上来就考虑 binlog 订阅,实际上“先更新数据库、再删除缓存、配合 TTL 兜底”已经能覆盖绝大多数业务。binlog 方案真正的价值不在于一致性更强,而在于把失效逻辑从业务代码里抽出来,避免十几个服务各自实现一遍删缓存。是否值得上,取决于服务数量和缓存 key 的归属复杂度,而不是取决于一致性理论。
实践:按优先级落地的检查清单
落地时最容易犯的错,是同时上所有方案。更稳妥的顺序是:先量化,再分层,最后才加复杂度。
- 先确认问题类型:统计未命中 key 的分布,如果集中在大量不重复的 ID 上,是穿透;如果集中在少数几个 key 上,是击穿;如果集中在某个时间点,是雪崩。
- 给所有缓存设置 TTL 上限,包括空值缓存,并为空值 key 单独设置容量和更短的过期时间。
- 热点 key 用逻辑过期代替物理过期,让重建在后台异步完成,读请求继续返回旧值。
- 对回源路径加限流和熔断,用 Sentinel 或 Resilience4j 把数据库保护起来,这比任何缓存技巧都更兜底。
- 多级缓存只给明确的热点 key 开启,不要全量复制,并接受“本地缓存 TTL 就是不一致窗口”这个事实。
- 用压测验证,而不是靠推理:用 JMeter 或 Locust 模拟缓存整体失效,观察数据库连接数和慢查询是否被打穿。
需要说明不适用的场景:如果业务本身是写多读少,或者单次查询已经足够便宜(比如主键查询加上良好的索引),那么引入复杂缓存策略的收益可能还不如优化一条慢 SQL。缓存的复杂度应该由实测的数据库压力来驱动,而不是由架构图的美观程度驱动。
总结与讨论
三句话概括:穿透是数据建模问题,击穿是并发收敛问题,雪崩是容量与时间分布问题,三者的方案不能互相替代;随机 TTL 只对雪崩有效,对击穿甚至可能有害;多级缓存提升的是延迟,付出的是多实例一致性窗口,不是免费的。
下一步最值得做的一件事,不是加新组件,而是把“缓存全部失效时数据库会怎样”当成一个必须演练的故障场景,先测出真实的承受边界,再决定要不要引入互斥锁、逻辑过期或本地缓存。
留一个开放问题:在你们系统里,热点 key 的重建是由读请求同步触发,还是由后台异步触发?如果是前者,你们是怎么避免“重建期间所有请求排队等同一把锁”变成新的瓶颈的?