跳到主要内容

缓存问题与一致性

穿透 / 击穿 / 雪崩​

三个名字容易混,用一句话区分:穿透是查不存在的数据(缓存永远不会有);击穿是热点 key 过期的瞬间(缓存暂时没有);雪崩是大批 key 同时失效或 Redis 挂了(缓存大面积没有)。

缓存穿透​

现象:请求查一个数据库里也不存在的 id,缓存永远不命中,每次都打到数据库。被恶意构造(遍历不存在的 id)就是一种攻击。

解法:

  1. 缓存空值 —— 查不到也写一个空标记进缓存,TTL 设短(1~5 分钟)。最简单有效,代价是占内存,且存在短窗口的数据不一致(数据刚被创建但缓存还是空标记)
  2. 布隆过滤器 —— 在缓存之前加一层,把所有存在的 id 预先放进去。查询先过布隆过滤器,判定不存在就直接返回。
    • 特性:说不存在一定不存在,说存在可能不存在(有假阳性,无假阴性)
    • 缺点:标准布隆过滤器不支持删除(删一个元素会影响其他元素的位),数据删除场景要用 Counting Bloom Filter 或定期重建
  3. 参数校验 —— id 格式明显非法的直接拒绝,成本最低的一道闸

缓存击穿​

现象:某个热点 key 过期的瞬间,大量并发请求同时发现缓存没有,一起去查数据库并回写缓存。数据库瞬时压力飙升。

解法:

  1. 互斥锁 / 单飞 —— 只让第一个请求去查库,其余请求等待或返回旧值。Go 里用 singleflight,Java 里用分布式锁
  2. 逻辑过期 —— 缓存里存 {data, expireAt} 且物理上永不过期。读到逻辑过期时,起一个异步任务刷新,当前请求先返回旧数据。保证高可用,代价是短时间读到旧值
  3. 热点 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 到期。

解法:缓存重建时强制读主库,或延迟双删的间隔覆盖主从延迟。