分布式系统中的缓存击穿是什么?如何解决?
简化版
缓存击穿指一个本来存在的热点 Key 失效后,大量并发同时回源,瞬间把压力转移到数据库。常用方案是按 Key 合并重建:一个请求加载,其他请求限时等待、返回允许范围内的旧值或降级。超热点可用逻辑过期配合后台刷新,但必须限定旧值时长并保护刷新线程池。
详细版
缓存击穿的重点是“单个热点 key”。它和雪崩、穿透不同:
| 问题 | 数据是否存在 | 触发点 | 主要方案 |
|---|---|---|---|
| 击穿 | 存在 | 热点 key 过期 | 互斥锁、逻辑过期、热点续期 |
| 雪崩 | 存在 | 大量 key 同时失效 | TTL 随机、高可用、限流降级 |
| 穿透 | 不存在 | 查询不存在数据 | 缓存空值、布隆过滤器 |
同步重建时,拿锁后必须再次检查缓存,避免前一个持锁者已经回填而当前请求重复查库;等待方要有截止时间和抖动,不能排成长队。锁使用唯一 token,并以原子“比较 token 后删除”释放;租约还要覆盖最坏重建时间,或支持受控续租,否则锁先过期会出现第二个重建者。
高并发核心热点更适合逻辑过期:缓存保留数据和 expireAt,过期后只有一个刷新任务回源,其余请求在业务允许的最大陈旧时间内读旧值。刷新队列满、数据库超时或旧值超过上限时,必须快速降级或失败,不能无限返回旧数据。
完整版教学
一、击穿的现场是什么样
假设首页有一个爆款商品,QPS 很高。这个商品详情缓存过期的一瞬间,几千个请求同时进来,全部发现 Redis 没有值。如果每个请求都去查数据库并回填缓存,数据库会同时承担几千次相同查询,压力瞬间飙升。
这就是击穿:缓存层对热点 key 的保护突然消失,热点流量直接穿过缓存打到 DB。它的本质不是缓存未命中,而是“热点 + 并发重建”。
二、互斥锁方案怎么做
互斥锁方案的核心是只允许一个请求重建缓存。流程是:请求发现缓存未命中,尝试用 Redis SET lockKey token NX PX leaseMs 抢锁;抢到锁的请求查数据库、写缓存、释放锁;没抢到锁的请求等待几十毫秒后再读缓存,或者直接返回兜底。
这个方案实现简单,但细节决定它是否真的能挡住回源:
- 抢锁成功后再读一次缓存,命中就直接返回;
- 锁必须带唯一 token 和租约,释放时用 Lua 原子校验 token;
- 租约覆盖数据库 P99、序列化和写缓存时间,长任务可续租;
- 等待方按 20~50ms 抖动重读,并受请求 deadline 约束;
- 数据库返回不存在时写短 TTL 空值,避免把穿透伪装成击穿。
若重建通常需要 300ms,却把锁租约设为 100ms,首个请求尚未完成时锁就失效,后续请求会再次拿锁。此时即使释放脚本校验 token 没问题,数据库仍会出现并行重建。因此 token 解决“误删别人的锁”,租约与续租解决“同一时刻出现多个合法持锁者”,两者不能混为一谈。
三、逻辑过期为什么更适合超热点
逻辑过期不是让 Redis key 真正过期,而是在缓存 value 中保存一个 expireTime。请求读到缓存后,如果发现逻辑时间过期,不立即删除,也不让所有请求查 DB,而是尝试抢锁异步刷新。抢不到锁的请求先返回旧值。
它的好处是热点 Key 不会在过期瞬间消失,绝大多数请求无需等待数据库。代价是会返回旧数据,所以 value 必须同时记录 generatedAt、逻辑过期时间和版本,并定义 staleUntil。商品详情、榜单等可容忍短暂陈旧;余额、权限判定和库存扣减不能靠旧值完成最终决策。
异步刷新也会失败:任务可能没投进线程池、进程可能崩溃、数据库可能超时。应监控刷新提交失败、刷新耗时和旧值年龄;超过 staleUntil 后改为受控同步回源或明确降级,而不是让“逻辑过期”变成“永久陈旧”。
四、热点永不过期不是字面上不管
有些热点 Key 会设置很长 TTL,甚至不设置物理过期,由后台任务定时刷新。这能降低到期击穿概率,但“无 TTL”并不等于一定存在:淘汰策略、节点故障、扩缩容、误删和数据污染仍会造成 miss。读路径仍需有受控回源与降级,不能假设该 Key 永远命中。
同时,“永不过期”不等于永远不更新。必须有后台刷新、数据库变更事件、版本校验和旧值年龄告警,否则错误数据会长期驻留。即使使用主动刷新,也建议保留物理 TTL 或巡检机制作为通知丢失后的自愈边界。
如果热点 key 体量不大,可以把热点列表提前识别出来,单独采用更稳的刷新策略;普通 key 继续用常规 TTL 即可。
五、如何选择方案
读请求必须尽量拿到最新值,可用互斥锁同步重建,代价是等待延迟;更看重可用性且允许短暂陈旧,可用逻辑过期;热点可预测且变化不频繁,可用定时刷新和多级缓存。方案选择必须同时写明数据的新鲜度上限、数据库回源预算和失败时返回什么。
假设热点为 2 万 QPS、一次重建 400ms,无合并时理论上会产生约 20000×0.4=8000 次并发回源。若采用单进程 singleflight,但服务有 100 个实例,仍可能同时回源约 100 次;数据库只能承受 30 个并发时,还要加跨实例锁或全局回源信号量。即使数据库被保护,等待锁的请求也可能堆满线程池,所以等待时间、重试次数和排队人数都要设上限。
六、面试追问与工程边界
互斥锁会不会把请求都堵住?会。因此等待者只能在自己的 deadline 内尝试,不应每个请求固定间隔同时轮询;可用带抖动退避、进程内通知或直接返回可信旧值。热点极高时,逻辑过期通常比大量同步等待更稳。
还要注意锁粒度。锁应对应具体业务 Key,而不是整个缓存类型,否则一个商品重建会阻塞所有商品。重建失败需要释放锁、记录失败并对后续重试做退避;如果继续立即争锁,数据库故障期间仍会形成持续回源。
判断方案是否有效,不看“有没有锁”,而看数据库最坏会收到多少并发、请求最多等多久、旧值最多旧多久,以及刷新失败后是否能收敛。
七、重建结果也要防止版本倒退
互斥锁只能合并缓存重建者,不能阻止业务写与重建交错。请求 A 拿锁后从数据库读到 v10,随后业务请求 B 把数据库更新为 v11并删除缓存;若 A 最后无条件写回 v10,缓存仍会倒退。锁没有失效,也没有两个重建者,但结果依旧是旧的。
可让缓存 value 携带业务版本,回填时用 Lua/CAS 与最低可接受版本比较;数据库更新事件也可留下短期版本 tombstone,防止缓存为空时旧查询重新写入。若数据没有单调版本,只能依赖再次失效和较短 TTL 缩小窗口。
A: DB read v10 ----------- cache SET v10(应被拒绝)
B: DB commit v11 -> invalidate / version floor v11
逻辑过期的异步刷新同样要做版本保护。刷新任务可能排队很久,完成时读取结果已落后;不能因为它持有刷新锁就认为结果一定最新。
八、上线后看哪些指标
至少按 Key 或热点组监控 miss QPS、数据库回源 QPS、重建成功/失败、锁等待人数与时长、逻辑过期旧值年龄、刷新队列拒绝数。只看全局命中率会掩盖单个热点正在击穿:其他数百万 Key 的高命中可以把异常平均掉。
演练时让热点 Key 物理过期、注入数据库 500ms 延迟和刷新线程池满载,验证回源并发不超过预算、等待请求按 deadline 退出、旧值不超过 staleUntil。若数据库故障后重试频率持续上升,说明退避或熔断没有真正生效。
| 信号 | 说明 | 处置方向 |
|---|---|---|
| 同 Key 回源并发升高 | 合并或锁租约失效 | 检查二次读、租约和实例数 |
| 锁等待 P99 升高 | 重建慢或等待过多 | 限队列、旧值/降级 |
| stale age 越过上限 | 刷新链路失效 | 熔断刷新、受控回源 |
| DB QPS 周期尖峰 | TTL 或重试同步 | 加抖动、指数退避 |
九、常见误区与追问
- 误区:击穿和穿透一样。 击穿查询的是存在的热点 key,穿透查询的是不存在的数据。
- 误区:加锁后所有问题都没了。 租约过短会并行重建,等待队列也可能先拖垮应用。
- 误区:拿到锁后可以直接查库。 必须二次检查缓存,因为前一个重建者可能刚完成回填。
- 误区:热点 Key 不设 TTL 就不会击穿。 淘汰、故障、迁移和误删仍会产生 miss,且还会带来长期陈旧风险。
- 追问:逻辑过期如何避免永久旧值? 记录最大
staleUntil,监控刷新失败,越界后受控回源或失败。 - 追问:singleflight 为什么可能还不够? 它通常只合并单进程请求,多实例仍会各自回源一次。
- 追问:锁到期而重建未结束怎么办? 按最坏耗时设置租约或受控续租,并用版本条件防旧结果覆盖新值。
- 追问:重建失败后怎么做? 对重试退避,保留有时限的旧值,并用回源配额保护数据库。
十、加强记忆
缓存击穿是“单个热点 key 过期后,大量并发一起回源”。治理核心是避免所有请求同时重建缓存:互斥锁让一个请求查库,逻辑过期让大部分请求先返回旧值,热点永不过期和后台刷新让 key 不突然消失。击穿看单个热点,雪崩看大面积失效,穿透看不存在数据。