一个超热点的商品缓存在过期那一瞬间,成千上万的请求同时涌向数据库去重建它,把 DB 瞬间打垮,我对着缓存击穿这个热点 key 过期引发的惊群效应排查了大半天的复盘
这是一个让我对"缓存不是万能挡箭牌"彻底清醒的架构坑。它的诡异之处在于:平时缓存把数据库保护得好好的,DB 负载很低;可就在某个特定的瞬间,DB 会突然遭受一波排山倒海的并发冲击而崩溃——而这个瞬间,恰恰是某个最热门的缓存刚好过期的那一刻。
事情起于一次诡异的 DB 告警。我的系统用 Redis 缓存商品详情,给缓存设了过期时间(比如 1 小时),平时 DB 几乎没什么压力。可监控显示,DB 会毫无规律地、间歇性地出现一波瞬时高负载、甚至连接被打满。我把缓存读取的代码拎出来,逻辑很标准:
// 读商品详情: 缓存读不到就查DB并回填(有问题的版本)
public Product getProduct(Long id) {
String key = "product:" + id;
Product p = redis.get(key); // 1. 先读缓存
if (p == null) { // 2. 缓存没有(未命中或已过期)
p = db.queryProduct(id); // 3. 查数据库
redis.set(key, p, 1, HOURS); // 4. 回填缓存, 设1小时过期
}
return p;
}
这段"读缓存→没有就查 DB 回填"的标准写法,在大多数情况下没问题。可它有一个致命的并发隐患:设想一个超级热点的商品(比如正在大促的爆款),每秒有上万次请求访问它。当它的缓存过期的那一瞬间,这上万个并发请求几乎同时执行到第 2 步,同时发现缓存没了,于是同时涌向第 3 步——成千上万个请求在同一瞬间一起去查数据库、一起去重建这个缓存!数据库在这一瞬间遭受了平时上万倍的并发冲击,瞬间被打垮。我盯着监控上那一根根与"热点缓存过期"时刻吻合的 DB 负载尖刺,意识到问题的本质:缓存过期的那一刻,它对数据库的"保护"出现了一个短暂但致命的"真空期",而海量并发恰好涌入了这个真空。
第一件事:看清真相——热点 key 过期瞬间,海量并发同时穿透重建,这叫"缓存击穿"
我去深入理解了"缓存击穿(cache breakdown / thundering herd 惊群)"以及它和"缓存穿透""缓存雪崩"的区别,才彻底看清这场 DB 崩溃的成因——一个热点 key 在过期的瞬间,大量并发请求同时发现它失效、同时去查数据库重建缓存,这一波瞬时并发直击 DB、把它打垮,这就是"缓存击穿";它的根源是"缓存过期瞬间的并发真空"。
缓存击穿 / 穿透 / 雪崩 的真相
# 1. 缓存击穿(本文, cache breakdown / 惊群 thundering herd):
# - 场景: 某个【热点key】(高并发访问)在【过期的瞬间】
# - 大量并发请求同时发现它没了 → 同时去查DB、同时重建缓存
# - → DB 在这一瞬间被【上万倍并发】直击, 被打垮
# - 关键词: 【单个热点key】、【过期瞬间】、【并发重建】
# 2. 缓存雪崩(cache avalanche):
# - 场景: 【大量key】在【同一时刻】集体过期(比如都设了相同过期时间)
# - → 大批请求同时穿透到DB → DB被海量请求压垮
# - 区别: 击穿是"单个热点key", 雪崩是"大量key同时失效"
# 3. 缓存穿透(cache penetration):
# - 场景: 查询一个【根本不存在】的数据(缓存、DB里都没有)
# - → 缓存永远不命中(因为没数据可缓存) → 每次都打到DB
# - 若被恶意大量请求不存在的key → DB被压垮
# - 区别: 穿透是"查不存在的数据", 缓存帮不上忙
# 4. 本文"击穿"的发生链(以爆款商品为例):
# a) 商品缓存正常, 上万QPS的请求都命中缓存, DB很轻松
# b) 到了过期时间, 缓存失效的【那一瞬间】
# c) 这一瞬间涌入的成千上万个并发请求, 都发现缓存==null
# d) 它们【全部】执行"查DB + 回填缓存"
# e) → DB瞬间承受上万并发查询 → 连接打满/被压垮
# f) (而且它们重复查了同样的数据、重复回填, 极大浪费)
# 5. 共同本质: 缓存是DB的"保护层", 但这个保护层会在某些时刻"破洞"
# (热点key过期=击穿、大量key同时过期=雪崩、查不存在数据=穿透);
# 一旦破洞, 平时被缓存挡住的流量就会直击DB, 可能压垮它。
# 核心: 缓存击穿=热点key过期瞬间海量并发同时查DB重建打垮DB; 还有雪崩(大量key同时过期)、
# 穿透(查不存在的数据); 本质都是"缓存保护层破洞, 流量直击DB"。
真相大白,我恍然大悟。原来这叫"缓存击穿(惊群效应)":一个热点 key 在过期的瞬间,大量并发请求同时发现它失效、同时去查数据库重建缓存,这一波瞬时并发直击 DB、把它打垮;根源是"缓存过期瞬间的并发真空"。它和另外两个"缓存灾难"要区分开:缓存雪崩是"大量 key 在同一时刻集体过期"(比如都设了相同过期时间),大批请求同时穿透压垮 DB;缓存穿透是"查询根本不存在的数据"(缓存、DB 都没有),缓存永远不命中、每次都打 DB(被恶意大量请求不存在的 key 时更危险)。我这次的"击穿"发生链是:爆款商品缓存正常时上万 QPS 都命中缓存、DB 很轻松;到过期那一瞬间,涌入的成千上万并发请求都发现缓存为 null,全部去"查 DB + 回填",DB 瞬间承受上万并发查询被压垮(而且它们重复查同样的数据、重复回填,极大浪费)。共同本质是:缓存是 DB 的"保护层",但这个保护层会在某些时刻"破洞"(热点 key 过期=击穿、大量 key 同时过期=雪崩、查不存在数据=穿透),一旦破洞,平时被挡住的流量就直击 DB。
第二件事:正解——用互斥锁/单飞让只有一个请求重建,或热点永不过期
搞懂了原理,正解就清晰了:缓存击穿的核心是"让过期瞬间只有一个请求去重建、其他请求等结果",用互斥锁(单飞)或逻辑过期;并配合处理穿透(空值/布隆)和雪崩(过期加抖动)。
// ====== 正解一(击穿核心): 互斥锁, 只让一个请求重建 ======
public Product getProduct(Long id) {
String key = "product:" + id;
Product p = redis.get(key);
if (p != null) return p; // 命中直接返回
// ★ 没命中: 用分布式锁, 只让"第一个"请求去重建, 其他请求等
String lockKey = "lock:" + key;
if (redis.setIfAbsent(lockKey, "1", 10, SECONDS)) { // 抢到锁
try {
p = redis.get(key); // 双重检查(可能别人已重建好)
if (p == null) {
p = db.queryProduct(id);
redis.set(key, p, 1, HOURS); // 只有这一个请求查DB、回填
}
} finally {
redis.del(lockKey); // 释放锁
}
} else {
Thread.sleep(50); // 没抢到锁: 稍等, 然后重试读缓存(别人在建)
return getProduct(id);
}
return p;
}
// → 过期瞬间, 上万请求里只有1个真的去查DB重建, 其余都等那1个建好后读缓存
// → DB只承受1次查询, 而不是上万次! 击穿被堵住。
// ====== 正解二(击穿): 逻辑过期, 不真过期, 后台异步刷新 ======
// 缓存value里存一个"逻辑过期时间", key本身永不过期(或很长);
// 读到逻辑过期的, 返回旧值的同时, 异步起一个线程去刷新缓存。
// → 永远有值可返回(不阻塞)、只有一个后台任务刷新。适合热点。
// ====== 正解三(穿透): 缓存空值 + 布隆过滤器 ======
// 缓存空值: 查DB也没有时, 把"空"也缓存一小段时间(如5分钟),
// 下次查这个不存在的key直接返回空, 不再打DB。
if (p == null && dbHasNoData) redis.set(key, EMPTY, 5, MINUTES);
// 布隆过滤器: 用它快速判断"这个key一定不存在", 不存在直接拦截, 不查DB。
// ====== 正解四(雪崩): 过期时间加随机抖动, 别让大量key同时过期 ======
int ttl = 3600 + new Random().nextInt(600); // ★ 基础1小时 + 0~10分钟随机
redis.set(key, p, ttl, SECONDS);
// → 让key的过期时间分散开, 避免大量key在同一秒集体失效(防雪崩)
// 配合: 多级缓存、缓存预热、对DB加熔断限流兜底。
// 核心: 击穿用互斥锁(只让1个请求重建其余等)或逻辑过期+后台刷新; 穿透用缓存空值+布隆过滤器;
// 雪崩用过期时间加随机抖动+多级缓存+预热; 并对DB加限流熔断作最后兜底。
修复的核心,是"让过期瞬间只有一个请求去重建,并分别处理穿透和雪崩"。正解一(击穿核心):互斥锁(单飞)——没命中时用分布式锁(setIfAbsent),只让抢到锁的那"第一个"请求去查 DB 重建、其他请求稍等后重试读缓存;过期瞬间上万请求里只有 1 个真去查 DB,DB 只承受 1 次查询而非上万次。正解二(击穿):逻辑过期——value 里存逻辑过期时间、key 本身永不过期,读到逻辑过期的返回旧值同时异步刷新,永远有值可返回、只有一个后台任务刷新。正解三(穿透):缓存空值 + 布隆过滤器——查不到也把"空"缓存一小段时间,布隆过滤器快速拦截一定不存在的 key。正解四(雪崩):过期时间加随机抖动——3600 + random(600) 让 key 过期时间分散开,避免大量 key 同一秒集体失效;配合多级缓存、缓存预热、对 DB 加熔断限流。归根结底:击穿用互斥锁或逻辑过期+后台刷新;穿透用缓存空值+布隆;雪崩用过期加抖动+多级缓存+预热;并对 DB 加限流熔断作最后兜底。
第三件事:用缓存的其他常见坑
排查后我把用缓存相关的其他常见坑也系统梳理了一遍。
用缓存的其他常见坑
# 1. 击穿(本文): 热点key过期瞬间并发重建。→ 互斥锁/逻辑过期。
# 2. 雪崩: 大量key同时过期。→ 过期加随机抖动。
# 3. 穿透: 查不存在的数据。→ 缓存空值/布隆过滤器。
# 4. 缓存与DB一致性: 更新数据时, 缓存和DB怎么保持一致?
# → 常用"先更新DB, 再删除缓存"(Cache Aside); 仍有边界case, 要权衡。
# 5. 缓存了脏数据/老数据: 更新DB后忘了删缓存 → 一直读到老数据。
# 6. 大key/热key: 单个value巨大、或单key访问极热, 拖慢/压垮某个redis节点。
# → 拆分大key、热key做本地缓存/多副本。
# 7. 缓存数据结构选错/序列化开销: value序列化反序列化慢、占内存。
# 8. 没有过期时间: 缓存只增不减, 内存爆; 或永远是老数据。→ 合理设TTL。
# 9. 把缓存当数据库: 强依赖缓存(缓存挂了系统就挂)。→ 缓存应可降级到DB。
# 共同根源: 缓存是用"一定的不一致/复杂性"换"性能"的权衡; 它引入了
# "数据两副本(缓存+DB)、过期、并发、一致性"等一系列新问题, 用不好反而成为故障源。
# 核心: 缓存是性能利器但有代价——要处理击穿/穿透/雪崩、一致性、大热key、过期、可降级;
# 别把缓存当万能挡箭牌或当数据库, 要为它的各种失效模式做好设计。
排查让我把用缓存的其他坑也梳理清了。一、击穿(本文)。二、雪崩(大量 key 同时过期,过期加抖动)。三、穿透(查不存在的数据,空值/布隆)。四、缓存与 DB 一致性(常用先更 DB 再删缓存 Cache Aside)。五、缓存脏数据(更新 DB 忘删缓存)。六、大 key/热 key(拖垮单节点,拆分/本地缓存)。七、序列化开销。八、没有过期时间(内存爆/老数据)。九、把缓存当数据库(强依赖缓存,应可降级到 DB)。它们的共同根源是:缓存是用"一定的不一致/复杂性"换"性能"的权衡;它引入了"数据两副本、过期、并发、一致性"等一系列新问题,用不好反而成为故障源。核心是:缓存是性能利器但有代价——要处理击穿/穿透/雪崩、一致性、大热 key、过期、可降级;别把缓存当万能挡箭牌或当数据库。下面这张图,是这次缓存击穿的成因与解法:
第四件事:缓存三大问题对照表
这次踩坑后,我把缓存击穿、穿透、雪崩这三个最容易混淆的问题整理成一张表。
| 问题 | 触发场景 | 特点 | 解法 |
|---|---|---|---|
| 击穿 | 单个热点key过期瞬间 | 单key, 并发重建 | 互斥锁/逻辑过期/热点永不过期 |
| 雪崩 | 大量key同时过期 | 多key, 集体失效 | 过期加随机抖动/多级缓存/预热 |
| 穿透 | 查根本不存在的数据 | 缓存帮不上忙 | 缓存空值/布隆过滤器/参数校验 |
这张表把三个常被混淆的缓存问题钉清了。核心区别是:击穿是"单个热点 key 在过期瞬间被并发重建"(关键是单 key + 并发)、雪崩是"大量 key 同时失效"(关键是大量 key + 集体)、穿透是"查不存在的数据"(关键是缓存根本帮不上忙);三者的触发场景和解法各不相同。它给我的最大启发是:这三个问题虽然现象类似(都是"流量直击 DB"),但成因截然不同;能精确地区分它们,是对症下药的前提——你得先搞清"到底是哪种破洞"(单热点过期?大量同时过期?查不存在的?),才能用对应的解法去补。这其实反映了一个普遍的排查/解决问题的方法论:当几个问题"表象相似"时,绝不能笼统地一概而论、用一个解法套所有;而要透过相似的表象,去精确辨别它们各自的成因;因为成因不同,解法就不同——把击穿当雪崩治(给所有 key 加抖动),解决不了热点 key 的并发重建问题。这让我形成一个习惯:面对一类问题,要去建立"清晰的分类"——搞清楚它有哪几种不同的子类型、各自的特征和成因是什么;有了这个分类框架,遇到具体问题时就能快速"对号入座"、精准施治,而不是凭模糊的印象乱试。建立清晰的问题分类、透过相似表象精确辨别成因——是这个缓存坑教给我的高效解决问题的方法。
第五件事:缓存的本质是一种"权衡"
这次也让我重新认识了缓存的本质——它从来不是"免费的午餐"。
| 缓存带来的好处 | 缓存带来的代价 |
|---|---|
| 大幅提升读性能 | 引入数据一致性问题(两副本) |
| 降低数据库压力 | 过期瞬间可能击穿/雪崩 |
| 提高系统吞吐 | 增加架构复杂度(多一层) |
| 降低响应延迟 | 额外的内存/运维成本 |
| — | 缓存本身可能成为新的故障点/单点 |
这张表道出了缓存的"两面性"。核心是:缓存用"引入一致性问题、过期失效风险、架构复杂度、额外成本、新的故障点"这些代价,换取了"性能、吞吐、低延迟";它不是一个"只有好处"的银弹,而是一个需要认真权衡、并妥善管理其代价的工程选择。它给我的深刻启发是:几乎所有"用空间换时间、用冗余换性能"的优化手段(缓存、索引、副本、预计算、CDN),本质上都是引入了"同一份数据的多个副本/表示";而"多副本"必然带来一个永恒的难题——一致性(如何让这些副本保持同步、不出现矛盾);以及一系列衍生问题(副本失效、副本与源的差异、副本本身的开销和风险)。这让我对"加缓存"这类决策更加审慎:引入缓存(或任何"副本式"优化)之前,不能只看到它"让读变快了"的好处,而要同时想清楚它带来的全套代价:数据怎么保持一致?它失效/出问题时系统怎么办?它值不值得引入这层复杂度?;很多系统的复杂性和故障,都源于"为了性能加了缓存,却没有妥善管理缓存带来的那些代价"。清醒地认识"缓存是用代价换性能的权衡"、并为它的全套代价做好设计——是这个击穿坑,在技术之上,教给我的关于"如何做优化决策"的成熟思考。
第六件事:用缓存时,我现在的判断习惯
现在每当我给一个数据加缓存,我都会按这张图先想清楚:
这张图的精髓,是"加缓存前,把击穿/雪崩/穿透/一致性/兜底逐一想清楚"。超热点 key 防击穿(互斥锁/逻辑过期);大量 key 一起过期防雪崩(加随机抖动);会被查不存在的 key 防穿透(空值/布隆/校验);数据会更新就想清一致性(更 DB 后删缓存),并对 DB 加限流熔断兜底。这套习惯,让我用缓存时,从"读不到就查 DB 回填一把梭"变成了"先想清这个缓存的各种失效模式怎么防"——核心始终是:缓存会破洞(击穿/雪崩/穿透),加缓存就要为它的失效模式做好设计,并给 DB 留兜底。
我立下的几条规矩
这场"缓存击穿打垮 DB"的事故,换来了我做架构时,刻进骨子里的几条铁律:
- 热点 key 过期瞬间会被并发击穿。读不到就查 DB 回填的写法藏着这个雷。
- 击穿用互斥锁,只让一个请求重建。或逻辑过期/热点永不过期。
- 雪崩用过期时间加随机抖动。别让大量 key 同一秒集体失效。
- 穿透用缓存空值 + 布隆过滤器。拦住查不存在数据的请求。
- 缓存与 DB 一致性要想清。更新 DB 后删缓存,别留脏数据。
- 给 DB 加限流熔断兜底。缓存破洞时保护 DB 最后一道防线。
- 缓存是权衡不是银弹。用代价换性能,要管好它的全套代价。
附:一个集防击穿/穿透/雪崩于一体的缓存读取封装
这次踩坑后,我把"防击穿 + 防穿透 + 防雪崩"的逻辑封装成了一个统一的缓存读取方法,所有"读缓存→回源"都走它,从一开始就内建这三道防护:
public class SafeCache {
private final RedisClient redis;
private static final String EMPTY = "__NULL__"; // 空值标记(防穿透)
/**
* 安全的"读缓存, 没有则回源"——内建防击穿(互斥锁)、防穿透(空值)、防雪崩(抖动TTL)
* @param loader 回源函数(查DB)
*/
public T get(String key, long baseTtlSec, Supplier loader, Class type) {
String cached = redis.get(key);
if (EMPTY.equals(cached)) return null; // 防穿透: 命中空值标记, 直接返回null
if (cached != null) return deserialize(cached, type); // 正常命中
// 未命中: 防击穿——用互斥锁, 只让一个线程回源重建
String lock = "lock:" + key;
if (redis.setIfAbsent(lock, "1", 10, SECONDS)) {
try {
String again = redis.get(key); // 双重检查
if (again != null) return EMPTY.equals(again) ? null : deserialize(again, type);
T data = loader.get(); // 只有这一个线程查DB
if (data == null) {
redis.set(key, EMPTY, 60, SECONDS); // 防穿透: 缓存空值(短TTL)
return null;
}
// 防雪崩: 基础TTL + 随机抖动, 避免大量key同时过期
long ttl = baseTtlSec + ThreadLocalRandom.current().nextLong(baseTtlSec / 5);
redis.set(key, serialize(data), ttl, SECONDS);
return data;
} finally {
redis.del(lock);
}
} else {
sleep(50); // 没抢到锁, 稍等重试(别人在重建)
return get(key, baseTtlSec, loader, type);
}
}
}
// 用起来极简, 三道防护自动生效:
// Product p = safeCache.get("product:" + id, 3600, () -> db.queryProduct(id), Product.class);
// 核心: 把防击穿(互斥锁)+防穿透(缓存空值)+防雪崩(抖动TTL)封装进一个统一的缓存读取方法;
// 所有回源都走它, 让"安全的缓存使用"成为默认, 而不是每处自己拼、容易漏。
这个安全缓存封装,是我这次踩坑后最有价值的工程沉淀。它把缓存的三大经典灾难的防护——防击穿(互斥锁让单线程重建)、防穿透(缓存空值标记)、防雪崩(TTL 加随机抖动)——全部内建进了一个统一的 get 方法;此后所有"读缓存、没有则回源查 DB"的地方都调用它,一行代码就同时拥有这三道防护,而不必每个地方自己手写、东漏一个西漏一个。它和我最初那个"裸读缓存、没有就查 DB 回填"的版本最大的不同,不在代码多寡,而在于一种设计立场的转变:最初版本只考虑了"缓存正常工作"的happy path,对各种失效模式毫无防备;而这个封装,从设计之初就把"缓存会被击穿、会被穿透、会雪崩"这些失效模式,当成必须默认防住的常态。这正是我想分享的核心思想:对于"缓存使用"这种"每处都要写、又处处有同样的坑"的模式,最可靠的做法,是把"正确且安全的用法"沉淀成一个统一的封装,让"安全"成为调用它就自动拥有的默认能力,而不是依赖"每个开发者每次都记得手动加上三道防护"。因为"防护"如果是"需要每次自觉去加"的,就一定会在某个角落被遗漏(就像我最初连一道都没加);只有把它做成"用这个封装就默认有",整个系统的缓存使用才真正安全可靠;这也是"用架构/封装来保证正确,而非靠个人纪律"这一原则,在缓存场景的又一次体现。把缓存的安全用法沉淀成统一封装、让"防住击穿穿透雪崩"成为默认——这,是我用一次缓存击穿的事故,换来的、关于"如何系统性地用好缓存"的实用架构智慧。
写在最后
回头看,这场由"缓存击穿"引发的、DB 被瞬时并发打垮的事故,真正教给我的,远不止"加个互斥锁"这一个技巧。它让我对"防护手段本身也需要被防护",以及"系统的边界与极端时刻",有了一次深刻的体会。我栽跟头,根源是我把"缓存"当成了一个"一旦加上,数据库就高枕无忧"的、完美无缺的"保护罩"。我享受着平时缓存带来的高性能、低 DB 压力,却从没想过这个保护罩本身会不会失效、会在什么时候失效、失效的那一刻会发生什么。我只设计了"缓存正常工作时"的美好路径,却完全忽略了"缓存失效的那一瞬间"这个虽然短暂、却致命的边界时刻——而真实的灾难,恰恰就发生在这个被我忽略的边界上。这让我领悟到一个深刻的认知:任何一个"防护/优化机制"(缓存、限流、降级、连接池),它自己也有失效、被绕过、或在边界条件下出问题的时候;一个健壮的系统设计,不仅要让这些机制在"正常情况下"发挥作用,更要认真考虑"当这个机制本身失效时,会发生什么?有没有下一道防线接住?";"缓存击穿"的本质,正是"缓存这道防线在过期瞬间失效了,而后面没有第二道防线(如互斥锁、DB 限流)接住涌向 DB 的洪流"。这其实就是"纵深防御"思想在另一个场景的体现:不要把系统的安危,寄托在任何单一的防护机制永不失效上;而要假设"每一道防线都可能在某个时刻、某个边界条件下失效",并为这种失效准备好下一道防线(缓存可能击穿 → 用互斥锁兜住并发重建 → 再用 DB 限流熔断兜住极端流量);真正的鲁棒,来自层层设防、来自对"万一这道防线破了怎么办"的反复追问。不迷信任何单一防护、为每道防线的失效准备下一道防线——这,是我用一次缓存击穿的事故,换来的、关于架构、关于系统鲁棒性的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给热点数据加缓存时,多想一句"它过期的那一瞬间,会发生什么?",那我对着那一根根 DB 负载尖刺排查的这大半天,就值了。
—— 别看了 · 2026