Redis:03.实战应用二:商户缓存查询

参考资料:黑马redis课程、3种常用的缓存读写策略 | JavaGuide

一、什么是缓存

缓存就是数据交换的缓冲区(称作Cache),是存贮数据的临时地方,一般读写性能较高。

缓存的作用:

  • 降低后端负载(不用去访问数据库)
  • 提高读写效率,降低响应时间

缓存的成本:

  • 数据一致性成本(数据更新导致缓存里还是旧数据)

  • 代码维护成本

  • 运维成本

二、添加Redis缓存

本小节应用的功能背景是查询商户信息,这是一个明显的根据商户id去数据库里查信息的业务。如图所示

image-20220621152022278

而改为使用Redis添加缓存后,会变成如下模型

image-20220621152140006

根据这个模型,不难写出如下的业务流程

image-20220621152310645

开始对原来的代码进行改写,给商户查询业务添加缓存。

业务层 IShopService接口定义queryById方法,然后去实现类里实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public Result queryById(Long id) {
// redis的key就用店铺id
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存(这里value结构用hash也行,视频演示的是string)
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 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){
return Result.fail("店铺不存在");
}
// 6.存在,写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 7.返回
return Result.ok(shop);
}
}

然后启动服务测试,去随便查一个商户信息,能够发现第一次查询时,时间是几百毫秒,第二次查询时就是十几毫秒了,时间很快。Redis里也有对应的key-value,此时观看idea后台信息,会发现还有一些查询sql语句,那些不是查商户信息的,是查其他一些信息的,比如优惠券啥的,暂时与本节内容无关

image-20220621160441029

image-20220621155808081

讲到这一节时,视频里还留了个作业,为首页店铺类型添加缓存,这个功能是在 ShopTypeController中的queryTypeList方法,这个功能更加符合添加缓存的需要,因为它长时间都不会变,大概率都是这几种商户类型。后文再讲缓存更新策略时还会提到该需求

image-20220621161058827

三、缓存更新策略

1. 理论知识

缓存更新是指数据库里的内容变了,但缓存里还是旧的数据,这显然会造成数据不一致问题,所以需要缓存更新。这部分也是面试常问的问题。

常见的缓存更新策略有如下三种

内存淘汰 超时剔除 主动更新
说明 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存。 给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。 编写业务逻辑,在修改数据库的同时,更新缓存。
一致性 一般
维护成本

针对不同的业务场景,可以选择不同的更新策略

  • 低一致性需求:使用内存淘汰机制,顶多再加个超时剔除。例如店铺类型的查询缓存,长时间都不会变
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情、店铺优惠券查询的缓存,经常会变

其中主动更新策略要开发人员自己开发,这其中又有三种主动更新的策略。这3 种缓存读写策略各有优劣,不存在最佳,需要我们根据具体的业务场景选择更适合的。

(1)Cache Aside Pattern(旁路缓存模式)

由缓存的调用者,在更新数据库的同时更新缓存。是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景,知识点比较多。

这其中,由于是同时操作数据库和缓存,有三个问题需要考虑,也能更好的理解该模式

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,但不一定有查询缓存的操作,所以无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案
  • 先删除缓存还是先更新数据库?

    有两种办法如下,这两种办法都不能保证数据一致性,但前者问题更大一些

    • 先删除缓存,再更新数据库。正常来讲也没问题,但容易出现下面这种情况:

      线程1先把cache中的A数据删除 -> 线程2从DB中读取数据并写入缓存(读取和写入都是旧值)->线程1再把DB中的A数据更新。这种情况发生概率较大,因为前面这几步的删除缓存都很快,更新数据库是较慢的操作,其他线程很容易“趁虚而入”,进行查询和写入缓存的操作

      image-20220621180009762

    • 先更新数据库,再删除缓存。也可能会出现数据不一致的情况:

      线程1从DB读数据A,正准备写入cache -> 此时线程2写更新数据 A 到数据库并删除cache中的A数据 -> 线程1将最开始的数据A写入cache。不过概率非常小,因为缓存的查询和写入速度非常快,数据库的更新速度比较慢,不太可能是在查询和写入缓存之间插入一个完整的更新数据库操作。如果真的发生了,我们前面提到过超时剔除的策略,用来保底

      image-20220621182617096

那么旁路缓存模式的缺陷有哪些?

缺陷1:首次请求数据一定不在 cache 的问题

解决办法:可以将热点数据可以提前放入cache 中。

缺陷2:写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率 。

解决办法(分场景):

  • 数据库和缓存数据强一致场景 :这种场景不接受数据不一致,那么更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。
  • 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间(超时剔除),这样的话就可以保证即使数据不一致的话影响也比较小。

(2)Read/Write Through Pattern(读写穿透)

服务端开发过程中把缓存视为主要数据存储,从中读取数据并将数据写入其中。缓存服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。

写(Write Through):

  • 先查 cache,cache 中不存在,直接更新 DB。
  • cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)。

读(Read Through):

  • 从 cache 中读取数据,读取到就直接返回 。
  • 读取不到的话,先从 DB 加载,写入到 cache 后返回响应。

读写穿透模式 实际只是在 旁路缓存模式 之上进行了封装。将缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但寻找一个现成的第三方框架或服务比较困难,现在不太常见。并且维护起来也要耗费成本。

这种缓存读写策略在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入DB的功能。

(3)Write Behind Pattern(异步缓存写入)

异步缓存写入模式读写穿透模式 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。但是,两个又有很大的不同:**读写穿透模式 是同步更新 cache 和 DB,而 异步缓存写入模式 则是只更新缓存,不直接更新 DB,而是改为使用其他线程异步批量的方式来更新 DB,来保证一致性。**

很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就就挂掉了。

这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。

通过以上内容,我们能够总结出缓存更新策略的最佳实践方案:

低一致性需求:使用Redis自带的内存淘汰机制

高一致性需求:主动更新,采取旁路缓存策略,先更新DB,再删除cache,并以超时剔除作为兜底方案

强一致性需求:主动更新,采取旁路缓存策略,先更新DB,再删除cache,并加锁避免线程安全问题,并以超时剔除作为兜底方案

后两者读写操作需要注意:

  • 读操作:缓存命中则直接返回,缓存未命中则查询数据库,并写入缓存,设定超时时间
  • 写操作:先更新数据库,然后再删除缓存,要确保数据库与缓存操作的原子性

2. 实现缓存更新

功能:给查询商铺信息的缓存添加超时剔除和主动更新的策略

修改ShopController中的业务逻辑,满足下面的需求:

  • 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间
  • 根据id修改店铺时,先修改数据库,再删除缓存

第一个需求改动很少,相比之前,只需要在代码里多设置一个超时时间,ShopServiceImpl.java

1
2
3
4
5
6
@Override
public Result queryById(Long id) {
// ...
// 6.存在,写入redis,并设置超时时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); // 超时时间:30分钟
}

第二个需求需要在 ShopServiceImpl.java 创建新方法 update

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
@Transactional // 声明式事务
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
// 1.更新数据库,mybatis
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}

下面进行测试,测试前把redis数据先清除。启动项目选择一个商户信息,按照逻辑会在redis里进行缓存,变化是添加了一个新的超时时间

image-20220621205548909

更新功能的测试,由于前端页面只有用户端,没有商家端,所以可以使用postman进行测试,如下所示,其实只是把之前的商户名“103茶餐厅”改为“102茶餐厅”

image-20220621211356624

更新策略是先更新DB,再删除cache。所以首先观察数据库里的变化,更新成功。然后观察redis缓存的信息,会发现key被删除了

再次刷新商户信息时,信息发生变化

最后再去查看redis缓存,缓存信息得到更新。整体流程符合我们的设想

image-20220621213248979

四、缓存穿透

1. 理论知识

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,大量无效请求打到数据库,给数据库带来巨大压力。

常见的解决方案有两种:

  • 缓存空对象:

    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗(可以设置超时时间);可能造成短期的不一致(设置较短超时时间,如果完全不能接受不一致,可以插入数据时更新key)
  • 布隆过滤:

    • 优点:内存占用较少,没有多余key
    • 缺点:实现复杂(Redis内部有自带的,以后再说);存在误判可能

image-20220621230640720

上面两种都是被动的,就是人家已经方案来穿透你了,然后你想办法去弥补。事实上我们也有其他主动的解决方案来预防:

  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验(不符合格式直接pass)
  • 加强用户权限校验(比如要先登录)
  • 做好热点参数的限流(限制访问频次)

2. 代码解决缓存穿透

我们采用缓存空对象的方案来解决缓存穿透,逻辑上做出对应变更,如下图所示

image-20220621231354482

更改查询方法:queryById

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Override
public Result queryById(Long id) {
// redis的key就用店铺id
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存(这里value结构用hash也行,视频演示的是string)
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if(StrUtil.isNotBlank((shopJson))){
// 3.存在,直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 判断命中的是否是空值。不等于空就只能是空值了,因为上面的isNotBlank()方法是认定
// 只有字符串有内容才返回true,比如“abc”。其他类似NULL(为空),""(空值)返回都是false
// 或者直接写 if ("".equals(shopJson))
if (shopJson != null){
// 返回一个错误信息
return Result.fail("店铺不存在");
}

// 4.不存在,提供id查询数据库
Shop shop = getById(id);
// 5.不存在,返回错误
if(shop == null){
// 解决缓存穿透:将空值写入redis,设置较短超时时间:2分钟
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return Result.fail("店铺不存在");
}
// 6.存在,写入redis,并设置超时时间:30分钟
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 7.返回
return Result.ok(shop);
}

测试一个不存在的店铺id,比如0:http://localhost:8080/api/shop/0,会发现报信息”店铺不存在“。

image-20220621233716006

执行过程是redis缓存未命中后,先去DB里查,发现不存在,就往redis里存空值

image-20220621233808104

后面再次查询(刷新网页)时,就不会再去DB里查了(可以通过清空后台信息查看)

五、缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同的Key的TTL添加随机值(很多热点数据是提前存进redis里的,设置随机的超时时间)
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

image-20220622091419380

设置随机超时时间很简单,这里就没有代码演示了

六、缓存击穿

1. 理论知识

缓存击穿问题也叫热点Key问题(不同于缓存雪崩是大部分key失效,缓存击穿只是部分key,但这部分key很重要,是热点内容),就是一个被高并发访问并且缓存重建业务较复杂(有时需要多张表查询并进行复杂运算)的key突然失效了,无数的请求访问(因为它是热点key,有大量请求会在缓存重建过程中不断访问)会在瞬间给数据库带来巨大的冲击。比较经典的例子是商品秒杀时,大量的用户在抢某个商品时,商品的key突然过期失效了,所有请求都到数据库上了。

逻辑分析:假设线程1在查询缓存未命中之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

image-20220709113923161

2. 解决方案

常见的解决方案有两种

2.1 互斥锁

重建缓存前获取锁,其他线程来了就等着,直到释放锁

优点:没有额外的内存消耗;保证一致性;实现简单

缺点:线程需要等待,性能受影响;可能有死锁风险

image-20220622093251295

这里的锁不能用 synchronized 或者 lock,它们是得到锁进行操作,没得到就等待,但我们希望是没得到锁休眠一段时间再查询缓存,所以需要用自定义的锁。Redis里 String 类型有命令 setnx 能够有这样类似的效果,只有 key 不存在时才能设置值,这样有多个线程同时 setnx 时,只有第一个能成功,其他都阻塞,直到把这个 key 删掉。

image-20220709115056289

因为这个方法性能不太好,只是了解即可,这里就不用这个方法了,知道原理逻辑即可

2.2 逻辑过期

不设置真正的过期时间(TTL),让key永久生效。但我们又怎么知道到底什么时候缓存会失效呢,这里可以把时效的时间(当前时间+有效期)写到value里,让他只是逻辑上的过期时间。但其实这个key是没有TTL的,再搭配上合适的内存淘汰策略,理论上来讲是可以永远能查到的,不会存在未命中的情况。当然了,实际上我们总要判断一下key再逻辑上是否过期了,去value里取值就可以了,如果逻辑时间已过期,说明要更新一下数据。

我们把过期时间设置在 redis 的 value 中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。

注意:虽然本方案也用到了互斥锁,但并不是方法一中让其他线程全部阻塞,而是开启一个异步线程。其他线程获取锁失败就失败,返回旧值即可,不影响运行。这里使用的互斥锁也是redis的setnx指令

这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,其他线程查询缓存返回的都是旧数据。等缓存重建完,再来的线程,就是返回新值了

image-20220622094823087

优点:线程无需等待,性能较好。相比于互斥锁方案,此方案的其他线程不会阻塞住,只会在异步线程重建缓存之前返回旧缓存而已

缺点:不保证一致性、有额外内存消耗、实现复杂

思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据。如果过期,则判断获取锁(同样使用setnx)是否成功,成功则开启独立线程去重构缓存,自己本线程直接返回旧值,如果获取锁没成功,则表示现在锁被别人获取了,还是直接返回旧值。独立线程重构缓存完成后释放互斥锁。

image-20220709115209330

如何封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你新建一个实体类

步骤一、

新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。放到工具包里

1
2
3
4
5
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}

步骤二、

ShopServiceImpl 新增此方法,利用单元测试进行缓存预热(即提前把缓存信息写到redis里)。

注意对于缓存信息,有两种处理方式:

  • 先查询,没查询到就添加缓存
  • 提前添加好缓存

我们这里测试都是用后一种,即利用单元测试先添加缓存,再进行测试,如果没有先添加缓存就查询,是查不到的,没写这块的代码逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
public class SaveShop2RedisTest {
@Resource
private ShopServiceImpl shopService;
@Test
public void testSaveShop(){
shopService.saveShop2Redis(1L, 10L);
}
}

测试完之后,查看redis,能够发现其ttl为-1,并有一个逻辑过期的时间

Snipaste_2022-07-09_19-17-07

步骤三:正式代码,ShopServiceImpl,严格按照上面的逻辑图编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, 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){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
// 重建缓存,这里实际应该设置为30min,方便测试设置成了10s
this.saveShop2Redis(id,10L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.未获取锁成功,或者获取锁之后,都直接返回过期的商铺信息。这里只有独立线程会更新缓存
return shop;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
stringRedisTemplate.delete(key);
}

这样改写后,对应的 queryById 直接调用逻辑过期方法即可

1
2
3
4
5
6
7
8
@Override
public Result queryById(Long id) {
Shop shop = queryWithLogicalExpire(id);
if (shop == null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}

测试的时候我们应该测试如下内容:

  • 商户信息变更之后,redis缓存能否更新成功
  • 缓存一致性问题,前几个先来的线程查询到的仍然是旧值,缓存更新后,访问的才是新的值
  • 多线程访问同一个店铺的场景下,是否只进行一次缓存重建。

这里使用 JMeter 模拟高并发场景,设置100个线程,同时访问商铺信息,观察最后的结果。使用 JMeter 测试时有几个注意事项,务必注意!

  • 防止 jmeter HTTP请求查看结果树响应数据乱码,去配置文件里进行修改,参考:Jmeter 运行结果的查看结果树中的响应数据出现中文乱码
  • jmx文件可以用黑马提供的,注意把其中的HTTP请求里的登录状态头改为自己在redis里面的登陆凭证,为了方便调试,也建议在此处把登录凭证的有效期改为永久有效
  • 每次新测试之前,都要点击工具栏里的清除数据,否则会对测试有干扰,这点很重要!!!容易忽略

之前商户名是102茶餐厅,先在DB里将其改成101茶餐厅,用于观察缓存更新的变化,并且将IDEA后台消息清空,准备观察新的日志信息。随后我们在JMeter 用100个线程发起请求:localhost/shop/1,查看商户信息

测试结果,首先去redis里查看,商户信息已经发生变更,即缓存更新是成功的

image-20220709193528332

随后,在 HTTP请求查看结果树的响应数据,能够发现前几个请求的查询到的缓存信息里,还是102餐厅

image-20220709193723773

到后面的请求,就全是101餐厅了,足以说明缓存一致性问题如我们之前设想的一样,前几个先来的线程查询到的仍然是旧值,缓存更新后,访问的才是新的值

image-20220709193837565

最后就是多线程访问同一个店铺的场景下,是否只进行一次缓存重建,这里查看IDEA后台,能够发现,只去DB里查询了一次商户信息,即那个独立的线程去DB里查询到最新的商户信息,只进行了一次缓存重建

image-20220709194110921

总结:两种方案都是解决缓存重建这个过程中产生的并发问题,互斥锁保证了一致性但牺牲了可用性,而逻辑过期不能保证一致性,但可用性高。这也是分布式系统常见的可用性和一致性之间的抉择问题,根据需求选择。

七、封装缓存工具

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

  • 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
  • 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓

存击穿问题

  • 方法3:根据指定类型的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
  • 方法4:根据指定类型的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题

方法1和3是对应普通缓存,并解决缓存穿透的;方法2和4是对应解决缓存击穿的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
@Slf4j
@Component
public class CacheClient {
@Resource
private StringRedisTemplate stringRedisTemplate;

// redis缓存,设置TTL过期时间
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}

// redis缓存,设置逻辑过期
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));
}


// 根据指定类型的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
// 之前返回类型固定为店铺,现在使用泛型R,参数里加上Class<R> type,表示手动传入对象类型,进行泛型推断
// keyPrefix是redis里的前缀,前缀+id,才是redis里的key。ID也不一定为Long型,所以也使用泛型
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 json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回。返回的类型不再是店铺,而是传进来的type
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值。这里很容易忘记,查到的缓存只有三种情况:查到(正常情况);空值(“”);为null
// 所以不等于空就只能是空值了,上面的isNotBlank()方法是认定只有字符串有内容才返回true,比如“abc”。其他类似NULL(为空),""(空值)返回都是false
// 或者直接写 if ("".equals(shopJson))
if (json != null) {
// 返回一个错误信息
return null;
}

// 4.缓存不存在,根据id查询数据库。这里需要格外关注,使用到了函数式编程,不同对象的数据库查询方法自然不同,这里既然不知道是什么对象类型,只能把问题抛给调用者
// 所以我们在参数里加上函数,有参(参数类型ID)有返回值(返回值类型R),起名dbFallback,apply方法就是调用这个函数,并把id作为参数传进去
R r = dbFallback.apply(id);
// 5.DB里也不存在,返回错误
if (r == null) {
// 将空值写入redis,避免缓存穿透。TTL为2min
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.DB里存在,写入redis,调用了前面的普通设置缓存的方法,还需要参数时间和单位
this.set(key, r, time, unit);
return r;
}


private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

// 根据指定类型的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
// 细节同上,不赘述
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 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.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R r1 = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, r1, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}

ShopServiceImpl中更新方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Resource
private CacheClient cacheClient;

@Override
public Result queryById(Long id) {
// 调用类内的方法 queryWithPassThrough 解决缓存穿透
// Shop shop = queryWithPassThrough(id);

// 调用缓存工具类内的方法 queryWithPassThrough 解决缓存穿透
// 参数:店铺前缀,店铺id,店铺类型对象,根据id查询DB里店铺的方法,时间,时间单位
// 这里的方法引用this::getById是lamda表达式的简写,实际为:id -> getById(id),因为这里是在ShopServiceImpl里,继承了ShopMapper,所以知道getById是查询店铺类型
// 这里的逻辑过期时间设置为30min
// Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

// 调用类内的方法 queryWithLogicalExpire 解决缓存击穿
// Shop shop = queryWithLogicalExpire(id);

// 调用缓存工具类内的方法 queryWithLogicalExpire 解决缓存击穿
// 缓存逻辑过期时间设置较短,方便测试
Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

if (shop == null){
return Result.fail("店铺不存在");
}
return Result.ok(shop);
}

封装完了可以按照之前的逻辑测试一下,略

注意对于缓存信息,有两种处理方式:

  • 先查询,没查询到就添加缓存
  • 提前添加好缓存

我们这里测试都是用后一种,即先添加缓存,再进行测试,如果没有先添加缓存就查询,是查不到的,没写这块的代码逻辑

方便以后测试,我们写个for循环把所有缓存信息都添加进去,逻辑过期时间随便设置下,按照现在quaryById的逻辑都会重建缓存的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@SpringBootTest
public class SaveShop2RedisTest {
@Resource
private ShopServiceImpl shopService;

@Resource
private CacheClient cacheClient;

@Test
public void testSaveShop_1(){
shopService.saveShop2Redis(1L, 10L); // 为了方便测试只写了10s
}

@Test
public void testSaveShop_2(){
// 从DB里拿2号店铺信息
Shop shop = shopService.getById(2L);
// 这里是测试缓存工具类,为了方便测试只设置逻辑过期为10s
cacheClient.setWithLogicalExpire(CACHE_SHOP_KEY + 2L, shop, 10L, TimeUnit.SECONDS);
}

@Test
public void testSaveShop_all(){
for (int i = 1; i < 15; i++) {
Shop shop = shopService.getById((long) i);
cacheClient.setWithLogicalExpire(CACHE_SHOP_KEY + (long) i, shop, 10L, TimeUnit.SECONDS);
}
}
}

Redis:03.实战应用二:商户缓存查询
http://jswanyu.github.io/2022/06/13/Redis/Redis:03.实战应用二:商户缓存查询/
作者
万宇
发布于
2022年6月13日
许可协议