3、缓存击穿问题:定义与危害、解决方案(互斥锁、逻辑过期)、实战代码示例

3.1 缓存击穿到底是什么?

先说说缓存击穿。很多人容易把它和缓存穿透搞混,我刚开始也犯过这个错。

缓存击穿,说白了就是:某个热点 key 突然过期了,然后一瞬间大量请求直接打到数据库上。

注意几个关键词:

  • 热点 key:访问量极高的数据,比如秒杀商品、热搜榜单
  • 突然过期:不是慢慢失效,是瞬间失效
  • 大量请求:并发量很大,可能成千上万

你想想看,平时这些请求都从缓存拿数据,缓存扛得住。突然缓存里没了,所有请求就像没头苍蝇一样全冲进数据库。数据库哪受得了这个?

核心区别一句话总结:

  • 缓存穿透:查的数据本来就不存在
  • 缓存击穿:数据存在,但缓存刚好过期
  • 缓存雪崩:大量 key 同时过期

3.2 危害有多大?

我在项目中遇到过两次缓存击穿,印象特别深。

第一次是某个电商大促活动,商品详情页的缓存设置了 1 小时过期。结果整点的时候,所有商品缓存同时到期,数据库连接池瞬间被打满,接口响应时间从 5ms 飙升到 3 秒多。用户端直接看到页面加载失败。

第二次更惨,某个热点新闻的缓存过期后,大量用户刷新,数据库 CPU 直接飙到 100%,最后不得不重启数据库。

所以危害其实很明确:

  • 数据库压力暴增:瞬间 QPS 可能翻几十倍
  • 响应变慢甚至超时:用户体验极差
  • 可能引发雪崩:数据库挂了,整个系统都受影响

我曾经踩过的坑:

以为热点 key 设置永不过期就没事了。结果数据更新后,缓存和数据库不一致,用户看到的是旧数据。嗯,这里要注意,缓存击穿和一致性是两回事,不能为了防击穿就放弃一致性。

3.3 解决方案一:互斥锁

互斥锁的思路很简单:只让一个请求去查数据库,其他请求等着

流程是这样的:

  1. 请求来了,先查缓存
  2. 缓存没命中,尝试获取互斥锁
  3. 拿到锁的请求去查数据库,然后回写缓存
  4. 没拿到锁的请求,等待一段时间后重试
  5. 缓存有了,直接返回

我个人习惯用 Redis 的 SETNX 命令来实现互斥锁。代码示例如下:

// 伪代码 - 互斥锁方案
public String getData(String key) {
    // 1. 先查缓存
    String value = redis.get(key);
    if (value != null) {
        return value;
    }
    
    // 2. 缓存没命中,尝试获取锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        try {
            // 3. 拿到锁,查数据库
            value = db.query(key);
            // 4. 回写缓存,设置过期时间
            redis.set(key, value, 60, TimeUnit.SECONDS);
            return value;
        } finally {
            // 5. 释放锁
            redis.del(lockKey);
        }
    } else {
        // 6. 没拿到锁,等待后重试
        Thread.sleep(100);
        return getData(key); // 递归重试
    }
}

避坑指南:

我曾经犯过一个错误:锁的过期时间设得太短。数据库查询慢一点,锁就自动释放了,其他请求又冲进来。建议锁的过期时间设成数据库查询时间的 3-5 倍。

3.4 解决方案二:逻辑过期

互斥锁有个问题:会阻塞请求。如果数据库查询很慢,所有请求都得等着。

逻辑过期方案就不一样了。它的核心思想是:缓存永不过期,但数据里带一个过期时间

流程是这样的:

  1. 缓存里存的是数据 + 过期时间
  2. 请求来了,检查过期时间
  3. 没过期,直接返回
  4. 过期了,先返回旧数据,再异步去更新缓存

说白了就是:用户永远能拿到数据,只是可能拿到的是旧数据

// 伪代码 - 逻辑过期方案
public String getData(String key) {
    // 1. 查缓存
    String cacheData = redis.get(key);
    if (cacheData == null) {
        return null; // 缓存都没了,走兜底逻辑
    }
    
    // 2. 解析数据
    CacheData data = JSON.parse(cacheData);
    
    // 3. 检查逻辑过期时间
    if (data.getExpireTime() > System.currentTimeMillis()) {
        // 没过期,直接返回
        return data.getValue();
    }
    
    // 4. 过期了,尝试获取互斥锁
    String lockKey = "lock:" + key;
    boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (locked) {
        // 5. 拿到锁,异步更新缓存
        executor.submit(() -> {
            String newValue = db.query(key);
            CacheData newData = new CacheData(newValue, 
                System.currentTimeMillis() + 60000);
            redis.set(key, JSON.toJSON(newData));
            redis.del(lockKey);
        });
    }
    
    // 6. 返回旧数据
    return data.getValue();
}

两种方案对比:

对比项 互斥锁 逻辑过期
数据一致性 强一致 弱一致(可能读到旧数据)
请求延迟 可能阻塞 无阻塞
实现复杂度 简单 中等
适用场景 对一致性要求高 对可用性要求高

3.5 实战经验总结

我个人习惯这样选:

  • 数据一致性要求高(比如订单状态):用互斥锁
  • 数据一致性要求不高(比如商品详情):用逻辑过期
  • 热点 key 特别多:考虑用本地缓存做二级缓存,减少 Redis 压力

我记得有一次做直播带货系统,商品库存数据用了互斥锁。结果主播一喊「上链接」,瞬间几十万请求进来,锁竞争特别激烈。后来改成逻辑过期 + 异步更新,用户体验好多了。

嗯,这里还要提醒一点:不管用哪种方案,都要做好降级和熔断。万一缓存和数据库都挂了,得有兜底方案。

我的建议:

刚开始做缓存击穿防护时,先用互斥锁。它简单、容易理解、不容易出错。等系统稳定了,再考虑优化成逻辑过期方案。别一上来就搞复杂的,容易翻车。