3024 字
约 10 分钟
13
S12-X07 · 缓存穿透、击穿、雪崩:三种故障与三种对策

凌晨两点,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. 自测

  1. 命中率从 98% 掉到 41%,DB QPS 上涨 70 倍,先看哪个指标区分穿透与雪崩?
  2. 空值缓存为什么必须设短 TTL,不设会怎样?
  3. 布隆过滤器说"存在",实际一定存在吗?删掉数据后它会怎样?
  4. 互斥重建里"二次检查"防的是什么?
  5. 逻辑过期和永不过期 + 后台刷新,本质区别在哪?
  6. 缓存集群整体挂掉时,哪类接口应该快速失败而不是降级读旧值?
S12-X07 · 缓存穿透、击穿、雪崩:三种故障与三种对策
https://www.clxhxhhr.top/posts/5805/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。