文章目录
-
- 代码
- 执行流程
-
- 第①步:所有请求进来,先查Redis
- [第②步:多个线程同时尝试获取互斥锁 `lock:shop:1`](#第②步:多个线程同时尝试获取互斥锁
lock:shop:1) - 第③步:拿到锁的线程A,**二次查询Redis(重点!)**
- 第④步:线程A查询数据库
- 第⑤步:线程A进入finally,释放锁
- 看线程B(等待重试的线程)的后续过程
- 两种分支场景完整走一遍
- 面试容易问
- 文字时序图(热点key过期,多请求并发,互斥锁解决缓存击穿)
- 另一条分支时序:查询不存在的id(缓存穿透场景)
缓存击穿:某个热点 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)→ falseshopJson != null→ false
👉 进入缓存重构、抢锁逻辑
第②步:多个线程同时尝试获取互斥锁 lock:shop:1
java
boolean isLock = tryLock(lockKey);
底层命令 SET lock:shop:1 "" EX ... NX,Redis保证只有一个线程能设置成功
- ✅ 线程A :
tryLock返回true,拿到锁,继续向下执行 - ❌ 线程B、C、D :
tryLock返回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),从头执行:
- 先查Redis:此时线程A已经把商铺数据写入Redis
StrUtil.isNotBlank(shopJson)→ true- 直接把缓存数据返回,不再抢锁,不再访问数据库 ✅
这就是
互斥锁解决缓存击穿的核心:只有一个线程去访问DB重建缓存,剩下的请求等一会,直接读建好的缓存
两种分支场景完整走一遍
场景1:数据库存在该商铺(热点key过期,典型缓存击穿场景)
- 缓存key失效 → shopJson = null
- 并发请求抢锁,A拿到锁,B C D 休眠重试
- A二次查缓存,依旧无数据
- A查询MySQL,拿到shop数据
- A把shop的json存入Redis
- A释放锁,返回shop
- B / C / D 休眠结束,再次查询Redis,缓存已经存在,直接返回缓存,不访问数据库
场景2:数据库没有这个商铺id(缓存穿透场景,顺带处理)
- 请求查询不存在id,Redis没有key → shopJson = null
- 线程A抢到锁,二次查缓存还是null
- A查询DB,shop = null
- Redis写入空字符串
"",短过期时间 - A释放锁,返回"商铺不存在"
- 后续请求再来:查到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 存入空字符串,解决缓存穿透。