4. 缓存击穿原理与防护:互斥锁、逻辑过期、热点数据预加载、分布式锁

4.1 缓存击穿:定义与成因

缓存击穿(Cache Breakdown)是指一个热点 Key 在缓存中过期失效的瞬间,大量并发请求同时穿透缓存,直接打到后端数据库,导致数据库负载瞬间飙升甚至宕机的现象。它与缓存雪崩的区别在于:雪崩是大量 Key 同时失效,而击穿是单个热点 Key 失效引发的连锁反应。

核心特征:热点 Key + 高并发 + 缓存失效瞬间 = 数据库压力尖峰。
对比维度 缓存穿透 缓存击穿 缓存雪崩
失效对象 不存在的数据(Key 无效) 单个热点 Key 大量 Key 同时过期
并发特征 持续攻击 瞬时高并发 批量失效
数据库压力 持续空查 瞬间尖峰 持续高负载
典型场景 恶意请求不存在的 ID 秒杀商品详情页 缓存服务重启

4.2 防护策略一:互斥锁(Mutex Lock)

互斥锁是最直接的防护手段:当缓存失效后,只允许一个线程去数据库加载数据,其他线程等待该线程完成加载并更新缓存后,再从缓存中获取数据。

实现要点

  • 锁粒度:必须针对具体的 Key 加锁,避免锁住整个缓存。
  • 锁超时:设置合理的锁超时时间,防止线程异常退出导致死锁。
  • 重试机制:等待线程需要自旋或阻塞等待,避免无限等待。
// 伪代码:互斥锁实现缓存击穿防护
public String getData(String key) {
    String value = cache.get(key);
    if (value != null) {
        return value;
    }

    // 尝试获取分布式锁(以 Redis SETNX 为例)
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 5, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 双重检查:防止在等待锁期间其他线程已更新缓存
            value = cache.get(key);
            if (value != null) {
                return value;
            }
            // 从数据库加载数据
            value = db.query(key);
            cache.set(key, value, 300, TimeUnit.SECONDS);
            return value;
        } finally {
            redis.del(lockKey); // 释放锁
        }
    } else {
        // 未获取到锁的线程等待后重试
        Thread.sleep(50);
        return getData(key); // 递归重试
    }
}
适用场景:对数据一致性要求高、并发量中等(QPS < 10万)的场景。缺点在于等待线程会阻塞,增加响应延迟。

4.3 防护策略二:逻辑过期(Logical Expiration)

逻辑过期方案不设置物理 TTL,而是在缓存数据中嵌入一个逻辑过期时间字段。当读取缓存时,检查逻辑时间是否过期:若未过期,直接返回;若已过期,则异步触发缓存更新,同时返回旧数据给调用方。

核心数据结构

// 缓存值包装类
public class CacheWrapper<T> {
    private T data;           // 实际数据
    private long expireTime;  // 逻辑过期时间戳(毫秒)
    private long version;     // 版本号(可选,用于防并发更新)
}

执行流程

  1. 从缓存获取 CacheWrapper 对象。
  2. 判断当前时间是否超过 expireTime。
  3. 若未过期:直接返回 data。
  4. 若已过期:尝试获取分布式锁(或使用 CAS 更新版本号)。
  5. 获取锁成功的线程异步执行数据库查询并更新缓存。
  6. 获取锁失败的线程直接返回旧数据,不等待。
注意:逻辑过期方案返回的是“脏数据”,适用于对短暂数据不一致容忍度较高的场景(如排行榜、非关键配置)。必须配合异步补偿机制保证最终一致性。

4.4 防护策略三:热点数据预加载(Preload)

热点数据预加载是一种主动防御策略:在缓存失效之前,提前将热点数据加载到缓存中,避免失效瞬间的并发冲击。

实现方式

方式 原理 适用场景
定时任务预加载 通过 Cron 或调度框架,在缓存 TTL 过期前 1/3 时间刷新缓存 数据更新频率可预测、有固定刷新窗口
延迟双删 + 预加载 数据库更新后,先删除缓存,延迟一段时间后再将新数据写入缓存 写多读少、允许短暂不一致
热点探测 + 自动预热 实时监控 Key 访问频率,当达到阈值时自动触发预加载 流量突发、热点动态变化

预加载策略对比

  • 固定预加载:简单可靠,但可能浪费缓存空间(预加载了不常用的数据)。
  • 动态预加载:基于滑动窗口统计访问频次,精准识别热点,资源利用率高。
  • 预热脚本:系统启动或大促前,通过脚本批量加载热点数据到缓存。
最佳实践:预加载 + 互斥锁组合使用。预加载处理大部分正常流量,互斥锁作为兜底,防止预加载失败或时间偏差导致的击穿。

4.5 防护策略四:分布式锁(Distributed Lock)

在微服务架构下,多个服务实例同时访问缓存,必须使用分布式锁来保证只有一个实例去加载数据。分布式锁是互斥锁在分布式环境下的扩展。

主流实现方案

方案 实现方式 优点 缺点
Redis SETNX SET key value NX EX 5 性能高、实现简单 锁超时可能导致并发问题
Redisson 看门狗机制自动续期 解决锁超时问题 依赖 Redis 主从一致性
ZooKeeper 临时顺序节点 + Watch 强一致性、无超时问题 性能较低、依赖 ZK 集群
Etcd Lease + TTL + Revision 强一致、云原生友好 运维复杂度较高

分布式锁在缓存击穿中的关键设计

  • 锁粒度:必须按 Key 粒度加锁,锁 Key 格式建议 lock:cache:{originalKey}。
  • 锁超时与续期:使用 Redisson 看门狗或手动续期,避免业务执行时间超过锁 TTL。
  • 可重入性:同一线程多次获取同一把锁时不会死锁。
  • 容错性:Redis 主从切换时,使用 Redlock 算法保证锁的安全性。
// 使用 Redisson 实现分布式锁防护
public String getDataWithRedisson(String key) {
    String value = cache.get(key);
    if (value != null) {
        return value;
    }

    RLock lock = redissonClient.getLock("lock:cache:" + key);
    try {
        // 尝试加锁,最多等待 100ms,锁自动释放时间 10s
        if (lock.tryLock(100, 10, TimeUnit.SECONDS)) {
            try {
                // 双重检查
                value = cache.get(key);
                if (value != null) {
                    return value;
                }
                value = db.query(key);
                cache.set(key, value, 300, TimeUnit.SECONDS);
                return value;
            } finally {
                lock.unlock();
            }
        } else {
            // 等待后重试
            Thread.sleep(50);
            return getDataWithRedisson(key);
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return db.query(key); // 降级:直接查库
    }
}

4.6 四种策略对比与选型

策略 一致性 性能影响 实现复杂度 适用并发量 脏数据风险
互斥锁 强一致 中等(等待阻塞) 低 中(QPS < 10万) 无
逻辑过期 最终一致 低(无阻塞) 中 高(QPS > 10万) 有(短暂脏读)
热点预加载 强一致(配合锁) 低(提前加载) 中高 高 无(预加载成功时)
分布式锁 强一致 中等(网络开销) 高 中高 无(锁机制完善时)
选型建议:
  • 金融、交易等强一致场景:互斥锁 + 分布式锁。
  • 高并发、可接受短暂不一致:逻辑过期 + 异步更新。
  • 大促、秒杀等可预知热点:热点预加载 + 互斥锁兜底。
  • 微服务多实例部署:分布式锁(Redisson/Redlock)。

4.7 缓存击穿防护架构总览

下图展示了四种防护策略在缓存击穿场景下的协同工作流程:

缓存击穿防护策略协同架构 客户端并发请求 缓存层(Redis) 直接返回数据 热点 Key 检测 预加载模块 互斥锁 / 分布式锁 逻辑过期判断 后端数据库 更新缓存 说明:预加载模块在缓存失效前主动刷新;互斥锁/分布式锁保证单线程加载;逻辑过期允许返回旧数据并异步更新

4.8 实战避坑指南

常见陷阱:
  • 锁未设置超时:线程崩溃导致锁永久占用,所有请求阻塞。必须设置 TTL 并实现续期机制。
  • 单机锁用于分布式:synchronized 或 ReentrantLock 在多实例下无效,必须使用分布式锁。
  • 预加载时间偏差:预加载任务执行时间过长,导致缓存实际已过期但新数据未写入。建议预加载 TTL 设为缓存 TTL 的 1/3。
  • 逻辑过期未做版本控制:多个线程同时检测到过期并触发更新,导致数据库压力翻倍。需配合 CAS 或分布式锁。
  • 锁粒度太大:对整个缓存服务加锁,导致所有 Key 的读取串行化。必须按 Key 粒度加锁。
生产环境推荐组合:
  • Redis 缓存 + Redisson 分布式锁 + 热点探测预加载。
  • 对于超高并发场景(QPS > 50万),使用逻辑过期 + 本地缓存(Caffeine)二级缓存架构。
  • 所有方案必须配合降级策略:当锁获取失败或数据库异常时,返回缓存中的旧数据或默认值。