Skip to content

Redis 缓存穿透、击穿、雪崩

提出问题

缓存穿透、缓存击穿、缓存雪崩——这三个词面试出现频率极高,但很多人只背了"布隆过滤器防穿透、互斥锁防击穿、随机过期防雪崩"就以为过关了。面试官换个问法:"设计一个高可用缓存层,怎么防止缓存失效导致 DB 被打崩?"大多数人的回答就露馅了。

这三个问题本质上是缓存与数据库之间的数据一致性 + 可用性博弈:什么时候该查 DB?什么时候该拒绝?什么时候该降级?每个方案都有代价,能说出代价的人才算懂。

分析问题

先厘清三个概念,不是单纯背定义,而是看它们各自在什么场景下发生

缓存穿透:查询不存在的数据

发生条件:请求查询一个缓存和数据库中都不存在的数据。比如查一个不存在的用户 ID,缓存没有,DB 也没有,但每次请求都穿透缓存打进 DB。

为什么危险:如果恶意攻击者刻意构造大量不存在的 key,DB 的连接池和 IO 会瞬间被打满。因为缓存层没有任何拦截,攻击流量直接打到数据库。

生产案例:某电商做周年庆活动,被黑产扫了一波不存在的优惠券码——构造了 100 万个不存在的 code 来请求,Redis 缓存命中率为 0,MySQL 5 分钟内 CPU 冲到 100%,慢查询 2000 条/秒,最终导致整条链路的优惠券查询接口 502。

缓存击穿:热点 key 过期

发生条件:一个被高并发访问的热点 key 突然过期,大量请求同时打到 DB 去重建缓存。

为什么危险:不是所有 key 过期都有问题——只有热点 key 在过期瞬间,大量请求同时发现缓存为空,全部涌入 DB。DB 峰值 QPS 可能瞬间飙到正常值的 10 倍以上。

生产案例:某社交媒体首页热榜,缓存 key 为 hot:list:page:1,TTL=3600s。某个整点所有用户的刷新请求都打到了这个 key,过期后 3 秒内 5000+ 请求直接打到 MySQL,DB 连接池打满,接口超时从 30ms 飙到 8s。

缓存雪崩:大量 key 同时过期或 Redis 宕机

发生条件:要么是大量 key 设了相同过期时间(比如业务代码里 EXPIRE 3600 写死了一个整数),要么是 Redis 实例宕机,导致所有请求都落到 DB。

生产案例:某在线教育平台课程列表缓存,所有 key 过期时间统一设为 EXPIRE 86400(24h),业务上线时恰好是早上 8:00 整。第二天 8:00,所有 key 同时过期,8000+ QPS 瞬间打到 MySQL 从库,主从延迟从 200ms 变成 30s,大量读到脏数据,线上课程价格显示异常,持续 10 分钟才恢复。

解决方案

1. 缓存穿透:布隆过滤器 + 空值缓存

方案一:布隆过滤器(Bloom Filter)

在缓存层之前加一层布隆过滤器,把所有已知的 key 预加载到位数组里。查询时先判断 key 是否存在,不存在直接返回,存在才查缓存。

原理简述:布隆过滤器由 bitmap 和 k 个哈希函数组成。添加元素时,用 k 个哈希函数计算出 k 个位位置,全部置为 1。查询时,k 个位置只要有一个是 0,则元素一定不存在;全部为 1,则可能存在(有误判率)。

流程

请求到达 → 布隆过滤器查 key 是否存在
  ├─ 不存在 → 直接返回 null(拦截成功)
  └─ 可能存在 → 查 Redis → 查 DB → 回填缓存

参数选择BF.RESERVE {key} {error_rate} {capacity}

  • error_rate:误判率,推荐 0.01(1%),每降低一个数量级,每个元素多占约 1.44 倍内存
  • capacity:预估元素数量,设低了会大幅提高误判率
  • 内存估算公式:n * ln(p) / (ln(2)^2) bit,其中 n=预估容量,p=误判率
    • 100 万元素 + 1% 误判率 ≈ 1.13 MB
    • 100 万元素 + 0.1% 误判率 ≈ 1.7 MB
java
// 初始化布隆过滤器(误判率 1%,预估容量 100 万)
BF.RESERVE cache:bloom 0.01 1000000

// 添加已知 key
BF.ADD cache:bloom "user:1001"

// 查询时先判断
Boolean exists = jedis.bfExists("cache:bloom", key);
if (!exists) {
    return null; // 一定不存在,直接返回
}
// 存在才查缓存

代价

  • 误判率 1% 意味着 100 个不存在 key 里有 1 个会穿透到 DB,不是 100% 拦住
  • key 新增时需要同步更新布隆过滤器,如果 key 频繁新增(比如注册用户),维护成本高
  • 布隆过滤器不支持删除,如果 key 过期或被删除,不能从布隆过滤器中移除(除非用计数布隆过滤器,但会多占 3-4 倍内存)

实际落地:把布隆过滤器放在 Redis 自身,用 BF.RESERVEBF.ADD 命令。别自作聪明用 Java 自建本地布隆过滤器——jmh 压测显示,本地布隆过滤器 + Redis 缓存的双次 IO 对延迟敏感业务(要求 < 10ms)不友好,且多实例间数据不同步。

方案二:缓存空值

如果布隆过滤器不好维护(比如 key 频繁新增),可以退而求其次:查到 DB 的 null 结果也缓存起来,设置一个短 TTL(1-5 分钟)。

java
Object value = cache.get(key);
if (value == null) {
    value = db.query(key);
    if (value == null) {
        // 缓存空值,防穿透
        cache.set(key, "NULL", 60); // 1 分钟过期
    } else {
        cache.set(key, value, 3600);
    }
}

代价

  • 空值缓存会占用内存。假设攻击者每秒构造 1 万个不同 key,1 分钟内缓存会多出 60 万个空值条目,按 50 字节/key 算,约 30 MB——对 Redis 来说不算大,但如果持续攻击 1 小时,就是 1.8 GB
  • 需要配合 key 前缀白名单或者空值缓存数量上限使用(比如 SyneRedis cache null.maxKeys=10000,超限后不再缓存空值)
  • 数据真正写入后,必须手动删除空值缓存,否则要等 TTL 过期

方案对比

方案防穿透率内存成本维护成本适用场景
布隆过滤器99%(1% 误判)低(1M 元素 ≈ 1MB)中(需维护 key 同步)key 总量可控,变化不频繁
空值缓存100%高(随攻击量线性增长)低(无需额外组件)key 频繁变化,短期防御

2. 缓存击穿:互斥锁 + 永不过期

方案一:互斥锁(SETNX)

当缓存过期时,不是所有请求都去重建缓存,而是只让一个请求拿到锁去查 DB,其他请求等待或返回旧缓存。

流程

请求读取缓存
  ├─ 缓存命中 → 返回
  └─ 缓存过期 → 尝试获取分布式锁(SETNX)
       ├─ 成功 → 查 DB → 重建缓存 → 释放锁
       └─ 失败 → 短暂等待(50ms)→ 重试读缓存
java
String value = cache.get(key);
if (value == null) {
    // 尝试获取分布式锁,超时 3 秒
    String lockKey = "lock:" + key;
    Boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 双重检查:可能在等待锁的期间,其他线程已经重建了缓存
            value = cache.get(key);
            if (value == null) {
                value = db.query(key);
                cache.set(key, value, 3600);
            }
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 没拿到锁的请求:等待 50ms 后重试,或返回旧缓存
        Thread.sleep(50);
        return cache.get(key); // 此时可能已经重建了
    }
}

关键细节

  • 用 Redisson 的 tryLock(waitTime, leaseTime) 代替原始 SETNX,避免死锁
  • leaseTime 设置一个合理的锁超时(比如 10 秒),确保锁不因程序异常而永久持有
  • 注意双重检查:拿到锁后读一次缓存,可能等待锁期间缓存已经被其他线程重建了
  • 没拿到锁的请求不要一直阻塞——设置 waitTime 上限(比如 500ms),超时直接返回降级数据

代价:锁等待会增加响应延迟。正常缓存命中 1ms,加锁场景下最差情况可能到 20-50ms,对读密集型接口影响明显。

方案二:永不过期 + 异步更新

真正的热点 key,干脆不设过期时间,而是后台异步线程定时刷新

流程

物理不设过期时间,value 里嵌入逻辑过期时间戳
请求读缓存
  ├─ value 中 expireTime > now → 直接返回(新鲜数据)
  └─ value 中 expireTime <= now → 返回旧数据 + 触发异步刷新
       └─ 后台线程异步查 DB → 更新缓存 → 写入新过期时间戳
java
// 缓存永不过期,但 value 里带上过期时间戳
String data = cache.get(key);
if (data != null) {
    long expireTime = parseExpireTime(data);
    if (expireTime < System.currentTimeMillis()) {
        // 逻辑过期,但不阻塞当前请求,返回旧数据
        asyncRefresh(key); // 异步线程去 DB 拉新数据
    }
    return parseData(data);
}

代价:数据会有短暂的不一致(旧数据最多服务到下一次刷新完成)。如果数据一致性要求极高(比如支付金额),这个方案不适用。

方案对比

方案响应延迟数据一致性实现复杂度适用场景
互斥锁增加 20-50ms查询时重建,强一致中(注意死锁)一致性要求高,并发量适中
永不过期+异步无增加最多窗口期不一致低(需额外线程池)读多写少的热点数据

3. 缓存雪崩:过期时间随机化 + 高可用 + 本地缓存兜底

方案一:过期时间加随机偏移

这是成本最低、效果最明显的方案,能避免大量 key 同时过期。

java
// 基础过期时间 1 小时,加上随机 0-600 秒偏移
int baseTtl = 3600;
int randomOffset = new Random().nextInt(600);
cache.set(key, value, baseTtl + randomOffset);

实际效果:假设 10000 个 key 基础 TTL 都是 3600s,不带随机偏移时全部在同一秒过期。加上 0-600s 随机偏移后,过期时间分布在 3600-4200s 之间,同一秒过期的 key 从 10000 个降到约 16 个,DB 压力降了 99.8%。

方案二:Redis 高可用

  • 哨兵模式:主节点挂了自动切换,秒级恢复
  • Redis Cluster:数据分片 + 副本,单节点宕机不影响整体

注意:哨兵切换期间会有 10-30s 的不可用窗口,期间所有请求穿透到 DB。如果业务不能容忍这个窗口,需要配合方案三。

方案三:本地缓存兜底 + 熔断降级

当 Redis 缓存全空时,不能直接让请求打到 DB。需要在业务侧加一层本地缓存(Caffeine/Guava Cache),即使 Redis 数据全丢了,本地缓存还能扛 1-2 分钟。

完整查询链路

客户端 → 本地缓存(Caffeine,1分钟过期)
            ├─ 命中 → 返回
            └─ 未命中 → 布隆过滤器
                          ├─ 不存在 → 返回 null
                          └─ 可能存在 → Redis Cluster
                                          ├─ 命中 → 返回
                                          └─ 未命中 → DB(熔断保护)
                                                        ├─ 正常 → 回填各层缓存
                                                        └─ 超时/错误 → 熔断返回降级数据
java
// 本地缓存,1 分钟过期
Cache<String, String> localCache = Caffeine.newBuilder()
    .expireAfterWrite(1, TimeUnit.MINUTES)
    .maximumSize(10000)
    .build();

// 查询链路:本地缓存 → Redis → DB
String value = localCache.get(key);
if (value == null) {
    value = redis.get(key);
    if (value == null) {
        // 熔断检查:如果 DB 已熔断,直接返回降级数据
        if (circuitBreaker.isOpen()) {
            return fallbackValue;
        }
        value = db.query(key);
        redis.set(key, value, 3600 + randomOffset);
    }
    localCache.put(key, value);
}

熔断参数(Sentinel 推荐配置):

  • 最小请求数:5(采样窗口内至少 5 个请求才做熔断判断)
  • 慢调用阈值:500ms(超过该时长的请求视为慢调用)
  • 错误比例阈值:50%(慢调用和错误占所有请求的 50% 以上时熔断)
  • 熔断时长:10s(熔断后多久尝试半开恢复)
  • 统计窗口:10000ms(滑动窗口统计时间范围)

本地缓存容量估算maximumSize(10000) 意味着如果热点 key 有 10000 个,每个 key 的 value 平均 1KB,本地缓存约 10MB。JVM 堆外内存不受限制,但要考虑 GC 压力——Caffeine 使用 WeakReference 存储,Full GC 频繁时可能导致缓存被提前回收。

综合架构设计

面试官期望的"设计一个高可用缓存层"答案,应该是一张完整的分层架构图:

                    ┌─────────────────────────────────┐
                    │        客户端(App/Web)          │
                    └──────────────┬──────────────────┘

                    ┌──────────────▼──────────────────┐
                    │     本地缓存(Caffeine)          │
                    │  容量:10,000 条,TTL:60s        │
                    │  作用:扛 Redis 宕机场景          │
                    └──────────────┬──────────────────┘

                    ┌──────────────▼──────────────────┐
                    │     布隆过滤器(Redis BF)        │
                    │  误判率:1%,容量:100 万         │
                    │  作用:拦截穿透查询                │
                    └──────────────┬──────────────────┘

                    ┌──────────────▼──────────────────┐
                    │     Redis Cluster(3 主 3 从)    │
                    │  过期策略:随机化 ±10% TTL        │
                    │  热点 key:永不过期 + 异步刷新     │
                    │  作用:缓存层                      │
                    └──────────────┬──────────────────┘

                    ┌──────────────▼──────────────────┐
                    │     Sentinel 熔断降级              │
                    │  慢调用阈值:500ms                 │
                    │  错误比例阈值:50%                 │
                    │  熔断时长:10s                     │
                    └──────────────┬──────────────────┘

                    ┌──────────────▼──────────────────┐
                    │         数据库(MySQL)            │
                    │  连接池:HikariCP(最大 50 个)    │
                    │  读超时:5s                       │
                    └─────────────────────────────────┘

每一层各司其职:

  • 本地缓存扛 Redis 整个宕机(1 分钟内无压力)
  • 布隆过滤器扛穿透攻击(99% 拦截率)
  • 随机化 TTL 扛雪崩(99.8% 减少同时过期)
  • 互斥锁/异步刷新扛热点 key 击穿
  • 熔断器兜底 DB 极限情况

生产落地建议

先做监控,再做治理

没有以下数据之前,不要急着改策略:

  1. 缓存命中率INFO statskeyspace_hits / keyspace_misses。如果命中率低于 90%,说明穿透问题严重
  2. 慢查询SLOWLOG GET 10,看 Redis 侧的慢命令
  3. DB 流量:监控 MySQL 的 QPS 和连接数,按业务接口维度拆分
  4. 延迟毛刺redis-cli --latency -h <host> -p <port>,看 Redis 端到端延迟

分层方案优先级

防御层优先级实施成本防什么
过期时间随机化P0 必做极低(改一行代码)雪崩
空值缓存P0 必做低(加几行代码)穿透
互斥锁P1 推荐中(需把控死锁风险)击穿
本地缓存兜底P1 推荐中(需引入 Caffeine)雪崩+宕机
布隆过滤器P2 可选高(需维护 key 同步)穿透
熔断降级P2 可选高(需全套 Sentinel 部署)所有异常

面试进阶:这三个问题可以串成一个完整的系统设计题——"设计一个高可用缓存层"。如果面试中能说出"布隆过滤器 1% 误判率下每个元素 10 bit"、"本地缓存 1 分钟过期 + 异步刷新保证最终一致"、"熔断器 50% 错误率阈值 10 秒内打开"这些具体参数,就和背八股拉开差距了。再提一句"先看监控再下手,没有指标别做架构决策",面试官会知道你真有生产经验。

生产提醒:数据是架构决策的锚点,不是感觉。先跑一周监控,拿到缓存命中率、DB QPS 峰值、慢查询分布,再决定哪一层加固到哪一步。不要上来就上布隆过滤器和熔断器——一个 1% 误判率的布隆过滤器 + 一个 500ms 阈值的熔断器,可能是你 80% 的收益,但只占 20% 的代码量。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。