4. 缓存击穿:热点Key失效问题与解决方案

缓存击穿是指一个热点 Key 在缓存中恰好过期失效,此时大量并发请求同时穿透到数据库,导致数据库负载瞬间飙升甚至崩溃。与缓存雪崩不同,击穿针对的是单个热点 Key,而非大批 Key 同时失效。

4.1 热点Key失效问题分析

热点 Key 通常具有以下特征:

  • 高并发访问:每秒数万甚至数十万次读取
  • 写少读多:数据更新频率低,但读取量极大
  • 失效影响面广:一旦失效,所有请求直接打到数据库
核心矛盾:缓存失效瞬间,N 个并发请求同时发现缓存不存在,同时回源数据库。数据库连接池瞬间被占满,响应延迟飙升,引发级联故障。
场景 并发量 数据库承受压力 典型后果
正常缓存命中 10,000 QPS 0(缓存直接返回) 无
热点 Key 失效 10,000 QPS 10,000 QPS 穿透 数据库连接池耗尽
数据库超时 10,000 QPS 持续重试 + 新请求 服务雪崩
缓存击穿:热点 Key 失效前后对比 正常情况(缓存命中) 大量并发请求 缓存命中(热点 Key) 直接返回数据 击穿情况(Key 失效) 大量并发请求 缓存未命中(Key 过期) 全部穿透到数据库 左侧:缓存命中时,数据库零压力;右侧:Key 失效后,所有请求同时击穿数据库 解决方案核心:让同一时刻只有一个请求回源数据库

4.2 互斥锁解决方案

互斥锁(Mutex Lock)是最直接的解决方案:当缓存失效时,只允许一个线程去数据库加载数据,其他线程等待该线程完成后再从缓存读取。

实现原理

  1. 请求到达,检查缓存是否存在
  2. 缓存不存在,尝试获取分布式锁(如 Redis SETNX)
  3. 获取锁成功的线程,查询数据库并回写缓存
  4. 获取锁失败的线程,等待后重试从缓存读取
// 互斥锁方案伪代码(Java + Redis)
public String getData(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;  // 缓存命中
    }

    // 缓存未命中,尝试获取锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 双重检查:防止等待锁期间其他线程已更新缓存
            value = redis.get(key);
            if (value != null) {
                return value;
            }
            // 查询数据库
            value = db.query(key);
            redis.setex(key, 300, value);
            return value;
        } finally {
            redis.del(lockKey);  // 释放锁
        }
    } else {
        // 未获取到锁,等待后重试
        Thread.sleep(50);
        return getData(key);  // 递归重试
    }
}
适用场景:对数据一致性要求高,可以接受少量请求延迟的场景。互斥锁能严格保证只有一个线程回源数据库,但会引入等待延迟和锁开销。

优缺点对比

维度 说明
优点 实现简单,数据强一致,不会出现缓存与数据库不一致
缺点 引入锁等待延迟,高并发下可能造成线程阻塞;锁超时需谨慎设置
风险点 锁未正确释放会导致死锁;锁超时时间过短会导致多次回源

4.3 逻辑过期方案

逻辑过期方案不依赖 Redis 的 TTL 过期机制,而是在缓存 value 中额外存储一个过期时间字段。读取时判断逻辑时间是否过期,过期则触发异步更新。

实现步骤

  1. 缓存中存储数据 + 逻辑过期时间(如 JSON 封装)
  2. 读取时检查逻辑时间是否过期
  3. 未过期:直接返回数据
  4. 已过期:尝试获取分布式锁,获取成功的线程异步更新缓存
  5. 获取锁失败的线程直接返回旧数据(不等待)
// 逻辑过期方案伪代码
public class CacheWrapper<T> {
    private T data;          // 实际数据
    private long expireTime; // 逻辑过期时间戳
}

public String getData(String key) {
    CacheWrapper<String> wrapper = redis.get(key);
    if (wrapper == null) {
        return null;  // 缓存不存在,走降级
    }

    if (wrapper.getExpireTime() > System.currentTimeMillis()) {
        return wrapper.getData();  // 逻辑未过期,直接返回
    }

    // 逻辑已过期,尝试获取锁进行异步更新
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS);
    if (locked) {
        // 异步线程更新缓存
        executor.submit(() -> {
            try {
                String newData = db.query(key);
                CacheWrapper<String> newWrapper = new CacheWrapper<>();
                newWrapper.setData(newData);
                newWrapper.setExpireTime(System.currentTimeMillis() + 300_000);
                redis.set(key, newWrapper);
            } finally {
                redis.del(lockKey);
            }
        });
    }

    // 返回旧数据(允许短暂不一致)
    return wrapper.getData();
}
注意:逻辑过期方案的核心思想是“用短暂的不一致换取可用性”。在缓存更新完成前,所有请求都读取旧数据。适用于对实时性要求不高的场景,如热门文章列表、商品详情页。

4.4 永不过期 + 异步更新策略

永不过期策略是指缓存 Key 不设置 TTL,永远存在于缓存中。数据更新通过后台异步任务定期刷新,彻底避免 Key 失效导致的击穿问题。

实现方式

  • 缓存 Key 不设 TTL:Redis 中 Key 永不过期
  • 后台定时任务:每隔固定时间(如 5 分钟)刷新缓存数据
  • 主动更新触发:数据变更时,通过消息队列通知缓存更新
  • 兜底策略:若缓存数据为空(如 Redis 重启),使用互斥锁回源
// 永不过期 + 异步更新策略
// 1. 写入缓存时不设置过期时间
public void writeCache(String key, String value) {
    redis.set(key, value);  // 不调用 expire()
}

// 2. 后台定时刷新任务(每5分钟执行一次)
@Scheduled(fixedRate = 300_000)
public void refreshHotKey() {
    List<String> hotKeys = getHotKeyList();
    for (String key : hotKeys) {
        String newData = db.query(key);
        redis.set(key, newData);
    }
}

// 3. 数据变更时主动推送更新
public void onDataChange(String key) {
    String newData = db.query(key);
    redis.set(key, newData);
}

// 4. 读取时兜底处理
public String getData(String key) {
    String value = redis.get(key);
    if (value == null) {
        // Redis 重启或数据丢失,使用互斥锁回源
        return loadDataWithLock(key);
    }
    return value;
}
三种缓存击穿解决方案对比 方案一:互斥锁 请求 → 缓存未命中 → 获取分布式锁 → 锁成功:回源数据库 → 锁失败:等待重试 特点:强一致,有等待延迟 方案二:逻辑过期 请求 → 逻辑时间过期 → 获取锁异步更新 → 返回旧数据(不等待) → 后台线程刷新缓存 特点:最终一致,无等待延迟 方案三:永不过期 Key 永不过期 → 定时任务刷新 → 数据变更主动推送 → 兜底:互斥锁回源 特点:零击穿风险,需维护 方案选型建议 互斥锁:数据一致性要求极高,可接受少量延迟 逻辑过期:高并发读多写少,可接受短暂不一致 永不过期:极端热点 Key,需要主动维护缓存生命周期 最佳实践:根据业务场景组合使用,如永不过期 + 逻辑过期兜底

4.5 方案对比与选型建议

方案 数据一致性 请求延迟 实现复杂度 适用场景
互斥锁 强一致 有等待延迟 低 金融、交易等一致性敏感场景
逻辑过期 最终一致 无等待延迟 中 内容详情页、商品展示等读多写少场景
永不过期 + 异步更新 最终一致 无延迟 高 极端热点 Key、首页推荐等需要极致性能场景
实践建议:对于真正的热点 Key,推荐采用“永不过期 + 异步更新”为主策略,配合“逻辑过期”作为兜底。当异步更新失败或延迟时,逻辑过期机制能保证系统仍然返回旧数据,避免缓存穿透。互斥锁方案更适合对一致性要求极高且并发量可控的场景。