目录
1、缓存的作用与成本
2、关于缓存双写一致性
2.1 四种更新策略
2.2 最佳实践方案
3、关于缓存穿透
3.1 使用 缓存空对象 方案
3.2 使用 布隆过滤器 方案
4、关于缓存雪崩
5、关于缓存击穿
5.1采用双检加锁策略
5.1.1 使用 互斥锁 方案
5.1.2 使用 逻辑过期 方案
5.2 设置差异失效时间
6、封装 Redis 工具类
6.1 封装 缓存穿透中的缓存空对象 方案
6.2 封装 缓存击穿中的逻辑过期 方案
7、总结
三种淘汰策略
作用: 暂存数据处理结果,并提供下次访问使用。在很多场合,数据的处理或者数据获取可能非常费时,当对这个数据的请求量很大时,频繁的数据处理会消耗大量资源。缓存的作用就是将这些来之不易的数据存储起来,当再次请求此数据时,直接从缓存中获取而省略数据处理流程,从而降低资源的消耗提高响应速度
成本:数据不一致问题,缓存层和数据层有时窗口不一致,和更新策略有关;代码维护成本,原本只需读写MySQL就能实现功能,但加入了缓存之后就需要去维护缓存的数据,增加了代码复杂度
相关图解:
相关代码实现:
从 redis 中进行查询,若缓存命中,则直接返回对应数据
//1.从 redis 中查询缓存
String shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//1.1 若缓存中有店铺的相关信息则进行返回,即缓存命中
if(StrUtil.isNotBlank(shopJSON)){
//1.2 这里需要将 JSON 类型的数据,转换为 java 类型的数据进行返回
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
return Result.ok(shop);
}
若 redis 中命中缓存失败,则需要从 mysql 中进行查询数据返回,并将查询到的数据写回 redis 中作为缓存
//2.缓存未命中,则从 mysql 中查询相关信息
Shop shop = shopService.getById(id);
//2.1 mysql 中存在相关信息,写入 redis 缓存,并则进行返回
if(!Objects.isNull(shop)){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop));
}else {
//2.2 若不存在,则给出提示信息
Result.fail("店铺ID 为"+id+"的信息不存在!");
}
return Result.ok(shop);
这时需要考虑到一个问题,在数据库中的数据进行更新时,这时 redis 缓存那边保存的还是旧数据,需要将数据库中的数据更新到 redis 中,以保证双写一致性
- 先更新数据库,再更新缓存
其中,可能出现的问题:
- 先更新mysql的某商品的库存,当前商品的库存是100,更新为99个。
- 先更新mysql修改为99成功,然后更新redis。
- 此时假设异常出现,更新redis失败了,这导致mysql里面的库存是99而redis里面的还是100 。
- 上述发生,会让数据库里面和缓存redis里面数据不一致,读到redis脏数据
- 先更新缓存,再更新数据库(不太推荐,业务上一般把mysql作为底单数据库,以保证最后解释)
其中,可能出现的问题:
【异常逻辑】多线程环境下,A、B两个线程有快有慢有并行
A update redis 100
B update redis 80
B update mysql 80
A update mysql 100
最终结果:mysql100,redis80
- 先删除缓存,再更新数据库
其中,可能出现的问题:
- 请求A进行写操作,删除redis缓存后,工作正在进行中,更新mysql......A还么有彻底更新完mysql,还没commit
- 请求B开工查询,查询redis发现缓存不存在(被A从redis中删除了)
- 请求B继续,去数据库查询得到了mysql中的旧值(A还没有更新完)
- 请求B将旧值写回redis缓存
- 请求A将新值写入mysql数据库
解决方法:使用延时双删策略
- 先更新数据库,再删除缓存(一般推荐使用这种方法)
其中,可能出现的问题:
假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。
解决方法:使用中间件,如MQ消息队列
- 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
- 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
- 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
- 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
- 低一致性需求:使用 redis 自带的内存淘汰机制
- 高一致性需求:主动更新,并以超时剔除作为兜底方案
读操作:
- 缓存命中则直接返回
- 缓存未命中则查询数据库,并且写入缓存,加上超时时间
写操作:
- 先写数据库,再删除缓存
- 确保数据库与 redis 缓存的原子性
代码案例:
@Transactional //这里进行添加事务
public Result updateShop(Shop shop) {
if(!Objects.isNull(shop.getId())) {
//1.先更新数据库
shopService.updateById(shop);
//2.再删除 redis 缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
}else {
Result.fail("店铺的 ID 不能为空!");
}
return Result.ok();
}
定义:当去查询一条记录,先查 redis 中,查询不到,然后再查询 mysql 中,也查询不到;即使这样,请求最终每次都会打到 mysql 数据库中,从而导致后台数据库的压力暴增,redis 形同虚设 ,接下来,介绍对应的解决方案
可以增强回写机制。mysql也查不到的话也让redis存入刚刚查不到的key并保护mysql。第一次来查询uid:abcdxxx,redis和mysql都没有,返回null给调用者,但是增强回写后第二次来查uid:abcdxxx,此时redis就有值了。可以直接从Redis中读取default缺省值(null)返回给业务应用程序,避免了把大量请求发送给mysql处理,打爆mysql。
但是,这样存在缺点,只能解决 key 值相同的问题,同时会带来额外的内存消耗与短期的不一致性(由于TTL过期时间)
代码案例:(将上面 “缓存双写一致性” 的代码进行添加了部分功能,进行完善)
根据传入的对应的商铺 ID 进行判断当前商铺是否存在,若不存在,则设置 "" 空值与TTL过期时间,存入 redis 中
//2.缓存未命中,则从 mysql 中查询相关信息
Shop shop = shopService.getById(id);
//2.1 mysql 中存在相关信息,写入 redis 缓存,并则进行返回
if(!Objects.isNull(shop)){
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
}else {
//2.2若不存在,不仅要提示错误信息,而且还要返回对应 key 的空值到 redis 中,避免缓存穿透
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return Result.fail("店铺ID 为"+id+"的信息不存在!");
}
return Result.ok(shop);
这里是在 redis 缓存命中时,进行判断是否为空值,若为空值,则直接返回提示信息
//1.从 redis 中查询缓存
String shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//1.1 若缓存中有店铺的相关信息则进行返回,即缓存命中
if(StrUtil.isNotBlank(shopJSON)){
//1.2 这里需要将 JSON 类型的数据,转换为 java 类型的数据进行返回
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
}
//1.3 由于设置了缓存空对象,这里判断缓存命中是否为 “” 空字符串
if(Objects.equals(shopJSON, "")){
return Result.fail("该店铺不存在!");
}
布隆过滤器使用场景:
什么是布隆过滤器:
布隆过滤器实际上是一个很长的二进制数组(初始值为很长一串0的bit数组),和一系列随机的 Hash 算法函数组成 ,主要作用是判断一个元素是否在集合中,而不是保存数据的信息
特点:能够高效的插入和查询,占用的空间少,返回的结果具有不确定性
思路分析:
相关图解:
向布隆过滤器查询某个key是否存在时,先把这个 key 通过相同的多个 hash 函数进行运算,查看对应的位置是否都为 1,只要有一个位为零,那么说明布隆过滤器中这个 key 不存在;如果这几个位置全都是 1,那么说明极有可能存在;因为这些位置的 1 可能是因为其他的 key 存在导致的,也就是hash冲突
就比如我们在 add 了字符串wmyskxz数据之后,很明显下面1/3/5 这几个位置的 1 是因为第一次添加的 wmyskxz 而导致的;此时我们查询一个没添加过的不存在的字符串inexistent-key,它有可能计算后坑位也是1/3/5 ,这就是误判了......
如图所示:
缺点:
缓存雪崩的由来:
解决方法:
首先声明,缓存穿透和缓存击穿截然不同
定义:当大量的请求查询同一个 key 时(热点 key),此时,这个 key 突然失效了,就会导致大量的请求都打到数据库中,如果这样,数据库又承受了这个年纪不该承受的痛苦~~>_<~~
解决方法:
由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
案例图解:(这里将上面的缓存穿透的流程,做进一步的优化,达到 “缓存穿透” 中的缓存空对象方案 与 “缓存击穿” 中互斥锁的方案 相结合)
代码实现案例:
下面是模拟一个商铺查询,当热点 key 突然过期,这时,有一大堆的请求发送过来,从而打到数据库上,所带来的这一系列问题的解决方式
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L;
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 36000L;
public static final Long CACHE_NULL_TTL = 2L;
public static final Long CACHE_SHOP_TTL = 30L;
public static final String CACHE_SHOP_KEY = "cache:shop:";
public static final String CACHE_SHOP_TYPE_LIST_KEY="cache:shop:type";
public static final String LOCK_SHOP_KEY = "lock:shop:";
public static final Long LOCK_SHOP_TTL = 10L;
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
public static final String BLOG_LIKED_KEY = "blog:liked:";
public static final String FEED_KEY = "feed:";
public static final String SHOP_GEO_KEY = "shop:geo:";
public static final String USER_SIGN_KEY = "sign:";
}
由于上面关联到获取锁与释放锁,这里需要提供对应的方法:
private boolean tryLock(String key){
//1.使用 redis 中 String 类型命令中的 setnx ,此数据类型内嵌锁元素,若当前 key 已经存在,则不进行任何操作
Boolean getLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(getLock); //防止自动拆箱,这里使用工具类,表示为true才为 true ,为 null 和 false 都为 false
}
上面返回值需要注意,这里采用的是 hutool 中的 Boolean工具类进行返回,为了防止 java 自动拆箱,若返回 null 的话,则会报空指针异常,这时会很难受的ヽ(*。>Д<)o゜
下面是hutool 中的 Boolean工具类 的源码,可以帮助更好的理解:
/**
* 检查 {@code Boolean} 值是否为 {@code true}
*
*
* BooleanUtil.isTrue(Boolean.TRUE) = true
* BooleanUtil.isTrue(Boolean.FALSE) = false
* BooleanUtil.isTrue(null) = false
*
*
* @param bool 被检查的Boolean值
* @return 当值为true且非null时返回{@code true}
*/
public static boolean isTrue(Boolean bool) {
return Boolean.TRUE.equals(bool);
}
private void unLock(String key){
stringRedisTemplate.delete(key);
}
查询店铺信息时,缓存未命中,则去获取互斥锁:
获取锁失败时,需要进行休眠一段时间(因为需要给上一个获取到锁的线程足够的时间完成缓存回写操作),任何继续去查询 redis 缓存
Shop shop = null;
try {
//2.实现缓存重建
//2.1 获取互斥锁
boolean tryLock = tryLock(LOCK_SHOP_KEY+id);
//2.2判断是否获取锁成功
if (!tryLock) {
//2.3 获取锁失败,休眠,并且重试
Thread.sleep(50);
// return queryShopByIdWithMutex(id); //这里进行重试,重新查询 redis 缓存中是否存在该商铺的信息
//2.4 //这里进行再次获取缓存,以免在创建锁的时候缓存又有了,尽量节省时间
shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(shopJSON)){
unLock(LOCK_SHOP_KEY+id);
return JSONUtil.toBean(shopJSON, Shop.class);
}
}
获取锁成功,则需要去 mysql 中根据对应的 id 查询数据,然后回写到 redis 缓存中,最后将互斥锁释放
//3. 获取锁成功,则从 mysql 中查询相关信息
shop = shopService.getById(id);
//TODO 这里进行模拟重建时的延时
Thread.sleep(200);
//3.1 mysql 中存在相关信息,写入 redis 缓存,并则进行返回
if (!Objects.isNull(shop)) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} else {
//3.2 若不存在,不仅要提示错误信息,而且还要返回对应 key 的空值到 redis 中,避免缓存穿透
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//3.3 这里释放互斥锁
unLock(LOCK_SHOP_KEY+id);
}
public Shop queryShopByIdWithMutex(Long id) {
//1.从 redis 中查询缓存
String shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//1.1 若缓存中有店铺的相关信息则进行返回,即缓存命中
if(StrUtil.isNotBlank(shopJSON)){
//1.2 这里需要将 JSON 类型的数据,转换为 java 类型的数据进行返回
Shop shop = JSONUtil.toBean(shopJSON, Shop.class);
return shop;
}
//1.3 由于设置了缓存空对象,这里判断缓存命中是否为 “” 空字符串
if(Objects.equals(shopJSON, "")){ //由于 isNoBlank 中进行判断 "" 空字符串和 null 时返回的是 false,所以这里定义不等于 null,即等于空字符串 "" 时
return null;
}
Shop shop = null;
try {
//2.实现缓存重建
//2.1 获取互斥锁
boolean tryLock = tryLock(LOCK_SHOP_KEY+id);
//2.2判断是否获取锁成功
if (!tryLock) {
//2.3 获取锁失败,休眠,并且重试
Thread.sleep(50);
// return queryShopByIdWithMutex(id); //这里进行重试,重新查询 redis 缓存中是否存在该商铺的信息
//2.4 //这里进行再次获取缓存,以免在创建锁的时候缓存又有了,尽量节省时间
shopJSON = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if(StrUtil.isNotBlank(shopJSON)){
unLock(LOCK_SHOP_KEY+id);
return JSONUtil.toBean(shopJSON, Shop.class);
}
}
//3. 获取锁成功,则从 mysql 中查询相关信息
shop = shopService.getById(id);
//TODO 这里进行模拟重建时的延时
Thread.sleep(200);
//3.1 mysql 中存在相关信息,写入 redis 缓存,并则进行返回
if (!Objects.isNull(shop)) {
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} else {
//3.2 若不存在,不仅要提示错误信息,而且还要返回对应 key 的空值到 redis 中,避免缓存穿透
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
//3.3 这里释放互斥锁
unLock(LOCK_SHOP_KEY+id);
}
return shop;
}
/**
* 这里是进行锁的获取功能,判断是否获取锁成功
*/
private boolean tryLock(String key){
//1.使用 redis 中 String 类型命令中的 setnx ,此数据类型内嵌锁元素,若当前 key 已经存在,则不进行任何操作
Boolean getLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(getLock); //防止自动拆箱,这里使用工具类,表示为true才为 true ,为 null 和 false 都为 false
}
/**
* 这里是释放锁 的功能
*/
private void unLock(String key){
stringRedisTemplate.delete(key);
}
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis 的 value 中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设 线程1 去查询缓存,然后从value中判断出来当前的数据已经过期了,此时 线程1 去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而 线程1 直接进行返回,假设现在 线程3 过来访问,由于 线程2 持有着锁,所以 线程3 无法获得锁,线程3 也直接返回数据,只有等到新开的 线程2 把重建数据构建完后,其他线程才能走返回正确的数据。
特点:线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,缺点在于重构数据完成前,其他的线程只能返回之前的数据,即脏数据,且实现起来麻烦
案例相关图解:
案例思路分析以及代码实现:
前提准备:
//这里进行创建固定的线程数线程池,用来获取线程,可提高系统资源使用率
private static final ExecutorService CACHE_BUILD_EXECUTOR = Executors.newFixedThreadPool(8);
关于 ExecutorService线程池 请参考:
https://blog.csdn.net/fwt336/article/details/81530581#:~:text=Executor,ice%E8%8E%B7%E5%BE%97%E7%BA%BF%E7%A8%8B%E3%80%82
不在原来的实体类中进行添加新的属性,而新创建一个类,避免不必要的麻烦,且更加灵活
@Data
@AllArgsConstructor
@NoArgsConstructor
public class RedisData {
private LocalDateTime expireTime; //过期时间
private Object data; //这里是为了存储任意类型的数据而设置的属性
}
将之后的独立线程中,查询数据库并回写到 redis 缓存中的相关缓存重建操作封装到一个方法中,更加灵活,复用性好
/**
* 这里是将商铺数据,存放到 redis 中的功能,进行缓存的重建
* @param id 传入的商铺 id
* @param expireTime 设置的过期时间
*/
public void saveShopToRedis(Long id,Long expireTime){
//1. 查询商铺信息
Shop shop = shopService.getById(id);
//TODO 模拟缓存重建时所带来的延时效果
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//2. 这里进行封装过期时间与对应的商铺信息
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
//3. 将封装的信息存入 redis 中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
具体代码实现:
当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库;
//1.从 redis 查询商铺信息
String shopRedis = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.判断缓存是否命中
if(StrUtil.isBlank(shopRedis)){
//2.1 未命中,则返回空
return null;
}
而一旦命中后,将 value 取出,判断 value 中的过期时间是否满足,如果没有过期,则直接返回 redis 中的数据,如果过期,则进行获取互斥锁操作
//3.若命中,进行判断缓存是否过期
//TODO 这里使用泛型进行转换可能存在问题
RedisData redisData = JSONUtil.toBean(shopRedis, RedisData.class); //这里进行类型的转换
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//3.1 缓存未过期,则直接返回缓存中的商铺信息
if(expireTime.isAfter(LocalDateTime.now())){ //若设置的过期时间还在当前时间之后,说明还未过期
return shop;
}
若获取锁成功,则开启一个额外的独立线程去返回商铺信息,这里,这个独立线程需要进行查询 mysql 中的数据,然后再写回 redis 缓存,并且将锁释放
try {
//4. 若缓存过期,则尝试获取互斥锁,尽可能的将从 mysql 中查询的数据写入缓存
boolean tryLock = tryLock(LOCK_SHOP_KEY + id);
//TODO 4.1 二次查询 redis 缓存,判断是否存在,存在则无需进行缓存重建,节省时间 (因为有可能当前线程在获取锁时,上一个线程已经进行了缓存重建)
shopRedis = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopRedis)) {
redisData = JSONUtil.toBean(shopRedis, RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { //这里需要判断过期时间
return JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
}
}
if (tryLock) {
//TODO 4.2 获取锁成功,则开启独立线程,实现缓存重建
CACHE_BUILD_EXECUTOR.submit(new Runnable() {
@Override
public void run() {
shopService.saveShopToRedis(id, 20L);
}
});
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//4.3 释放锁
unLock(LOCK_SHOP_KEY + id);
}
若该线程获取锁失败,则直接摆烂 doge,返回之前过期的数据信息(之前上面未过期的数据已经 返回,剩下的为过期数据)
//4.4 否则,返回之前缓存中过期的数据信息
return shop;
}
完整代码:
//这里进行创建固定的线程数线程池,用来获取线程,可提高系统资源使用率
private static final ExecutorService CACHE_BUILD_EXECUTOR = Executors.newFixedThreadPool(8);
/**
* 查询商铺信息
*
* 【这里是基于缓存击穿中的逻辑删除方案】
*/
public Shop queryShopByIdWithTombstones(Long id) {
//1.从 redis 查询商铺信息
String shopRedis = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
//2.判断缓存是否命中
if (StrUtil.isBlank(shopRedis)) {
//2.1 未命中,则返回空
return null;
}
//3.若命中,进行判断缓存是否过期
//TODO 这里使用泛型进行转换可能存在问题
RedisData redisData = JSONUtil.toBean(shopRedis, RedisData.class); //这里进行类型的转换
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//3.1 缓存未过期,则直接返回缓存中的商铺信息
if (expireTime.isAfter(LocalDateTime.now())) { //若设置的过期时间还在当前时间之后,说明还未过期
return shop;
}
try {
//4. 若缓存过期,则尝试获取互斥锁,尽可能的将从 mysql 中查询的数据写入缓存
boolean tryLock = tryLock(LOCK_SHOP_KEY + id);
//TODO 4.1 二次查询 redis 缓存,判断是否存在,存在则无需进行缓存重建,节省时间 (因为有可能当前线程在获取锁时,上一个线程已经进行了缓存重建)
shopRedis = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopRedis)) {
redisData = JSONUtil.toBean(shopRedis, RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { //这里需要判断过期时间
return JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
}
}
if (tryLock) {
//TODO 4.2 获取锁成功,则开启独立线程,实现缓存重建
CACHE_BUILD_EXECUTOR.submit(new Runnable() {
@Override
public void run() {
shopService.saveShopToRedis(id, 20L);
}
});
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//4.3 释放锁
unLock(LOCK_SHOP_KEY + id);
}
//4.4 否则,返回之前缓存中过期的数据信息
return shop;
}
/**
* 这里是将商铺数据,存放到 redis 中的功能,进行缓存的重建
*
* @param id 传入的商铺 id
* @param expireTime 设置的过期时间
*/
public void saveShopToRedis(Long id, Long expireTime) {
//1. 查询商铺信息
Shop shop = shopService.getById(id);
//TODO 模拟缓存重建时所带来的延时效果
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//2. 这里进行封装过期时间与对应的商铺信息
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
//3. 将封装的信息存入 redis 中
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
由于上面代码中,重复的代码过多,所以这里进行总体的封装,更加的灵活,复用性更强
这里,需要注意的是:
相关代码实现:
这里先分别进行封装普通的缓存存储信息,以及关于逻辑过期的缓存存储信息
/**
* 这里是进行存储缓存信息,以及过期时间的封装
* @param key 缓存键
* @param value 缓存值
* @param expireTime 过期时间
* @param timeUnit 时间过期单位的工具类
*/
public void setToRedis(String key, Object value, Long expireTime, TimeUnit timeUnit){
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),expireTime,timeUnit);
}
/**
* 这里是逻辑过期的封装
*/
public void setToRedisByTombstones(String key, Object value, Long expireTime, TimeUnit timeUnit){
//1. 设置逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.toSeconds(expireTime))); //为了保证传入的数据是以秒为单位,这里进行单位转换
//2. 将数据回写到 redis 中
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(redisData));
}
代码实现:
/**
* 这里是 缓存穿透中缓存空对象方案 的封装
*
* @param keyPrefix redis 中 key 值的前缀
* @param id redis 中 key 值的 id
* @param type 转换为返回值的类型
* @param function 传入查询数据库的方法,返回对应类型的值
* @param expireTime 过期时间
* @param timeUnit 过期时间单位的封装类
*/
public T queryShopByIdWithCacheEmptyObj(String keyPrefix, ID id, Class type
, Function function, Long expireTime, TimeUnit timeUnit) {
//1. 从 redis 缓存中更具对应的 key 查询对应的缓存值
String json = stringRedisTemplate.opsForValue().get(keyPrefix + id);
//1.1 若缓存中有相关信息则进行返回,即缓存命中
if(StrUtil.isNotBlank(json)){
//1.2 这里需要将 JSON 类型的数据,转换为 java 类型的数据进行返回
return JSONUtil.toBean(json, type);
}
//1.3 由于设置了缓存空对象,这里判断缓存命中是否为 “” 空字符串
if(Objects.equals(json, "")){ //由于 isNoBlank 中进行判断 "" 空字符串和 null 时返回的是 false,所以这里定义不等于 null,即等于空字符串 "" 时
return null;
}
//2.缓存未命中,则从 mysql 中查询相关信息
// Shop shop = shopService.getById(id);
T obj = function.apply(id);
//2.1 mysql 中存在相关信息,写入 redis 缓存,并则进行返回
if(!Objects.isNull(obj)){
stringRedisTemplate.opsForValue().set(keyPrefix + id,JSONUtil.toJsonStr(obj),expireTime, timeUnit);
}else {
//2.2若不存在,设置并返回对应 key 为空值到 redis 中,避免缓存穿透
stringRedisTemplate.opsForValue().set(keyPrefix + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
return null;
}
return obj;
}
以上封装类的使用案例代码:
Shop shop = redisCacheUtil.queryShopByIdWithCacheEmptyObj(CACHE_SHOP_KEY, id, Shop.class, new Function() {
@Override
public Shop apply(Long aLong) {
return shopService.getById(id);
}
}, CACHE_SHOP_TTL, TimeUnit.MINUTES);
代码实现:
//这里进行创建固定的线程数线程池,用来获取线程,可提高系统资源使用率
private static final ExecutorService CACHE_BUILD_EXECUTOR = Executors.newFixedThreadPool(8);
/**
* 这里是 基于缓存击穿中的逻辑过期方案 的封装
*
* @param keyPrefix redis 中 key 值的前缀
* @param id redis 中 key 值的 id
* @param type 转换为返回值的类型
* @param function 传入查询数据库的方法,返回对应类型的值
* @param expireTime 过期时间
* @param timeUnit 过期时间单位的封装类
*/
public T queryShopByIdWithTombstones(String keyPrefix, ID id, Class type, Function function,
Long expireTime, TimeUnit timeUnit) {
//1.从 redis 查询信息
String json = stringRedisTemplate.opsForValue().get(keyPrefix + id);
//2.判断缓存是否命中
if (StrUtil.isBlank(json)) {
//2.1 未命中,则返回空
return null;
}
//3.若命中,进行判断缓存是否过期
//TODO 这里使用泛型进行转换可能存在问题
RedisData redisData = JSONUtil.toBean(json, RedisData.class); //这里进行类型的转换
T obj = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime dataExpireTime = redisData.getExpireTime();
//3.1 缓存未过期,则直接返回缓存中的商铺信息
if (dataExpireTime.isAfter(LocalDateTime.now())) { //若设置的过期时间还在当前时间之后,说明还未过期
return obj;
}
try {
//4. 若缓存过期,则尝试获取互斥锁,尽可能的将从 mysql 中查询的数据写入缓存
boolean tryLock = tryLock(keyPrefix + id);
//TODO 4.1 二次查询 redis 缓存,判断是否存在,存在则无需进行缓存重建,节省时间 (因为有可能当前线程在获取锁时,上一个线程已经进行了缓存重建)
json = stringRedisTemplate.opsForValue().get(keyPrefix + id);
if (StrUtil.isNotBlank(json)) {
redisData = JSONUtil.toBean(json, RedisData.class);
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { //这里需要判断过期时间
return JSONUtil.toBean((JSONObject) redisData.getData(), type);
}
}
if (tryLock) {
//TODO 4.2 获取锁成功,则开启独立线程,实现缓存重建
CACHE_BUILD_EXECUTOR.submit(new Runnable() {
@Override
public void run() {
//根据传入的 ID 查询数据库
T dataBySQL = function.apply(id);
//将查询的数据回写到 redis 中,同时设置逻辑过期时间
redisCacheUtil.setToRedisByTombstones(keyPrefix+id,dataBySQL,expireTime,timeUnit);
}
});
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//4.3 释放锁
unLock(keyPrefix + id);
}
//4.4 否则,返回之前缓存中过期的数据信息
return obj;
}
/**
* 这里是进行锁的获取功能,判断是否获取锁成功
*/
private boolean tryLock(String key){
//1.使用 redis 中 String 类型命令中的 setnx ,此数据类型内嵌锁元素,若当前 key 已经存在,则不进行任何操作
Boolean getLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(getLock); //防止自动拆箱,这里使用工具类,表示为true才为 true ,为 null 和 false 都为 false
}
/**
* 这里是释放锁 的功能
*/
private void unLock(String key){
stringRedisTemplate.delete(key);
}
以上封装类的使用案例代码:
Shop shop = redisCacheUtil.queryShopByIdWithTombstones(CACHE_SHOP_KEY, id, Shop.class, new Function() {
@Override
public Shop apply(Long aLong) {
return shopService.getById(id);
}
}, CACHE_SHOP_TTL, TimeUnit.MINUTES);
相关图解:
更多且更加详细的淘汰策略请参考:https://blog.csdn.net/crazymakercircle/article/details/115360829