4. 缓存击穿问题:定义、热点key失效场景、互斥锁与逻辑过期方案

4.1 缓存击穿的定义与危害

缓存击穿(Cache Breakdown)是指当某个热点 key 在缓存中过期失效的瞬间,大量并发请求同时穿透缓存,直接打到后端数据库,导致数据库负载瞬间飙升甚至宕机的现象。

⚠️ 核心区别:缓存击穿与缓存雪崩不同。雪崩是大量 key 同时失效,而击穿是单个热点 key 在失效瞬间被高并发访问。虽然范围小,但热点 key 的流量往往占整体请求的 30%~70%,破坏力极强。

4.2 热点key失效的典型场景

场景 热点key示例 并发量级 失效后果
秒杀商品详情 product:1001:detail 10万+/秒 商品页白屏、数据库连接池打满
微博热搜榜单 hot:weibo:top50 5万+/秒 榜单接口超时、首页加载失败
大V粉丝数 user:9527:fans_count 3万+/秒 用户主页接口雪崩
系统配置缓存 config:global:switch 2万+/秒 全站配置失效、功能异常

4.3 解决方案一:互斥锁(Mutex Lock)

互斥锁方案的核心思想是:当缓存失效时,只允许一个线程去数据库加载数据,其他线程等待该线程完成加载后,直接从缓存读取。

4.3.1 实现流程

  1. 请求到达,从缓存获取数据。
  2. 缓存未命中,尝试获取分布式锁(如 Redis SETNX)。
  3. 获取锁成功的线程,查询数据库,回写缓存,释放锁。
  4. 获取锁失败的线程,休眠重试(或自旋等待),直到缓存中有数据。
public String getProductDetail(Long productId) {
    String cacheKey = "product:" + productId + ":detail";
    String cacheData = redisTemplate.opsForValue().get(cacheKey);
    
    if (cacheData != null) {
        return cacheData;
    }
    
    // 尝试获取分布式锁
    String lockKey = "lock:" + cacheKey;
    String lockValue = UUID.randomUUID().toString();
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
    
    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查:可能其他线程已经加载完成
            cacheData = redisTemplate.opsForValue().get(cacheKey);
            if (cacheData != null) {
                return cacheData;
            }
            // 查询数据库
            cacheData = productMapper.getDetail(productId);
            // 回写缓存,设置过期时间
            redisTemplate.opsForValue()
                    .set(cacheKey, cacheData, 30, TimeUnit.MINUTES);
            return cacheData;
        } finally {
            // 释放锁(使用Lua脚本保证原子性)
            String luaScript = 
                "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                "return redis.call('del', KEYS[1]) " +
                "else return 0 end";
            redisTemplate.execute(
                new DefaultRedisScript<>(luaScript, Long.class),
                Collections.singletonList(lockKey), lockValue);
        }
    } else {
        // 获取锁失败,休眠重试
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getProductDetail(productId); // 递归重试
    }
}
✅ 优点:实现简单,数据一致性高,不会出现缓存与数据库不一致的情况。
⚠️ 缺点:存在线程阻塞等待,高并发下会累积请求延迟;需要处理锁超时和死锁问题。

4.4 解决方案二:逻辑过期(Logical Expiration)

逻辑过期方案不设置物理过期时间,而是在缓存数据中嵌入一个逻辑过期时间戳。当发现逻辑过期时,由当前线程触发异步更新,同时返回旧数据给调用方。

4.4.1 数据模型

// 缓存数据结构
public class CacheData<T> {
    private T data;           // 实际业务数据
    private Long expireTime;  // 逻辑过期时间戳(毫秒)
    private Long createTime;  // 数据创建时间
    
    public boolean isExpired() {
        return System.currentTimeMillis() > expireTime;
    }
}

4.4.2 实现流程

  1. 请求到达,从缓存获取数据(包含逻辑过期时间)。
  2. 判断逻辑是否过期:未过期则直接返回数据。
  3. 已过期:尝试获取互斥锁。
  4. 获取锁成功:开启异步线程更新缓存,当前线程返回旧数据。
  5. 获取锁失败:直接返回旧数据,不等待。
public String getProductDetailV2(Long productId) {
    String cacheKey = "product:" + productId + ":detail_v2";
    CacheData<String> cacheData = redisTemplate.opsForValue().get(cacheKey);
    
    // 1. 缓存不存在,直接查库并初始化
    if (cacheData == null) {
        String dbData = productMapper.getDetail(productId);
        CacheData<String> newCache = new CacheData<>();
        newCache.setData(dbData);
        newCache.setCreateTime(System.currentTimeMillis());
        newCache.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
        redisTemplate.opsForValue().set(cacheKey, newCache);
        return dbData;
    }
    
    // 2. 逻辑未过期,直接返回
    if (!cacheData.isExpired()) {
        return cacheData.getData();
    }
    
    // 3. 逻辑已过期,尝试获取锁
    String lockKey = "lock:renew:" + cacheKey;
    Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS);
    
    if (Boolean.TRUE.equals(locked)) {
        // 4. 获取锁成功,异步更新缓存
        CompletableFuture.runAsync(() -> {
            try {
                String dbData = productMapper.getDetail(productId);
                CacheData<String> newCache = new CacheData<>();
                newCache.setData(dbData);
                newCache.setCreateTime(System.currentTimeMillis());
                newCache.setExpireTime(
                    System.currentTimeMillis() + 30 * 60 * 1000);
                redisTemplate.opsForValue().set(cacheKey, newCache);
            } finally {
                redisTemplate.delete(lockKey);
            }
        });
    }
    
    // 5. 无论是否获取到锁,都返回旧数据
    return cacheData.getData();
}
💡 核心优势:逻辑过期方案实现了非阻塞读取,所有请求都能立即得到响应(返回旧数据),用户体验远优于互斥锁方案。适用于允许短暂数据不一致的场景。

4.5 两种方案对比

对比维度 互斥锁方案 逻辑过期方案
数据一致性 强一致(所有请求拿到最新数据) 最终一致(过期瞬间返回旧数据)
请求延迟 部分请求阻塞等待(50~200ms) 零等待(立即返回)
数据库压力 仅1个线程查库 仅1个线程查库(异步)
实现复杂度 低(需处理锁超时) 中(需管理异步线程池)
适用场景 对数据一致性要求高的场景(如支付、库存) 允许短暂不一致的场景(如榜单、商品详情)

4.6 缓存击穿解决方案架构图

缓存击穿解决方案架构对比 互斥锁方案 ① 并发请求到达 ② 缓存未命中 ③ 竞争分布式锁 锁成功者 查库+回写缓存 锁失败者 休眠重试 ④ 所有请求获取最新数据 数据强一致 ✅ 逻辑过期方案 ① 并发请求到达 ② 逻辑过期判断 ③ 竞争更新锁 锁成功者 异步更新缓存 锁失败者 直接返回旧数据 ④ 所有请求立即返回 零等待 + 最终一致 ✅

4.7 实践建议与避坑指南

📌 选型建议:
  • 金融、支付、库存类:优先使用互斥锁方案,保证数据强一致。
  • 内容、榜单、商品详情类:优先使用逻辑过期方案,提升吞吐量。
  • 混合使用:核心数据用互斥锁,非核心数据用逻辑过期,同一系统内可并存。
⚠️ 常见坑点:
  • 锁超时时间设置过短:数据库查询慢时,锁提前释放导致多个线程同时查库。
  • 递归重试导致栈溢出:互斥锁方案中,重试次数应有限制(如最多重试3次)。
  • 异步线程池未隔离:逻辑过期方案的异步更新应使用独立线程池,避免影响业务线程。
  • 逻辑过期时间与缓存淘汰策略冲突:Redis 内存淘汰可能提前删除未过期的逻辑数据,需配合主动刷新。