2. 缓存基础:缓存穿透、缓存击穿、缓存雪崩

在数据库工程中,缓存层是抵御高并发读压力的第一道防线。然而,当缓存与数据库之间的数据一致性或访问模式出现异常时,会引发三种典型的缓存故障:缓存穿透、缓存击穿和缓存雪崩。理解它们的定义、成因及经典解决方案,是构建高可用缓存系统的前提。

2.1 三种缓存故障的定义与成因

故障类型 定义 核心成因 影响范围
缓存穿透 请求查询一个缓存和数据库中都不存在的数据,导致每次请求都直接打到数据库。 恶意攻击(如使用不存在的ID)、业务逻辑漏洞(如查询空值未做缓存)。 数据库连接数飙升,可能拖垮数据库。
缓存击穿 某个热点Key在缓存中过期失效的瞬间,大量并发请求同时穿透缓存,直接访问数据库。 热点数据过期时间设置不当;高并发场景下单个Key失效。 数据库瞬间压力激增,可能导致数据库宕机。
缓存雪崩 在同一时刻,大量缓存Key同时过期失效,或缓存节点宕机,导致海量请求涌入数据库。 批量Key设置了相同的过期时间;缓存服务集群故障。 数据库被压垮,引发系统级雪崩。
关键区分:穿透是“查不到”,击穿是“单个热点过期”,雪崩是“大量Key同时过期或缓存不可用”。

2.2 经典解决方案对比

故障类型 解决方案 实现要点 适用场景
缓存穿透 1. 缓存空对象 将数据库查询为null的结果也写入缓存,设置较短的过期时间(如30-60秒)。 空值查询频率较高,且数据更新不频繁的场景。
2. 布隆过滤器 在缓存层之前加一层布隆过滤器,判断请求的Key是否一定不存在,直接拦截非法请求。 数据量极大,且Key集合相对固定的场景(如用户ID、商品ID)。
缓存击穿 1. 互斥锁(Mutex Key) 当缓存失效时,只允许一个线程去数据库加载数据,其他线程等待或重试。 并发量极高,且对一致性要求严格的场景。
2. 逻辑过期 缓存中存储数据的逻辑过期时间,后台异步线程负责更新缓存,避免热点Key在高峰期失效。 允许短暂的数据不一致,追求极致性能的场景。
缓存雪崩 1. 过期时间打散 在基础过期时间上增加一个随机值(如1-5分钟),避免大量Key同时过期。 批量加载缓存数据的场景(如定时任务预热)。
2. 缓存集群高可用 使用Redis Sentinel或Redis Cluster,避免单点故障;配合本地缓存(如Caffeine)做二级缓存。 对缓存服务可用性要求极高的生产环境。

2.3 缓存穿透、击穿、雪崩的请求流程对比

下图展示了三种故障在请求链路中的不同表现位置,帮助理解其本质差异。

缓存穿透 / 击穿 / 雪崩 请求流程对比 客户端请求 缓存层 数据库层 穿透 缓存和DB都无数据 热点Key请求 缓存层 数据库层 击穿 单个热点Key过期 大量并发请求 缓存层 数据库层 雪崩 大量Key同时过期 核心差异总结 穿透:数据不存在 → 布隆过滤器 / 缓存空对象 击穿:单个热点过期 → 互斥锁 / 逻辑过期 雪崩:大量Key同时过期 → 过期时间打散 / 集群高可用

2.4 代码示例:互斥锁解决缓存击穿

以下是一个使用Redis分布式锁(SETNX)防止缓存击穿的典型实现。当缓存失效时,只有一个线程能获取锁并加载数据,其他线程等待或返回降级数据。

public String getProductById(String productId) {
    // 1. 先从缓存中获取
    String product = redis.get("product:" + productId);
    if (product != null) {
        return product;
    }

    // 2. 缓存未命中,尝试获取分布式锁
    String lockKey = "lock:product:" + productId;
    boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);

    if (locked) {
        try {
            // 3. 双重检查:获取锁后再次检查缓存
            product = redis.get("product:" + productId);
            if (product != null) {
                return product;
            }

            // 4. 从数据库加载数据
            product = productDao.getById(productId);
            if (product != null) {
                redis.set("product:" + productId, product, 300, TimeUnit.SECONDS);
            } else {
                // 缓存空对象,防止穿透
                redis.set("product:" + productId, "", 60, TimeUnit.SECONDS);
            }
            return product;
        } finally {
            // 5. 释放锁
            redis.del(lockKey);
        }
    } else {
        // 6. 未获取到锁,等待重试或返回降级数据
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductById(productId); // 递归重试
    }
}
工程建议:互斥锁方案适用于并发量极高且对数据一致性要求严格的场景。如果允许短暂的不一致,可以考虑“逻辑过期”方案,由后台线程异步更新缓存,避免锁竞争带来的性能损耗。

2.5 缓存雪崩的预防策略

缓存雪崩的破坏力最大,预防策略需要从“过期时间”和“缓存架构”两个维度同时入手。

  • 过期时间打散:在设置缓存过期时间时,在基础时间上增加一个随机偏移量(如基础时间 + random(1min, 5min)),避免大量Key在同一时刻失效。
  • 多级缓存:使用本地缓存(如Caffeine)作为一级缓存,Redis作为二级缓存。即使Redis集群故障,本地缓存仍能提供部分数据服务。
  • 缓存预热 + 慢加载:在业务低峰期提前加载热点数据,并控制数据库加载的并发度,避免缓存失效时数据库瞬间被打满。
  • 集群高可用:部署Redis Sentinel或Redis Cluster,确保缓存服务不因单点故障而整体不可用。
注意:缓存雪崩的预防需要结合业务场景。例如,对于秒杀系统的商品库存缓存,除了打散过期时间外,还应配合限流和熔断机制,保护数据库不被突发流量冲垮。