目录
三、商户查询缓存
1.缓存介绍
2.添加商户缓存
(1)缓存模型和思路
(2)代码实现
3.店铺类型缓存
4.缓存更新策略
5.实现商铺缓存与数据库双写一致
6.缓存穿透
(1)介绍
(2)编码解决商品查询的缓存穿透问题
7.缓存雪崩
8.缓存击穿
(1)介绍
(2)利用互斥锁解决缓存击穿问题
(3)利用逻辑过期解决缓存击穿问题
9.缓存工具封装
三、商户查询缓存
1.缓存介绍
缓存(Cache)就是数据交换的缓冲区,是存储数据的临时地方,一般读写性能较高。
缓存有很多种形式,如:
硬件缓存: 一般指的是机器上的 CPU、硬盘等等组件的缓存区间,一般是利用的内存作为一块中转区域,都通过内存交互信息,减少系统负载,提供传输效率。
客户端缓存: 一般指的是某些应用,例如浏览器、手机 App、视频缓冲等等,都是在加载一次数据后将数据临时存储到本地,当再次访问时候先检查本地缓存中是否存在,存在就不必去远程重新拉取,而是直接读取缓存数据,这样来减少远端服务器压力和加快载入速度。
服务端缓存: 一般指远端服务器上,考虑到客户端请求量多,某些数据请求量大,这些热点数据经常要到数据库中读取数据,给数据库造成压力,还有就是 IO、网络等原因有一定延迟,响应客户端较慢。所以,在一些不考虑实时性的数据中,经常将这些数据存在内存中(内存速度非常快),当请求时候,能够直接读取内存中的数据及时响应。
为什么要使用缓存?
① 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力。
② 实际开发中,企业的数据量,少则几十万,多则几千万,这么大的数据量。如果没有缓存,系统是几乎撑不住的,所以企业会大量运用缓存技术。
③ 但是缓存也会增加代码复杂度和运营成本,也有可能引发数据不一致的现象(缓存中的数据没有及时更新客户端中的新数据)。
2.添加商户缓存
(1)缓存模型和思路
我们先启动前端和后端的项目,登陆之后随便访问一个商户,查看浏览器发送的请求。
不出意外是 ShopController 里的业务逻辑:
可以发现:在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,这样效果肯定慢
所以我们可以在客户端与数据库之间加上一个 Redis 缓存:
先从 Redis 中查询,如果没有查到,再去 MySQL 中查询,同时查询完毕之后,将查询到的数据也存入 Redis,这样当下一个用户来进行查询的时候,就可以直接从 Redis 中获取到数据。以此往复,Redis 中的数据越来越多,命中率也越来越大,大大加快了服务端的响应速度。
因此,我们根据 id 查询商户的流程就可以修改为:
(2)代码实现
ShopController:
@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
IShopService:
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
}
ShopServiceImpl:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存
String shopKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.判断数据库中是否存在
if(shop == null){
// 6.不存在,返回错误
return Result.fail("店铺不存在");
}
// 7.存在,写入redis
stringRedisTemplate.opsForValue().set(shopKey,JSONUtil.toJsonStr(shop));
// 8.返回
return Result.ok(shop);
}
}
运行结果:
打开 Redis,也能够看到我们缓存的数据:
3.店铺类型缓存
完成了商户数据缓存之后,我们尝试做一下商户类型数据缓存。
在很多地方,我们都会用到商户类型的数据,而且这个数据还不会变,所以很合适加入缓存中。
在 ShopTypeController 中,这个操作也只是简单的一个查询数据库的操作。
修改后,代码如下:
ShopTypeController:
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
@Resource
private IShopTypeService typeService;
@GetMapping("list")
public Result queryTypeList() {
return typeService.queryList();
}
}
IShopTypeService:
public interface IShopTypeService extends IService<ShopType> {
Result queryList();
}
ShopTypeServiceImpl:
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryList() {
// 1.在redis中查询店铺类型缓存
String key = CACHE_SHOP_TYPE_KEY;//"cache:shop:list"
List<String> stringTypeList = stringRedisTemplate.opsForList().range(key, 0, -1);//0是开始索引,-1是结束索引(表示最后一个元素)
// 2.判断是否命中
if (!stringTypeList.isEmpty()) {
// 3.命中,直接返回
// List<String> ===> List<ShopType>
List<ShopType> shopTypeList = stringTypeList.stream()
.map(jsonStr -> JSONUtil.toBean(jsonStr, ShopType.class))
.collect(Collectors.toList());
return Result.ok(shopTypeList);
}
// 4.没有命中,查询数据库
List<ShopType> shopTypeList = query().orderByAsc("sort").list();
// 5.判断数据库中是否存在
if (shopTypeList == null) {
// 6.不存在,返回错误信息
return Result.fail("店铺类型不存在!");
}
// 7.存在,存入redis
// List<ShopType> ===> List<String>
stringTypeList = shopTypeList.stream()
.map(shopType -> JSONUtil.toJsonStr(shopType))
.collect(Collectors.toList());
stringRedisTemplate.opsForList().rightPushAll(key, stringTypeList);
// 8.返回
return Result.ok(shopTypeList);
}
}
运行结果:
可以看到,第二次加载时速度明显快速很多很多。
打开 Redis,也能够看到我们缓存的数据:
4.缓存更新策略
数据同时保存在缓存和数据中,涉及数据一致性问题,如果对数据库数据做了一些修改,缓存是不知道的,这种场景下会造成业务数据错误。
所以,这时就涉及到缓存更新策略。
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护, 利用Redis的内存淘汰机制, 当内存不足时自动淘汰部分数据。 下次查询时更新缓存。 | 给缓存数据添加TTL时间, 到期后自动删除缓存。 下次查询时更新缓存。 | 编写业务逻辑, 在修改数据库的同时, 更新缓存。 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
业务场景:
- 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存(因为这个很长一段时间都不需要更新)
- 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存
主动更新策略有如下三种方式:
- Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案
- Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
- Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了
综上,我们还是选择方案一,因为它最为可控。
但是,人工编码方式会同时操作缓存和数据库,这时就会有三个问题需要考虑:
① 删除缓存还是更新缓存?
更新缓存
:每次更新数据库都需要更新缓存,无效写操作较多删除缓存
:更新数据库时让缓存失效,再次查询时更新缓存
假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大。
所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来
② 如何保证缓存与数据库的操作同时成功 / 同时失败?
单体系统:
将缓存与数据库操作放在同一个事务分布式系统:
利用TCC等分布式事务方案
③ 先操作缓存还是先操作数据库?
我们来仔细分析一下这两种方式的线程安全问题:
假设我们有两个线程:一个更新操作,一个查询操作(初始缓存和数据库中的值都为 10)
i.先删除缓存,再操作数据库:
正常情况:
异常情况:
删除缓存的操作很快,但是更新数据库的操作相对较慢。
如果此时有一个线程 2 刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程 2 需要查询数据库,并写入缓存(旧数据:10)。
但是我们更新数据库的操作还未完成,所以线程 2 查询到的数据是脏数据,出现线程安全问题。
发生这种情况的概率还是比较大的,因为更新数据库(写)操作很慢,查询数据库(读)和写入缓存的操作又很快,所以完全有可能在这之间发生异常。
而且,后续继续查询操作,缓存中数据还是旧数据,就会引发长时间的数据不一致问题。
ii.先操作数据库,再删除缓存:
正常情况:
异常情况:
线程 1 在查询缓存的时候,缓存 TTL 刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存。
但是线程 1 虽然查询完了数据(更新前的旧数据:10),但是还没来得及写入缓存,所以线程 2 的更新数据库与删除缓存,并没有影响到线程 1 的查询旧数据。
之后线程 1 继续写入缓存,又会造成数据不一致,引发线程安全问题。
发生这种情况的概率并不高,因为前提是查询前缓存 TTL 刚好失效,这个本身就具有偶然性。再者,更新数据库操作非常慢,而写入缓存的这个操作很快(微秒级别),所以很难在这么短的时间内,线程 2 能一直抢占到 CPU 把数据库进行更新。
所以大概率写入缓存操作是先执行的,后面更新数据库,再删除缓存。下次继续查询的话,缓存中没有,还是会去数据库中查,就不会持续引发数据不一致问题了。
综上所述,虽然这二者都存在线程安全问题,但是相对来说,方案二出现线程安全问题的概率相对较低,所以我们最终采用方案二:先操作数据库,再删除缓存。
5.实现商铺缓存与数据库双写一致
修改 ShopController 中的业务逻辑,满足以下要求:
- 根据 id 查询店铺时,如果缓存未命中,则查询数据库,并将数据库结果写入缓存,并设置TTL
- 根据 id 修改店铺时,先修改数据库,再删除缓存
查询操作:
修改 ShopService的queryById 方法,写入缓存时设置一下 TTL
stringRedisTemplate.opsForValue().set(shopKey,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
更新操作:
之前的 update 方法:
修改后的 update 方法:
@PutMapping
public Result updateShop(@RequestBody Shop shop) {
// 写入数据库
return shopService.update(shop);
}
IShopService:
public interface IShopService extends IService<Shop> {
Result queryById(Long id);
Result update(Shop shop);
}
ShopServiceImpl:
@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if(id == null){
return Result.fail("店铺id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
重启服务器,删除之前 Redis 中残留的商铺信息,因为之前没有设置 TTL,默认永不删除。
所以后续查询操作都会直接从缓存中找,不会从数据库中查,从而也就不会覆盖原来的商品信息,给它设置TTL 了。
此时,我们点击任意一个餐厅,该餐厅的数据就会缓存到 Redis 中,并设置 TTL 了。
对于更新的操作一般是由管理端去做,我们这里是用户端,所以我们得借助 postman 工具。
修改前:
我们打开 postman,发送一个 PUT 请求,请求路径为:http://localhost:8080/api/shop/
json 数据如下:
{
"area": "大关",
"openHours": "10:00-22:00",
"sold": 4215,
"images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-_QiKuOvyio1OOxsRtFoXqu0G3iT2T27qat3WhLVEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vfCF2ubeXzk49OsGrXt_KYDCngOyCwZK-s3fqawWswzk.jpg,https://qcloud.dpfile.com/pc/IOf6VX3qaBgFXFVgp75w-KKJmWZjFc8GXDU8g9bQC6YGCpAmG00QbfT4vCCBj7njuzFvxlbkWx5uwqY2qcjixFEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vmIU_8ZGOT1OjpJmLxG6urQ.jpg",
"address": "金华路锦昌文华苑29号",
"comments": 3035,
"avgPrice": 80,
"updateTime": 1642066339000,
"score": 37,
"createTime": 1640167839000,
"name": "101茶餐厅",
"x": 120.149192,
"y": 30.316078,
"typeId": 1,
"id": 1
}
运行结果:
打开 redis,发现我们商铺的缓存已经被删除了。
如果我们重新查看商铺:
打开 redis,发现商铺信息同时发生了修改。
6.缓存穿透
(1)介绍
缓存穿透:是指客户端查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,那么这个数据就穿透了缓存,直击数据库,进而给数据库带来压力。
但是数据库能承载的并发不如 redis 这么高,如果此时有恶意攻击创建很多线程,并发访问不存在的资源,那一直会反复的查询数据库,数据库就有可能崩溃。
常见的解决方案有两种:
① 缓存空对象:
哪怕这个数据在数据库里不存在,我们也把这个这个数据存在 redis 中去(也就会产生额外的内存消耗),这样下次用户过来访问这个不存在的数据时,redis 缓存中也能找到这个数据,不用去查数据库。
可能造成的短期不一致是指:在缓存中空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的 TTL 还没过,所以当用户来查询的时候,查询到的还是空对象,等 TTL 过了之后,才能访问到正确的数据,不过这种情况可以通过缩短 TTL 或者新增时主动加入缓存进行解决。
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗,可能造成短期的不一致(可以通过新增的时候主动把数据放入redis中解决)
② 布隆过滤:
布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,根据哈希思想去计算这个数据的哈希值,在二进制数组对应位置填 0 或 1,表示是否存在。
每次查询数据,先经过判断布隆过滤器当前这个要查询的数据是否存在。如果布隆过滤器判断存在,则放行,这个请求会去访问 redis,如果此时 redis 中的数据过期了,就去数据库中查询该数据,再将其放到 redis 中。如果布隆过滤器判断这个数据不存在,则直接返回。
这种思想的优点在于节约内存空间,但存在误判,原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希碰撞现象。
- 优点:内存占用较少,没有多余的 key
- 缺点:实现复杂,可能存在误判
(2)编码解决商品查询的缓存穿透问题
在原来的逻辑中,我们如果发现这个数据在 MySQL 中不存在,就直接返回一个错误信息了,但是这样存在缓存穿透问题。
所以我们选用方案一:缓存空对象的方式解决该问题。
现在的逻辑是:
如果这个数据不存在,将这个数据写入到 Redis 中,并且将 value 设置为空字符串,然后设置一个较短的 TTL,返回错误信息。
当再次发起查询时,先去Redis 中判断 value 是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息。
@Override
public Result queryById(Long id) {
// 1.从redis查询商铺缓存
String shopKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断redis中是否为空值
if(shopJson != null){
// 是我们设置的空字符串,返回错误信息
return Result.fail("店铺不存在");
}
// 4.不存在,根据id查询数据库
Shop shop = getById(id);
// 5.判断数据库中是否存在
if(shop == null){
// 6.不存在,返回错误
// 将空值写入redis
stringRedisTemplate.opsForValue().set(shopKey,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在");
}
// 7.存在,写入redis
stringRedisTemplate.opsForValue().set(shopKey,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 8.返回
return Result.ok(shop);
}
重启项目,访问地址:http://localhost:8080/api/shop/0
第一次访问,由于缓存中没有,我们会直接查询数据库,控制台就会打印出 SQL 语句:
第二次访问,此时 redis 中已经缓存了空值,将不再经过数据库,控制台也就不会打印 SQL 语句了。
总结:
- 缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和在数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力
- 缓存产投的解决方案有哪些?
- 缓存 null 值
- 布隆过滤
- 增强id复杂度,避免被猜测 id 规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流(sentinel)
7.缓存雪崩
缓存雪崩:是指在同一时间段,大量缓存的 key 同时失效,或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的 Key 的 TTL 添加随机值,让其在不同时间段分批失效
- 利用 Redis 集群提高服务的可用性(使用一个或者多个哨兵实例组成的系统,对 redis 节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
- 给缓存业务添加降级限流策略(sentinel、resilience4j)
- 给业务添加多级缓存(浏览器本地缓存、 Nginx 本地缓存、JVM进程缓存、数据库缓存)
8.缓存击穿
(1)介绍
缓存击穿:也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务比较复杂的 key 突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击。
比如:一件秒杀中的商品的 key 突然失效了,大家都在疯狂抢购。但这个商品信息写入缓存前需要从多个数据库的表中查找数据,甚至要做各种表关联的运算,最终才得到一个结果写入 redis 缓存。
那么这个写入缓存的耗时可能就会比较长(几十甚至上百毫秒),在这个时间段内 redis 中一直没有缓存,无数的请求访问都会直接抵达数据库,从而造成数据库崩溃。
解决方案:
① 互斥锁
利用锁的互斥性,所有线程在操作数据库之前,都必须获得一个互斥锁。
假设第一个线程过来,查询缓存未命中后,先获取互斥锁,再进行查询数据库重建缓存数据操作。这其中有其他线程线程过来,发现缓存中没有,想要查询数据库的,都得先获取互斥锁。
由于互斥锁已经被第一个线程拿到了,所以它们都只能等待一段时间继续重试。直到第一个线程写入缓存后,把锁释放。其他线程才能从缓存中拿到数据。
② 逻辑过期
我们之所以会出现缓存击穿问题,主要原因是在于我们对 key 设置了TTL,如果我们不设置 TTL,那么就不会有缓存击穿问题,但是不设置 TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案。
我们设置一个过期时间在 redis 的 value 中,注意:这个过期时间并不会直接作用于 Redis,而是我们后续通过逻辑去处理。
假设线程 1 去查询缓存,然后从 value 中判断当前数据已经过期了,此时线程 1 去获得互斥锁。获得了锁后,它会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成者逻辑之后,才会释放锁。而线程 1 直接进行返回旧数据。
这个释放锁的过程中,只要有其他线程来访问数据,同样会获取互斥锁。但是它们如果获取不到,不会一直等待,而是直接返回旧数据。
直到锁释放之后,其他线程才能从缓存中拿到新数据。
对比互斥锁与逻辑删除:
解决方案 | 优点 | 缺点 |
---|---|---|
互斥锁 | 没有额外的内存消耗 保证一致性 实现简单(只要加个锁就行) | 线程需要等待,性能受影响 可能有死锁风险(互相等待) |
逻辑过期 | 线程无需等待,性能较好 | 不保证一致性(一段时间内获取的都是旧数据) 有额外内存消耗(多了一个expire 字段) 实现复杂 |
(2)利用互斥锁解决缓存击穿问题
需求:修改根据 id 查询商铺的业务,基于互斥锁方式来解决缓存击穿问题。
注意:
① 这里的锁不能用 Lock 或者 synchronized 实现,因为基于两者实现的话,线程拿不到锁就只能一直等待。而我们这里,线程拿到锁和拿不到锁,它的执行逻辑是需要我们自定义的。
核心思路就是利用 redis 的 setnx 方法来表示获取锁,如果 redis 没有这个key,则插入成功,返回1,如果已经存在这个 key,则插入失败,返回 0。
② 为避免发生异常情况,线程 1 在拿到锁之后由于某种原因发生了异常,导致将来这个锁一直没有释放,其他线程不仅获取不到数据,还拿不到锁。
所以我们在利用 setnx 获取锁的时候,往往会设置一个 TTL 有效期,一般是程序正常执行的十倍。如果程序出现异常没有释放锁,将来过一段时间之后还会自动释放。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
//互斥锁解决缓存击穿
Shop shop = querywithMuyex(id);
if (shop == null) {
return Result.fail("店铺不存在"):
}
return Result.ok(shop);
}
public Shop querywithMuyex(Long id) {
// 1.从redis查询商铺缓存
String shopKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断redis中是否为空值
if (shopJson != null) {
// 是我们设置的空字符串,返回错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.失败,则休眠并重试
Thread.sleep(50);
return querywithMuyex(id);
}
// 4.4.不存在,根据id查询数据库
shop = getById(id);
// 模拟热点key重建的延时
Thread.sleep(200);
// 5.判断数据库中是否存在
if (shop == null) {
// 6.不存在,返回错误
// 将空值写入redis
stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 7.存在,写入redis
stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 8.释放互斥锁
unlock(lockKey);
}
// 9.返回
return shop;
}
// 获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
System.out.println(flag);
// 需要转换为基本数据类型,自动拆箱可能会有空指针异常,所以使用hutool的工具类拆箱
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
细节:
热点 key 问题需要满足两个条件:高并发和缓存重建较为复杂。我们这里由于查询数据库是在本地,所以缓存重建一瞬间就结束了。为了模拟缓存重建的延迟,我们在查询数据库后休眠 200 ms。
我们要测试高并发,所以要借助 JMeter 工具:
执行,查看结果:
可以看到,执行结果都是成功的。
再打开控制台,我们发现 SQL 语句只执行了一次, 证明在如此高并发的场景下,没有出现所有的请求都全打到数据库的情况,说明我们互斥锁的解决方案成功了。
(3)利用逻辑过期解决缓存击穿问题
需求:修改根据 id 查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。
注意:
① 理论上来说,缓存是不会出现未命中的情况,因为 key 是不会过期的。一旦这个 key 添加到缓存中,它是可以永久存在的。像这种热点 key,一般在活动开始前,人工提前加入缓存设置逻辑过期时间,直到活动结束,再人工删除。
因此,在查询时,其实不用判断缓存是否命中,如果真的查到缓存不存在,这只能说明这个商品的 key 它不在活动当中,它不属于一个热点 key。在这里,为了健壮性考虑,我们还是判断一下它有没有命中,但不考虑缓存穿透的问题,直接返回空就行。
② 我们想要给商铺添加一个过期时间,可以在 Shop 类中加入一个新的字段,但这样会修改原有的代码。为了对原有的代码没有侵入性,我们可以新建一个实体类(包含原有的数据和过期时间)。
热点 key 加入缓存步骤:
① 新增实体类
@Data
public class RedisData<T> {
private LocalDateTime expireTime;
private T data;
}
② 在 ShopServiceImpl 中新增方法,模拟人工将热点 key 加入缓存,并设置过期时间
public void saveShop2Redis(Long id, Long expireSeconds) {
// 1.查询店铺数据
Shop shop = getById(id);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
③ 编写测试方法,进行单元测试
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private ShopServiceImpl shopService;
@Test
void testSave() {
shopService.saveShop2Redis(1L, 10L);
}
}
运行,打开 redis:
热点 key 提前加入缓存成功了,接下来我们就来解决缓存击穿的问题。
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 逻辑过期解决缓存击穿
Shop shop = queryWithLogicalExpire(id);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
//自定义线程池(上限为10)
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
// 1.从redis查询商铺缓存
String shopKey = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
// 2.判断是否存在
if (StrUtil.isBlank(shopJson)) {
// 3.不存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2 已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock) {
// 6.3.成功,再次检查缓存是否过期
// doubleCheck:重新检查缓存,可能在获取锁之前其他线程刚好重建缓存完毕
RedisData redisDataDoubleCheck = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(shopKey), RedisData.class);
LocalDateTime expireTimeDoubleCheck = redisDataDoubleCheck.getExpireTime();
if (LocalDateTime.now().isBefore(expireTimeDoubleCheck)) {
// 6.3.1.未过期,直接返回
shop = JSONUtil.toBean((JSONObject) redisDataDoubleCheck.getData(), Shop.class);
// 返回前记得释放锁
unlock(lockKey);
return shop;
}
// 6.3.2.过期,继续重建缓存
// 6.4.开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.5.失败,返回过期的商铺信息
return shop;
}
// 获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
// 需要转换为基本数据类型,自动拆箱可能会有空指针异常,所以使用hutool的工具类拆箱
return BooleanUtil.isTrue(flag);
}
// 释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
// 1.查询店铺数据
Shop shop = getById(id);
Thread.sleep(200);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3.写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
}
细节:
① 同样,这里为了模拟缓存重建的延迟,我们在查询数据库后休眠 200 ms。
② 在拿到锁,准备重建缓存前,我们需要二次检查(DoubleCheck)缓存中的数据是否过期。因为,可能恰好在拿到锁之前,另一个线程完成了缓存重建,把锁还了回来,而当前线程刚好拿到。
在使用 JMeter 进行测试之前,我们可以先把数据库中的数据修改一下,造成缓存和数据库出现数据不一致的现象。这样就会有一部分线程使用之前的旧数据,一部分线程在缓存重建完使用新数据。
redis 中的数据还是之前手动加入的旧数据:
打开 JMeter,设置 1s 内 100 个 QPS,不要设置太多,以免后面的将前面的覆盖了,只能看到新数据的请求:
测试,结果可以看到:
前一部分请求取出的还是缓存中的旧数据, 后一部分请求取出的就是缓存重建后的新数据:
打开控制台,发现查询数据库的 SQL 也只是执行了一次:
注意:如果这里全是旧数据,可以把缓存重建时,查询数据库后的休眠时间缩小一点。
9.缓存工具封装
基于 StringRedisTemplate 封装一个缓存工具类,需满足下列要求:
- 方法1:将任意 Java 对象序列化为 JSON,并存储到 String 类型的 Key 中,并可以设置 TTL 过期时间。
//方法1
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
- 方法2:将任意 Java 对象序列化为 JSON,并存储在 String 类型的 Key 中,并可以设置逻辑过期时间,用于处理缓存击穿问题
//方法2
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
// 设置逻辑过期
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
- 方法3:根据指定的 Key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
// 方法3
public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time, TimeUnit unit) {
// 1.从redis查询缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断redis中是否为空值
if (json != null) {
// 是我们设置的空字符串,返回错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.判断数据库中是否存在
if (r == null) {
// 6.不存在,返回错误
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", time, unit);
return null;
}
// 7.存在,写入redis
// 调用刚刚写好的方法1
this.set(key,r,time,unit);
// 8.返回
return r;
}
此时,ShopServiceImpl 使用缓存穿透,就可以直接调用该方法:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 缓存穿透
Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
}
- 方法4:根据指定的 Key 查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
//自定义线程池(上限为10)
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
// 1.从redis查询缓存
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.不存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回信息
return r;
}
// 5.2 已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock) {
// 6.3.成功,再次检查缓存是否过期
// doubleCheck:重新检查缓存,可能在获取锁之前其他线程刚好重建缓存完毕
RedisData redisDataDoubleCheck = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(key), RedisData.class);
LocalDateTime expireTimeDoubleCheck = redisDataDoubleCheck.getExpireTime();
if (LocalDateTime.now().isBefore(expireTimeDoubleCheck)) {
// 6.3.1.未过期,直接返回
r = JSONUtil.toBean((JSONObject) redisDataDoubleCheck.getData(), type);
// 返回前记得释放锁
unlock(lockKey);
return r;
}
// 6.3.2.过期,继续重建缓存
// 6.4.开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R r1 = dbFallback.apply(id);
// 写入redis
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.5.失败,返回过期的信息
return r;
}
此时,ShopServiceImpl 使用缓存击穿,就可以直接调用该方法:
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private CacheClient cacheClient;
@Override
public Result queryById(Long id) {
// 逻辑过期解决缓存击穿
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
if (shop == null) {
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}
}