Lesson 64 · 数据库与中间件实战

缓存设计:穿透、击穿、雪崩与一致性方案

高级·⭐ 必问·#缓存·#设计·#面试必问

高级 · 面试必问

缓存设计:穿透、击穿、雪崩与一致性方案

缓存穿透用布隆过滤器、击穿用互斥锁、雪崩用过期时间随机化。双写一致性:先删缓存还是先更新数据库?延迟双删怎么做?

缓存穿透缓存击穿缓存雪崩布隆过滤器双写一致性延迟双删
第 1 站

缓存穿透——查一个根本不存在的数据

缓存穿透:客户端请求的 key 在缓存和数据库中都不存在,每次都要穿透到数据库。如果被恶意利用(大量请求不存在的 key),数据库会被打垮。

缓存穿透攻击流程 恶意请求 Redis 缓存 miss! (key不存在) MySQL 数据库 查不到 → 返回 null key=id:-1, id:-2, id:-999 ... 每次都是 miss → 全打到 DB 解决方案 方案 1: 缓存空值(TTL 2~5 分钟) 方案 2: 布隆过滤器(前置拦截)
图 1-1 缓存穿透:不存在的 key 直达数据库
方案 1:缓存空值
public User getUser(Long id) {
    String key = "user:" + id;
    String cached = redis.get(key);

    // 注意区分"key 不存在"和"key 存在但值为空"
    if (cached != null) {
        if ("NULL".equals(cached)) return null; // 之前缓存的空值
        return JSON.parseObject(cached, User.class);
    }

    User user = db.selectById(id);
    if (user != null) {
        redis.set(key, JSON.toJSONString(user), 30, MINUTES);
    } else {
        // 缓存空值,设置较短的 TTL
        redis.set(key, "NULL", 5, MINUTES);
    }
    return user;
}

缓存空值有什么缺点?什么时候该用布隆过滤器?

缓存空值的问题是:如果攻击者用大量不同的不存在的 key 来请求,Redis 里会存很多"NULL"值(虽然 TTL 短,但数量大时仍然占内存)。布隆过滤器更适合这种场景——它在 Redis 之前加一层,用极小的内存(位数组)判断 key 是否可能存在。判断"不存在"是 100% 准确的,可以直接拦截。

布隆过滤器原理
布隆过滤器 = 位数组(bit array)+ 多个哈希函数

写入:对 key 用 k 个哈希函数得到 k 个位置,全部置 1
BF.add("user:1001")
  → hash1("user:1001") = 3,  hash2("user:1001") = 17,  hash3("user:1001") = 42
  → bitArray[3] = 1,  bitArray[17] = 1,  bitArray[42] = 1

查询:检查 k 个位置是否全为 1
BF.mightContain("user:9999")
  → hash1 = 5, hash2 = 23, hash3 = 42
  → bitArray[5] = 0肯定不存在!直接返回 null

特点:可能误判"存在"(false positive),但不会误判"不存在"
Redis 有 RedisBloom 模块,也可用 Redisson 的 RBloomFilter
关键结论

穿透 = 查不存在的数据。简单场景用缓存空值(TTL 2~5 分钟),高风险场景用布隆过滤器前置拦截。两者可组合使用。

第 2 站

缓存击穿——热点 key 过期的瞬间

缓存击穿:某个热点 key 突然过期,大量并发请求同时发现缓存 miss,全部涌向数据库,瞬间压垮 DB。

缓存击穿时序 热点 key 存在 TTL 到期! 缓存重建完成 瞬间 10000 并发请求 全部 cache miss 同时查数据库! DB 连接池被打满 与穿透的区别:击穿针对的是"存在的热点 key",穿透是"不存在的 key"
图 2-1 缓存击穿:热点 key 过期瞬间的并发风暴
方案 1:互斥锁(Mutex Lock)
public User getUser(Long id) {
    String key = "user:" + id;
    String cached = redis.get(key);
    if (cached != null) return parse(cached);

    // 尝试获取分布式锁(SET NX EX)
    String lockKey = "lock:" + key;
    boolean locked = redis.set(lockKey, "1", NX, 10, SECONDS);

    if (locked) {
        try {
            // 双重检查:可能别的线程已经重建了缓存
            cached = redis.get(key);
            if (cached != null) return parse(cached);

            User user = db.selectById(id);
            redis.set(key, toJSON(user), 30, MINUTES);
            return user;
        } finally {
            redis.del(lockKey); // 释放锁
        }
    } else {
        // 没拿到锁 → 短暂等待后重试
        Thread.sleep(50);
        return getUser(id); // 递归重试
    }
}
方案 2:逻辑过期(不设置 TTL,由业务代码判断)
// 缓存值中嵌入过期时间
class CacheData<T> {
    T data;
    long expireTime; // 逻辑过期时间戳
}

// 查询时发现逻辑过期 → 异步线程重建缓存,当前线程返回旧数据
if (System.currentTimeMillis() > cacheData.expireTime) {
    // 提交异步任务重建缓存(用线程池 / 协程)
    executor.submit(() -> rebuildCache(key, id));
}
return cacheData.data; // 返回旧数据(可接受短暂过期)
方案优点缺点
互斥锁保证一致性,不会返回旧数据没拿到锁的线程要等待/重试,有延迟
逻辑过期无阻塞,性能好会返回过期数据,一致性弱;需要额外内存存过期时间
第 3 站

缓存雪崩——大面积 key 同时失效

缓存雪崩:大量缓存 key 在同一时间集中过期(或 Redis 宕机),所有请求都打到数据库。

缓存雪崩 vs 击穿 vs 穿透 穿透 不存在的 key 每次 miss 布隆过滤器 缓存空值 击穿 单个热点 key 过期 并发集中打 DB 互斥锁 逻辑过期 雪崩 大量 key 同时过期 或 Redis 宕机 随机 TTL 多级缓存
图 3-1 三大缓存问题的对比
雪崩解决方案
方案 1:过期时间随机化
// 基础 TTL + 随机偏移量 → 避免集中过期
int baseTTL = 30 * 60; // 30 分钟
int randomOffset = ThreadLocalRandom.current().nextInt(60, 300); // 1~5 分钟随机
redis.set(key, value, baseTTL + randomOffset);

方案 2:多级缓存
// L1: 本地缓存(Caffeine/Guava) → L2: Redis 分布式缓存
// 即使 Redis 宕机,本地缓存还能扛一段时间

方案 3:限流 + 降级
// 缓存大面积失效时,对 DB 查询做限流(令牌桶 / 信号量)
// 超出阈值的请求直接降级返回默认值或友好提示

方案 4:集群 + 高可用
// Redis Cluster / Sentinel 避免单点故障导致全部缓存失效
关键结论

雪崩的根因是"集中失效"或"缓存不可用"。核心解法: 随机 TTL 打散过期时间; 多级缓存(本地 + Redis)兜底; 限流降级保护 DB; 高可用集群防宕机。

第 4 站

双写一致性——先删缓存还是先更新数据库?

这是面试中最高频的缓存一致性问题。核心矛盾:缓存和数据库是两个独立的数据源,无法做到原子性地同时更新

四种更新策略对比 ① 先更新DB,再更新缓存 并发写可能覆盖:A写DB→B写DB→B更新缓存→A更新缓存 结果缓存=A的值,但DB=B的值 → 不一致 ② 先删缓存,再更新DB 读请求在删缓存和更新DB之间读到旧数据 并写回缓存 → 脏数据残留到 TTL 过期 ③ 先更新DB,再删缓存(推荐) Cache Aside 模式,问题最少 极端情况:读请求读到旧缓存(概率极低) ④ 延迟双删 先删缓存 → 更新DB → sleep N ms → 再删缓存 第二次删除清理读请求写入的脏数据 推荐方案 ③:Cache Aside(旁路缓存) 读流程: 查缓存 → miss → 查DB → 写入缓存 写流程: 更新DB → 删除缓存(不是更新缓存!) 为什么是"删"不是"更新"缓存? → 避免并发写覆盖问题;→ 懒加载,下次读的时候再重建;→ 减少无效计算(缓存可能还没被读就被更新了)
图 4-1 四种双写策略对比,Cache Aside 最推荐

方案 ③ "先更新DB再删缓存"真的万无一失吗?

有一个极端场景:缓存刚好过期 → 读请求查DB得到旧值 → 写请求更新DB并删缓存 → 读请求把旧值写入缓存。但这个场景要求"缓存恰好失效 + 读DB比写DB还慢",概率极低。如果对一致性要求极高,可以用延迟双删消息队列重试删缓存

第 5 站

延迟双删与最终一致性方案

延迟双删实现
public void updateUser(Long id, User user) {
    String key = "user:" + id;

    // 第一次删除缓存
    redis.del(key);

    // 更新数据库
    db.updateById(user);

    // 延迟第二次删除(异步)
    // 延迟时间 >= 读请求的耗时(通常 500ms ~ 1s)
    executor.schedule(() -> {
        redis.del(key);
    }, 1000, MILLISECONDS);
}
更可靠的方案:消息队列重试删缓存
方案:Canal 监听 binlog + MQ 异步删缓存

1. 业务代码正常更新 DB(无需关心缓存)
2. Canal(阿里开源)订阅 MySQL binlog
3. 解析出变更的表和 key → 发送 MQ 消息
4. 消费者收到消息 → 删除对应的 Redis key
5. 如果删除失败 → MQ 自动重试(保证最终一致)

优点:
- 业务代码与缓存解耦,不需要手动维护一致性
- MQ 重试机制保证删除一定成功
- 适合中大型项目
Canal + MQ 异步删缓存架构 业务服务 MySQL binlog Canal MQ Redis 业务只写 DB → Canal 监听 binlog → MQ → 消费者删缓存 删除失败时 MQ 自动重试,保证最终一致性
图 5-1 Canal + MQ 实现缓存最终一致性
面试话术

"我们用的是 Cache Aside 模式:先更新DB再删缓存。对于一致性要求更高的场景,用延迟双删或者 Canal 监听 binlog 异步删缓存,配合 MQ 重试机制保证最终一致性。"

第 6 站

多级缓存架构——L1 本地 + L2 分布式

在高并发场景下,仅靠 Redis 可能成为瓶颈(网络开销、序列化开销)。多级缓存在应用进程内加一层本地缓存,进一步减少 Redis 的压力。

多级缓存请求链路 请求 L1 本地缓存 Caffeine, 10s TTL miss L2 Redis 分布式, 30min TTL miss MySQL 持久化存储 多级缓存设计要点 L1 本地缓存: Caffeine(高性能,堆内),TTL 短(5~30秒),容量小(热点数据) L2 分布式缓存: Redis Cluster,TTL 中等(5~30分钟),容量大 一致性: 更新时通过 MQ 广播通知所有节点清除 L1 本地缓存
图 6-1 L1 本地缓存 + L2 Redis 多级缓存架构
L1 本地缓存一致性方案
问题:多个应用实例各自有 L1 本地缓存,更新后如何同步?

方案:Redis Pub/Sub 广播 + MQ
1. 更新 DB 后,发送消息到 MQ(如 RocketMQ)
2. 所有应用实例订阅该 topic
3. 收到消息后,清除本地 L1 缓存中对应的 key
4. 下次读请求会重新从 L2/DB 加载

也可以用 Redis Pub/Sub:
PUBLISH cache:invalidate "user:1001"
所有订阅者收到后清除本地缓存
第 7 站

缓存监控与运维最佳实践

监控指标含义告警阈值参考
命中率 (hit rate)缓存命中数 / 总请求数< 90% 需关注
内存使用率used_memory / maxmemory> 80% 告警
驱逐数 (evicted_keys)因内存满而被淘汰的 key 数持续增长需关注
连接数当前客户端连接数接近 maxclients 告警
慢查询SLOWLOG 中 O(N) 命令KEYS * / 大 key 操作
延迟PING 响应时间> 5ms 需关注
生产环境 Redis 配置清单
# 内存
maxmemory 8gb
maxmemory-policy volatile-lru  # 优先淘汰设了 TTL 的 key

# 持久化
appendonly yes
aof-use-rdb-preamble yes
save 900 1

# 安全
rename-command FLUSHALL ""    # 禁用危险命令
rename-command KEYS ""
requirepass your_strong_password

# 性能
tcp-backlog 2048
timeout 300                    # 空闲连接超时
hz 50                          # 事件循环频率(默认 10)
面试一招鲜

缓存三板斧:穿透(布隆过滤器/空值缓存)、击穿(互斥锁/逻辑过期)、雪崩(随机TTL/多级缓存/限流降级)。一致性:Cache Aside + 延迟双删或 Canal + MQ。这套组合拳打出去,缓存设计的问题基本就覆盖全了。

全篇回顾

  1. 缓存穿透:查不存在的数据 → 布隆过滤器 / 缓存空值
  2. 缓存击穿:热点 key 过期 → 互斥锁 / 逻辑过期
  3. 缓存雪崩:大面积 key 同时过期 → 随机 TTL / 多级缓存 / 限流
  4. 双写一致性:推荐 Cache Aside(先更新DB再删缓存)
  5. 延迟双删:删缓存 → 更新DB → sleep → 再删缓存
  6. 最终一致性:Canal 监听 binlog + MQ 异步删缓存
  7. 多级缓存:L1 本地(Caffeine)+ L2 Redis,广播清除 L1