4. 缓存击穿:热点Key失效、互斥锁方案、逻辑过期方案、永不过期方案

缓存击穿是指某个热点 Key 在缓存失效的瞬间,大量并发请求同时穿透缓存,直接打到后端数据库,导致数据库负载瞬间飙升甚至宕机。与缓存雪崩不同,击穿针对的是单个热点 Key,而非大批 Key 同时失效。

核心矛盾:热点 Key 承载高并发读,一旦失效,所有请求在重建缓存前全部穿透,数据库成为唯一防线。

4.1 热点Key失效场景分析

热点 Key 失效通常由以下原因触发:

  • Key 到达 TTL 过期时间:缓存自然淘汰,最常见。
  • 缓存服务重启或内存淘汰:Redis 因内存压力主动驱逐 Key。
  • 手动删除或更新:业务主动删除 Key 后未及时重建。

失效瞬间的并发模型如下:

热点Key失效并发穿透模型 客户端请求 客户端请求 客户端请求 …… 大量并发 Redis 缓存 热点Key已失效 数据库 所有请求穿透至数据库 数据库负载瞬间飙升

4.2 互斥锁方案(Mutex Lock)

互斥锁方案的核心思想:当缓存失效时,只允许一个线程去重建缓存,其他线程等待或快速返回。通过分布式锁(如 Redis SETNX)保证同一时刻只有一个请求访问数据库。

实现步骤

  1. 从缓存获取数据,若命中则直接返回。
  2. 若未命中,尝试获取分布式锁(SETNX key_lock 1 EX 10)。
  3. 获取锁成功的线程查询数据库,重建缓存,释放锁。
  4. 获取锁失败的线程:自旋等待(重试)或快速返回(降级)。
// 伪代码:互斥锁方案
public Object getData(String key) {
    Object data = redis.get(key);
    if (data != null) return data;

    // 尝试获取锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 双重检查:防止等待期间其他线程已重建
            data = redis.get(key);
            if (data != null) return data;

            data = db.query(key);
            redis.set(key, data, 300, TimeUnit.SECONDS);
            return data;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 未获取锁:自旋等待或快速降级
        Thread.sleep(50);
        return getData(key); // 递归重试
    }
}
适用场景:对数据一致性要求高,允许少量等待延迟。不适合极端高并发且对延迟敏感的场景。

4.3 逻辑过期方案(Logical Expiration)

逻辑过期方案不依赖 Redis 的 TTL 淘汰,而是在缓存数据中嵌入过期时间字段,由业务代码判断是否过期。过期后,当前线程返回旧数据,同时异步线程去更新缓存。

数据结构

字段 说明 示例
data 实际业务数据 {"id": 1001, "name": "商品A"}
expireTime 逻辑过期时间戳(毫秒) 1718000000000

实现流程

  1. 从缓存获取数据,解析 expireTime。
  2. 若当前时间 < expireTime,数据有效,直接返回。
  3. 若当前时间 >= expireTime,数据逻辑过期:
    • 尝试获取互斥锁(防止多个线程同时更新)。
    • 获取锁成功的线程:异步线程池执行缓存更新,当前线程返回旧数据。
    • 获取锁失败的线程:直接返回旧数据,不等待。
// 伪代码:逻辑过期方案
public Object getDataLogical(String key) {
    String cacheStr = redis.get(key);
    if (cacheStr == null) return db.query(key); // 缓存未命中,降级

    CacheWrapper wrapper = JSON.parse(cacheStr, CacheWrapper.class);
    if (System.currentTimeMillis() < wrapper.getExpireTime()) {
        return wrapper.getData(); // 未过期,直接返回
    }

    // 逻辑过期:尝试获取锁,异步更新
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 5, TimeUnit.SECONDS);
    if (locked) {
        threadPool.execute(() -> {
            Object newData = db.query(key);
            CacheWrapper newWrapper = new CacheWrapper(newData, futureExpireTime);
            redis.set(key, JSON.toJSONString(newWrapper));
            redis.del(lockKey);
        });
    }
    return wrapper.getData(); // 返回旧数据
}
优势:读请求无阻塞,始终返回数据(即使旧数据),适合读多写少、允许短暂不一致的场景。

4.4 永不过期方案(No Expiration)

永不过期方案即缓存 Key 不设置 TTL,由后台任务或消息队列主动更新缓存数据。Redis 中 Key 永远存在,不会因过期触发击穿。

实现方式

  • 定时任务更新:如每 5 分钟全量刷新热点 Key。
  • 数据库变更通知:监听 binlog 或业务事件,变更时立即更新缓存。
  • 双写一致性:写数据库时同步写缓存,保证数据新鲜。
注意:永不过期方案需要解决数据一致性问题。若更新失败,缓存会长期持有旧数据。建议配合版本号或校验机制。

4.5 三种方案对比

方案 一致性 延迟影响 实现复杂度 适用场景
互斥锁 强一致 少量等待 中 数据一致性要求高,并发适中
逻辑过期 最终一致 无等待 高 高并发读,允许短暂不一致
永不过期 依赖更新机制 无等待 低 数据更新可预测,后台可控
三种缓存击穿防护方案决策流程 热点Key失效 强一致性要求? 互斥锁方案 强一致,少量等待 是 允许短暂不一致? 否 逻辑过期方案 无等待,最终一致 是 永不过期方案 后台更新,无击穿 否

4.6 选型建议

  • 互斥锁方案:适合金融、库存等强一致场景,但需控制锁粒度与超时时间,避免死锁。
  • 逻辑过期方案:适合首页推荐、排行榜等允许短暂不一致的高并发场景,需额外维护异步线程池。
  • 永不过期方案:适合数据更新频率可控、后台任务成熟的环境,需解决首次加载与一致性问题。
最佳实践:实际生产环境常组合使用——热点 Key 采用逻辑过期 + 异步更新,非热点 Key 使用互斥锁兜底,同时监控缓存命中率与数据库负载。