一个被高频访问的热点缓存恰好过期的那一瞬间,几千个请求同时扑向数据库去重建它,瞬间把数据库打垮了:一次缓存击穿的深度复盘
那次数据库被瞬间打垮是从一个缓存过期开始的:我有个被高频访问的热点数据(比如首页的热门榜单),放在缓存里、设了过期时间(比如 5 分钟)。平时一切顺滑——请求来了读缓存、命中、返回。可某次,监控突然报警:数据库 CPU/连接数瞬间飙满,大量慢查询,差点雪崩;而这恰好发生在那个热点缓存过期的那一刻。我复盘那一瞬间发生了什么,才看明白,后背发凉:这是典型的"缓存击穿(cache breakdown / hotspot invalid)"。那个热点 key 被极高频地访问(每秒几千请求);当它到点过期、从缓存里消失的那一瞬间,这几千个并发请求会同时发现"缓存里没有了(未命中)";于是它们几乎同时地、全部地,扑向数据库去"查数据、重建缓存"——本来该由缓存挡住的几千 QPS,在这一瞬间全部直接砸到了数据库;数据库被这突如其来的、瞬时的几千个相同查询(它们查的还是同一个数据)瞬间压垮,慢查询堆积、连接耗尽,引发雪崩。根本原因是:一个高并发访问的热点 key 过期的瞬间,所有本由缓存承接的请求会同时穿透到数据库去重建缓存,瞬时把数据库打垮——这就是缓存击穿。问题的根,是热点 key 过期瞬间,大量并发请求同时未命中、一起穿透到数据库重建缓存,瞬时流量打垮 DB。这篇就把这次"缓存击穿"的坑,从头到尾复盘一遍。
故障现场:热点 key 过期,千军万马扑向 DB
问题在于热点 key 过期瞬间,大量请求同时穿透到 DB 重建缓存:
// ✗ 出问题的缓存读取: 朴素的 Cache-Aside, 没防击穿
public Data getHotData(String key) {
Data data = cache.get(key);
if (data != null) return data; // 命中
// 未命中 → 查DB重建缓存
data = db.query(key); // ✗ 击穿点: 热点key过期瞬间, 几千请求都走到这里!
cache.set(key, data, Duration.ofMinutes(5));
return data;
}
// 缓存击穿的瞬间(热点key, 每秒几千请求):
// 1. 热点key一直被高频访问、一直命中, 岁月静好;
// 2. 某一刻, key到点【过期】, 从缓存消失;
// 3. 紧接着的那一瞬间, 几千个并发请求【同时】 cache.get → 【全部未命中】;
// 4. 这几千个请求【同时】走到 db.query(key) → 几千个相同查询【瞬间砸向DB】;
// 5. DB被这突如其来的几千QPS压垮 → 慢查询堆积、连接耗尽 → 雪崩。
// 6. (而且它们查的是【同一个数据】, 重复做了几千次同样的查询和重建, 纯浪费。)
// 区分三个相关但不同的缓存问题:
// - 缓存击穿(本文): 某个【热点key】过期瞬间, 大量请求穿透到DB(单个热点key);
// - 缓存穿透: 查询【根本不存在】的key(缓存和DB都没有), 每次都穿透到DB(常是恶意/异常);
// - 缓存雪崩: 【大量key在同一时刻】集体过期, 大量请求同时穿透, DB被打垮(多个key同时过期)。
// 关键: 高并发热点key过期的瞬间, 大量请求同时未命中、一起穿透到DB重建缓存, 瞬时流量打垮DB;
// 这是缓存击穿——缓存这道"挡箭牌"在热点key过期的瞬间出现了一个"空窗", 流量直冲DB。
第一次想明白"原来是那个热点 key 过期的一瞬间,几千个请求一起扑向了 DB"时,我又懊恼又后怕:"我以为加了缓存、DB 就被保护得好好的;完全没想到缓存过期的那一瞬间会出现一个'空窗',让积压的高并发流量一下子直冲 DB。"这个坑最隐蔽的地方在于:它只在"热点 key"+"恰好过期的那一瞬间"才爆发,平时缓存命中、一切正常,偶发且极短促(就过期那一下);而且key 越热(QPS 越高),击穿时砸向 DB 的瞬时流量越大、越致命;它和"缓存一致性"是两类问题(一个是可用性、一个是正确性),容易混。下面就来拆解,缓存击穿/穿透/雪崩怎么防。
第一件事:搞懂缓存击穿、穿透、雪崩
我顺着这次事故,把缓存的三类可用性问题彻底理清了。
缓存击穿 / 穿透 / 雪崩: 是什么? 怎么防?
【核心: 击穿=热点key过期瞬间大量请求穿透; 穿透=查不存在的key每次穿透; 雪崩=大量key同时过期; 都是"流量绕过缓存直冲DB", 要各自防护】
1. 缓存击穿(hotspot key invalid)——本文:
- 现象: 单个【热点key】过期瞬间, 大量并发请求同时未命中、一起穿透到DB重建;
- 危害: 热点key的高QPS瞬间全砸DB, 把DB打垮;
- 防护: ①互斥锁重建(只让一个请求去查DB重建, 其他等待/用旧值); ②逻辑过期(不真过期, 后台异步刷新);
③热点key永不过期(或很长TTL)+主动更新。
2. 缓存穿透(penetration):
- 现象: 查询【根本不存在的key】(缓存没有、DB也没有), 每次都穿透到DB(查了个寂寞);
- 危害: 常被恶意利用(故意查不存在的id刷DB); 正常也会浪费;
- 防护: ①把"空结果"也缓存起来(短TTL), 下次直接返回空, 不再查DB; ②布隆过滤器(快速判断key一定不存在就拦掉)。
3. 缓存雪崩(avalanche):
- 现象: 【大量key在同一时刻集体过期】(如都设了相同TTL、或缓存集体重启), 大量请求同时穿透;
- 危害: 不是单个热点, 而是一大批key同时失效, DB被海量请求打垮;
- 防护: ①TTL加随机抖动(别让大批key同时过期); ②多级缓存; ③缓存高可用(别整个挂); ④限流/降级保护DB。
4. 共同本质: 缓存是DB前面的"挡箭牌", 这三种问题都是"流量绕过/穿透了挡箭牌、直冲DB"
- 击穿: 挡箭牌上某个热点位置出现瞬时空窗;
- 穿透: 挡箭牌根本挡不住"查不存在的"(它不缓存不存在的);
- 雪崩: 挡箭牌大面积同时失效。
- → 防护的共同思路: 不让流量在缓存失效时一窝蜂直冲DB; 给DB留保护。
5. 兜底: 无论哪种, DB前都该有"保护"
- 限流(限制打到DB的QPS)、熔断、降级——即使缓存失效, 也别让DB被打死。
一句话: 缓存击穿(热点key过期瞬间穿透)、穿透(查不存在的key)、雪崩(大量key同时过期), 都是流量绕过缓存
直冲DB; 击穿用互斥重建/逻辑过期/永不过期, 穿透用空值缓存/布隆过滤器, 雪崩用TTL随机抖动, 并给DB加限流兜底。
这套认知,是整个坑的根。缓存击穿(本文):单个热点 key 过期瞬间大量请求同时穿透重建、把 DB 打垮;防护:①互斥锁重建(只让一个请求去重建、其他等待)②逻辑过期(不真过期、后台异步刷新)③热点 key 永不过期+主动更新。缓存穿透:查根本不存在的 key(缓存 DB 都没有)每次都穿透;防护:空值也缓存(短 TTL)、布隆过滤器。缓存雪崩:大量 key 同一时刻集体过期、海量请求同时穿透;防护:TTL 加随机抖动、多级缓存、缓存高可用、限流降级。共同本质:缓存是 DB 前的"挡箭牌",三者都是"流量绕过/穿透挡箭牌直冲 DB"。兜底:无论哪种,DB 前都该有限流/熔断/降级保护。一句话:缓存击穿(热点 key 过期瞬间穿透)、穿透(查不存在的 key)、雪崩(大量 key 同时过期),都是流量绕过缓存直冲 DB;击穿用互斥重建/逻辑过期/永不过期,穿透用空值缓存/布隆过滤器,雪崩用 TTL 随机抖动,并给 DB 加限流兜底。
第二件事:正解——互斥锁重建 / 逻辑过期,配合穿透雪崩防护
搞懂了原理,正解就清晰了:击穿用互斥锁重建(只让一个请求查 DB 重建,其他等待)或逻辑过期(后台异步刷新);配合空值缓存防穿透、TTL 随机抖动防雪崩、DB 前限流兜底。
// ====== 正解一: 互斥锁重建(防击穿: 只让一个请求重建) ======
public Data getHotData(String key) {
Data data = cache.get(key);
if (data != null) return data; // 命中
// 未命中: 用分布式锁, 保证同一时刻只有一个请求去查DB重建
String lockKey = "lock:" + key;
if (tryLock(lockKey, 5)) { // 抢到锁的那个请求负责重建
try {
data = cache.get(key); // 双重检查(可能别人刚重建好)
if (data != null) return data;
data = db.query(key); // 只有这一个请求查DB
cache.set(key, data, ttlWithJitter()); // 重建, TTL加随机抖动(顺带防雪崩)
return data;
} finally { unlock(lockKey); }
} else {
// 没抢到锁的请求: 短暂等待后重试读缓存(等那个请求重建好), 或先返回旧值/降级
sleep(50);
return getHotData(key);
}
}
// → 热点key过期瞬间, 几千请求里只有【一个】去查DB重建, 其余等它重建好直接读缓存 → DB不再被打垮。
# ====== 正解二: 逻辑过期(热点key不真过期, 后台异步刷新) ======
# - 缓存的value里存一个"逻辑过期时间", key本身设很长TTL/不过期;
# - 读时: 如果逻辑上已过期, 返回旧值的同时, 【异步】触发一个线程去重建缓存(只触发一个);
# - → 用户永远拿得到值(旧值或新值)、不阻塞、不穿透DB; 适合"能容忍短暂旧数据"的热点。
# ====== 配套: 穿透 + 雪崩 + 兜底 ======
# 防穿透(查不存在的key):
# - 空值缓存: 查DB也没有 → 把"空"也缓存(短TTL), 下次直接返回空, 不再查DB;
# - 布隆过滤器: 用布隆过滤器快速判断"key一定不存在"就直接拦掉, 不查DB。
# 防雪崩(大量key同时过期):
# - TTL加随机抖动: ttl = base + random(0, jitter), 别让大批key同一秒过期;
# - 缓存高可用(集群)、多级缓存(本地+分布式)。
# 兜底(无论哪种, 保护DB):
# - DB前限流(限制穿透到DB的QPS)、熔断、降级返回默认值/旧值。
# ====== 选型 ======
# - 严格不能读到旧数据: 用互斥锁重建(其他请求等待);
# - 能容忍短暂旧数据、要绝不阻塞: 用逻辑过期(异步刷新);
# - 都要配上: 防穿透(空值/布隆)、防雪崩(TTL抖动)、DB限流兜底。
# 核心: 缓存击穿用互斥锁重建(只一个请求重建)或逻辑过期(异步刷新); 配合空值缓存/布隆防穿透、
# TTL随机抖动防雪崩、DB前限流降级兜底; 别让缓存失效的瞬间, 流量一窝蜂直冲、打垮DB。
修复的核心,是"击穿用互斥重建/逻辑过期,配齐穿透雪崩防护和 DB 兜底"。正解一:互斥锁重建(防击穿)——未命中时用分布式锁,同一时刻只让一个请求去查 DB 重建、其余等它重建好直接读缓存(双重检查),DB 不再被打垮。正解二:逻辑过期——key 不真过期,逻辑过期时返回旧值+异步触发一个线程重建,用户永不阻塞不穿透(适合能容忍短暂旧数据)。配套:防穿透(空值缓存/布隆过滤器)、防雪崩(TTL 随机抖动/多级缓存/高可用)、兜底(DB 前限流/熔断/降级)。选型:不能读旧数据用互斥重建、要绝不阻塞用逻辑过期、都要配齐穿透雪崩防护和 DB 限流。归根结底:缓存击穿用互斥锁重建(只一个请求重建)或逻辑过期(异步刷新);配合空值缓存/布隆防穿透、TTL 随机抖动防雪崩、DB 前限流降级兜底;别让缓存失效的瞬间流量一窝蜂直冲、打垮 DB。
第三件事:缓存高可用设计的其他常见坑
排查后我把缓存可用性、保护后端相关的其他坑也系统梳理了一遍。
缓存高可用与保护后端的其他坑
# 1. 缓存击穿(本文): 热点key过期瞬间穿透。→ 互斥重建/逻辑过期/永不过期。
# 2. 缓存穿透: 查不存在的key每次穿透。→ 空值缓存/布隆过滤器。
# 3. 缓存雪崩: 大量key同时过期。→ TTL随机抖动/多级缓存/高可用。
# 4. 缓存一致性(同550篇): 更新策略不当致脏数据。→ Cache-Aside删缓存+TTL。
# 5. 缓存挂了没降级: 缓存集群宕机, 全部流量直冲DB把DB也打垮。→ DB限流/熔断/本地缓存兜底。
# 6. 没有DB保护: 任何缓存失效都直接威胁DB。→ DB前永远要有限流/熔断这层保护。
# 7. 大key/热key: 单个key过大或过热拖垮某缓存节点。→ 拆分大key、热key多副本/本地缓存。
# 8. 缓存预热缺失: 系统启动/缓存重建时缓存空, 大量请求直接打DB。→ 启动时预热关键缓存。
# 共同根源: 缓存的核心价值是"挡在后端(DB)前面、吸收掉绝大部分读流量、保护后端"; 而上述问题都是
# "在某些时刻(过期/穿透/雪崩/宕机), 缓存这道防线出现缺口, 流量直冲并可能打垮后端"; 缓存设计不仅要
# 考虑"命中时多快", 更要考虑"未命中/失效时, 后端会不会被瞬间冲垮"。
# 核心: 缓存设计要把"保护后端"放在和"提升性能"同等重要的位置——防住击穿/穿透/雪崩、缓存挂了有降级、
# DB前永远有限流兜底; 时刻问"如果缓存这层突然失效, 后端扛得住吗?", 别让缓存的缺口变成后端的灾难。
排查让我把缓存高可用的其他坑也梳理清了。一、缓存击穿(本文)。二、缓存穿透。三、缓存雪崩。四、缓存一致性。五、缓存挂了没降级。六、没有 DB 保护。七、大 key/热 key。八、缓存预热缺失。它们的共同根源是:缓存的核心价值是"挡在后端(DB)前面、吸收掉绝大部分读流量、保护后端";而上述问题都是"在某些时刻(过期/穿透/雪崩/宕机)缓存这道防线出现缺口、流量直冲并可能打垮后端";缓存设计不仅要考虑命中时多快,更要考虑未命中/失效时后端会不会被瞬间冲垮。核心是:缓存设计要把"保护后端"放在和"提升性能"同等重要的位置——防住击穿/穿透/雪崩、缓存挂了有降级、DB 前永远有限流兜底;时刻问"如果缓存这层突然失效,后端扛得住吗?",别让缓存的缺口变成后端的灾难。下面这张图,是这次缓存击穿坑的成因与解法:
第四件事:缓存击穿/穿透/雪崩对比表
这次踩坑后,我把缓存的三类可用性问题对比成一张表。
| 维度 | 击穿(本文) | 穿透 | 雪崩 |
|---|---|---|---|
| 触发 | 单个热点 key 过期瞬间 | 查根本不存在的 key | 大量 key 同时过期 |
| 缓存里有吗 | 有, 但刚过期 | 没有(DB 也没有) | 有, 但同时失效 |
| 规模 | 单 key 的高 QPS | 持续/恶意 | 大批 key |
| 主防护 | 互斥重建/逻辑过期 | 空值缓存/布隆 | TTL 随机抖动 |
| 共同点 | 流量绕过缓存直冲 DB → 都要 DB 限流兜底 | ||
这张表把三者钉清了。核心是:击穿、穿透、雪崩,表象都是"DB 被大量请求打垮",但触发的"缺口"不同——击穿是"单点瞬时空窗",穿透是"挡不住不存在的查询",雪崩是"大面积同时失效";它们需要不同的针对性防护(对症下药),但又有共同的兜底(给 DB 加限流)。它给我的最大启发是:面对"同一种表象(DB 被打垮)、但成因不同"的一组问题,要先精准地区分"到底是哪种成因"、再施以"对应的针对性解法"——而不是用一个笼统的方案试图覆盖所有(比如以为"加缓存"就万事大吉,其实击穿穿透雪崩各有各的坑);同时, 在针对性解法之上, 往往还有一层"通用的兜底"(限流保护 DB)能托住所有情况。这给了我一种解决问题的层次感:解决一类问题时,既要有"针对每种具体成因的精准解法"(治本、对症),也要有"覆盖所有情况的通用兜底"(托底、防意外)——两层结合:精准解法解决已知的具体问题,通用兜底防住未预料到的和漏网的;"对症的针对性方案 + 兜底的通用防护, 双层防御",是稳健地解决一类相关问题的体系化思路。认清同一表象不同成因要对症下药+通用兜底双层防御——是这个缓存击穿坑带给我的认知。
第五件事:这次事故暴露的"防线的空窗期"
这次让我反思更深一层:缓存这道"防线"在 key 过期的瞬间,出现了一个致命的"空窗期"。我把"有防线的常态"和"空窗期"对比成表。
| 维度 | 常态(缓存命中) | 空窗期(key 刚过期) |
|---|---|---|
| 缓存这道防线 | 有效, 挡住流量 | 出现缺口, 挡不住 |
| DB 承受 | 几乎为 0 | 瞬间几千 QPS |
| 持续时间 | 绝大部分时间 | 极短(重建完就好) |
| 危险性 | 安全 | 极危险(瞬时打垮) |
| 易被忽略 | — | 是(太短、平时看不到) |
这张表道出了问题的时间维度。核心是:缓存击穿的致命之处,在于它发生在一个"防线的瞬时空窗期"——缓存这道防线 99.99% 的时间都好好地挡着流量,可就在 key 过期、新值还没重建好的那"极短的一瞬间",防线出现了一个缺口;而高并发的流量,恰恰会在这个缺口出现的瞬间蜂拥而入、瞬时击穿;"防线绝大部分时间有效"麻痹了我,让我忽略了"那个极短的、防线交接的空窗"里的巨大风险。它给我的深刻启发是:很多系统的脆弱点,不在"稳态运行时",而在那些"短暂的、过渡性的、状态切换的瞬间"——缓存过期重建的瞬间、服务重启/切换的瞬间、扩缩容的瞬间、主从切换的瞬间、锁释放到重新获取的瞬间;这些"空窗期/过渡态"虽然短暂、平时不显,却往往是防护最薄弱、最容易被击穿的时刻。这给了我一种审视系统脆弱性的视角:评估系统的健壮性时,不能只看"稳态下运行得好不好",更要专门审视那些"过渡态/切换瞬间/防线交接的空窗期"——问"在这些短暂的过渡瞬间, 防护还在吗?流量/请求会不会趁虚而入?"——并专门为这些空窗期设计保护(如互斥重建消除缓存空窗、优雅切换、预热);"关注并守护状态切换的过渡瞬间、别让短暂的空窗成为致命缺口",是构建真正健壮系统的一个深层意识。认清脆弱常在过渡态/空窗期、要专门守护状态切换的瞬间——是这个缓存击穿坑带给我的认知。
第六件事:给热点数据加缓存时,我现在的自检习惯
现在每当我要给一个数据加缓存(尤其热点数据),我都会先按这张图问自己:
这张图的精髓,是"热点防击穿、防穿透、防雪崩、DB 永远有兜底"。热点 key互斥重建/逻辑过期、查不存在空值缓存+布隆、批量过期TTL 随机抖动、最后DB 前限流兜底。这套习惯,让我从"加个缓存就以为保护好了 DB"变成了"系统地防住击穿穿透雪崩+DB 兜底"——核心始终是:缓存击穿用互斥重建/逻辑过期,穿透用空值缓存/布隆,雪崩用 TTL 随机抖动,DB 前永远加限流兜底,别让缓存失效的瞬间流量直冲打垮 DB。
我立下的几条规矩
这场"热点缓存过期瞬间打垮 DB"的事故,换来了我做缓存设计时,刻进骨子里的几条铁律:
- 缓存击穿:热点 key 过期瞬间大量请求同时穿透到 DB 重建,瞬时打垮 DB。
- 防击穿:互斥锁重建(只一个请求重建)、逻辑过期(异步刷新)、热点 key 永不过期。
- 防穿透(查不存在的 key):空值也缓存(短 TTL)、布隆过滤器。
- 防雪崩(大量 key 同时过期):TTL 加随机抖动、多级缓存、缓存高可用。
- DB 前永远要有限流/熔断/降级——即使缓存失效,也别让 DB 被打死。
- 同一表象(DB 被打垮)不同成因,要对症下药+通用兜底双层防御。
- 关注并守护状态切换的过渡瞬间(缓存重建/服务切换的空窗期)。
写在最后
回头看,这场由"一个热点缓存过期"引发的、数据库瞬间被打垮的事故,真正教给我的,远不止"互斥重建、逻辑过期"这几个技巧。它让我对"一道'平时稳稳挡着'的防线, 一旦在某个瞬间出现哪怕极短的缺口, 积蓄的巨大压力就会从那个缺口瞬间倾泻而出; 而我们恰恰因为'它平时一直好好的', 而忽略了那个缺口的致命",有了一次刻骨的体会。我栽跟头,是因为我看到缓存"平时一直好好地挡着几千 QPS、DB 一片祥和",就彻底放心了——我把"缓存这道防线"当成了"永远、无缝地"挡在 DB 前面的;我完全没意识到:这道防线在每个 key 过期、重建的瞬间,都有一个极其短暂、却真实存在的"缺口";而那几千 QPS 的压力一直都在、从未消失, 只是被缓存挡着、积蓄着;一旦缺口出现的那一瞬间, 这积蓄的压力就毫不留情地、瞬间地从缺口涌入, 直接淹没了它身后毫无防备的 DB。这让我领悟到一个关于"防护与积压的压力"的深刻认知:当一道"防护/缓冲"(缓存、限流、队列、防火墙)在"挡住/缓冲大量压力"时,它的背后(被保护方)往往是"脆弱、无防备"的——因为压力都被前面挡住了;而一旦这道防护出现缺口(哪怕瞬时),被它挡住的、积蓄的压力就会瞬间、集中地冲击那个毫无防备的背后;"越是被严密保护、平时越安逸的后方, 一旦保护失效, 受到的冲击越猛烈"。这给了我一种设计"纵深防护"的清醒:不要把希望全寄托在"单一的、前置的防护"上(指望缓存永远挡住一切)——要假设"这道防护可能在某个瞬间失效/出缺口",并为它身后的'被保护方'也准备一层防护(纵深防御)(DB 自己也要有限流);"不依赖单层防护、为可能的缺口准备纵深防御、让被保护方自己也有抵抗力",是构建真正抗冲击系统的根本原则——一道防线再好, 也要假设它会破,并为破了之后做好准备。认清防护背后是无防备的后方、防护出缺口积压压力会瞬间冲垮后方、要做纵深防御别依赖单层——这,是我用一次缓存击穿的事故,换来的、关于系统架构、也关于如何构建纵深防护的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给热点数据加缓存时,顺手加上互斥重建、并给 DB 配上限流兜底,那我对着那被瞬间打垮的数据库复盘的这段时间,就值了。
—— 别看了 · 2026