4. 缓存穿透、击穿、雪崩:问题定义、常见解决方案

在数据库缓存异步任务编排体系中,缓存层是抵御高并发读请求的第一道防线。然而,缓存层自身存在三个经典的高危场景——缓存穿透、缓存击穿、缓存雪崩。三者成因不同,但都会导致请求绕过缓存直接压向数据库,引发系统响应延迟陡增甚至宕机。本节从问题定义出发,逐一剖析其原理与主流解决方案。

4.1 问题定义与对比

问题 触发条件 影响范围 典型特征
缓存穿透 查询一个不存在的数据(key 既不在缓存也不在数据库) 所有不存在 key 的请求 恶意攻击或误操作,大量请求直接穿透到 DB
缓存击穿 一个热点 key 在缓存中过期失效,同时大量并发请求涌入 单个热点 key 高并发下瞬间打爆数据库连接池
缓存雪崩 大量 key 在同一时间过期失效,或缓存节点宕机 大面积 key 或整个缓存层 数据库瞬间承受数倍于平时的请求量
核心区别:穿透是“查不到”,击穿是“热点过期”,雪崩是“大面积过期或节点不可用”。三者的解决方案各有侧重,但都围绕“减少无效 DB 查询”与“保护后端资源”展开。

4.2 缓存穿透:布隆过滤器与空值缓存

缓存穿透的根源在于请求的 key 在数据库中根本不存在,因此缓存永远无法命中,每次请求都直达数据库。攻击者常利用这一点构造大量不存在的 key 进行流量攻击。

4.2.1 布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它允许一定的误判率(false positive),但绝不漏判(false negative 为零)。

  • 原理:使用多个哈希函数将 key 映射到一个很长的位数组上,所有哈希位置都置为 1。查询时,只要有一个哈希位置为 0,则 key 一定不存在。
  • 应用方式:在缓存层之前部署布隆过滤器,所有查询 key 先经过过滤器校验。若过滤器判定不存在,直接返回空结果,不再查询缓存和数据库。
  • 注意事项:布隆过滤器无法删除元素;需要根据预期数据量和误判率合理计算位数组大小与哈希函数个数。
实践建议:布隆过滤器适合“数据集合相对稳定、不频繁变更”的场景。如果数据频繁增删,可考虑使用 Counting Bloom Filter 或 Cuckoo Filter 支持删除操作。

4.2.2 空值缓存(Cache Null Object)

当数据库查询结果为空时,仍然将该 key 写入缓存,但 value 设置为一个特殊的空值标记(如 null 或 ""),并设置一个较短的过期时间(如 30~60 秒)。

// 伪代码示例:空值缓存
public Object get(String key) {
    Object value = cache.get(key);
    if (value != null) {
        // 命中缓存,直接返回(包括空值标记)
        return value;
    }
    // 缓存未命中,查询数据库
    Object dbValue = db.query(key);
    if (dbValue == null) {
        // 数据库也不存在,写入空值缓存,过期时间 30 秒
        cache.set(key, NULL_MARK, 30);
        return null;
    }
    // 数据库存在,写入缓存并返回
    cache.set(key, dbValue, 300);
    return dbValue;
}
避坑:空值缓存会占用额外的缓存空间,且空值过期后仍可能被穿透。建议与布隆过滤器配合使用:布隆过滤器拦截大部分不存在 key,空值缓存兜底处理少量漏网之鱼。

4.3 缓存击穿:互斥锁与永不过期

缓存击穿的核心矛盾是:单个热点 key 过期 + 高并发读。解决方案的核心思路是“控制并发重建缓存的请求数量”。

4.3.1 互斥锁(Mutex Lock)

当缓存失效时,只允许一个线程去数据库加载数据并重建缓存,其他线程等待或降级。常用实现方式有 Redis SETNX 或 分布式锁。

// 伪代码示例:互斥锁重建缓存
public Object getWithLock(String key) {
    Object value = cache.get(key);
    if (value != null) {
        return value;
    }
    // 尝试获取分布式锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 3); // 锁超时 3 秒
    if (locked) {
        try {
            // 双重检查:可能其他线程已经重建了缓存
            value = cache.get(key);
            if (value != null) {
                return value;
            }
            // 查询数据库并重建缓存
            value = db.query(key);
            cache.set(key, value, 300);
            return value;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 未获取到锁,等待或返回降级数据
        Thread.sleep(50);
        return cache.get(key); // 重试获取缓存
    }
}
性能考量:互斥锁会增加请求延迟(等待锁的时间),但能有效防止数据库被打爆。适用于“缓存重建代价高、并发量极大”的热点 key。

4.3.2 永不过期(逻辑过期)

缓存中不设置物理过期时间,而是为每个缓存数据额外存储一个逻辑过期时间(如 expireTime 字段)。当读取到缓存时,检查逻辑时间是否过期:

  • 未过期:直接返回缓存数据。
  • 已过期:立即返回旧缓存数据,同时异步启动一个后台线程去数据库加载新数据并更新缓存。
// 伪代码示例:逻辑过期
public Object getWithLogicalExpire(String key) {
    CacheEntry entry = cache.get(key);
    if (entry == null) {
        return null; // 缓存不存在,走正常加载流程
    }
    // 检查逻辑过期时间
    if (entry.getExpireTime() > System.currentTimeMillis()) {
        // 未过期,直接返回
        return entry.getValue();
    }
    // 已过期:返回旧数据,异步重建缓存
    asyncRebuildCache(key);
    return entry.getValue();
}
优势:永不过期方案不会阻塞请求,用户体验好;但需要额外的异步线程池管理,且存在短暂的数据不一致窗口。适合“对一致性要求不高、但响应速度要求极高”的场景。

4.4 缓存雪崩:过期时间分散与高可用

缓存雪崩的成因分为两类:大量 key 同时过期 和 缓存节点宕机。解决方案需从过期策略和架构层面双管齐下。

4.4.1 过期时间分散化

  • 基础过期时间 + 随机偏移:在统一过期时间基础上,为每个 key 增加一个随机值(如 1~5 分钟),避免大量 key 同时失效。
  • 多级缓存:本地缓存(如 Caffeine)作为 L1 缓存,Redis 作为 L2 缓存。即使 Redis 层大量 key 过期,本地缓存仍能承载部分流量。

4.4.2 缓存高可用架构

方案 描述 适用场景
Redis 主从 + 哨兵 主节点故障时自动切换从节点,保证缓存层不整体宕机 中小规模集群
Redis Cluster 数据分片 + 自动故障转移,单节点故障不影响整体 大规模分布式缓存
本地缓存兜底 Redis 不可用时,回退到本地缓存(如 Guava Cache) 对可用性要求极高的核心链路
关键提醒:雪崩的“最后一层防线”是数据库限流与熔断。即使缓存层完全不可用,数据库端也应配置连接池限流、读/写分离、降级返回默认数据等保护机制。

4.5 三种场景解决方案对比总览

场景 首选方案 辅助方案 代价
缓存穿透 布隆过滤器 空值缓存 + 短过期时间 额外内存占用;布隆过滤器无法删除
缓存击穿 互斥锁(分布式锁) 永不过期 + 异步重建 锁等待增加延迟;异步方案存在短暂不一致
缓存雪崩 过期时间随机化 多级缓存 + 高可用架构 架构复杂度增加;本地缓存一致性维护成本

4.6 综合架构流程示意

下图展示了一个融合布隆过滤器、互斥锁与过期时间随机化的缓存防护流程,覆盖穿透、击穿、雪崩三种场景的防御节点。

缓存防护综合流程架构 客户端请求 布隆过滤器(穿透防御) 判断 key 是否存在 返回空结果 不存在 可能存在 Redis 缓存层 过期时间 + 随机偏移 返回缓存数据 命中 未命中 互斥锁(击穿防御) SETNX 分布式锁 查询数据库 重建缓存 + 释放锁 等待重试 锁失败 MySQL 数据库 返回最终数据
架构要点:布隆过滤器前置拦截穿透请求;互斥锁控制热点 key 的并发重建;缓存过期时间加入随机偏移防止雪崩;数据库层作为最终保底,配合限流与熔断机制。三层防护形成纵深防御体系。