主题
缓存三大问题:穿透 · 击穿 · 雪崩
一句话:三者都是缓存没挡住流量、请求打到数据库,区别在「查不存在的 key」「单个热点刚好过期」「大面积同时失效」;处置手段不能混用。
适用版本:Redis 7.x / 8.x。布隆过滤器命令需要 RedisBloom 模块或 Redis 8 内置能力。
为什么需要
缓存用 Cache-Aside:先读缓存,未命中再读数据库并回写。正常流量下数据库只承受少量未命中;一旦未命中的比例或并发突然升高,数据库就会被放大后的流量打穿。三种失效模式的触发条件不同,所以方案也不同:给穿透加布隆,给击穿加互斥,给雪崩加随机 TTL——用错等于没做。
核心机制
| 维度 | 穿透 | 击穿 | 雪崩 |
|---|---|---|---|
| 对象 | 缓存和数据库都不存在的 key | 单个热点 key 过期 | 大量 key 同时过期,或 Redis 整体不可用 |
| 现象 | 每次都 Miss,持续打库 | 一瞬间 N 个请求抢着重建 | 缓存层大面积失能 |
| 处置 | 空值缓存、布隆过滤器、参数校验、限流 | 互斥锁、逻辑过期、预热 | TTL 随机偏移、高可用、本地缓存、限流 |
穿透:查一个根本不存在的东西
来源通常是恶意构造的 ID、参数没校验(负数、超长字符串)、爬虫抓到的过期链接。缓存永远不会命中,每次请求都落到数据库。
空值缓存:数据库也没有时,缓存一个空标记,TTL 设 5–10 分钟。实现简单,但会占内存,而且数据库后来新增了这条数据时,缓存里还是空值;所以 TTL 要短,新增数据时主动删掉空标记。
布隆过滤器:写入时把存在的 key 哈希到 bit 数组;查询时如果任一 bit 为 0,说明一定不存在,直接返回;全为 1 只能说可能存在,仍要查缓存和数据库。它没有假阴性,只有假阳性,且标准布隆不支持删除(bit 会被多个 key 共享)。误判率常用 0.01%–0.1%,按容量和业务对误判的容忍度计算,不要写死一个「默认值」。理论估算:100 万条、误判率 0.01% 约需 2.4 MB,0.1% 约需 1.8 MB,实际还要加实现开销。
击穿:一个热点 key 过期
秒杀商品、明星微博这类 key,QPS 极高。过期瞬间大量请求同时 Miss,全部去查数据库重建,数据库被同一个查询反复轰击。
互斥锁:第一个发现 Miss 的请求用 SET lock NX EX 抢锁,只有抢到的去查库并回写,其余请求短暂等待后重读缓存。要点:锁必须带过期时间;等待用循环 + 最大重试次数,不要递归;超时后降级返回兜底值。
逻辑过期:key 在 Redis 里不设物理 TTL(或设得很长),value 里带一个逻辑过期时间。读到时如果逻辑过期,先返回旧值,再异步开线程重建。优点是所有请求都不阻塞,缺点是一段时间内返回旧数据。
| 互斥锁 | 逻辑过期 | |
|---|---|---|
| 一致性 | 等数据库返回后才返回,较强 | 可能短暂返回旧值 |
| 可用性 | 等待锁的请求会被阻塞 | 全部无阻塞返回 |
| 适用 | 实时性要求高 | 大促、秒杀等可用性优先 |
逻辑过期依赖预热:活动前把热点数据批量写入,避免活动开始瞬间缓存为空。
雪崩:大面积同时失效
两条路径:大量 key 在同一时刻过期(集中预热、批量写入),或者 Redis 节点/集群整体不可用。
- 事前:TTL 加随机偏移,例如
base + random(0, 300)秒;关键数据用 Sentinel/Cluster + 持久化,避免单点。 - 事中:本地缓存(Caffeine)顶一段时间;对数据库入口限流,宁可部分请求降级,也不要把数据库打死。
- 事后:AOF/RDB 恢复、Sentinel 自动切换、缓存预热。
反模式:为了防止雪崩把 TTL 设成永不过期,结果内存涨满触发淘汰,热点反而被淘汰,人为制造击穿。
实战场景
一、负数 ID 把数据库打满:穿透
现象:某内容详情接口 QPS 从 3000 涨到 1.2 万,MySQL CPU 95%,但 Redis 命中率从 98% 掉到 22%。审计日志显示请求的 content_id 大量是不存在的负数与超长随机串,每次查询都落库。
排查:缓存里永远不会命中——因为数据本身不存在。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 未命中的 key 形态 | 网关访问日志按 id 分类:负数、0、超长串、超出业务范围 | 不存在的 id 占比即穿透比例 |
| Redis 命中率 | keyspace_hits / (hits+misses) 与 miss 的 key 模式 | 命中率骤降 + miss 集中在异常 key |
| 是否有空值缓存 | 抽查一个不存在的 id 是否被缓存 | 没有即每次落库 |
| 布隆过滤器 | 是否存在、容量与误判率配置、是否覆盖新增数据 | 未覆盖或容量估算错误都会失效 |
| 参数校验 | 入口对 id 范围/格式是否有校验 | 校验缺失是穿透的入口 |
| 来源 | IP/UA 分布、是否有爬虫特征 | 恶意构造通常集中来源 |
对症做法:
java
// 1. 空值缓存:TTL 短(5~10 分钟),并在数据新增时主动删除空标记
public Content get(long id) {
if (id <= 0 || id > MAX_ID) return Content.EMPTY; // 入口校验先挡掉明显的
String cached = redis.get("c:" + id);
if (NULL_MARKER.equals(cached)) return Content.EMPTY; // 命中空标记,不落库
if (cached != null) return parse(cached);
Content c = dao.load(id);
if (c == null) {
redis.set("c:" + id, NULL_MARKER, Duration.ofMinutes(5)); // TTL 要短,避免新增数据长期读到空
return Content.EMPTY;
}
redis.set("c:" + id, serialize(c), ttlWithJitter(Duration.ofHours(2)));
return c;
}
// 2. 写路径同步清掉空标记:新增内容时 DEL c:{id},否则 5 分钟内仍读不到text
3. 布隆过滤器按容量与容忍误判率算参数,不写死“默认值”:
误判率常用 0.01%–0.1%;理论估算 100 万条约需 2.4 MB(0.01%)或 1.8 MB(0.1%),
实际还要加实现开销。
规则:任一 bit 为 0 → 一定不存在,直接返回;全为 1 → 可能存在,仍要查缓存和库。
4. 标准布隆不支持删除(bit 被多个 key 共享)。
需要删除时用计数布隆/缩放布隆,或改用“按天分桶 + 过期重建”的方式。
5. 兜底:对数据库入口限流(宁可部分请求降级返回“稍后重试”),
并在网关侧对异常 id 做频次限制。复盘要点:判据是“不存在 id 的落库比例接近 0”——用一条 SQL 审计口径统计“查询返回空结果的比率”,它应该与 Redis 空标记命中数对得上。布隆过滤器是概率结构,只能挡“一定不存在”,不能保证“存在”,把它当唯一防线会漏。
二、秒杀商品缓存到期的那 2 秒,数据库连接池被占满
现象:活动开始整点,商品详情缓存集中到期。Redis 里那个 key 过期的瞬间,同一查询在 2 秒内并发 4000+ 次打到 MySQL,连接池耗尽,连带其他接口一起超时;等缓存重建完成,DB 的慢查询日志里已经全是同一条 SQL。
排查:单个热点 key 过期 = 全部请求同时 Miss。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 过期时刻与失败时刻 | Redis key 的 TTL 与 DB 连接等待的起始时间 | 完全重合即击穿 |
| 热点集中度 | redis-cli --hotkeys 或访问日志按 key 聚合 | 单 key 占比极高 |
| 是否串行化重建 | 有无互斥锁;锁是否带过期时间 | 无锁即并发重建 |
| 等待策略 | 抢锁失败方是循环重试还是递归;有无最大次数 | 递归会栈溢出,无上限会长时间挂住 |
| 重建耗时 | 单条查询的 P99 与 DB 当时负载 | 重建越慢,Miss 窗口越长 |
对症做法:
java
// 方案 1:互斥锁重建 —— 一致性强,等待方被阻塞,适合实时性要求高的场景
public Sku loadSku(long id) {
String v = redis.get("sku:" + id);
if (v != null) return parse(v);
String lockKey = "lock:sku:" + id;
boolean held = redis.set(lockKey, token, SetArgs.Builder.nx().px(3000)); // 锁必须带过期
if (!held) {
for (int i = 0; i < 5; i++) { // 循环 + 最大重试,不递归
sleepWithJitter(20);
String again = redis.get("sku:" + id);
if (again != null) return parse(again);
}
return skuService.fallback(id); // 超时返回兜底值,不放行到 DB
}
try {
Sku s = dao.load(id); // 只有抢到锁的一个人查库
redis.set("sku:" + id, serialize(s), ttlWithJitter(Duration.ofHours(2)));
return s;
} finally {
redis.eval(CAD_LUA, lockKey, token); // 比对 token 再删,避免删别人的锁
}
}
// 方案 2:逻辑过期 —— 全部无阻塞返回,代价是短暂返回旧值,适合大促/秒杀
// key 不设物理 TTL(或设很长),value 内带 expireAt:
// 读到已逻辑过期 → 立即返回旧值 + 异步线程重建(单线程重建,避免并发打库)text
1. 逻辑过期依赖预热:活动前批量写入热点数据,避免活动开始瞬间缓存为空。
2. 两种方案按“一致性 vs 可用性”选,不是谁更高级:
库存校验类不能返回旧值 → 互斥锁;展示类宁可旧一点 → 逻辑过期。
3. 活动前把 TTL 打散(base + random),避免整点集体到期。
4. DB 侧再加一道:这类热点查询设语句超时与并发上限,
同一 SQL 并发超过阈值(例如 20,经验值需校准)直接拒绝并返回兜底。复盘要点:判据是“缓存 Miss 时到 DB 的并发数是否被限制在一个小常数内”——互斥锁与单线程重建都能做到,直接放行则做不到。击穿演练的做法:把一个热点 key 手动删除并在高 QPS 下读取,观察 DB QPS 曲线是否只出现一次重建。
三、集中预热 + 固定 TTL,五分钟一次全量落库
现象:为提升命中率做了“启动时全量预热 8 万个 key,统一 TTL 2 小时”。结果每两小时出现一次 DB 高峰,持续约 5 分钟,期间接口 P99 翻倍;后来 Redis 一次节点故障,恢复期间所有读全部落库,DB 直接被压垮。
排查:雪崩有两条路径——大面积同时失效,或节点整体不可用。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 过期时刻分布 | 预热脚本写入的 TTL 是否完全一致 | 全部同一秒到期即注定周期雪崩 |
| DB 高峰周期 | 慢查询突增的时间间隔是否等于 TTL | 与 TTL 一致即确认 |
| 淘汰策略 | maxmemory-policy 与 evicted_keys 计数 | 内存满时会主动淘汰热点 |
| 高可用 | 是否单节点、是否有 Sentinel/Cluster + 持久化 | 节点不可用是第二条路径 |
| 本地缓存 | 是否有 Caffeine 之类的进程内兜底 | 没有则 Redis 挂即全量穿透 |
| DB 入口保护 | 是否有并发上限/限流/熔断 | 决定是“部分降级”还是“打死” |
一个常见反模式要写清:为了防止雪崩把 TTL 设成永不过期,结果内存涨满触发淘汰,热点反而被淘汰,人为制造击穿。
对症做法:
java
// 1. TTL 加随机偏移,把“同一秒到期”摊成一段窗口
Duration ttlWithJitter(Duration base) {
long extra = ThreadLocalRandom.current().nextLong(0, 300); // base + random(0,300) 秒
return base.plusSeconds(extra);
}
// 2. 预热分批执行:按 key 分片 + 每批之间 sleep,把写入与后续过期都摊开
Lists.partition(keys, 2000).forEach(batch -> {
mset(batch);
sleep(200);
});
// 3. 多级缓存:进程内 Caffeine 顶一段,Redis 不可用时不至于全量落库
Cache<Long, Sku> local = Caffeine.newBuilder().maximumSize(5_000)
.expireAfterWrite(Duration.ofSeconds(3)).build(); // 短 TTL,控制不一致窗口text
4. Redis 侧的高可用是前提:Sentinel/Cluster + 持久化,避免单点;
恢复后要先预热再放流量,否则“恢复即雪崩”。
5. 数据库入口限流:超过阈值直接返回降级值,
宁可部分请求降级,也不要把数据库打死。
6. TTL 要有下限而不是无穷:永不过期会让内存占满并触发淘汰,
热点被淘汰比未命中更糟。复盘要点:判据是“过期时刻的分布是否平滑”——统计 expired_keys 的每分钟增速,出现尖峰就说明还有集中 TTL;配合 DB 高峰时间点交叉验证。三类问题要分开治理:穿透用空值/布隆,击穿用互斥锁或逻辑过期,雪崩用 TTL 分散 + 高可用 + 限流,任何一招都对不上另外两类,混用会导致问题反复出现。相关内存与淘汰口径见 内存管理。
最小上手
bash
redis-cli
# 布隆过滤器(Redis 8 或加载 RedisBloom)
BF.RESERVE login:filter 0.001 1000000
BF.ADD login:filter user:1
BF.EXISTS login:filter user:2 # 0:一定不存在;1:可能存在
# 击穿场景的互斥锁
SET lock:rebuild:product:1 <uuid> NX EX 10
# 观察命中率与淘汰
INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys"生产动作
- 空值 TTL 5–10 分钟,新增数据时主动删空标记;空值缓存要计入容量。
- 布隆过滤器要能重建:数据新增后定期重建或使用可扩容变体;删除需求用计数布隆或 Redis Set 白名单。
- 互斥锁要设超时和降级:锁 TTL 大于最慢的数据库查询,等待方有最大重试次数,超时返回兜底而不是无限等。
- 热点 key 用逻辑过期 + 预热:物理 TTL 仍要保留兜底,防止永远不失效。
- TTL 随机偏移:集中写入的场景必须加,偏移范围按业务容忍度定,常见 0–300 秒。
- 监控:
keyspace_hits/misses、数据库 QPS、evicted_keys、--hotkeys(需 LFU)、--bigkeys。
边界
- 布隆过滤器不能删元素:标准实现删除会误伤其他 key;数据变化频繁时重建成本高。
- 空值缓存会不一致:数据库新增数据后,最长要等一个空值 TTL 才能恢复。
- 互斥锁降低吞吐:等待方被阻塞,高并发下要有超时和降级,否则锁本身成为瓶颈。
- 逻辑过期返回旧数据:对金额、库存等强实时字段不适用。
- 永不过期不是方案:内存有限,最终仍会触发淘汰,只是把问题推迟到更不可控的时刻。
- Redis 整体挂掉时:布隆、互斥锁、逻辑过期都依赖 Redis,必须有本地缓存或限流兜底。
讲清楚
穿透是查一个缓存和数据库都没有的 key,解决办法是让请求在到达数据库前被拦掉:空值缓存、布隆过滤器、参数校验。击穿是单个热点 key 过期,核心是只让一个请求去重建,其他请求等或者拿旧值。雪崩是大量 key 同时失效或 Redis 挂了,要靠随机 TTL、高可用、本地缓存和限流一起顶。
三者容易混,是因为现象都表现为数据库压力升高;但根因不同,方案不能互换。生产上先把 TTL 打散、把空值和热点处理掉,再谈布隆和逻辑过期,顺序反了会做很多无用功。