java - redis 缓存击穿 - 互斥锁

文章目录

缓存击穿:某个热点 Key 过期的一瞬间,大量并发请求同时打到数据库,把数据库压垮

注意区分:

  • 缓存击穿:单个热点 key 过期,大量请求穿透到 DB
  • 缓存雪崩:大量 key 同一时间过期,大量请求同时打 DB
  • 缓存穿透:查询不存在的数据,缓存永远不命中,直接访问 DB



代码

  • 使用 Redis互斥锁,,解决,缓存击穿 :当热点 key 缓存失效时,通过 setIfAbsent 互斥锁,
    保证同一时刻, 只有一个线程去查询数据库重建缓存,其他线程休眠重试,避免大量并发请求打到数据库。
    同时使用 缓存空对象 解决,缓存穿透:数据库不存在的数据,在Redis存入空字符串,短TTL,后续请求直接命中空缓存,不再访问数据库。
java 复制代码
@Override
public Result queryByIdRedisLock(Long id) {
    String key = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);

    if (StrUtil.isNotBlank(shopJson)) {
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }

    // shopJson等于"", 说明数据库没有这条数据
    if (shopJson != null) {
        return Result.fail("商铺不存在");
    }

    // 缓存重构,使用互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        // 判断是否获取成功
        if (!isLock) {
            // 失败,则休眠重试
            Thread.sleep(50);
            return queryByIdRedisLock(id);
        }

        // 拿到锁之后,必须再查一次Redis
        String shopJson2 = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson2)) {
            Shop shop2 = JSONUtil.toBean(shopJson2, Shop.class);
            return Result.ok(shop2);
        }

        // shopJson等于"", 说明数据库没有这条数据
        if (shopJson2 != null) {
            return Result.fail("商铺不存在");
        }

        // 成功, 根据id查询数据库
        shop = getById(id);
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.SECONDS);
            return Result.fail("商铺不存在");
        }

        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        unlock(lockKey);
    }

    return Result.ok(shop);
}

private boolean tryLock(String lockKey) {
	"setIfAbsent 就是 setnx + 过期时间,原子命令"
	"底层命令是:SET lockKey value EX LOCK_SHOP_TTL MINUTES NX"
	"NX:NotExist    EX:给key设置过期时间"
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "", LOCK_SHOP_TTL, TimeUnit.MINUTES);
    return BooleanUtil.isTrue(flag);
}

private void unlock(String lockKey) {
    stringRedisTemplate.delete(lockKey);
}

执行流程

前提:热点商铺 id=1,缓存刚好过期,大量请求 同时 访问这个接口。

核心角色:线程A(抢到锁)、线程B / C / D...(抢锁失败,重试)

第①步:所有请求进来,先查Redis

java 复制代码
String shopJson = stringRedisTemplate.opsForValue().get(key);

key = cache:shop:1 已经过期被删除 → shopJson == null

  • StrUtil.isNotBlank(shopJson) → false
  • shopJson != null → false
    👉 进入缓存重构、抢锁逻辑

第②步:多个线程同时尝试获取互斥锁 lock:shop:1

java 复制代码
boolean isLock = tryLock(lockKey);

底层命令 SET lock:shop:1 "" EX ... NX,Redis保证只有一个线程能设置成功

  • 线程AtryLock 返回true,拿到锁,继续向下执行
  • 线程B、C、DtryLock 返回false,进入下面分支
java 复制代码
if (!isLock) {
    Thread.sleep(50);   // 当前线程原地等待50ms,不新建线程
    return queryByIdRedisLock(id); // 递归,从头重新执行整个方法
}

B / C / D 休眠50ms之后,重新从头执行:查Redis → 判断 → 尝试抢锁

第③步:拿到锁的线程A,二次查询Redis(重点!)

java 复制代码
String shopJson2 = stringRedisTemplate.opsForValue().get(key);

为什么二次查?

防止极端场景:在线程A抢到锁之前,别的线程已经重建完缓存。

现在场景是缓存为空 → shopJson2 == null,继续往下走。

第④步:线程A查询数据库

java 复制代码
shop = getById(id);
  • 情况1:数据库存在商铺
    把商铺json写入Redis,设置过期时间 CACHE_SHOP_TTL
  • 情况2:数据库没有该商铺
    Redis存入空字符串"",短TTL(缓存空对象,解决缓存穿透)。

第⑤步:线程A进入finally,释放锁

java 复制代码
finally {
    unlock(lockKey); // del lock:shop:1
}

锁删掉,其他线程才有机会抢到锁。线程A返回结果,请求结束。



看线程B(等待重试的线程)的后续过程

线程B 休眠50ms后,递归调用 queryByIdRedisLock(id),从头执行:

  1. 先查Redis:此时线程A已经把商铺数据写入Redis
  2. StrUtil.isNotBlank(shopJson) → true
  3. 直接把缓存数据返回,不再抢锁,不再访问数据库

这就是互斥锁解决缓存击穿的核心:只有一个线程去访问DB重建缓存,剩下的请求等一会,直接读建好的缓存



两种分支场景完整走一遍

场景1:数据库存在该商铺(热点key过期,典型缓存击穿场景)

  1. 缓存key失效 → shopJson = null
  2. 并发请求抢锁,A拿到锁,B C D 休眠重试
  3. A二次查缓存,依旧无数据
  4. A查询MySQL,拿到shop数据
  5. A把shop的json存入Redis
  6. A释放锁,返回shop
  7. B / C / D 休眠结束,再次查询Redis,缓存已经存在,直接返回缓存,不访问数据库

场景2:数据库没有这个商铺id(缓存穿透场景,顺带处理)

  1. 请求查询不存在id,Redis没有key → shopJson = null
  2. 线程A抢到锁,二次查缓存还是null
  3. A查询DB,shop = null
  4. Redis写入空字符串"",短过期时间
  5. A释放锁,返回"商铺不存在"
  6. 后续请求再来:查到shopJson = ""(shopJson != null),直接返回失败,不再访问数据库


面试容易问

Q:拿到锁之后为什么二次查询Redis?

A:B线程 在A释放锁后抢到锁,此时A已经把缓存写好了,二次查询直接返回缓存,避免重复查询数据库。

Q:Thread.sleep(50)做了什么?会不会开新线程?

A:不会开新线程,当前Tomcat工作线程暂停50ms,时间到,递归重新执行整套查询逻辑。

Q:这个代码有什么隐患?

递归重试,高并发容易栈溢出

锁直接删除,没有校验锁持有者,会出现锁误删

锁没有看门狗,业务超过锁过期时间,锁提前失效



文字时序图(热点key过期,多请求并发,互斥锁解决缓存击穿)

前提:cache:shop:1 缓存过期被删除;请求A、B、C 同时访问 /shop/1,3条请求,3个独立Tomcat工作线程

目标:只有1个线程查DB重建缓存,其他等待后读缓存

复制代码
时间轴:
T0:请求A、B、C 同时到达接口
    全部执行 stringRedisTemplate.get("cache:shop:1") → 返回null(缓存失效)
    全部进入抢锁逻辑 lockKey=lock:shop:1

T1:并发执行 tryLock()
    线程A:setIfAbsent 成功 → isLock=true ✅拿到锁
    线程B:setIfAbsent 失败 → isLock=false
    线程C:setIfAbsent 失败 → isLock=false

T2:
    线程A:拿到锁 → 二次查询Redis get(cache:shop:1),依然null,继续向下执行
    线程B:进入sleep(50ms),线程原地阻塞(Tomcat线程占用,不新建线程)
    线程C:进入sleep(50ms),线程原地阻塞

T3:
    线程A:查询DB getById(1),拿到商铺数据
    线程A:写入Redis cache:shop:1 = shopJson,设置TTL
    线程A:finally执行unlock,删除 lock:shop:1 → 锁释放
    线程A:return 商铺数据,请求A结束

T4:50ms时间到,B、C线程唤醒
    线程B:递归调用 queryByIdRedisLock(1),从头执行
        get(cache:shop:1) → 查到shopJson有值!
        StrUtil.isNotBlank = true,直接返回缓存数据,**不抢锁、不访问DB**
    线程C:同样递归执行,命中缓存,直接返回缓存数据,**不抢锁、不访问DB**
请求B、C全部结束

另一条分支时序:查询不存在的id(缓存穿透场景)

请求D查询 id=999(数据库没有这个商铺)

复制代码
T0:请求D进来,get(cache:shop:999) → null
T1:抢到锁,二次查缓存依旧null
T2:查询DB getById(999) → shop=null
T3:往Redis写入 cache:shop:999 = ""(空字符串,短TTL)
T4:释放锁,返回"商铺不存在"

T5:后续再来请求E查询id=999
    get(cache:shop:999) → ""
    shopJson != null,直接返回商铺不存在,**不再访问数据库**

多个请求,同时,访问同一个热点 key,此时缓存刚好过期。

所有请求先查 Redis,发现缓存不存在,一起去抢 Redis 互斥锁。

只有一个线程抢到锁,拿到锁之后再次查询 Redis 确认缓存还是空 ,然后去查询数据库,把数据写入 Redis,最后释放锁。

其他没抢到锁的线程,休眠 50 毫秒之后重试;重试的时候缓存已经建好,直接读取缓存返回,不会再访问数据库,避免大量请求同时打到数据库,解决缓存击穿。同时如果数据库查不到数据,向 Redis 存入空字符串,解决缓存穿透。

相关推荐
shehuiyuelaiyuehao1 小时前
算法39,位运算,消失的两个数字
java·数据结构·算法
小刘在重生~1 小时前
Java 异常体系完整笔记
java·笔记·python
抠脚小弟1 小时前
Spring Task 定时任务详解:从入门到实战
java·后端·spring
2601_962284501 小时前
Python 和Java 哪个更适合做自动化测试?
java·自动化测试·python·接口测试·性能测试
随遇而安zx2 小时前
【多线程】---AQS 原理 知识点(设计思想与源码深度解析)
java·多线程·aqs
LXMXHJ2 小时前
springboot中的线程操作
java·spring boot·后端·线程
敲代码的嘎仔2 小时前
自己设计了一个兑换码算法:自增ID + Base32转码 + 按位加权签名 + 异或混淆,面试被追问细节时终于不用慌了
java·数据库·mysql·算法·微服务·面试·职场和发展
m0_587383002 小时前
外卖CPS系统开发实战:从架构设计到运营落地全指南
java·spring·小程序·架构·需求分析
zhangzeyuaaa2 小时前
Python asyncio 事件循环演进:从手动管理到现代化实践
java·服务器·python