欢迎光临
我们一直在努力

【黑马点评】解决缓存击穿:互斥锁 vs 逻辑过期

前言: 在高并发系统中,缓存是提升性能的神兵利器。然而,当一个热点 Key(比如头部主播的直播间、秒杀商品的详情)突然过期,数以万计的并发请求会在瞬间击穿缓存,直接打到数据库上,导致数据库瞬间宕机。这就是著名的“缓存击穿”问题。

本文将基于“黑马点评”项目,深入复盘解决该问题的两种终极方案:互斥锁(Mutex Lock) 与 逻辑过期(Logical Expiration),并从一致性、可用性等维度进行深度对比。

一、问题的本质与技术选型

缓存击穿不同于缓存穿透。它的核心在于“热点 Key” + “过期”。 当一个高并发访问的 Key(如头部主播直播间、秒杀商品详情)失效时,如果 1000 个线程同时涌入:

  • 无保护状态:1000 个线程全部去查库,DB 瞬间被打死。
  • 我们需要做的:让这 1000 个线程中,只有 1 个去查库,其他人要么等着(互斥锁),要么先看旧数据(逻辑过期)。
  • 二、 方案一:互斥锁 (Mutex Lock) —— 强一致性的坚守

    2.1 核心原理 与 Double Check

    互斥锁的核心思想是:“只许一人重建,他人排队等待”。

    当发现缓存失效时,不是所有线程都去查数据库,而是大家一起去抢一个“锁”。

    • 抢到锁的人:负责查询数据库、写入缓存。
    • 没抢到锁的人:休眠一会儿(Thread.sleep),醒来后再次查询缓存(此时缓存可能已经重建好了)。

    思考:为什么拿到锁后,还需要再查一次缓存?

    假设线程 A 和线程 B 同时发现缓存过期。A 抢到了锁,开始查库建缓存。 B 休眠 50ms。 等 A 建好缓存释放锁, B 醒来再次抢到锁,如果 B 不做 Double Check,它会再次查库、写缓存。在高并发场景下,这会导致不必要的数据库查询,锁的意义大大折扣。

    2.2 代码实战

    在 queryWithMutex 方法中,我们利用 Redis 的 setnx 特性实现了互斥锁。

    关键代码解析:

    Java

    public Shop queryWithMutex(Long id) {
    // 1.从 redis 查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
    // 3.如果命中 – 直接返回
    Shop shop = JSONUtil.toBean(shopJson, Shop.class);
    return shop;
    }

    // TODO Redis 空对象
    if (shopJson != null) {
    return null;
    } // 防止缓存穿透

    // 4. 实现缓存重构 解决 缓存击穿问题
    // 4.1 尝试获取互斥锁
    String lockKey = "lock:shop:" + id;
    Shop shop = null;

    try {
    boolean isLock = tryLock(lockKey);
    // 4.2 判断获取是否成功
    if(!isLock){
    // 4.3 失败:休眠重试
    Thread.sleep(50);
    return queryWithMutex(id);
    }
    // 4.4 double check
    String shopJson2 = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    if (StrUtil.isNotBlank(shopJson2)) {
    return JSONUtil.toBean(shopJson2, Shop.class);
    }
    if (shopJson2 != null) {
    return null;
    }
    // double check 失败 :根据 id 查询数据库
    shop = getById(id);

    // 模拟重建的延时
    Thread.sleep(200);

    // 5. 不存在,返回错误
    if(shop == null){
    // 将空值写入 redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "" , CACHE_NULL_TTL , TimeUnit.MINUTES);
    // 返回错误信息
    return null;
    }

    // 6. 写入 redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL , TimeUnit.MINUTES);

    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    } finally {
    // 7. 释放互斥锁
    unlock(lockKey);
    }

    return shop;

    }

    2.3 优缺点分析

    • 优点:
      • 强一致性:保证了用户查到的数据一定是最新的。
      • 内存占用小:不需要多余的字段。
    • 缺点:
      • 性能受损:大量线程被阻塞、排队,系统吞吐量下降。
      • 死锁风险:如果有复杂的锁依赖,可能导致死锁。

    2.4 踩坑记录

    ❌ 踩坑现场:Hutool 的 BeanUtil 误用 起初,因为不了解,我在反序列化时使用了 BeanUtil.toBean()。

    Java

    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 错误写法:BeanUtil 并不适合将 JSON 字符串直接转为对象
    Shop shop = BeanUtil.toBean(shopJson, Shop.class);

    后果:代码运行不报错,但返回的 shop 对象属性全空,或者直接抛出类型转换异常。 修正:存入 Redis 时既然用了 JSONUtil.toJsonStr,取出时必须成对使用 JSONUtil.toBean。

    三、 方案二:逻辑过期 (Logical Expiration) —— 极致的高可用

    3.1 核心原理

    互斥锁保证了一致性,但牺牲了可用性。对于“商铺详情”这种对实时性要求没那么高的场景,我们追求高可用性。

    逻辑过期的核心思想是:“永不过期,异步重建,零等待”。

    我们不给 Redis 的 Key 设置 TTL(物理过期),而是把过期时间 expireTime 封装在数据内部。

    • 查询时:发现 expireTime 已过期,立刻返回旧数据(不管它是不是旧的,先返回再说,保证不卡顿)。
    • 后台:开启一个独立线程去抢锁。抢到锁的线程负责去查数据库、更新缓存、重置过期时间。

    需要注意的是逻辑过期在工程上几乎一定要配合缓存预热来使用。

    而在本项目中,缓存预热是必须的,请看下面这一段代码:

    // 1. 查询缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    // 2. 如果未命中,直接返回 null
    if (StrUtil.isBlank(shopJson)) {
    return null; // <— 罪魁祸首在这里
    }

    不做缓存预热,等同于手动废除了逻辑过期的核心机制。 一旦 Redis 中缺失数据,代码会在入口处直接‘短路’返回 null,导致异步重建线程成为摆设,系统彻底丧失了从无到有的自我修复能力。 对于本就是为了应对高并发而生的热点业务来说,这不仅仅是查不到数据的问题,而是核心业务线的灾难性停摆。

    3.2 代码实战

    我们需要引入 RedisData 包装类和线程池 CACHE_REBUILD_EXECUTOR。

    // RedisData

    package com.hmdp.utils;

    import lombok.Data;

    import java.time.LocalDateTime;

    @Data
    public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
    }

    // 缓存预热 / 重建缓存
    public void saveShop2Redis(Long id, Long expireSeconds){
    // 1. 查询店铺数据
    Shop shop = getById(id);
    // 2. 封装逻辑过期时间
    RedisData redisData = new RedisData();
    redisData.setData(shop);
    redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
    // 3. 写入 Redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id , JSONUtil.toJsonStr(redisData));
    }

    // ShopServiceImpl

    // 线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public Shop queryWithLogicExpire(Long id){
    String key = CACHE_SHOP_KEY + id;
    // 1.从 redis 查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    // 2.判断是否存在
    if (StrUtil.isBlank(shopJson)) {
    // 3.如果存在 – 直接返回
    return null;
    }

    // 4.命中,JSON 反序列化为对象
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    JSONObject data = (JSONObject) redisData.getData();
    Shop shop = JSONUtil.toBean(data, Shop.class);

    LocalDateTime expireTime = redisData.getExpireTime();
    // 5. 判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())){
    // 5.1 未过期:直接返回店铺信息
    return shop;
    }

    // 5.2 已过期:缓存重建
    // 6. 缓存重建
    // 6.1 获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    // 6.2 判断是否获取锁成功
    boolean isLock = tryLock(lockKey);
    if(isLock){
    CACHE_REBUILD_EXECUTOR.execute(() -> {
    try{
    // 重建缓存
    this.saveShop2Redis(id , 20L);
    } catch(Exception e){
    e.printStackTrace();
    } finally {
    unlock(lockKey);
    }
    });
    }

    // 6.4 返回过期的商铺信息
    return shop;
    }

    四、 如何选择?

    在项目中不能“一刀切”地使用某一种方案,而是需要根据业务场景进行划分。这体现了技术选型中的Trade-off(权衡)。

    维度

    互斥锁 (Mutex)

    逻辑过期 (Logical Exp)

    深度思考

    一致性

    强一致

    最终一致

    涉及金钱(如优惠券库存、余额)必须用互斥锁,不能让用户看到错误的库存。

    可用性

    差(存在阻塞/等待)

    优(零等待)

    涉及浏览体验(如商铺信息、资讯详情)首选逻辑过期,用户不在乎晚几秒看到新标题,但在乎页面能不能秒开。

    实现复杂度

    中等

    较高

    逻辑过期需要额外的线程池管理,且必须配合“缓存预热”机制,否则新 Key 上线时会有问题。

    内存开销

    低

    略高

    逻辑过期需要存储 expireTime

    字段,且 Key 不会被自动清理,需配合定期清理策略。

    五、 结语:架构设计的“不完美”艺术

    通过对“互斥锁”与“逻辑过期”的深度复盘,我们可以发现,没有任何一种缓存方案是万能的“银弹”:

            1. 从阻塞到非阻塞的进化:

    互斥锁代表了传统的同步阻塞思维,它简单、可靠,但在极端并发下显得笨重。而逻辑过期则体现了异步非阻塞的现代架构思想,通过牺牲短暂的数据一致性,换取了系统近乎无损的可用性。

            2. 防御性编程的深度:

    我们在代码中加入的 Double Check(双重检查) 以及对 Hutool 序列化陷阱的规避,本质上是在不确定的并发环境下,通过工程手段构建确定性的防御。

            3. 架构师的权衡哲学:

    一个优秀的开发人员不应只看“快不快”,而应看“稳不稳定”。在处理核心金融数据时,我们要有“守门员”式的严谨(互斥锁);在处理海量 C 端流量时,我们要有“太极”式的化解之道(逻辑过期)。

    缓存的设计不仅仅是为了快,更是为了在流量洪峰涌入时,给脆弱的数据库筑起一道可以柔性缩放的屏障。

    赞(0)
    未经允许不得转载:171主机测评 » 【黑马点评】解决缓存击穿:互斥锁 vs 逻辑过期
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址