凌晨两点,DB 的 QPS 从 800 涨到 6 万,缓存命中率从 98% 掉到 41%,应用线程池全卡在 SELECT 上。事后复盘发现不是缓存坏了,是有人写了个循环去查一批不存在的 orderId——每个请求都穿透到 DB,而 DB 本来只该承受 2% 的流量。
这三个词常被混用:穿透是"查的东西不存在",击穿是"一个热点 key 没了",雪崩是"一片 key 同时没了"。触发条件不同,对策完全不同,先把边界划清。
1. 三者分野
| 故障 | 触发条件 | 流量特征 | 直接后果 |
|---|---|---|---|
| 穿透 | 查不存在的数据,缓存永远不命中 | 大量不同的非法 key | 每次请求都打到 DB |
| 击穿 | 单个热点 key 到期或重建 | 同一 key 的高并发 | DB 上出现单点尖峰 |
| 雪崩 | 大批 key 同时失效,或缓存整体不可用 | 全站流量同时下坠 | DB 被打穿,恢复时间很长 |
判别口诀:看 key 的分布。各不相同且都不存在 → 穿透;全一样 → 击穿;各不相同但都合法 → 雪崩。
2. 穿透:三层防御
// 第一层:空值缓存,用短 TTL 把"不存在"也变成一次命中
String key = "order:" + id;
String v = redis.opsForValue().get(key);
if (v == null) {
Order o = mapper.selectById(id);
if (o == null) {
redis.opsForValue().set(key, "__NULL__", 60, TimeUnit.SECONDS); // 短 TTL
return null;
}
redis.opsForValue().set(key, JSON.toJSONString(o), 30, TimeUnit.MINUTES);
return o;
}
return "__NULL__".equals(v) ? null : JSON.parseObject(v, Order.class);
| 层次 | 手段 | 挡住什么 | 代价 |
|---|---|---|---|
| 入口 | 参数校验(id 格式、范围、签名) | 明显非法的随机串 | 只挡低级攻击 |
| 缓存 | 空值缓存 + 短 TTL(30–120 秒) | 重复查同一个不存在的 key | 占用内存;与新增数据有短暂不一致 |
| 前置 | 布隆过滤器 | 全部不存在的 key | 有误判;元素不可删 |
顺序不能反:参数校验最便宜,布隆次之,空值缓存最贵。直接上空值缓存,遇到随机 id 攻击会先爆内存——攻击者每次给的 key 都不同。
3. 布隆过滤器的两个硬限制
Redis 7 用 RedisBloom 模块(BF.ADD / BF.EXISTS),或在应用内用 Guava BloomFilter:
BF.RESERVE order:bloom 0.01 1000000 # 误判率 1%,容量 100 万
BF.ADD order:bloom 8848
BF.EXISTS order:bloom 9999 # 0 表示一定不存在;1 表示可能存在
| 限制 | 表现 | 应对 |
|---|---|---|
| 误判(false positive) | 判定"存在"但实际不存在,请求仍打到 DB | 误判率按容量与哈希函数数权衡,1% 量级可接受 |
| 不可删除 | 数据删了过滤器里还在,误判率只升不降 | 用计数布隆,或按周期重建(重建期间双写) |
| 容量固定 | 超容量后误判率快速上升 | 预留 2 倍容量,或分片成多个布隆 |
| 全量预热 | 启动时要装载全量 id | 冷启动从 DB 分批灌入,期间靠空值缓存兜 |
布隆只能答"一定不存在"或"可能存在",它不能替代缓存,只能把明显不存在的请求挡在缓存之前。
4. 击穿:互斥重建与逻辑过期
热点 key 到期的那一刻,几百个请求同时 miss,同时去查 DB——这就是击穿。两个思路:让只有一个请求去重建,或者干脆不让 key 消失。
public Order load(Long id) {
String key = "order:" + id;
String json = redis.opsForValue().get(key);
if (json != null) return JSON.parseObject(json, Order.class);
RLock lock = redisson.getLock("lock:rebuild:" + id);
if (!lock.tryLock(0, 3, TimeUnit.SECONDS)) { // 抢不到锁 → 走降级
return fallback(id);
}
try {
json = redis.opsForValue().get(key); // 二次检查,别人可能已重建
if (json != null) return JSON.parseObject(json, Order.class);
Order o = mapper.selectById(id);
redis.opsForValue().set(key, JSON.toJSONString(o), 30, TimeUnit.MINUTES);
return o;
} finally {
lock.unlock();
}
}
三个要点:锁等待给 0 或极短(抢不到就降级,不要排长队)、拿到锁必须二次检查(前一个重建者可能刚写完)、租期大于重建耗时(否则会引入第二个重建者)。
| 对策 | 一致性 | 实现复杂度 | 适用 |
|---|---|---|---|
| 互斥重建 | 强(同一时刻只有一个回源) | 中,需要分布式锁 | 常规热点数据 |
| 逻辑过期(不删 key,value 里带过期时间) | 弱(可能返回旧值) | 中,需异步刷新 | 可容忍短暂旧值 |
| 永不过期 + 后台定时刷新 | 弱(刷新间隔内是旧值) | 低 | 极热且变化少的数据 |
逻辑过期的写法是 key 不设 TTL,value 里放 expireAt;读到已过期就返回旧值并提交异步刷新。它把"阻塞等待"换成"短暂不一致",在详情页、榜单类场景更划算。
5. 雪崩:先想清楚降级到什么
// 写入时把过期时刻打散:基础 30 分钟 + 0–300 秒随机
long ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redis.opsForValue().set(key, json, ttl, TimeUnit.SECONDS);
| 对策 | 作用点 | 说明 |
|---|---|---|
| TTL 加随机 | 预防 | 基础 TTL 上叠随机量,打散失效时刻 |
| 多级缓存 | 缓冲 | 本地 Caffeine(秒级 TTL)+ Redis,Redis 挂了还有本地 |
| 熔断降级 | 止损 | DB 侧指标超阈值就切断非核心读 |
| 缓存高可用 | 恢复 | 哨兵/集群、多副本、故障转移 |
| 预热 | 恢复 | 重启后先灌热点,别让冷缓存直接接全量流量 |
只做第四项(高可用)是最常见的盲区:缓存恢复的瞬间,全站流量会同时回源,DB 未必扛得住。所以真正要回答的问题是:
| 缓存不可用时 | 降级到什么 |
|---|---|
| 商品详情、评论 | 旧值或占位图 + "稍后重试" |
| 库存、价格 | 不降级,直接失败——读旧值会超卖 |
| 榜单、推荐 | 静态兜底页,TTL 放到分钟级 |
| 登录态 | 有本地 token 校验的走本地,没有的直接要求重新登录 |
原则:能返回旧值的降级,不能返回旧值的就快速失败。最糟的是"降级到直接查 DB",那等于把缓存故障放大成 DB 故障。
6. 限流与预热放在哪一层
限流要放在回源之前,不是 DB 前面:回源线程池打满后,限流器自己也在排队。用信号量控制并发回源数(如 20),超出直接降级。预热放在流量接入之前:发布后先跑一遍热点 key,或从访问日志取 Top N 主动灌入,避免新实例冷启动接全量流量。
7. 三种故障的可观测信号
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)'
redis-cli --latency -h 127.0.0.1 -p 6379 # 主线程是否被阻塞
| 信号 | 采集方式 | 对应故障 |
|---|---|---|
| 命中率骤降 | INFO stats 的 keyspace_hits / (hits+misses) |
三者都会,需看 key 分布细分 |
| DB QPS 突增且慢查询变多 | 连接池活跃数、SHOW PROCESSLIST |
穿透(大量不同 key)、击穿(单点尖峰) |
| 缓存 QPS 正常但 DB 高 | 对比两层 QPS | 穿透最典型 |
大量 GET 返回 nil |
客户端埋点按 key 前缀聚合 | 穿透 |
| 同一 key 的并发回源 | 重建锁的等待数、抢锁失败率 | 击穿 |
| 多 key 同时 miss 的时间聚集 | miss 的时间戳直方图 | 雪崩(看是否集中在整点/发布后) |
只看总命中率不够:95% 的命中率也可能是灾难,如果那 5% 全落在 DB 最贵的查询上。
8. 每种对策的代价
| 对策 | 内存 | 一致性 | 复杂度 |
|---|---|---|---|
| 空值缓存 | 中(被攻击时不可控) | 新增数据后短暂查不到 | 低 |
| 布隆过滤器 | 低(约为元素数的 1–2 字节/个量级) | 删除后仍判存在 | 中 |
| 互斥重建 | 无额外 | 强 | 中(引入锁) |
| 逻辑过期 | 无额外 | 弱 | 中 |
| 多级缓存 | 高(本地堆内存) | 弱,各实例不一致 | 中高 |
| 熔断降级 | 无 | 功能降级 | 中(需要开关与预案演练) |
9. 常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 空值缓存不设 TTL | 攻击流量把 Redis 内存打满,触发淘汰把热点挤掉 | 空值必须短 TTL(分钟级以内) |
| 互斥锁租期太短 | 重建未完成锁就过期,第二个线程也去回源 | 租期 > 重建 P99,或走看门狗 |
| TTL 随机范围太小(如 ±5 秒) | 失效仍然聚集,雪崩没防住 | 随机幅度覆盖一个业务周期 |
| 只做缓存高可用,不看 DB 上限 | 恢复瞬间打穿 DB | 回源限流 + 预热 + 熔断 |
用 KEYS 或全量扫描找热点 |
阻塞 Redis 主线程,制造第二次故障 | 用 SCAN 或代理层统计 |
| 降级逻辑没演练 | 真故障时降级代码自己抛异常 | 定期注入缓存故障演练 |
10. 排错
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 加了空值缓存命中率仍不升 | 攻击 key 每次不同,空值缓存无效 | 上布隆 + 入口参数校验 |
| 布隆判定全部为"不存在" | 容量超限或重建时数据没灌完 | 扩容重建,检查误判率配置 |
| 重建锁大量超时 | 回源本身慢(慢 SQL),锁被长时间占用 | 先治 DB,再把锁等待设为 0 走降级 |
| 逻辑过期后数据一直不刷新 | 异步刷新线程池满 / 刷新任务被丢弃 | 刷新任务单独线程池 + 失败重试 |
| 缓存恢复后 DB 又挂一次 | 无预热、无回源限流 | 预热脚本 + 信号量限流 |
| 各实例读到的值不同 | 本地缓存 TTL 太长 | 缩短本地 TTL,或用订阅发布失效通知 |
11. 边界:什么时候不该用缓存兜
| 场景 | 说明 |
|---|---|
| 强一致读(余额、库存扣减前校验) | 缓存只能加速展示,判定必须回源或走 DB 条件更新 |
| 写多读少 | 缓存写放大,收益为负 |
| 数据量小到 DB 完全扛得住 | 加缓存只增加故障面 |
| 缓存被当作唯一数据源 | 那是"用 Redis 当库",要按持久化与高可用重新设计,不是缓存问题 |
缓存的作用是把 DB 从流量里摘出来,不是让数据变准。"读到旧值就出错"的地方,缓存只能是旁路。
12. 自测
- 命中率从 98% 掉到 41%,DB QPS 上涨 70 倍,先看哪个指标区分穿透与雪崩?
- 空值缓存为什么必须设短 TTL,不设会怎样?
- 布隆过滤器说"存在",实际一定存在吗?删掉数据后它会怎样?
- 互斥重建里"二次检查"防的是什么?
- 逻辑过期和永不过期 + 后台刷新,本质区别在哪?
- 缓存集群整体挂掉时,哪类接口应该快速失败而不是降级读旧值?