#黑马点评#(三)缓存穿透/雪崩/击穿
目录
一 缓存穿透
二 缓存雪崩
三 缓存击穿
1 基于互斥锁方式解决缓存击穿问题
2 基于逻辑过期方式解决缓存击穿问题
四 缓存工具封装
1 我们首先需要将stringRedisTemplate注入(构造器注入)
2 向Redis当中写入数据实现缓存重建(第一种是对TTL时间的缓存重建,第二种加上了逻辑过期时间的重建)
3 解决缓存穿透问题
4 解决缓存击穿问题(逻辑过期时间解决)
一 缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库都不存在,这样缓存永远不会生效,这些请求都会到达数据库。
同时还有数据校验的方式
实现(商铺的查询)-使用缓存空对象
核心修改点:
先判断是否有实际数据,接着再判断是否为""这样的情况属于之前查询后存储到缓存当中的,最后再判断为null就是在缓存当中没查询到,那就要在数据库当中查询。
代码实现:
/*** 根据id查询商铺信息** @param id 商铺id* @return 商铺详情数据*/@Overridepublic Result queryById(Long id) {// 1.从redis中商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {//这个条件是缓存当中有具体的数据(将null与""放行)// 3.存在且有具体值,返回数据Shop shop = JSONUtil.toBean(shopJson, Shop.class);return Result.ok(shop);}//判断是否是空值if(shopJson != null){//上面已经排除了不为空的情况。这里不为空只可能为"" (这是空串不是null)return Result.fail("店铺不存在");}//而这里是为null的情况// 4.不存在,根据id查询数据库Shop shop = getById(id);// 5.数据库存在,写入redis并返回if (shop != null) {stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);}// 6.数据库不存在,返回错误,将空值写入redisstringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return Result.fail("店铺不存在");}
这里不存在的信息只会在数据库当中查询一次,查询结束将“”值存储在缓存当中
二 缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或则和Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
三 缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
缓存方案
1 基于互斥锁方式解决缓存击穿问题
需求:修改根据id查询商铺的业务
锁的形式
代码实现:
首先我们先定义两个方法用来获取锁和释放锁
/*** 尝试获取锁** @param key* @return*/private boolean tryLock(String key) {Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return Boolean.TRUE.equals(flag);}/*** 释放锁** @param key*/private void unLock(String key) {stringRedisTemplate.delete(key);}
使用互斥锁解决缓存击穿
简单来说就是第一次获取缓存时没有存在那就去数据库当中查询获取最终将数据存储到缓存当中,但是第一个访问的线程的要将锁加上,其他线程再次访问时就会先sleep休眠一段时间,然后再调用方法获取缓存。
/*** 互斥锁解决缓存击穿** @param id* @return*/private Shop queryWithMutex(Long id) {// 1.从redis中商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {//这个条件是缓存当中有具体的数据(将null与""放行)// 3.存在且有具体值,返回数据return JSONUtil.toBean(shopJson, Shop.class);}//判断是否是空值if (shopJson != null) {//上面已经排除了不为空的情况。这里不为空只可能为"" (这是空串不是null)return null;}//4实现缓存重建//4.1.获取互斥锁boolean isLock = tryLock(RedisConstants.LOCK_SHOP_KEY + id);//4.2.判断是否获取成功if (!isLock) {//4.3.失败,则休眠并重试try {Thread.sleep(50);return queryWithMutex(id);} catch (InterruptedException e) {log.error("互斥锁获取失败");}}//4.4.成功,根据id查询数据库//缓存不存在,根据id查询数据库(这里是获取缓存为null的情况)Shop shop = getById(id);// 5.数据库不存在,返回错误,将空值写入redisif (shop == null) {stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);return null;}// 6.数据库存在,写入redis并返回stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);//释放互斥锁unLock(RedisConstants.LOCK_SHOP_KEY + id);//返回数据return shop;}
2 基于逻辑过期方式解决缓存击穿问题
需求:修改根据id查询商铺的业务。基于逻辑过期方式来解决缓存击穿问题
代码实现:
在商铺的存储过程当中,商铺信息是没有逻辑过期这个字段的,这样的话我们为了解决,我们再重新定义了一个实体类,将店铺信息存储在当中,再将逻辑过期时间存储封装在里面。
package com.hmdp.entity;import lombok.Data;import java.time.LocalDateTime;@Data
public class RedisData {//逻辑过期时间private LocalDateTime expireTime;//数据private Object data;
}
同时我们定义一个方法用于存储封装,将店铺信息查询出来封装到RedisData类当中,再根据情况,写入逻辑过期时间这个字段。
/*** 将店铺数据封装-用于逻辑过期解决缓存击穿** @param id*/public void saveShopRedis(Long id) {//1查询店铺数据Shop shop = getById(id);//2封装逻辑过期时间RedisData redisData = new RedisData();//店铺数据redisData.setData(shop);//逻辑过期时间redisData.setExpireTime(LocalDateTime.now().plusSeconds(RedisConstants.CACHE_SHOP_TTL-10));//20//3写入redisstringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData),RedisConstants.CACHE_SHOP_TTL, TimeUnit.SECONDS);//30s}
逻辑过期解决缓存击穿
首先从redis当中查询店铺缓存,判断缓存是否命中,未命中返回空,命中就判断是否过期,未过期返回店铺信息,过期实现缓存重建。
/*** 逻辑过期解决缓存击穿** @param id* @return*/public Shop queryWithLogicalExpire(Long id) {// 1 从redis中商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(RedisConstants.CACHE_SHOP_KEY + id);// 2 判断是否存在(缓存未命中返回空)if (StrUtil.isBlank(shopJson)) {return null;}//3 命中的话判断是否过期RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);//实现对json的反序列化JSONObject data = (JSONObject) redisData.getData();Shop shop = JSONUtil.toBean(data, Shop.class);//获取数据LocalDateTime expireTime = redisData.getExpireTime();//获取逻辑过期时间// 4 判断是否过期,未过期直接返回店铺数据if (expireTime.isAfter(LocalDateTime.now())) {return shop;}// 5 判断是否过期,过期需要缓存重建// 5.1获取互斥锁String lockKey = RedisConstants.LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);// 5.2判断是否获取锁成功if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 5.3将店铺数据重新写入redis并设置逻辑过期时间this.saveShopRedis(id);} catch (Exception e) {log.error("写redis失败", e);} finally {// 5.4释放互斥锁try {unLock(lockKey);} catch (Exception e) {log.error("释放锁失败");}}});}//未成功返回商铺信息return shop;}/*** 开启一个线程池(开启一个十个容量的线程池)*/private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
四 缓存工具封装
代码实现
package com.hmdp.utils;import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.entity.RedisData;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;@Component
@Slf4j
public class CacheClient {private final StringRedisTemplate stringRedisTemplate;public CacheClient(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}public void set(String key, Object value, Long time, TimeUnit unit) {stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);}public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {// 1.封装逻辑过期时间数据RedisData redisData = new RedisData();redisData.setData(value);
// redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));redisData.setExpireTime(LocalDateTime.now().plus(time, unit.toChronoUnit()));// 2.写入redisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));}/*** 缓存穿透*/public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;// 1.从redis中商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(key);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {//这个条件是缓存当中有具体的数据(将null与""放行)// 3.存在且有具体值,返回数据return JSONUtil.toBean(shopJson, type);}//判断是否是空值if (shopJson != null) {//上面已经排除了不为空的情况。这里不为空只可能为"" (这是空串不是null)return null;}//而这里是为null的情况// 4.不存在,根据id查询数据库R r = dbFallback.apply(id);// 5.不存在,返回错误,将空值写入redisif (r == null) {//将空值写入redisstringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);//返回错误信息return null;}//6.存在,写入redisthis.set(key, r, time, unit);return r;}/*** 逻辑过期解决缓存击穿** @param id* @return*/public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;// 1 从redis中商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(key);// 2 判断是否存在(缓存未命中返回空)if (StrUtil.isBlank(shopJson)) {return null;}//3 命中的话判断是否过期RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);//实现对json的反序列化JSONObject data = (JSONObject) redisData.getData();R r = JSONUtil.toBean(data, type);//获取数据LocalDateTime expireTime = redisData.getExpireTime();//获取逻辑过期时间// 4 判断是否过期,未过期直接返回店铺数据if (expireTime.isAfter(LocalDateTime.now())) {return r;}// 5 判断是否过期,过期需要缓存重建// 5.1获取互斥锁String lockKey = RedisConstants.LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);// 5.2判断是否获取锁成功if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 5.3将店铺数据重新写入redis并设置逻辑过期时间R newData = dbFallback.apply(id);this.setWithLogicalExpire(key, newData, time, unit);} catch (Exception e) {log.error("缓存构建失败", e);} finally {// 5.4释放互斥锁try {unLock(lockKey);} catch (Exception e) {log.error("释放锁失败");}}});}//未成功返回商铺信息return r;}/*** 尝试获取锁** @param key* @return*/private boolean tryLock(String key) {Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return Boolean.TRUE.equals(flag);}/*** 释放锁** @param key*/private void unLock(String key) {stringRedisTemplate.delete(key);}/*** 开启一个线程池(开启一个十个容量的线程池)*/private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);// 静态代码块:注册关闭钩子static {Runtime.getRuntime().addShutdownHook(new Thread(CacheClient::shutdownExecutor));}// 关闭线程池的具体逻辑private static void shutdownExecutor() {CACHE_REBUILD_EXECUTOR.shutdown(); // 平缓关闭(等待已有任务完成)try {// 等待线程池终止,最多等待 10 秒if (!CACHE_REBUILD_EXECUTOR.awaitTermination(10, TimeUnit.SECONDS)) {CACHE_REBUILD_EXECUTOR.shutdownNow(); // 强制关闭}} catch (InterruptedException e) {log.error("关闭线程池时发生中断", e);CACHE_REBUILD_EXECUTOR.shutdownNow(); // 强制关闭Thread.currentThread().interrupt(); // 重置中断状态}log.info("线程池已关闭");}
}
1 我们首先需要将stringRedisTemplate注入(构造器注入)
private final StringRedisTemplate stringRedisTemplate;public CacheClient(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}
2 向Redis当中写入数据实现缓存重建(第一种是对TTL时间的缓存重建,第二种加上了逻辑过期时间的重建)
public void set(String key, Object value, Long time, TimeUnit unit) {stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);}public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {// 1.封装逻辑过期时间数据RedisData redisData = new RedisData();redisData.setData(value);redisData.setExpireTime(LocalDateTime.now().plus(time, unit.toChronoUnit()));//这种形式可以实现对多种时间单位的转换但是对JDK的版本有所要求// 2.写入redisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));}// redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
3 解决缓存穿透问题
主要修改:数据的key类型关键字,序号id值,返回数据的类型值type,查询的方法function,时间,单位。(动态参数,可以应对多种情况)
-
缓存查询
- 使用
keyPrefix + id
生成 Redis 缓存键。 - 通过
stringRedisTemplate
查询缓存数据。
- 使用
-
缓存命中处理
- 如果缓存值非空(
StrUtil.isNotBlank(shopJson)
为 true),直接反序列化为对象返回,避免数据库查询。
- 如果缓存值非空(
-
空值缓存处理
- 若缓存值为 空字符串(
shopJson != null
但为空串),直接返回 null,避免重复查询数据库(防止缓存击穿)。 - 若缓存值为 null(未缓存),触发数据库查询流程。
- 若缓存值为 空字符串(
-
数据库回退机制
- 调用
dbFallback.apply(id)
查询数据库。 - 若数据库返回 空值(
r == null
),将空字符串写入 Redis 并设置短过期时间(CACHE_NULL_TTL
),防止后续请求穿透到数据库。 - 若数据库返回 有效数据,写入 Redis 缓存并设置指定的过期时间。
- 调用
-
核心目标
- 防缓存击穿:通过缓存空值("")和合理设置过期时间,避免大量并发请求直击数据库。
- 通用性:支持泛型参数,适用于任意类型数据的缓存处理。
- 降级容错:当缓存失效时自动回退到数据库查询,并补全缓存。
/*** 缓存击穿解决方法** @param id* @return*/public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;// 1.从redis中商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(key);// 2.判断是否存在if (StrUtil.isNotBlank(shopJson)) {//这个条件是缓存当中有具体的数据(将null与""放行)// 3.存在且有具体值,返回数据return JSONUtil.toBean(shopJson, type);}//判断是否是空值if (shopJson != null) {//上面已经排除了不为空的情况。这里不为空只可能为"" (这是空串不是null)return null;}//而这里是为null的情况// 4.不存在,根据id查询数据库R r = dbFallback.apply(id);// 5.不存在,返回错误,将空值写入redisif (r == null) {//将空值写入redisstringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);//返回错误信息return null;}//6.存在,写入redisthis.set(key, r, time, unit);return r;}
4 解决缓存击穿问题(逻辑过期时间解决)
主要修改:数据的key类型关键字,序号id值,返回数据的类型值type,查询的方法function,时间,单位。(动态参数,可以应对多种情况)
1. 缓存查询与反序列化
- 生成缓存键:使用
keyPrefix + id
组合生成 Redis 键。 - 查询缓存:通过
stringRedisTemplate
获取 JSON 格式的缓存数据。 - 判断缓存是否存在:
- 若缓存为空(
StrUtil.isBlank(shopJson)
为 true),直接返回null
(缓存未命中)。 - 若缓存存在,反序列化为
RedisData
对象,提取数据和逻辑过期时间(expireTime
)。
- 若缓存为空(
2. 逻辑过期判断
- 未过期处理:若当前时间在
expireTime
之前(expireTime.isAfter(LocalDateTime.now())
为 true),直接返回缓存数据。 - 已过期处理:若当前时间超过
expireTime
,触发缓存重建流程。
3. 缓存重建(互斥锁控制)
- 获取互斥锁:
- 使用
lockKey = RedisConstants.LOCK_SHOP_KEY + id
构造锁的键。 - 调用
tryLock(lockKey)
尝试获取锁,确保只有一个线程进入重建流程。
- 使用
- 锁成功时:
- 提交异步任务到线程池
CACHE_REBUILD_EXECUTOR
,执行以下操作:- 查询数据库:调用
dbFallback.apply(id)
获取最新数据。 - 更新缓存:将新数据写入 Redis,并设置新的逻辑过期时间(通过
setWithLogicalExpire
方法)。 - 异常处理:捕获异常并记录日志,确保流程健壮性。
- 释放锁:无论是否成功,最终释放锁(
unLock(lockKey)
)。
- 查询数据库:调用
- 提交异步任务到线程池
- 锁失败时:
- 不执行重建操作,直接返回旧数据(避免并发重建)。
/*** 逻辑过期解决缓存击穿** @param id* @return*/public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {String key = keyPrefix + id;// 1 从redis中商铺缓存String shopJson = stringRedisTemplate.opsForValue().get(key);// 2 判断是否存在(缓存未命中返回空)if (StrUtil.isBlank(shopJson)) {return null;}//3 命中的话判断是否过期RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);//实现对json的反序列化JSONObject data = (JSONObject) redisData.getData();R r = JSONUtil.toBean(data, type);//获取数据LocalDateTime expireTime = redisData.getExpireTime();//获取逻辑过期时间// 4 判断是否过期,未过期直接返回店铺数据if (expireTime.isAfter(LocalDateTime.now())) {return r;}// 5 判断是否过期,过期需要缓存重建// 5.1获取互斥锁String lockKey = RedisConstants.LOCK_SHOP_KEY + id;boolean isLock = tryLock(lockKey);// 5.2判断是否获取锁成功if (isLock) {CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 5.3将店铺数据重新写入redis并设置逻辑过期时间R newData = dbFallback.apply(id);this.setWithLogicalExpire(key, newData, time, unit);} catch (Exception e) {log.error("缓存构建失败", e);} finally {// 5.4释放互斥锁try {unLock(lockKey);} catch (Exception e) {log.error("释放锁失败");}}});}//未成功返回商铺信息return r;}
补充的方法
锁的释放与获取
/*** 尝试获取锁** @param key* @return*/private boolean tryLock(String key) {Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);return Boolean.TRUE.equals(flag);}/*** 释放锁** @param key*/private void unLock(String key) {stringRedisTemplate.delete(key);}
线程池的开启与释放
/*** 开启一个线程池(开启一个十个容量的线程池)*/private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);// 静态代码块:注册关闭钩子static {Runtime.getRuntime().addShutdownHook(new Thread(CacheClient::shutdownExecutor));}// 关闭线程池的具体逻辑private static void shutdownExecutor() {CACHE_REBUILD_EXECUTOR.shutdown(); // 平缓关闭(等待已有任务完成)try {// 等待线程池终止,最多等待 10 秒if (!CACHE_REBUILD_EXECUTOR.awaitTermination(10, TimeUnit.SECONDS)) {CACHE_REBUILD_EXECUTOR.shutdownNow(); // 强制关闭}} catch (InterruptedException e) {log.error("关闭线程池时发生中断", e);CACHE_REBUILD_EXECUTOR.shutdownNow(); // 强制关闭Thread.currentThread().interrupt(); // 重置中断状态}log.info("线程池已关闭");}