4、缓存击穿:定义与危害、热点key失效场景、解决方案

4.1 定义与危害

缓存击穿 是指一个被 高并发访问 并且 缓存重建业务较复杂 的 热点 key,在缓存失效的瞬间,大量请求同时穿透缓存直接访问数据库,导致数据库负载瞬间飙升甚至宕机。

核心特征: 单点热点 key 失效 + 超高并发读请求 = 数据库压力雪崩。

危害:

  • 数据库瞬时过载: 大量请求同时涌入数据库,CPU/IO 瞬间打满,导致查询超时或连接池耗尽。
  • 级联故障风险: 数据库响应变慢后,应用线程阻塞,可能引发上游服务超时重试,形成雪崩效应。
  • 数据不一致窗口: 并发重建缓存时,多个线程同时写缓存,可能导致旧数据覆盖新数据。
对比维度 缓存穿透 缓存击穿 缓存雪崩
问题本质 查询不存在的数据 热点 key 失效 大量 key 同时失效
并发特征 少量恶意/异常请求 超高并发读 大规模并发读
影响范围 单个 key 无影响 单个热点 key 多个 key 或分片
数据库压力 持续少量穿透 瞬时峰值冲击 持续高负载

4.2 热点 key 失效场景

热点 key 失效通常发生在以下典型场景中:

  1. 定时过期策略: 业务代码为热点数据设置了固定的 TTL(如 1 小时),到期瞬间所有请求同时发现缓存缺失。
  2. Redis 内存淘汰: 内存达到 maxmemory 阈值时,LRU/LFU 算法可能淘汰掉热点 key,导致后续请求击穿。
  3. 缓存预热失效: 系统重启或发布后,热点 key 未被及时预热,首次大量请求直接打到数据库。
  4. 数据更新后主动删除: 为保证最终一致性,更新数据库后主动删除缓存,若删除后瞬间有大量读请求,则触发击穿。
避坑提示: 热点 key 的 TTL 不要设置成整点过期(如 00:00:00),应加上随机偏移量(如 3600 + random(0,300) 秒),避免多个热点 key 同时失效。

4.3 解决方案

业界主流方案有三种:互斥锁、逻辑过期、永不过期 + 异步更新。下表对比其核心差异:

方案 核心思想 一致性 复杂度 适用场景
互斥锁 只允许一个线程重建缓存 强一致 低 缓存重建耗时短、并发极高
逻辑过期 缓存永不过期,通过逻辑过期时间判断 最终一致 中 缓存重建耗时长、允许短暂不一致
永不过期+异步更新 物理永不过期,后台定时刷新 最终一致 高 数据变化不频繁、可接受延迟

4.3.1 互斥锁方案

利用 Redis 的 SETNX 或 Redisson 分布式锁,保证同一时刻只有一个线程去数据库加载数据并重建缓存,其他线程等待或快速返回。

// 伪代码:互斥锁防止缓存击穿
public String getWithMutex(String key) {
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    // 尝试获取分布式锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 30, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 双重检查:获取锁后再次检查缓存
            value = redis.get(key);
            if (value != null) {
                return value;
            }
            // 从数据库加载
            value = db.query(key);
            redis.set(key, value, 3600);
            return value;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 未获取到锁,等待后重试
        Thread.sleep(50);
        return getWithMutex(key);
    }
}
优化点: 使用 Redisson 的 tryLock 并设置等待超时,避免线程无限阻塞;锁的自动过期时间应大于缓存重建耗时,防止死锁。

4.3.2 逻辑过期方案

缓存中不设置 TTL,而是存储一个 逻辑过期时间。读取时判断是否过期,若过期则尝试获取锁,由单个线程异步重建缓存,其他线程直接返回旧数据。

// 伪代码:逻辑过期方案
public String getWithLogicalExpire(String key) {
    CacheData cacheData = redis.get(key);
    long now = System.currentTimeMillis();
    // 未过期,直接返回
    if (cacheData.getExpireTime() > now) {
        return cacheData.getValue();
    }
    // 已过期,尝试获取锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
    if (locked) {
        // 异步线程重建缓存
        executor.submit(() -> {
            String newValue = db.query(key);
            CacheData newData = new CacheData(newValue, now + 3600 * 1000);
            redis.set(key, newData);
            redis.del(lockKey);
        });
    }
    // 返回旧数据(允许短暂不一致)
    return cacheData.getValue();
}
注意: 逻辑过期方案会返回过期数据,适用于对一致性要求不高的场景(如排行榜、资讯列表)。需要配合 MQ 或定时任务保证最终一致性。

4.3.3 永不过期 + 异步更新方案

缓存物理上永不过期,由后台守护线程或定时任务定期刷新热点 key 的数据。读取时直接返回缓存数据,不存在击穿窗口。

// 伪代码:永不过期 + 异步更新
@Component
public class HotKeyRefresher {
    @PostConstruct
    public void init() {
        // 定时刷新热点 key,每 30 秒执行一次
        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
        scheduler.scheduleAtFixedRate(() -> {
            for (String hotKey : hotKeyList) {
                String newValue = db.query(hotKey);
                redis.set(hotKey, newValue);  // 不设置 TTL
            }
        }, 0, 30, TimeUnit.SECONDS);
    }
}

// 读取时直接返回,无击穿风险
public String get(String key) {
    return redis.get(key);
}
适用场景: 配置信息、基础数据字典、商品详情等变化频率低、数据量小、可接受秒级延迟的业务。

4.4 方案选型对比

缓存击穿解决方案选型决策树 缓存击穿 互斥锁 逻辑过期 永不过期+异步 特征 • 强一致性 • 实现简单 • 锁等待增加延迟 • 适合重建耗时短 特征 • 最终一致性 • 无锁等待 • 返回旧数据 • 适合重建耗时长 特征 • 最终一致性 • 无击穿窗口 • 需要定时任务 • 适合低频变化数据 选型建议 强一致选互斥锁 | 高可用选逻辑过期 | 低延迟选永不过期+异步

4.5 最佳实践总结

  • 优先使用互斥锁: 对于一致性要求高、缓存重建快的场景(如用户会话、订单状态),互斥锁方案最可靠。
  • 逻辑过期用于高并发读: 当缓存重建耗时超过 200ms 且允许短暂不一致时(如首页推荐、排行榜),逻辑过期可避免锁竞争。
  • 永不过期 + 异步更新用于基础数据: 配置类、字典类数据,变化频率低,后台定时刷新即可。
  • 热点 key 发现: 结合 Redis 的 hotkey 监控或客户端本地统计,提前识别热点 key 并做保护。
  • 降级兜底: 所有方案都应配合本地缓存(如 Caffeine)做二级缓存,即使 Redis 击穿,本地缓存仍可提供短暂保护。
生产建议: 不要只依赖一种方案。通常采用 互斥锁 + 本地缓存 的组合,既保证一致性,又通过本地缓存降低锁竞争概率。