缓存问题与一致性
穿透 / 击穿 / 雪崩
三个名字容易混,用一句话区分:穿透是查不存在的数据(缓存永远不会有);击穿是热点 key 过期的瞬间(缓存暂时没有);雪崩是大批 key 同时失效或 Redis 挂了(缓存大面积没有)。
缓存穿透
现象:请求查一个数据库里也不存在的 id,缓存永远不命中,每次都打到数据库。被恶意构造(遍历不存在的 id)就是一种攻击。
解法:
- 缓存空值 —— 查不到也写一个空标记进缓存,TTL 设短(1~5 分钟)。最简单有效,代价是占内存,且存在短窗口的数据不一致(数据刚被创建但缓存还是空标记)
- 布隆过滤器 —— 在缓存之前加一层,把所有存在的 id 预先放进去。查询先过布隆过滤器,判定不存在就直接返回。
- 特性:说不存在一定不存在,说存在可能不存在(有假阳性,无假阴性)
- 缺点:标准布隆过滤器不支持删除(删一个元素会影响其他元素的位),数据删除场景要用 Counting Bloom Filter 或定期重建
- 参数校验 —— id 格式明显非法的直接拒绝,成本最低的一道闸
缓存击穿
现象:某个热点 key 过期的瞬间,大量并发请求同时发现缓存没有,一起去查数据库并回写缓存。数据库瞬时压力飙升。
解法:
- 互斥锁 / 单飞 —— 只让第一个请求去查库,其余请求等待或返回旧值。Go 里用
singleflight,Java 里用分布式锁 - 逻辑过期 —— 缓存里存
{data, expireAt}且物理上永不过期。读到逻辑过期时,起一个异步任务刷新,当前请求先返回旧数据。保证高可用,代价是短时间读到旧值 - 热点 key 预热 / 永不过期 —— 定时任务主动刷新,不依赖请求触发
缓存雪崩
现象:两种触发方式 —— 大批 key 在同一时刻过期;或 Redis 实例整体宕机。结果都是流量全量打到数据库,数据库被打挂,进而整个系统不可用。
解法:
| 针对 | 手段 |
|---|---|
| 集中过期 | TTL 加随机抖动(基础 TTL + rand(0, 300s)) |
| 实例宕机 | 主从 + 哨兵 / Cluster 保证高可用 |
| 兜底 | 服务端限流 + 熔断降级(Sentinel / Hystrix),保证数据库不被压垮 |
| 兜底 | 多级缓存:本地缓存(Caffeine)+ Redis,Redis 挂了本地还能撑一会 |
| 恢复 | 重启后先预热再放流量,否则刚起来又被打挂 |
双写一致性
缓存和数据库是两个系统,无法用一个事务保证原子更新。核心问题是更新顺序和失败处理。
四种方案的分析
| 方案 | 问题 |
|---|---|
| 先更新缓存,再更新数据库 | 缓存更新成功、数据库失败 → 脏数据且难回滚 |
| 先更新数据库,再更新缓存 | 并发下两个写请求的缓存更新顺序可能和数据库顺序相反;且如果缓存值要计算,浪费资源 |
| 先删缓存,再更新数据库 | ⚠️ 删完缓存、数据库还没更新时,读请求把旧值加载进缓存 → 长期脏数据 |
| 先更新数据库,再删缓存 | ✅ 推荐(Cache-Aside)。仍有极小概率不一致 |
为什么推荐「先更新数据库再删缓存」:它出问题需要一个很苛刻的时序 —— 读请求发现缓存失效、查到旧值、但在写回缓存之前被写请求插入(更新数据库 + 删缓存完成),之后读请求才把旧值写回。而「读库 + 写缓存」通常比「写库 + 删缓存」快得多,这个窗口极难命中。
为什么是「删」而不是「更新」:删除是惰性加载,只有真正被读时才重建,避免为不常访问的数据做无用计算;且规避了并发更新的顺序问题。
更强的保证
延迟双删 —— 更新数据库后删一次缓存,再延迟几百毫秒删第二次,覆盖「旧值被写回」的窗口。缺点是延迟时间难确定,且这段时间内读到旧值。
消息队列重试 —— 删缓存失败时把 key 投进 MQ,消费者重试直到成功。解决的是删除失败而非时序问题。
订阅 binlog(推荐) —— 用 Canal / Debezium 订阅 MySQL binlog,数据库变更后自动去删缓存。优点是业务代码完全不用管缓存,解耦彻底,且 binlog 是数据库变更的唯一真相源,不会漏。缺点是引入新组件,有秒级延迟。
读写都加分布式锁 —— 能做到强一致,但并发性能退化严重,只在金额等关键字段上用。
:::tip 答题框架 问「怎么保证缓存和数据库一致」,最好先反问 / 明确业务能接受多少不一致:
- 能接受秒级不一致(绝大多数业务)→ Cache-Aside + 合理 TTL,靠 TTL 兜底最终一致
- 要求准实时 → 订阅 binlog 删缓存
- 要求强一致 → 不要用缓存,或读写都走分布式锁
直接背方案不如先讲清这个取舍。 :::
主从延迟下的读缓存
一个容易被忽略的叠加问题:如果「更新数据库 → 删缓存」后,读请求查的是从库,而主从还没同步完,那么写回缓存的就是旧值,且会一直错到 TTL 到期。
解法:缓存重建时强制读主库,或延迟双删的间隔覆盖主从延迟。