2024 年我们的商品详情服务接连出过三次性质不同、但都很危险的故障。第一次,有人用一批不存在的商品 ID 疯狂刷接口,每个请求都绕过 Redis 直接打到数据库,DB 的 CPU 被打满。第二次,一个超级热门的爆款商品,它的缓存恰好过期的那一瞬间,成千上万的请求同时涌向数据库去查同一条数据,DB 又一次扛不住。第三次更夸张,我们一批缓存设了相同的过期时间,结果它们在同一秒集体失效,整个 Redis 仿佛瞬间"失灵",流量像决堤一样冲垮了数据库。这三次故障,正是缓存领域最经典的三个问题——缓存穿透、缓存击穿、缓存雪崩。投了几天把缓存方案系统重做了一遍,本文复盘这次实战。
问题背景
业务:商品详情服务,Redis 缓存 + MySQL,读多写少
事故现象:
- 故障一:大量不存在的商品 ID 请求,DB CPU 被打满
- 故障二:某爆款商品缓存过期瞬间,DB 出现请求尖刺
- 故障三:大批缓存同时失效,DB 瞬间被流量冲垮
现场排查:
# 1. 看缓存读取代码
public Product getProduct(Long id) {
Product p = redis.get("product:" + id);
if (p == null) {
p = db.queryProduct(id); // 缓存没有就查 DB
redis.set("product:" + id, p, 3600); // 回写缓存
}
return p;
}
# 2. 三个问题逐一暴露:
# - id 不存在时:db 查出来是 null,null 不回写缓存,
# 下次同样的 id 又穿透到 db -> 穿透
# - 热点 key 过期瞬间:大量请求同时进入 "查 DB" 分支 -> 击穿
# - 大批 key 用了相同 TTL:同一时刻集体失效 -> 雪崩
根因:
1. 不存在的数据,查询结果(null)没被缓存,每次都穿透到 DB
2. 热点 key 过期时,没有任何机制阻止大量请求并发回源
3. 大量 key 设了相同的过期时间,集中失效
4. 强依赖缓存,Redis 一抖动 DB 就直接裸奔
修复 1:缓存穿透 —— 查一个根本不存在的数据
// === 穿透:请求的数据在缓存和数据库里都不存在 ===
// 缓存里没有 -> 去查 DB -> DB 也没有 -> 不回写缓存
// -> 下次同样的请求,又一次穿透到 DB。
// 如果有人拿大量不存在的 id 来刷,DB 就被直接打穿。
// === 方案 1:把"空结果"也缓存起来 ===
public Product getProduct(Long id) {
String key = "product:" + id;
String cached = redis.get(key);
if (cached != null) {
// 命中缓存,但要区分"真数据"和"空值标记"
return EMPTY_FLAG.equals(cached) ? null : parse(cached);
}
Product p = db.queryProduct(id);
if (p == null) {
// DB 也没有 -> 缓存一个"空值标记",过期时间设短一点
redis.set(key, EMPTY_FLAG, 60); // 空值只缓存 60s
return null;
}
redis.set(key, toJson(p), 3600);
return p;
}
// 空值缓存的过期时间要【短】:万一这个 id 后来真的有数据了,
// 短过期能让缓存尽快自我修正。
// === 方案 2:布隆过滤器(海量不存在 id 时更省内存)===
// 布隆过滤器:一个极省空间的"概率型集合",能快速判断
// "某个元素【一定不存在】或【可能存在】"。
// 把所有真实存在的商品 id 预先放进布隆过滤器,
// 请求进来先问过滤器:
if (!bloomFilter.mightContain(id)) {
return null; // 过滤器说"一定没有" -> 直接返回,不碰缓存和 DB
}
// 过滤器说"可能有",再走正常的缓存 -> DB 流程。
// 特点:有极小的误判率(说"有"其实没有),但绝不会把
// 真实存在的判成"没有",所以用来挡穿透是安全的。
// === 还要在入口做参数校验 ===
// id <= 0、id 明显越界等非法值,在接口层直接拦掉,
// 这是挡掉恶意穿透最廉价的一道防线。
修复 2:缓存击穿 —— 热点 key 失效的瞬间
// === 击穿:某个【热点 key】过期的瞬间,大量请求同时回源 ===
// 平时这个 key 一直在缓存里,岁月静好;
// 但它一旦过期,在重新写回缓存之前的那一小段时间里,
// 成千上万个并发请求同时发现"缓存没有",于是一起冲向 DB
// 查同一条数据 -> DB 被这一下尖刺打垮。
// 注意:击穿是【单个热点 key】,雪崩是【大批 key】,区别在这。
// === 方案 1:用互斥锁,只放一个请求去回源 ===
public Product getProductWithLock(Long id) {
String key = "product:" + id;
String cached = redis.get(key);
if (cached != null) return parse(cached);
// 缓存没命中:用分布式锁,保证只有一个线程去查 DB
String lockKey = "lock:product:" + id;
if (redis.setIfAbsent(lockKey, "1", Duration.ofSeconds(3))) {
try {
// 抢到锁了,double-check 一次(可能别人刚写好了)
cached = redis.get(key);
if (cached != null) return parse(cached);
Product p = db.queryProduct(id); // 只有我去查 DB
redis.set(key, toJson(p), 3600);
return p;
} finally {
redis.delete(lockKey);
}
} else {
// 没抢到锁:稍等一下再读缓存(此时别人多半已写好)
sleep(50);
return getProductWithLock(id);
}
}
// 效果:热点 key 过期后,1000 个并发请求里只有 1 个真正查 DB,
// 其余 999 个等那一个把缓存填好后直接读缓存。
// === 方案 2:热点数据"逻辑过期",永不真正过期 ===
// key 在 Redis 里【不设】物理过期时间,而是把一个
// "逻辑过期时间"字段存进 value 里。
// 读取时判断逻辑时间是否过期:
// - 没过期:直接返回
// - 过期了:返回旧数据的同时,【异步】起一个线程去刷新缓存
// 好处:用户永远能拿到数据(哪怕是稍旧的),绝不阻塞、不打 DB 尖刺。
// === 方案 3:热点 key 干脆不过期,由更新逻辑主动刷新 ===
// 对极少数超级热点,数据变更时主动更新缓存,缓存不设过期。
修复 3:缓存雪崩 —— 大批 key 同时失效
// === 雪崩:大量 key 在同一时刻集体失效,流量瞬间全压到 DB ===
// 两种触发方式:
// 1. 大批 key 设了【相同的过期时间】,到点一起失效
// 2. Redis 实例整体宕机,所有缓存瞬间全部失效
// === 防雪崩 1:过期时间加随机值,把失效时间打散 ===
// 错:所有 key 都 set(key, val, 3600) -> 1 小时后集体失效
// 对:基础过期时间 + 一个随机扰动,让失效时间分散开
int baseTtl = 3600;
int randomTtl = baseTtl + ThreadLocalRandom.current().nextInt(600);
redis.set(key, toJson(p), randomTtl); // 3600 ~ 4200s 之间
// 这样 key 的失效被均匀摊到一个时间段里,DB 压力平滑。
// === 防雪崩 2:Redis 高可用,别让"整体宕机"发生 ===
// 单机 Redis 一挂,缓存全没 = 必然雪崩。
// 用 Redis 主从 + 哨兵,或 Redis Cluster,
// 保证单节点故障时缓存层整体仍然可用。
// === 防雪崩 3:多级缓存,本地缓存兜一层 ===
// 在应用进程内再加一层本地缓存(Caffeine):
// 请求先查本地缓存 -> 没有再查 Redis -> 再没有才查 DB。
// 即使 Redis 整体抖动,本地缓存还能扛住相当一部分热点流量。
// === 防雪崩 4:给 DB 加一道限流 / 熔断,作为最后兜底 ===
// 万一缓存真的整体失效,要有机制限制"同时打到 DB 的请求数",
// 让 DB 在过载边缘"少接一点、慢一点",而不是被冲垮、彻底挂掉。
// 宁可拒绝一部分请求,也要保住 DB 不死 —— 留得青山在。
修复 4:三个问题的对比与组合拳
=== 三者的区别(最容易混淆,务必分清)===
穿透 击穿 雪崩
数据是否存在 DB 里也没有 DB 里有 DB 里有
key 的范围 各种不存在的 key 单个热点 key 大批 key
触发时机 持续被刷 热点 key 过期瞬间 大批 key 同时失效
本质 缓存形同虚设 一个点被打穿 一片同时塌方
=== 一句话记忆 ===
- 穿透:查的东西压根不存在,缓存挡不住,每次都落 DB
- 击穿:一个热点突然失效,大家一起冲过去补
- 雪崩:一大片缓存同时没了,DB 瞬间裸奔
=== 对应的解法 ===
穿透 -> 缓存空值 + 布隆过滤器 + 入口参数校验
击穿 -> 互斥锁回源 + 逻辑过期 + 热点 key 不过期
雪崩 -> 过期时间加随机 + Redis 高可用 + 多级缓存 + DB 限流兜底
=== 组合拳:一个完整的读缓存方法该有什么 ===
1. 参数校验,挡掉明显非法的 id
2. 查缓存,命中(含空值标记)直接返回
3. 没命中,加互斥锁,只放一个请求回源
4. 回源查 DB,有数据正常缓存,无数据缓存空值标记
5. 所有写入缓存的 TTL 都带随机扰动
6. 整条链路对缓存"弱依赖":Redis 挂了也要能降级访问 DB
修复 5:缓存与数据库的一致性
// === 缓存治理绕不开的问题:数据更新时,缓存怎么办 ===
// 数据库更新了,缓存里还是旧的 -> 用户读到脏数据。
// === 错误方案:更新 DB + 更新缓存 ===
// db.update(product);
// redis.set(key, newProduct);
// 问题:两个写操作并发时,缓存可能被旧值覆盖;
// 而且很多写不一定带来读,白白浪费一次缓存写。
// === 推荐方案:更新 DB + 删除缓存(Cache Aside)===
@Transactional
public void updateProduct(Product product) {
db.update(product); // 1. 先更新数据库
redis.delete("product:" + product.getId()); // 2. 再删除缓存
}
// 缓存被删后,下一次读会自然地回源、加载最新数据并重建缓存。
// "删除"比"更新"简单可靠:不用关心新值算法,也少了并发覆盖问题。
// === 仍有的小概率不一致,用延迟双删兜底 ===
// 极端并发下:A 删缓存->A 更新 DB 之间,B 读到旧 DB 值
// 并把旧值写回了缓存。解法是更新后延迟一会儿再删一次:
public void updateWithDoubleDelete(Product product) {
redis.delete("product:" + product.getId()); // 删一次
db.update(product);
// 延迟一段时间(覆盖掉并发读回写的窗口)后再删一次
scheduler.schedule(() ->
redis.delete("product:" + product.getId()),
500, TimeUnit.MILLISECONDS);
}
// === 更强一致:监听 binlog 异步刷缓存 ===
// 用 Canal 订阅 MySQL 的 binlog,数据一变更就异步删/更新缓存,
// 把"维护缓存一致性"从业务代码里彻底解耦出去。
// === 务实的态度 ===
// 缓存与 DB 的强一致代价很高,多数业务能接受"最终一致"。
// 选方案前先问:这个数据短暂不一致,业务能不能容忍?
// 能容忍,就别为了理论上的完美一致把系统搞得过度复杂。
修复 6:缓存监控告警
# 缓存健康监控:命中率是缓存是否"在干活"的核心指标
groups:
- name: cache
rules:
# 1. 缓存命中率过低(缓存形同虚设,可能在穿透)
- alert: CacheHitRateLow
expr: |
rate(cache_hit_total[5m])
/ (rate(cache_hit_total[5m]) + rate(cache_miss_total[5m]))
< 0.9
for: 5m
annotations:
summary: "缓存命中率 < 90%,排查缓存穿透或 key 设计问题"
# 2. 回源 DB 的 QPS 突增(击穿/雪崩的直接信号)
- alert: CacheMissDbQpsSpike
expr: rate(cache_db_fallback_total[1m]) > 500
for: 2m
annotations:
summary: "缓存回源 DB 流量突增,排查热点 key 击穿或缓存雪崩"
# 3. Redis 不可用
- alert: RedisDown
expr: redis_up == 0
for: 1m
annotations:
summary: "Redis 实例不可用,缓存层失效,DB 面临雪崩风险"
# 4. 空值缓存占比过高(可能正被恶意刷不存在的 id)
- alert: NullCacheHigh
expr: rate(cache_null_set_total[5m]) > 100
for: 5m
annotations:
summary: "空值缓存写入量过高,疑似缓存穿透攻击"
优化效果
指标 治理前 治理后
=============================================================
不存在 id 请求 每次穿透到 DB 空值缓存+布隆过滤器拦截
热点 key 过期 千请求齐打 DB 互斥锁,仅 1 个回源
缓存过期时间 大批 key 相同 TTL 基础值 + 随机扰动
Redis 架构 单机,挂了即雪崩 主从哨兵 + 本地缓存兜底
缓存命中率 故障时跌到 60% 稳定 98%+
回源 DB QPS 尖刺打满 DB 平滑,峰值降 95%
缓存一致性 更新缓存,易脏 更新 DB 删缓存 + 双删
DB 兜底 无,缓存失效即裸奔 DB 限流,过载也不被打死
缓存可观测 无 命中率/回源/存活监控
治理过程:
- 复盘三次故障,归类穿透/击穿/雪崩:0.5 天
- 缓存空值 + 布隆过滤器防穿透:1 天
- 互斥锁 + 逻辑过期防击穿:1 天
- TTL 随机化 + 多级缓存防雪崩:1 天
- 一致性方案改造 + 监控接入:1.5 天
避坑清单
- 穿透是查不存在的数据、击穿是单个热点 key 失效、雪崩是大批 key 同时失效
- 防穿透:把空结果也缓存起来(过期时间设短),配合布隆过滤器和入口参数校验
- 布隆过滤器能判定元素"一定不存在",有极小误判率但不会漏真实数据,适合挡穿透
- 防击穿:热点 key 失效时用互斥锁,只放一个请求回源,其余等缓存重建
- 逻辑过期方案让热点 key 永不物理过期,过期后返回旧值并异步刷新,不阻塞
- 防雪崩:过期时间一定要加随机扰动,避免大批 key 在同一时刻集体失效
- Redis 要做高可用(主从哨兵/Cluster),单机挂掉就是整体雪崩
- 加本地缓存做多级缓存,Redis 抖动时仍能扛住热点流量
- 缓存更新用 Cache Aside:更新 DB 后删除缓存,而不是更新缓存
- 给 DB 留一道限流兜底,缓存整体失效时宁可拒绝部分请求也要保住 DB 不死
总结
这次缓存治理,把缓存穿透、击穿、雪崩这三个经典问题在我们自己的系统里完整地各演了一遍,也让我对缓存这件事有了一个更清醒的认识。过去我们对缓存的理解非常朴素:缓存就是挡在数据库前面的一道墙,请求先问缓存,缓存没有再问数据库——这个模型在岁月静好的时候完全够用,可它对"墙会不会被绕过""墙塌了会怎样"这些问题毫无准备。这三次故障,恰好就是从三个不同的角度,把这道墙的脆弱之处一一击穿。先说穿透,它的可怕在于,被请求的数据在缓存和数据库里压根就不存在,于是"查缓存没有就查数据库"这个逻辑彻底失效——数据库查出来也是空,空结果又没有被写回缓存,导致下一个相同的请求会以一模一样的路径再穿透一次,缓存这道墙对这类请求形同虚设。如果有人恶意拿海量不存在的 ID 来刷,数据库就等于在直接对外裸奔。解法的核心思路很有意思:既然"没有数据"也是一种确定的结果,那就把"没有"这个结论本身也缓存下来,只是过期时间要设得短一些,以免这个 ID 将来真的有了数据时缓存迟迟不更新;数据量特别大时,再用布隆过滤器这种极省内存的概率型结构在最前面拦一道。再说击穿,它和穿透的区别必须分清:击穿针对的是一个真实存在、而且极度热门的单个 key,问题出在它过期的那一个瞬间——在缓存被重新填好之前的极短窗口里,原本全部由缓存承接的巨大流量会齐刷刷地涌向数据库去查同一条数据,形成一个尖锐的脉冲把数据库打垮。解法的精髓是"只放一个进去":用一把互斥锁,保证面对同一个失效的热点 key,一千个并发请求里只有一个真正去查数据库并重建缓存,其余的稍作等待后直接读重建好的缓存。最后是雪崩,它和击穿的区别在于规模——击穿是一个点被打穿,雪崩是一大片缓存在同一时刻集体消失,要么是因为大量 key 当初被设置了完全相同的过期时间而到点一起失效,要么是因为 Redis 整体宕机。雪崩的防范因此也是多层的:最基础也最容易被忽略的一条,是给每个 key 的过期时间都叠加一个随机扰动,让它们的失效时刻被均匀地摊开;再往上是保证 Redis 自身的高可用,不让"整体宕机"这件事轻易发生;还可以在应用进程内再加一层本地缓存做多级兜底。但这次复盘让我感触最深的,其实是贯穿这三个问题始终的最后一条防线——给数据库本身也加一道限流。因为不管缓存方案做得多周密,我们都必须承认一个前提:缓存终究是可能失效的,它是性能的优化项,而不是不可动摇的依赖。所以系统真正的底线,是当缓存这道墙因为任何原因塌掉时,数据库不能跟着一起死——哪怕这意味着要主动拒绝掉一部分请求、让一部分用户暂时失败,也好过数据库被流量彻底冲垮、导致整个系统全盘崩溃。留得青山在,这就是缓存治理里最朴素、也最重要的那条命脉。
—— 别看了 · 2026