跳转至

Redis 缓存三大问题:穿透、击穿、雪崩


1. 引入:为什么会有这三大问题?

Redis 作为缓存层,正常流程是:先查缓存,命中则返回;未命中则查数据库,并将结果写入缓存

三大问题都是这个流程被破坏的情况:

问题 本质 后果
缓存穿透 查询的数据根本不存在,缓存永远不会命中 每次请求都打到 DB
缓存击穿 单个热点 Key 突然过期,大量并发同时未命中 瞬间大量请求打到 DB
缓存雪崩 大量 Key 同时过期,缓存层集体失效 大规模请求打到 DB,DB 崩溃

2. 三大问题对比图

flowchart TB
    subgraph ct["缓存穿透 - 查询不存在的数据"]
        direction LR
        A1["大量请求\n查询 ID=-1"] --> B1{"Redis 缓存?"} -->|未命中| C1["查 MySQL\n数据不存在"] --> D1["每次都穿透到 DB\n→ DB 被打垮"] --> E1["解决方案:\n① 布隆过滤器拦截\n② 缓存空值(TTL 短)"]
    end

    subgraph cb["缓存击穿 - 热点 Key 突然过期"]
        direction LR
        A2["秒杀商品 Key\n突然过期"] --> B2{"Redis 缓存?"} -->|未命中| C2["大量并发请求\n同时查 DB"] --> D2["DB 瞬间压力骤增"] --> E2["解决方案:\n① 互斥锁(只有一个请求重建)\n② 逻辑过期(不设 TTL)"]
    end

    subgraph ca["缓存雪崩 - 大量 Key 同时过期"]
        direction LR
        A3["大量 Key\n设置相同 TTL"] --> B3["同一时刻集体过期"] --> C3["大量请求同时穿透\n→ DB 崩溃"] --> D3["解决方案:\n① TTL 加随机值\n② 多级缓存\n③ 熔断降级"]
    end

    ct --> cb --> ca

3. 缓存穿透

3.1 问题描述

攻击者或异常请求不断查询不存在的数据(如 id=-1id=99999999),由于数据不存在,缓存中永远没有,每次都穿透到数据库。

典型场景: - 恶意攻击:构造大量不存在的 ID 发起请求 - 业务 Bug:查询逻辑错误,传入了无效参数

3.2 解决方案一:布隆过滤器

原理:在缓存层前加一个布隆过滤器,存储所有合法的 Key。请求进来先经过布隆过滤器,如果判断 Key 不存在,直接返回,不查缓存和 DB。

flowchart LR
    subgraph 布隆过滤器原理
        Key["Key: user_123"] --> H1["Hash函数1\n→ 位置 3"]
        Key --> H2["Hash函数2\n→ 位置 7"]
        Key --> H3["Hash函数3\n→ 位置 12"]
        H1 --> Bit["位数组\n[0,0,0,1,0,0,0,1,0,0,0,0,1,0...]"]
    end
    Query["查询 user_999"] -->|"3个位置都为1才可能存在\n有一个为0则一定不存在"| Bit

布隆过滤器特点: - 误判率:可能误判"不存在的 Key 存在"(假阳性),但不会误判"存在的 Key 不存在" - 不可删除:标准布隆过滤器不支持删除(可用 Counting Bloom Filter) - 为什么用多个 Hash 函数:单个 Hash 函数碰撞率高,多个 Hash 函数降低误判率

Redis 实现布隆过滤器

# 方式1:使用 RedisBloom 模块(推荐)
BF.ADD users user:123
BF.EXISTS users user:999   # 返回 0 表示一定不存在

# 方式2:用 String 的 SETBIT 手动实现
SETBIT bloom:users 3 1     # 将位置3设为1
GETBIT bloom:users 3       # 查询位置3

Java 代码示例(Guava 布隆过滤器)

// 初始化布隆过滤器(预期100万数据,误判率0.01%)
BloomFilter<Long> bloomFilter = BloomFilter.create(
    Funnels.longFunnel(), 1_000_000, 0.001);

// 数据库中所有合法 ID 加入布隆过滤器
bloomFilter.put(userId);

// 查询前先检查
public User getUser(Long userId) {
    // 布隆过滤器判断不存在,直接返回
    if (!bloomFilter.mightContain(userId)) {
        return null;
    }
    // 查缓存
    User user = redis.get("user:" + userId);
    if (user != null) return user;
    // 查数据库
    user = db.findById(userId);
    if (user != null) redis.set("user:" + userId, user, 300);
    return user;
}

3.3 解决方案二:缓存空值

原理:查询数据库发现数据不存在时,将空值也缓存起来(设置较短的 TTL,如 5 分钟),下次相同请求直接从缓存返回空值。

public User getUser(Long userId) {
    String cacheKey = "user:" + userId;
    String cached = redis.get(cacheKey);

    // 命中缓存(包括空值缓存)
    if (cached != null) {
        return "NULL".equals(cached) ? null : JSON.parse(cached, User.class);
    }

    // 查数据库
    User user = db.findById(userId);
    if (user != null) {
        redis.set(cacheKey, JSON.toJSON(user), 300);  // 正常数据缓存5分钟
    } else {
        redis.set(cacheKey, "NULL", 60);  // 空值缓存1分钟(TTL 要短)
    }
    return user;
}

两种方案对比

方案 优点 缺点 适用场景
布隆过滤器 内存占用极小,拦截效果好 有误判率,不支持删除 数据量大,Key 相对固定
缓存空值 实现简单,无误判 占用缓存空间,可能缓存大量空值 数据量小,Key 变化频繁

4. 缓存击穿

4.1 问题描述

单个热点 Key(如秒杀商品、热门文章)突然过期,此时大量并发请求同时未命中缓存,全部打到数据库,造成 DB 瞬间压力骤增。

与缓存穿透的区别: - 穿透:数据根本不存在,任何时候都不会命中缓存 - 击穿:数据存在,只是热点 Key 在某一时刻过期了

4.2 解决方案一:互斥锁

原理:缓存未命中时,只允许一个请求去查数据库重建缓存,其他请求等待。

flowchart TD
    A["请求到来"] --> B{"查缓存"}
    B -->|命中| C["返回缓存数据"]
    B -->|未命中| D{"尝试获取互斥锁"}
    D -->|获取成功| E["双重检查缓存"]
    E -->|仍未命中| F["查数据库\n重建缓存"]
    F --> G["释放锁\n返回数据"]
    D -->|获取失败| H["等待50ms后重试"]
    H --> B

Java 代码实现

public String getWithMutex(String key) {
    // 1. 查缓存
    String value = redis.get(key);
    if (value != null) return value;

    // 2. 缓存未命中,尝试获取互斥锁
    String lockKey = "lock:" + key;
    boolean locked = redis.set(lockKey, "1", "NX", "PX", 30000); // 30秒超时防死锁

    if (locked) {
        try {
            // 3. 双重检查(防止其他线程已重建缓存)
            value = redis.get(key);
            if (value != null) return value;

            // 4. 查数据库重建缓存
            value = db.query(key);
            redis.set(key, value, 300);
            return value;
        } finally {
            redis.del(lockKey); // 释放锁
        }
    } else {
        // 5. 未获取到锁,等待后重试
        Thread.sleep(50);
        return getWithMutex(key); // 递归重试
    }
}

⚠️ 注意:互斥锁方案会降低并发性能(大量请求在等待),适合对一致性要求高的场景。

4.3 解决方案二:逻辑过期

原理:Key 不设置 TTL(永不过期),在 Value 中存储一个逻辑过期时间。查询时检查逻辑过期时间,如果过期则异步重建缓存,当前请求返回旧数据。

// Value 结构
class CacheData {
    Object data;           // 实际数据
    LocalDateTime expireTime; // 逻辑过期时间
}

public Object getWithLogicalExpire(String key) {
    CacheData cached = redis.get(key);

    // 1. 未命中(Key 不存在),直接返回 null
    if (cached == null) return null;

    // 2. 检查逻辑过期时间
    if (cached.expireTime.isAfter(LocalDateTime.now())) {
        // 未过期,直接返回
        return cached.data;
    }

    // 3. 已过期,尝试获取互斥锁
    String lockKey = "lock:" + key;
    boolean locked = redis.set(lockKey, "1", "NX", "PX", 30000);

    if (locked) {
        // 4. 异步重建缓存(不阻塞当前请求)
        THREAD_POOL.submit(() -> {
            try {
                Object newData = db.query(key);
                CacheData newCache = new CacheData(newData, LocalDateTime.now().plusSeconds(300));
                redis.set(key, newCache); // 不设 TTL
            } finally {
                redis.del(lockKey);
            }
        });
    }

    // 5. 返回旧数据(可能是过期数据)
    return cached.data;
}

两种方案对比

方案 一致性 可用性 适用场景
互斥锁 高(等待重建完成) 低(等待期间请求阻塞) 对数据一致性要求高
逻辑过期 低(可能返回旧数据) 高(始终有数据返回) 对可用性要求高,允许短暂数据不一致

5. 缓存雪崩

5.1 问题描述

大量 Key 在同一时刻集体过期,或 Redis 服务宕机,导致大量请求同时打到数据库,DB 被压垮。

典型场景: - 系统启动时批量加载缓存,所有 Key 设置了相同的 TTL,到期时集体失效 - Redis 集群发生故障,缓存层整体不可用

5.2 解决方案

方案一:TTL 加随机偏移量(最简单有效)

// ❌ 错误:所有 Key 相同 TTL
redis.set(key, value, 300);

// ✅ 正确:TTL 加随机偏移量,错开过期时间
int ttl = 300 + new Random().nextInt(60); // 300~360秒随机
redis.set(key, value, ttl);

方案二:多级缓存

flowchart LR
    Client[客户端] --> L1["本地缓存\nCaffeine/Guava\n(进程内,最快)"]
    L1 -->|未命中| L2["Redis 缓存\n(分布式,快)"]
    L2 -->|未命中| DB["数据库\n(持久化,慢)"]

即使 Redis 雪崩,本地缓存仍能抵挡大部分请求。

方案三:熔断降级

// 使用 Sentinel 或 Hystrix 配置熔断
// 当 DB 请求失败率超过阈值,触发熔断,直接返回降级数据
@SentinelResource(value = "getUser", fallback = "getUserFallback")
public User getUser(Long userId) {
    return db.findById(userId);
}

public User getUserFallback(Long userId) {
    return new User(userId, "服务繁忙,请稍后重试");
}

方案四:Redis 高可用(防止 Redis 宕机导致雪崩)


6. 三大问题总结对比

问题 触发条件 影响范围 核心解决方案
缓存穿透 查询不存在的数据 每次请求都打 DB 布隆过滤器 / 缓存空值
缓存击穿 单个热点 Key 过期 瞬间大量并发打 DB 互斥锁 / 逻辑过期
缓存雪崩 大量 Key 同时过期 大规模请求打 DB TTL 加随机值 / 多级缓存

7. 面试高频问题

Q:缓存穿透和缓存击穿的区别?

穿透是查询根本不存在的数据,缓存永远不会命中;击穿是查询存在的数据,但热点 Key 在某一时刻过期了。穿透是持续性问题,击穿是瞬时性问题。

Q:如何保证缓存与数据库的一致性?

推荐旁路缓存模式(Cache Aside): - :先读缓存,未命中再读 DB 并写缓存 - :先更新 DB,再删除缓存(而非更新缓存)

为什么删除而不是更新缓存?避免并发场景下的脏数据:若两个请求同时更新 DB,后写入 DB 的请求可能先更新缓存,导致缓存中是旧数据。

延迟双删:写 DB 后删缓存,延迟一段时间(如 500ms)再删一次,防止并发读写导致脏数据残留。

Q:布隆过滤器的误判率如何控制?

误判率由位数组大小哈希函数个数决定。位数组越大、哈希函数越多,误判率越低,但内存占用越大。实际使用时根据数据量和可接受的误判率来选择参数(Guava 的 BloomFilter.create 可直接指定误判率)。