← 返回题目列表

分布式系统中的缓存击穿是什么?如何解决?

高频 中等 第 4 / 26 题 更新于 2026/10/03
缓存击穿热点Key互斥锁
学习 AI 实战项目

简化版

缓存击穿指一个本来存在的热点 Key 失效后,大量并发同时回源,瞬间把压力转移到数据库。常用方案是按 Key 合并重建:一个请求加载,其他请求限时等待、返回允许范围内的旧值或降级。超热点可用逻辑过期配合后台刷新,但必须限定旧值时长并保护刷新线程池。

详细版

缓存击穿的重点是“单个热点 key”。它和雪崩、穿透不同:

问题数据是否存在触发点主要方案
击穿存在热点 key 过期互斥锁、逻辑过期、热点续期
雪崩存在大量 key 同时失效TTL 随机、高可用、限流降级
穿透不存在查询不存在数据缓存空值、布隆过滤器

同步重建时,拿锁后必须再次检查缓存,避免前一个持锁者已经回填而当前请求重复查库;等待方要有截止时间和抖动,不能排成长队。锁使用唯一 token,并以原子“比较 token 后删除”释放;租约还要覆盖最坏重建时间,或支持受控续租,否则锁先过期会出现第二个重建者。

高并发核心热点更适合逻辑过期:缓存保留数据和 expireAt,过期后只有一个刷新任务回源,其余请求在业务允许的最大陈旧时间内读旧值。刷新队列满、数据库超时或旧值超过上限时,必须快速降级或失败,不能无限返回旧数据。

完整版教学

一、击穿的现场是什么样

假设首页有一个爆款商品,QPS 很高。这个商品详情缓存过期的一瞬间,几千个请求同时进来,全部发现 Redis 没有值。如果每个请求都去查数据库并回填缓存,数据库会同时承担几千次相同查询,压力瞬间飙升。

这就是击穿:缓存层对热点 key 的保护突然消失,热点流量直接穿过缓存打到 DB。它的本质不是缓存未命中,而是“热点 + 并发重建”。

二、互斥锁方案怎么做

互斥锁方案的核心是只允许一个请求重建缓存。流程是:请求发现缓存未命中,尝试用 Redis SET lockKey token NX PX leaseMs 抢锁;抢到锁的请求查数据库、写缓存、释放锁;没抢到锁的请求等待几十毫秒后再读缓存,或者直接返回兜底。

这个方案实现简单,但细节决定它是否真的能挡住回源:

  1. 抢锁成功后再读一次缓存,命中就直接返回;
  2. 锁必须带唯一 token 和租约,释放时用 Lua 原子校验 token;
  3. 租约覆盖数据库 P99、序列化和写缓存时间,长任务可续租;
  4. 等待方按 20~50ms 抖动重读,并受请求 deadline 约束;
  5. 数据库返回不存在时写短 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 不突然消失。击穿看单个热点,雪崩看大面积失效,穿透看不存在数据。