目录
缓存概念
添加Redis缓存
业务场景
缓存作用模型
java代码
缓存更新策略
主动更新的三种策略
主动更新——Cache Aside Pattern
实际应用
缓存穿透
概念
解决方法
实际应用
缓存雪崩
概念
解决方法
缓存击穿
互斥锁
介绍
实际应用
逻辑过期
介绍
实际应用
互斥锁 VS 逻辑过期
缓存工具封装
缓存概念
缓存就是数据交换的缓冲区(称作Cache),是存储数据的临时地方,一般读写性能较高。缓存有多种类型,比如以下的几种:
- 浏览器缓存:常见的是缓存静态资源到本地,如CSS、JS、图片等,这样就不用每次访问都去加载数据,大大降低了网络的延时,提高了页面的显示速度,提升用户体验
- Tomcat 中应用层缓存:将数据库中的数据缓存到redis中,当有请求访问数据时,就会首先去redis中获取,如果redis中有需要的数据就可以直接返回,不需要再去访问数据库,只有redis查询不到数据时才去访问数据库。redis的读写速度很快,所以可以提高数据的响应速度
- 数据库缓存:比如可以对索引进行缓存,如id,当根据id查询数据时,可以在内存中进行快速检索,而不需要去读取磁盘中,只有当缓存中找不到时才去读磁盘进行查询,效率也会提高
- CPU缓存
- 磁盘缓存
缓存的作用:
- 降低后端负载
- 提高读写效率,降低响应时间
缓存的成本:
- 数据一致性成本:当更新数据库而还没来得及更新缓存时,此时缓存中的数据就是旧数据,就会产生和数据库中的数据不一致的问题
- 代码维护成本:为了解决数据一致性问题,就会通过较为复杂的代码来维护,而且在数据一致性问题的处理过程中,也可能产生缓存穿透、击穿等问题,解决这些问题也会让代码复杂度提高,也就提高了代码维护的成本
- 运维成本:为了解决缓存雪崩问题以及保证缓存的高可用性,缓存一般需要搭建集群,而集群的部署、维护等都会产生相应的成本
添加Redis缓存
业务场景
比如有一个后端接口是根据商家id查看商家详情信息,在不使用缓存时,这个后端接口的实现是直接根据传递过来的商家id去数据库查询商家详情信息,然后返回给前端,这里对这个接口用Redis做缓存
缓存作用模型
java代码
只包含service实现类的代码,因为主要业务逻辑都在这个类里
package com.hmdp.service.impl;import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;import javax.annotation.Resource;import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;/*** <p>* 服务实现类* </p>** @author 虎哥* @since 2021-12-22*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryById(Long id) {// 1、从Redis中查询商家信息String key = CACHE_SHOP_KEY + id;// 可以使用Redis中的Hash结构
// stringRedisTemplate.opsForHash().entries("" + id);// 这里用String来演示String jsonStr = stringRedisTemplate.opsForValue().get(key);// 2、判断缓存是否命中,即Redis中是否有要查询的商家信息if (StrUtil.isNotBlank(jsonStr)) {// 2.1 命中,直接返回给前端// 先把JSON字符串转出java对象Shop shop = JSONUtil.toBean(jsonStr, Shop.class);return Result.ok(shop);}// 2.2 未命中,根据id去数据库里查询Shop shop = getById(id);// 2.1.1 判断数据库中该商家是否存在if (shop == null){// 2.1.1.1 数据库中不存在,返回404return Result.fail("店铺不存在");}// 2.1.1.2 数据库中存在,写入Redis,并返回给前端stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));return Result.ok(shop);}
}
缓存更新策略
当更新数据库而没有更新缓存数据时,就会产生数据一致性问题,为了解决这个问题,就需要对缓存中的数据进行更新
有以下三种缓存更新策略:
- 内存淘汰:当内存不足时,redis自带的内存淘汰机制就会淘汰掉一部分数据,当需要查询这部分数据时,就会去数据库查询,进而重新写入到缓存,也就实现了缓存的更新,在一定程度上保证了数据的一致性。但是这种方式是不可控的,我们不知道什么时候会进行淘汰,不知道淘汰的是哪些数据,有可能内存一直充足,那么就不会进行内存淘汰,获取到的数据就一直是旧数据。但维护成本很低,这是redis自带的功能,默认是开启的,不需要我们维护
- 超时剔除:在向缓存写入数据的同时设置数据的超时时间,当时间到了就会自动删除数据,然后查询时缓存中没有就会去查询数据库并重新写入缓存,也就实现了缓存的更新。数据的一致性可靠程度可以通过设置超时时间的长短来控制,但是在超时时间没到之前,还是可能产生数据不一致的情况,但总的来说还是比内存淘汰可靠些,而且维护成本也很低,因为只需要在存入时设置一个超时时间即可
- 主动更新:在数据库更新之后,通过编写代码主动更新缓存中的数据,这种方式的一致性比较好,但维护成本也相对较高,因为需要手动编写代码来进行维护,在业务逻辑复杂时,代码也会较为复杂,代码的维护成本也就会提高
主动更新的三种策略
- Cache Aside Pattern:由缓存的调用者,在更新数据库的同时更新缓存,也就是自己写代码实现
- Read/Write Through Pattern:缓存和数据库整合为一个服务,由服务来维护一致性,调用者使用该服务,无需关心缓存的一致性问题。即有一个现成的服务可以直接调用,调用者不需要关心它底层到底是怎么工作的。但是要维护这样的服务也是比较难的,而且一般市面上不容易找到这样的服务,自己开发维护成本会很高
- Write Behind Caching Pattern:调用者只操作缓存,不关心数据库,由其他线程异步的将缓存数据持久化到数据库,也就是对数据的增删改查操作都是在缓存里进行,而操作结果的持久化由其他线程异步进行,保证一致性。好处是当对缓存中的数据更新N次时,只有第N次是有效的,刚好其他线程就在此时来进行数据持久化,就会把最后一次的缓存更新保持到数据库,即多次缓存更新结果只需一次持久化。坏处一是这个异步任务会比较复杂,需要实时监控缓存数据的变化;二是数据一致性不能保证,当缓存进行多次更新时,此时还未触发线程进行数据持久化,就会造成缓存和数据库数据不一致,如果此时缓存再出现问题,如宕机,数据就会丢失,且也没有进行数据的持久化
综上,第一种方式比较好,下面对第一种方式进行介绍
主动更新——Cache Aside Pattern
操作缓存和数据库时有三个问题需要考虑:
1、删除缓存还是更新缓存
- 更新缓存:每次更新数据库都更新缓存,无效写操作比较多。也就是说每次更新数据库,都会去更新一次缓存中对应的数据,但如果是写操作比较多,读操作比较少,比如更新了一百次数据库后,才会进行一次数据查询,那么就会更新一百次缓存,如果在前面的更新中都没有数据查询请求,只在第一百次更新完缓存之后,才有一次数据查询请求,那么就会将缓存中的结果返回(此时返回的是第一百更新后的最新数据),那么就会造成前面九十九次的缓存更新都白费,所以一般不用这种方法
- 删除缓存,更新数据库时让缓存失效,查询时再更新缓存。每次更新完数据库,就把缓存中对应的数据设置为失效(可以通过设置超时时间来完成),并且没有对缓存中的数据马上进行更新,而是当有数据查询请求时,查询缓存时未命中,就会去查询数据库,然后把查询到数据写入缓存(此时才进行缓存更新)并返回
2、如何保证缓存与数据库的操作同时完成或同时失败
- 对于单体系统:将缓存和数据库操作放在一个事务内即可
- 对于分布式系统:利用TCC等分布式事务方案
3、先操作缓存还是先操作数据库(线程安全问题)
- 先删除缓存,再操作数据库
这种情况下,在多线程并发时出现异常的概率还是比较大的,因为删除缓存,读缓存和查数据库(直接获取数据库数据)以及写入缓存的操作也很快,但是写入数据库(这里要组织数据,然后再进行插入操作,比直接从数据库获取数据要慢)的速度相对上述操作来说就很慢,所以很容易出现下图中的异常情况:
在线程1删除缓存(速度很快)之后,还没把更新数据库的操作完成(因为该操作比较耗时)的这段时间内,有另一个线程2来查找数据,此时因为已经删除了缓存,所以未命中就会去查找数据库然后写入缓存(读缓存->查数据库->写入缓存这三个操作加起来都比更新数据库操作块),此时缓存中再次被写入了旧数据10,而线程2更新完数据库后,数据库中的数据就变成了20,造成了数据不一致的情况
并且,可以从图中看出,出现异常情况之后,缓存中的数据就一直是旧数据,后续如果线程2或其他线程来获取数据,得到的就一直是缓存中的旧数据
- 先操作数据库,再删除缓存
异常情况1:在线程1更新完数据库之后,删除缓存之前的这段时间里,有另一个线程2来查询数据,此时缓存还未删除,命中并返回旧数据10,然后线程1执行删除缓存操作,缓存变为空,此时也造成了数据不一致
但是,由于线程1在已经更新完数据库到删除缓存的这段时间非常短(因为耗时的写入数据库已经完成,而删除缓存速度非常块),所以在这段很短的时间里出现线程2的概率比较小。其次,就算真的发生这种异常情况,线程2第一次查询得到的是旧数据10,第二次来查询时缓存已经为空了,即未命中,那么就会去查找数据库然后写入缓存,此时得到的又会是最新的数据20,缓存中的数据也被更新为20,所以这种异常情况的代价是比较小的(线程2第一次查询返回的是旧数据)
异常情况2:刚好缓存中的数据因为某些原因失效(如过期时间到了),可以理解成被删除,假设线程2查询数据未命中(数据已失效),去查询数据库得到10,并准备将10写入缓存,在这期间线程1进行更新数据库和删除缓存操作,然后线程2才执行写入缓存操作,如图所示
这种情况的概率也很低,要同时满足两个条件:1、有两个进程并行执行;2、线程2查询时恰好缓存失效;同时有另一个线程1要在线程2写入缓存之前来执行更新数据库和删除缓存操作
从上面的《先删除缓存,再操作数据库》分析中知道,在第二个条件中,在线程2已经查询到数据库之后写入缓存之前的这段时间很短,而线程1的更新数据库以及删除缓存操作相比之下耗时更长(其实光更新数据库这一操作就已经很耗时了),所以要同时满足上面的两个条件概率就很低
假如这种概率很小的情况真的发生了,也可以通过设置超时时间,当超时时间到了就会删除旧数据
综上所述,虽然上面两种方式都有可能产生线程安全问题,但是先操作数据库再删除缓存发生的概率更小,所以选择先操作数据库再删除缓存
综上,缓存更新的最佳实践方案为:
实际应用
java代码
package com.hmdp.service.impl;import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import javax.annotation.Resource;import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.CACHE_SHOP_KEY;
import static com.hmdp.utils.RedisConstants.CACHE_SHOP_TTL;/*** <p>* 服务实现类* </p>** @author 虎哥* @since 2021-12-22*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryById(Long id) {// 1、从Redis中查询商家信息String key = CACHE_SHOP_KEY + id;// 可以使用Redis中的Hash结构
// stringRedisTemplate.opsForHash().entries("" + id);// 这里用String来演示String jsonStr = stringRedisTemplate.opsForValue().get(key);// 2、判断缓存是否命中,即Redis中是否有要查询的商家信息if (StrUtil.isNotBlank(jsonStr)) {// 2.1 命中,直接返回给前端// 先把JSON字符串转出java对象Shop shop = JSONUtil.toBean(jsonStr, Shop.class);return Result.ok(shop);}// 2.2 未命中,根据id去数据库里查询Shop shop = getById(id);// 2.1.1 判断数据库中该商家是否存在if (shop == null){// 2.1.1.1 数据库中不存在,返回404return Result.fail("店铺不存在");}// 2.1.1.2 数据库中存在,写入Redis,并返回给前端// CACHE_SHOP_TTL, TimeUnit.MINUTES:设置Redis过期时间为30分钟stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);}@Override@Transactional // 事务注解public Result update(Shop shop) {// 该方法使用了事务注解,所以可以保证更新数据库操作好删除Redis缓存操作同时成功或同时失败Long id = shop.getId();if (id == null){return Result.fail("店铺id不存在");}// 更新数据库updateById(shop);// 删除缓存stringRedisTemplate.delete(CACHE_SHOP_KEY + id);return Result.ok();}
}
缓存穿透
概念
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,那么查询请求在缓存中找不到数据时就会直接去数据库查询,而数据库中也没有要查询的数据,所以就返回给客户端找不到的提示信息
这种情况下,如果有人恶意使用多线程来请求根本不存在的数据,那么这些请求就会让程序直接去数据库查询(因为redis,也就是缓存中没有),就会给数据库造成巨大压力
解决方法
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:1、有额外的内存消耗(因为要对不存在的数据值存储为空,当有很多不存在的数据被请求时,这些数据都会被缓存为空,此时缓存中就会有很多没用的key缓存着空值,可以通过给key设置超时时间来解决);2、可能造成短期的数据不一致(比如第一次访问的某个数据确实不存在,将其存为空值,后来真的在数据库中插入该数据,而此时缓存中还是空值,就会产生数据不一致的情况,可以设置超时时间来解决或者当数据库更新时主动更新缓存)
- 布隆过滤(具体是啥百度吧)
- 优点:内存占用较少,没有多余的key(因为不用将不存在的数据缓存为空值)
- 缺点:1、实现复杂(但Redis自带了一个布隆过滤,可以帮助简化开发);2、存在误判的可能(当告诉数据不存在时,是真的不存在,但当告诉存在时,不一定真的存在,此时还是有缓存穿透的风险)
- 增强id的复杂度,避免被攻击者猜测到id的组成规律
- 做好数据的基础格式校验(比如校验id是否符合格式,这也是上一条说的增强id复杂度的作用),只有数据符合要求才能访问
- 加强用户的权限校验(是否已经登录,是否有权限访问)
- 做好热点参数的限流,对于一些比较热门的请求接口,做限流处理,比如一些秒杀活动,限制用户的访问次数
实际应用
基于上面的代码和业务需求(根据id查询店铺详情信息)
流程图
java代码
package com.hmdp.service.impl;import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import javax.annotation.Resource;import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.*;/*** <p>* 服务实现类* </p>** @author 虎哥* @since 2021-12-22*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryById(Long id) {// 1、从Redis中查询商家信息String key = CACHE_SHOP_KEY + id;// 这里用String来演示String jsonStr = stringRedisTemplate.opsForValue().get(key);// 2、判断缓存是否命中,即Redis中是否有要查询的商家信息if (StrUtil.isNotBlank(jsonStr)) {// 2.1 命中,直接返回给前端// 先把JSON字符串转出java对象Shop shop = JSONUtil.toBean(jsonStr, Shop.class);return Result.ok(shop);}// 判断命中的是否是空值(缓存雪崩问题解决方案:缓存空值)if (jsonStr != null){ // 命中的是空值return Result.fail("店铺不存在");}// 2.2 未命中,根据id去数据库里查询Shop shop = getById(id);// 2.1.1 判断数据库中该商家是否存在if (shop == null){// 缓存空值(缓存雪崩问题解决方案),并设置较短的过期时间stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 2.1.1.1 数据库中不存在,返回404return Result.fail("店铺不存在");}// 2.1.1.2 数据库中存在,写入Redis,并返回给前端// CACHE_SHOP_TTL, TimeUnit.MINUTES:设置Redis过期时间为30分钟stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return Result.ok(shop);}
}
缓存雪崩
概念
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,而此时有大量的请求来访问这些丢失的数据,那么在Redis中没有,这些请求就会直接到达数据库,给数据库带来巨大压力
解决方法
- 给不同的key设置不同的过期时间,比如过期时间原本要设置为30分钟,可以在这30分钟的基础上随机加上一个时间值,如随机值为3到5分钟(也可以是其他值),这样就不会让多个key同时过期失效(针对同一时段大量的缓存key同时失效的情况)
- 利用Redis集群提高服务的可用性(避免Redis服务宕机的情况)
- 给缓存业务添加降级限流策略,如当Redis宕机时,有客户端来请求数据时,一律返回类似于“服务暂时不可用”等提示信息,拒绝服务(Redis真的宕机且无法恢复的情况)
- 给业务添加多级缓存,如添加浏览器缓存、反向代理Nginx缓存、Redis缓存、JVM缓存、数据库缓存
缓存击穿
缓存击穿问题也叫热点key问题,就是一个被高并发访问并且缓存重建业务较复杂的key失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
常见的解决方法有两种:互斥锁和逻辑过期
互斥锁
介绍
当有一个线程1开始重建key时,就用锁锁住,这样当其他线程也想重建key时,就要等待线程1释放锁,这样就不会出现多个线程都重建key的情况
下图的过程是:线程1最先要获取数据,去查询缓存,未命中,开始获取锁,获取锁成功之后开始查询数据库重建缓存数据,将数据写入缓存中,最后释放锁。而在线程1获取锁成功之后,线程2也来查询数据(和线程1要查询的数据相同),去查询缓存,未命中,开始获取锁,获取失败(因为已经被线程1先获取到了),休眠一会,然后重新去缓存中获取数据,如果缓存中已经有了(线程1已经写入)直接返回,如果缓存还是未命中,就去获取锁,还是失败,继续休眠....一直重复,直到缓存中有数据或者锁被释放
互斥锁存在的问题:多个没有拿到锁的线程会一直处于等待状态,如果key构建的时间比较久,那这些线程的等待时间也就会变长,响应速度也就会比较慢,性能就会比较差。而且有可能存在死锁的情况,比如构建一个key需要获取多个锁,而这些锁被不同的线程获取到,它们就会相互等待没有获取到的锁,就会造成死锁现象
实际应用
业务需求及流程图描述
java代码
代码放在下面逻辑过期中,因为分别把互斥锁和逻辑过期两种实现方式封装成了一个方法
逻辑过期
介绍
不直接给缓存中的key设置过期时间TTL,而是在要缓存的数据中增加一个字段,用这个字段标明过期时间,这个字段一般是用当前时间(也就是向缓存中存入数据时的时间)加上真正要设置的过期时间(如30分钟)得到的,这个字段就是逻辑过期时间
因为没有给key设置实际的过期时间TTL,再配合一些合适的内存淘汰策略,那么理论上key一旦存入Redis就会永不过期,也就是一直能从缓存中获取到,如果想删除掉这个key,再手动进行删除
实际应用
业务需求及流程图如下
java实现代码
测试类代码,提前向缓存中存入数据
package com.hmdp;import com.hmdp.service.impl.ShopServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;import javax.annotation.Resource;@SpringBootTest
class HmDianPingApplicationTests {@Resourceprivate ShopServiceImpl shopService;@Testvoid testSaveShop() throws InterruptedException {shopService.saveShop2Redis(1l, 10l);}
}
RedisData类
package com.hmdp.utils;import lombok.Data;import java.time.LocalDateTime;/*** 为了不改动原有的Shop类的代码,在此重新定义一个数据类* 该类的expireTime字段就表示逻辑过期时间* data字段表示原有的数据,在这里指的是Shop类对象,即店铺信息* 也就是通过该类在原有数据的基础上添加一个逻辑过期时间* 根据不同的业务需要,data可以表示任意的数据,因为器类型是Object*/
@Data
public class RedisData {private LocalDateTime expireTime;private Object data;
}
ShopServiceImpl类中的代码
package com.hmdp.service.impl;import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisData;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import javax.annotation.Resource;import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.*;/*** <p>* 服务实现类* </p>** @author 虎哥* @since 2021-12-22*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate StringRedisTemplate stringRedisTemplate;/*** 根据id查询店铺详情信息** @param id 要查询的店铺id* @return 返回店铺信息*/@Overridepublic Result queryById(Long id) {// 缓存空值解决缓存穿透
// Shop shop = queryWithPassThrough(id);// 互斥锁解决缓存击穿
// Shop shop = queryWithPassMutex(id);
// if (shop == null) {
// return Result.fail("店铺不存在");
// }// 逻辑过期解决缓存击穿Shop shop = queryWithLogicalExpire(id);if (shop == null) {return Result.fail("店铺不存在");}return Result.ok(shop);}/*** 互斥锁解决缓存击穿** @param id 要查询的店铺id* @return 店铺信息*/public Shop queryWithPassMutex(Long id) {// 从Redis中查询商家信息Map<String, Object> stringObjectMap = queryShopFromCache(id);Boolean isExist = (Boolean) stringObjectMap.get("isExist");if (BooleanUtil.isTrue(isExist)) {// 缓存中存在数据,直接返回return (Shop) stringObjectMap.get("shop");}// 缓存未命中数据String lockKey = LOCK_SHOP_KEY + id;Shop shop = null;try {// 尝试获取锁boolean isGetLock = tryGetLock(lockKey);if (!isGetLock) {// 获取锁失败// 休眠等待Thread.sleep(80);// 休眠结束之后重新查询,即递归调用queryWithPassMutex方法return queryWithPassMutex(id);}// 获取锁成功// 再次判断缓存中是否已经有数据Map<String, Object> stringObjectMap2 = queryShopFromCache(id);Boolean isExist2 = (Boolean) stringObjectMap2.get("isExist");if (BooleanUtil.isTrue(isExist2)) {// 缓存中存在数据,直接返回return (Shop) stringObjectMap2.get("shop");}// 开始缓存重建// 缓存未命中数据String key = CACHE_SHOP_KEY + id;// 根据id去数据库里查询shop = getById(id);// 模拟缓存重建的延时Thread.sleep(200);// 判断数据库中该商家是否存在if (shop == null) {// 缓存空值(缓存雪崩问题解决方案),并设置较短的过期时间stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 数据库中不存在,返回404return null;}// 数据库中存在,写入Redis,并返回给前端// CACHE_SHOP_TTL, TimeUnit.MINUTES:设置Redis过期时间为30分钟stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);} catch (InterruptedException e) {throw new RuntimeException(e);} finally {// 释放锁releaseLock(lockKey);}return shop;}/*** 判断缓存中是否命中数据** @param id 要查询的数据id* @return 返回一个map集合,表示是否命中数据和命中的数据内容*/private Map<String, Object> queryShopFromCache(Long id) {HashMap<String, Object> map = new HashMap<>();// 从Redis中查询商家信息String key = CACHE_SHOP_KEY + id;String jsonStr = stringRedisTemplate.opsForValue().get(key);// 判断缓存是否命中,即Redis中是否有要查询的商家信息if (StrUtil.isNotBlank(jsonStr)) {// 命中,直接返回给前端// 先把JSON字符串转出java对象Shop shop = JSONUtil.toBean(jsonStr, Shop.class);map.put("isExist", true);map.put("shop", shop);return map;}// 判断命中的是否是空值(缓存雪崩问题解决方案:缓存空值)if (jsonStr != null) { // 命中的是空值map.put("isExist", true);map.put("shop", null);return map;}// 未命中,返回false和nullmap.put("isExist", false);map.put("shop", null);return map;}/*** 在实际业务中,热点key一般都是通过后台系统提前添加进Redis里* 这里用单元测试模拟一下后台管理,提前向Redis中存入热点key** @param id 店铺的id* @param expireSeconds 逻辑过期时间,单位秒*/public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {// 查询数据库获取店铺信息Shop shop = getById(id);// 模拟重建缓存的延时Thread.sleep(200);RedisData redisData = new RedisData();// 设置Shop数据redisData.setData(shop);// 设置过期时间redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));// 存入Redis,注意Redis没有设置TTLstringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));}// 定义一个线程池private final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);/*** 逻辑过期解决缓存击穿** @param id 要查询的店铺id* @return 店铺信息*/public Shop queryWithLogicalExpire(Long id) {// 从Redis中查询商家信息String key = CACHE_SHOP_KEY + id;String jsonStr = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(jsonStr)) {// 未命中数据,直接返回空return null;}// 命中数据,判断数据是否过期// 把json反序列化成java对象RedisData redisData = JSONUtil.toBean(jsonStr, RedisData.class);// redisData.getData()由于是Object类型,在反序列化的时候会反序列化为JSONObject// 所以需要再次将JSONObject反序列化为Shop// Shop shop = (Shop) redisData.getData();// 我觉得redisData.getData()得到的是一个Object对象,可以把Object强转成Shop// 但是实际上不可以,会报错:cn.hutool.json.JSONObject cannot be cast to com.hmdp.entity.Shop// 具体为啥不能直接强转成Shop我还没弄明白JSONObject jsonObj = (JSONObject) redisData.getData();Shop shop = JSONUtil.toBean(jsonObj, Shop.class);// 逻辑时间没有过期if (LocalDateTime.now().isBefore(redisData.getExpireTime())) {// 当前时间在逻辑过期时间之前 => 逻辑时间没有过期,直接返回数据return shop;}/* 逻辑时间过期 */String lockKey = LOCK_SHOP_KEY + id;// 获取锁if (tryGetLock(lockKey)) {// 获取锁成功,需要再次判断缓存中的数据是否过期/*因为在多线程的情况下,很有可能出现这一种情况:线程1判断数据逻辑过期,并且获得锁成功,它会新建一个线程2来重建key,然后线程1返回旧数据在线程2重构完成释放锁之前,有另一个线程3判断缓存中的数据过期开始往下尝试获得锁切好此时线程2构建完成(此时缓存中已经是新数据了)释放锁,释放掉的锁被线程3拿到如果不进行二次判断缓存中的数据是否过期,那么线程3又会再次去重建key,但是此时缓存中的数据并没有过期(因为刚刚线程2已经重建好了)所以为了避免重复重建缓存,就需要再次进行判断*/String jsonStr2 = stringRedisTemplate.opsForValue().get(key);RedisData redisData2 = JSONUtil.toBean(jsonStr2, RedisData.class);if (LocalDateTime.now().isBefore(redisData2.getExpireTime())) {// 缓存中的数据未过期,直接返回return JSONUtil.toBean((JSONObject) redisData2.getData(), Shop.class);}// 获取锁成功,开启新线程重建缓存数据CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 重建缓存this.saveShop2Redis(id, 10l);} catch (Exception e) {throw new RuntimeException(e);} finally {// 释放锁releaseLock(lockKey);}});}// 无论获取锁成功还是失败,都要返回旧数据// 返回旧数据return shop;}/*** 尝试获取锁** @param key 代表锁的键* @return 返回布尔值,是否获取锁成功*/private boolean tryGetLock(String key) {// 设置锁并设置超时时间为10秒// setnx命令就对应着java中的setIfAbsent方法// 返回一个Boolean类型的变量Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);// return flag // 不建议直接返回flag,因为flag会被自动拆箱成boolean,在拆箱的过程中可能出现空指针异常// 使用工具类BooleanUtil进行返回return BooleanUtil.isTrue(flag);}/*** 释放锁,即删除key为lock的数据** @param key 表示锁的键*/private void releaseLock(String key) {stringRedisTemplate.delete(key);}/*** 用缓存空值的方式解决缓存穿透** @param id 要查询的店铺id* @return 店铺的信息*/public Shop queryWithPassThrough(Long id) {// 从Redis中查询商家信息String key = CACHE_SHOP_KEY + id;// 这里用String来演示String jsonStr = stringRedisTemplate.opsForValue().get(key);// 判断缓存是否命中,即Redis中是否有要查询的商家信息if (StrUtil.isNotBlank(jsonStr)) {// 命中,直接返回给前端// 先把JSON字符串转出java对象return JSONUtil.toBean(jsonStr, Shop.class);}// 判断命中的是否是空值(缓存雪崩问题解决方案:缓存空值)if (jsonStr != null) { // 命中的是空值return null;}// 根据id去数据库里查询Shop shop = getById(id);// 判断数据库中该商家是否存在if (shop == null) {// 缓存空值(缓存雪崩问题解决方案),并设置较短的过期时间stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);// 数据库中不存在,返回404return null;}// 数据库中存在,写入Redis,并返回给前端// CACHE_SHOP_TTL, TimeUnit.MINUTES:设置Redis过期时间为30分钟stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);return shop;}
}
互斥锁 VS 逻辑过期
这两种方式都是解决缓存重建key这段时间内产生的并发问题,优缺点如下
缓存工具封装
根据上述所说的,简单的封装一个缓存工具类,工具类要求如下
注意,如果是要调用逻辑过期的方法,要提前向缓存中存入数据,这里封装工具类和上面逻辑过期中的代码差不多,就是修改成了通用的写法
调用工具类
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.Shop;
import com.hmdp.mapper.ShopMapper;
import com.hmdp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.CacheClient;
import org.springframework.stereotype.Service;import javax.annotation.Resource;import java.util.concurrent.TimeUnit;import static com.hmdp.utils.RedisConstants.*;/*** <p>* 服务实现类* </p>** @author 虎哥* @since 2021-12-22*/
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {@Resourceprivate CacheClient cacheClient;/*** 根据id查询店铺详情信息** @param id 要查询的店铺id* @return 返回店铺信息*/@Overridepublic Result queryById(Long id) {// 缓存空值解决缓存穿透// lambda表达式:id2 -> getById(id2) 可以简化为:this::getById
// Shop shop = cacheClient.queryWithPassThrough(
// CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
// if (shop == null) {
// return Result.fail("店铺不存在");
// }
// return Result.ok(shop);// 逻辑过期时间解决缓存击穿// 注意提前使用单元测试向缓存写入数据,还有为了测试方便,把逻辑过期时间设置为30秒Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, LOCK_SHOP_KEY, id, Shop.class, this::getById, 30l, TimeUnit.SECONDS);if (shop == null) {return Result.fail("店铺不存在");}return Result.ok(shop);}
}
工具类具体代码
package com.hmdp.utils;import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
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;import static com.hmdp.utils.RedisConstants.LOCK_SHOP_TTL;@Slf4j
@Component
public class CacheClient {private final StringRedisTemplate stringRedisTemplate;public CacheClient(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}/*** 将任意类型对象缓存到Redis中,并设置缓存过期时间,key的类型为String** @param key Redis的键* @param value 要缓存的对象,因为可以保存任意类型,所以这里定义value的类型为Object* @param time 缓存过期时间* @param unit 时间单位*/public void set(String key, Object value, Long time, TimeUnit unit) {// 将java对象序列化为JSONString jsonStr = JSONUtil.toJsonStr(value);// 存入RedisstringRedisTemplate.opsForValue().set(key, jsonStr, time, unit);}/*** 将任意类型对象缓存到Redis中,并设置逻辑过期时间,key的类型为String** @param key Redis的键* @param value 要缓存的对象,因为可以保存任意类型,所以这里定义value的类型为Object* @param time 逻辑过期时间* @param unit 时间单位*/public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {// 实例化一个RedisData对象,其中对象的data存储具体的数据,expireTime存储逻辑过期时间RedisData redisData = new RedisData();// 保存逻辑过期时间// unit.toSeconds(time) 因为不确定时间单位是多少,但是这里统一转换成秒redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));redisData.setData(value);// 存入RedisstringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));}/*** 根据id查询数据(用缓存空值解决缓存穿透问题)** @param keyPrefix key的前缀* @param id 查询对象的id* @param type 查询对象的类型* @param func 查询对象的数据库方法* @param time 缓存过期时间* @param unit 时间单位* @param <R> 对象类型,因为不知道调用者要查询什么对象,所以这里用泛型* @param <ID> 对象id的类型,同理,不知道id的类型,这里用泛型* @return 返回查询到的对象数据*/public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type,Function<ID, R> func, Long time, TimeUnit unit) {// 拼接缓存的keyString key = keyPrefix + id;// 查询缓存String json = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isNotBlank(json)) {// 缓存命中,直接返回值return JSONUtil.toBean(json, type);}if (json != null) {// 缓存命中,只是命中的是缓存的空值(缓存空值解决缓存穿透)return null;}// 缓存未命中,查询数据库/*这里由于是工具类,使用该工具类的可以是任意对象,所以在查找数据库时不能明确知道是查询哪张表也就不知道该调用哪个数据库方法来查询,所以需要调用者将查询数据库的方法通过参数传递过来即函数式编程(我个人觉得也可以理解为回调)*/R r = func.apply(id);if (r == null) {// 如果数据库查不到,就缓存空值并设置过期时间,防止缓存穿透stringRedisTemplate.opsForValue().set(key, "", time, unit);return null;}// 将数据库查询结果缓存到Redis
// stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(r), time, unit);// 也可以调用上述定义好的方法this.set(key, r, time, unit);return r;}// 定义一个线程池private final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);/*** 根据id查询数据(用逻辑过期解决缓存击穿问题)** @param keyPrefix 店铺key的前缀* @param keyLockPrefix 店铺锁key的前缀* @param id 店铺id* @param type 查询对象的类型* @param func 查询对象的数据库方法* @param time 逻辑过期时间* @param unit 时间单位* @param <R> 对象类型,因为不知道调用者要查询什么对象,所以这里用泛型* @param <ID> 对象id的类型,同理,不知道id的类型,这里用泛型* @return 返回查询到的对象数据*/public <R, ID> R queryWithLogicalExpire(String keyPrefix, String keyLockPrefix, ID id, Class<R> type,Function<ID, R> func, Long time, TimeUnit unit) {String key = keyPrefix + id;String json = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(json)) {// 缓存未命中,直接返回空// 因为针对热点key且采用逻辑过期时间,那么就不可能取不到数据,真的取不到那就是真的没有,直接返回空return null;}RedisData redisData = JSONUtil.toBean(json, RedisData.class);LocalDateTime expireDate = redisData.getExpireTime();JSONObject jsonObject = (JSONObject) redisData.getData();R r = JSONUtil.toBean(jsonObject, type);if (LocalDateTime.now().isBefore(expireDate)) {// 当前时间在逻辑过期时间之前 => 数据未过期,直接返回return r;}// 逻辑时间过期,尝试获取锁String lockKey = keyLockPrefix + id;if (getLock(lockKey)) {// 获取锁成功,再次判断缓存中数据的逻辑时间是否过期,原因已经在之前说明String json2 = stringRedisTemplate.opsForValue().get(key);if (StrUtil.isBlank(json2)) {return null;}RedisData redisData2 = JSONUtil.toBean(json2, RedisData.class);if (LocalDateTime.now().isBefore(redisData2.getExpireTime())) {// 缓存中的数据未过期,直接返回return JSONUtil.toBean((JSONObject) redisData2.getData(), type);}// 新开一个线程,重建缓存CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 查询数据库R r1 = func.apply(id);// 模拟重建延时Thread.sleep(200);// 写入缓存
// RedisData redisData1 = new RedisData();
// redisData1.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// redisData1.setData(r1);
// stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData1));// 调用本工具类提供的方法写入缓存this.setWithLogicalExpire(key, r1, time, unit);} catch (Exception e) {throw new RuntimeException(e);} finally {// 释放锁unLock(lockKey);}});}// 无论获取锁成功还是失败,都要返回旧数据return r;}/*** 尝试获取锁** @param key 代表锁的键* @return 返回布尔值,是否获取锁成功*/private boolean getLock(String key) {// 设置锁并设置超时时间为LOCK_SHOP_TTL秒// setnx命令就对应着java中的setIfAbsent方法// 返回一个Boolean类型的变量Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);// return flag // 不建议直接返回flag,因为flag会被自动拆箱成boolean,在拆箱的过程中可能出现空指针异常// 使用工具类BooleanUtil进行返回return BooleanUtil.isTrue(flag);}/*** 释放锁,即删除key为lock的数据** @param key 表示锁的键*/private void unLock(String key) {stringRedisTemplate.delete(key);}
}