跳到正文

缓存三大问题:穿透 · 击穿 · 雪崩 ​

一句话:三者都是缓存没挡住流量、请求打到数据库,区别在「查不存在的 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 打散、把空值和热点处理掉,再谈布隆和逻辑过期,顺序反了会做很多无用功。

把一门技术讲透到既懂也会用