2026年7月13日 · 6 分钟阅读
缓存雪崩、穿透、击穿与数据库一致性:从故障到治理
区分缓存雪崩、穿透和热点击穿,系统梳理 TTL 抖动、布隆过滤器、互斥重建、限流降级,以及 Cache-Aside 与数据库一致性的工程方案。
缓存把数据库从高并发读取中保护起来,也可能在失效时把流量一次性还给数据库。雪崩、穿透和击穿表面上都是“请求落到数据库”,根因和治理方式却不同。
三类问题先分清
| 问题 | 根因 | 流量形态 | 典型治理 |
|---|---|---|---|
| 缓存雪崩 | 大量 key 同时失效或 Redis 整体不可用 | 大面积回源 | TTL 抖动、高可用、多级缓存、限流降级、预热 |
| 缓存穿透 | 查询本来就不存在的数据 | 不同无效 key 持续回源 | 参数校验、空值缓存、Bloom Filter、风控限流 |
| 缓存击穿 | 单个超热点 key 失效 | 同一 key 瞬时并发回源 | single-flight、互斥重建、逻辑过期、提前刷新 |
缓存雪崩
雪崩有两种常见来源:批量写入的 key 使用相同 TTL,在同一时刻过期;或者 Redis 节点、网络或集群整体异常,所有请求突然未命中。
治理要分层:
- TTL 加随机抖动,把集中失效摊开。
- 活动前预热热点,但控制预热速率,避免预热本身压垮数据库。
- 使用 Redis 高可用和合理的持久化恢复策略。
- 对极少量稳定热点使用短时本地缓存或陈旧数据兜底。
- 对数据库入口限流、熔断和降级,阻止故障级联。
- 冷启动时逐步放量,不让所有实例同时回源。
高可用不能替代限流。故障转移也有时间窗口,数据库必须能拒绝超过安全容量的请求。
缓存穿透
攻击者不断查询不存在的用户 ID,每次 Redis 和数据库都查不到。如果无效 key 不重复,简单空值缓存无法完全解决。
按成本从低到高治理:
- 在入口校验 ID 范围、格式、权限和签名。
- 对确实不存在的数据缓存短 TTL 空值,并区分“空值”与缓存未命中。
- 使用 Bloom Filter 判断 key 是否可能存在。
- 对异常来源做限流、验证码或风控封禁。
Bloom Filter 的“确定不存在”可以阻止回源,但“可能存在”仍需查询;它存在假阳性,并要处理新增、删除和重建。不能把它描述成绝对准确的数据库索引。
缓存击穿
爆款商品 key 过期的瞬间,几千个线程同时查库并重建。解决思路是让同一 key 同一时刻只有一个回源任务:
请求未命中
→ 尝试获得短租约重建权
→ 获得者查库并写缓存
→ 其他请求短暂等待、返回旧值或降级结果
分布式互斥锁需要唯一持有者标识、原子校验删除和超时;数据库慢于锁租约时仍可能出现多个重建者。缓存重建通常更适合 single-flight 或带时间预算的短锁,不应把它包装成强一致分布式锁。
对允许短暂陈旧的数据,可以采用逻辑过期:value 中记录业务过期时间,过期后先返回旧值,再由一个线程异步刷新。它牺牲新鲜度换取稳定尾延迟。
Cache-Aside 的一致性窗口
常见更新顺序是“先更新数据库,再删除缓存”:
@Transactional
public void updateProduct(Product product) {
productRepository.update(product);
// 实际工程中应在事务提交后执行删除,避免事务回滚却删掉有效缓存
afterCommit(() -> redis.del("cache:product:v2:" + product.id()));
}
为什么不先删缓存再更新数据库?
线程 A 删除缓存
线程 B 未命中,读取数据库旧值
线程 A 更新数据库
线程 B 把旧值写回缓存
先更新数据库再删缓存也不是原子操作:数据库成功而删除失败,会保留旧缓存;或者一个极慢的旧值回源在更新后才写缓存。工程上要用 TTL 限制最长陈旧时间,并通过重试、事件或 CDC 让删除最终成功。
一致性方案对比
| 方案 | 做法 | 优点 | 风险与适用范围 |
|---|---|---|---|
| 更新 DB 后删缓存 | 事务提交后失效 key | 简单、通用 | 删除失败和并发回填形成短窗口 |
| 延迟双删 | 先删,更新后再删,延迟后重删 | 降低旧值回填概率 | 延迟值难准确选择,不能证明强一致 |
| MQ/CDC 失效 | 订阅业务事件或 binlog 删除缓存 | 与业务事务解耦,可重试审计 | 有传播延迟,需要幂等和积压治理 |
| Write-Through | 统一缓存层同步写数据源 | 写路径集中 | 增加延迟和系统耦合 |
| Write-Behind | 缓存异步写数据库 | 写吞吐高 | 数据安全、顺序和恢复复杂 |
关键业务若必须读到最新值,可以在写后短时间绕过缓存、带版本号读取,或直接访问主库。不要用最终一致缓存承担强一致需求。
CDC 失效的工程流程
数据库 binlog 或 Outbox 产生变更事件,消费者根据实体 ID 删除对应 key:
数据库提交 → binlog / outbox → MQ → cache invalidator → DEL key
删除操作天然易于幂等,但仍需监控消息延迟、重试和死信。缓存 key 的映射必须可从变更事件确定;复杂聚合缓存可能依赖多个实体,需要维护反向索引或直接缩短 TTL。
故障保护与恢复
事故中按以下顺序处理:
- 判断是全局失效、无效请求还是单热点 key。
- 限制回源并发,保护数据库连接池和线程池。
- 对非关键功能返回默认值、旧值或静态页。
- 修复 Redis 或热点重建逻辑,分批预热。
- 观察命中率、回源 QPS、数据库延迟和错误率后逐步放量。
- 对账修复不一致数据,复盘 TTL 分布、告警和演练。
工程检查清单
- TTL 是否按业务新鲜度设计并加入抖动。
- 空值是否有独立标记和较短过期时间。
- 热点 key 失效是否采用 single-flight、逻辑过期或提前刷新。
- Redis 不可用时数据库入口是否有明确并发上限。
- 缓存删除失败是否可重试、可观测、可审计。
- 写后必须读新的场景是否绕过最终一致缓存。
- 预热、重放和 CDC 消费是否受速率控制。
面试回答要点
回答时先用“范围、数据是否存在、是否热点”区分雪崩、穿透和击穿,再分别给方案。谈一致性时说明 Cache-Aside 常用“更新数据库后删缓存”,但它仍有并发和删除失败窗口,需要 TTL、重试或 CDC 收敛;如果业务要求强一致,应绕过缓存或采用更强的业务协议。
总结
缓存故障治理的目标不是让 Redis 永不失败,而是让失败不会击穿数据库。TTL 抖动处理集中失效,空值与 Bloom Filter 处理不存在数据,single-flight 和逻辑过期处理热点重建;一致性则通过明确事实来源、删除缓存、最终重试和业务新鲜度边界来控制。