缓存三件套:穿透查没有,击穿热一个,雪崩倒一片——Java 工兵连第 16 篇
「Java 工兵连」第 16 篇,进入最贴业务的缓存篇。面试爱问「缓存穿透、击穿、雪崩的区别」,生产里它们是三种不同的急诊——共同点都是缓存缺席时流量直砸 DB,区别全在缺席的原因。背定义容易混,记一句辨析口诀就再也不混:
穿透查没有,击穿热一个,雪崩倒一片——个别(不存在)、个别(太热)、全体(一起没)。
一、三件套逐个拆
① 穿透:查「没有的东西」
请求的 key 在缓存和 DB 都不存在——典型是恶意攻击(id=-1、遍历不存在的 id)或爬虫脏请求。流程死循环:miss → 查 DB → DB 也没有 → 返回空 → 缓存里没法放(放什么?)→ 下一个同样请求继续打 DB。防御为零,成本全在 DB。
解药一:缓存空值。 DB 查不到就往 Redis 放一个「空标记」(如 "NULL"),短 TTL(30~60 秒)——短,是为了不占内存,也为了这个 id 将来真被创建时不至于被空值挡住。
解药二:布隆过滤器。 前置一层「存在性门卫」:把全量合法 id 预先注册进去,请求先问门卫。布隆的脾气你要记住:说「不在」就一定不在(直接拒,不碰 DB);说「在」只是可能(有误判率,放行去查)。误判率由位数组大小决定,工程上 1% 足够;普通布隆不支持删除(要删得用计数布隆/布谷鸟过滤器),全量重建走定时任务。
② 击穿:热「一个东西」
秒杀商品、爆款词条——单个超热 key 到期的那一毫秒,几万并发同时 miss,全部涌向 DB 重建同一份数据。关键词:热点集中、单个 key 的塌方。
解药一:互斥重建。 miss 后先抢一把锁(SET rebuild:sku:1001 nx ex 10——第 7 篇分布式锁的正用武之地),只有一个线程去查 DB 回填,其他人短暂等待或先返回兜底数据。锁的粒度是「这一个 key 的重建权」,代价是等待。
解药二:逻辑过期。 key 物理上永不过期,过期时间写进 value 里;读到「逻辑过期」的值时不等——先返回旧数据,同时异步触发重建。用短暂的不一致换永远可用的读。对「旧一秒无所谓」的热点数据(榜单、首页推荐),这是最优雅的答案。
③ 雪崩:倒「一片」
两种形态:批量 key 同时到期(凌晨批量导入时统一设了 EXPIRE 3600,一小时后集体阵亡)或 Redis 整体倒下(宕机、网络分区)。洪水整体灌进 DB,DB 倒下后依赖它的服务连环倒——「雪崩」名副其实。
解药是一组组合拳:TTL 加随机抖动(基础 3600s + random(0, 300s),把到期时间打散);多级缓存(本地 Caffeine → Redis → DB,Redis 倒下时本地层还能顶一阵——注意本地缓存容量要小而精);高可用架构(第 15 篇的哨兵/集群,防「整体倒下」这半边);DB 前挂限流熔断(第 20 篇预告)——雪崩已经发生时,限流是保住 DB 的最后一道闸。
二、集大成:一条带防护的读路径
三件解药合到一条代码路径里(hero 图下半的流程),Java 骨架:
public Product getProduct(long id) {
// ① 本地缓存(防雪崩的缓冲层)
Product p = caffeine.getIfPresent(id);
if (p != null) return p;
// ② Redis
String raw = redis.get("sku:" + id);
if (raw != null) {
return "NULL".equals(raw) ? null : parse(raw); // 空值标记也是答案
}
// ③ 布隆门卫:说不在就不在(防穿透)
if (!bloom.mightContain(id)) return null;
// ④ 互斥重建:一个名额查 DB(防击穿)
String lockKey = "rebuild:sku:" + id;
if (redis.set(lockKey, "1", "NX", "EX", 10)) {
try {
p = productDao.findById(id);
// ⑤ 回填:TTL = 基础 + 抖动(防雪崩);DB 没有就缓存空值(防穿透)
if (p == null) redis.setex("sku:" + id, 60, "NULL");
else redis.setex("sku:" + id, 3600 + ThreadLocalRandom.current().nextInt(300), toJson(p));
return p;
} finally { redis.del(lockKey); }
}
Thread.sleep(50); // 没抢到的稍等重试(或返回兜底值)
return getProduct(id);
}
四层防护各管一段:本地顶整体、空值拦假的、锁管重建、抖动打散到期——每个解药都长在读路径上,写路径一条没动。写路径的一致性问题(先更 DB 还是先动缓存)是下一篇的事——读管「不出事」,写管「不脏数据」,两半分开想。
老兵点评
- 三件套的病根不同,药就不能开混。见过「防雪崩的 TTL 抖动」被拿来治穿透(无效),也见过「缓存空值」一把梭所有 miss(新数据的可见性被空值挡住,客诉上门)。先问「缺席的原因是什么」,再翻药方——这篇口诀就是分诊台。
- 热 key 是击穿的近亲,单列一档。击穿是「过期那一下」,热 key 是「一直热到单节点打满」(第 15 篇 Cluster 也救不了——同一个 key 只会在一个槽)。解法是新那套:本地缓存扛读、读写分离副本、key 打散成
sku:1001:{1..N}随机读一份。热点治理值得单独立项,别等大促当天现学。 - 面试段位差:初级背三定义;中级讲三套解药并知道布隆的误判方向(只假阳不假阴);高级讲逻辑过期的一致性代价、热 key 与击穿的边界、以及「限流是雪崩最后的闸」的纵深防御——聊到这层是在聊容量设计。
带走三句话
- 辨析口诀:穿透查没有(空值缓存 / 布隆门卫),击穿热一个(互斥重建 / 逻辑过期),雪崩倒一片(TTL 抖动 / 多级缓存 / 高可用 / 限流兜底)。
- 布隆的脾气:说「不在」必真,说「在」可能误判——只拦「一定不存在」,别指望它当字典。
- 读路径四层防护:本地顶整体、空值拦假的、锁管重建、抖动打散到期;写路径的一致性下一篇见。
本篇是「Java 工兵连」第 16 篇。→ 上一篇:第 15 篇 · Redis 持久化与高可用选型 | 继续阅读:第 17 篇 · 缓存一致性:写库删缓存,删优于更 | 浏览整个专栏 →。
❦ 终 ❦