Redis:03.实战应用二:商户缓存查询
参考资料:黑马redis课程、3种常用的缓存读写策略 | JavaGuide
一、什么是缓存
缓存就是数据交换的缓冲区(称作Cache),是存贮数据的临时地方,一般读写性能较高。
缓存的作用:
- 降低后端负载(不用去访问数据库)
- 提高读写效率,降低响应时间
缓存的成本:
数据一致性成本(数据更新导致缓存里还是旧数据)
代码维护成本
运维成本
二、添加Redis缓存
本小节应用的功能背景是查询商户信息,这是一个明显的根据商户id去数据库里查信息的业务。如图所示
而改为使用Redis添加缓存后,会变成如下模型
根据这个模型,不难写出如下的业务流程
开始对原来的代码进行改写,给商户查询业务添加缓存。
业务层 IShopService接口定义queryById
方法,然后去实现类里实现
1 |
|
然后启动服务测试,去随便查一个商户信息,能够发现第一次查询时,时间是几百毫秒,第二次查询时就是十几毫秒了,时间很快。Redis里也有对应的key-value,此时观看idea后台信息,会发现还有一些查询sql语句,那些不是查商户信息的,是查其他一些信息的,比如优惠券啥的,暂时与本节内容无关
讲到这一节时,视频里还留了个作业,为首页店铺类型添加缓存,这个功能是在 ShopTypeController中的queryTypeList方法,这个功能更加符合添加缓存的需要,因为它长时间都不会变,大概率都是这几种商户类型。后文再讲缓存更新策略时还会提到该需求
三、缓存更新策略
1. 理论知识
缓存更新是指数据库里的内容变了,但缓存里还是旧的数据,这显然会造成数据不一致问题,所以需要缓存更新。这部分也是面试常问的问题。
常见的缓存更新策略有如下三种
内存淘汰 | 超时剔除 | 主动更新 | |
---|---|---|---|
说明 | 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存。 | 给缓存数据添加TTL时间,到期后自动删除缓存。下次查询时更新缓存。 | 编写业务逻辑,在修改数据库的同时,更新缓存。 |
一致性 | 差 | 一般 | 好 |
维护成本 | 无 | 低 | 高 |
针对不同的业务场景,可以选择不同的更新策略
- 低一致性需求:使用内存淘汰机制,顶多再加个超时剔除。例如店铺类型的查询缓存,长时间都不会变
- 高一致性需求:主动更新,并以超时剔除作为兜底方案。例如店铺详情、店铺优惠券查询的缓存,经常会变
其中主动更新策略要开发人员自己开发,这其中又有三种主动更新的策略。这3 种缓存读写策略各有优劣,不存在最佳,需要我们根据具体的业务场景选择更适合的。
(1)Cache Aside Pattern(旁路缓存模式)
由缓存的调用者,在更新数据库的同时更新缓存。是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景,知识点比较多。
这其中,由于是同时操作数据库和缓存,有三个问题需要考虑,也能更好的理解该模式
删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,但不一定有查询缓存的操作,所以无效写操作较多
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
如何保证缓存与数据库的操作的同时成功或失败?
- 单体系统,将缓存与数据库操作放在一个事务
- 分布式系统,利用TCC等分布式事务方案
先删除缓存还是先更新数据库?
有两种办法如下,这两种办法都不能保证数据一致性,但前者问题更大一些
先删除缓存,再更新数据库。正常来讲也没问题,但容易出现下面这种情况:
线程1先把cache中的A数据删除 -> 线程2从DB中读取数据并写入缓存(读取和写入都是旧值)->线程1再把DB中的A数据更新。这种情况发生概率较大,因为前面这几步的删除缓存都很快,更新数据库是较慢的操作,其他线程很容易“趁虚而入”,进行查询和写入缓存的操作
先更新数据库,再删除缓存。也可能会出现数据不一致的情况:
线程1从DB读数据A,正准备写入cache -> 此时线程2写更新数据 A 到数据库并删除cache中的A数据 -> 线程1将最开始的数据A写入cache。不过概率非常小,因为缓存的查询和写入速度非常快,数据库的更新速度比较慢,不太可能是在查询和写入缓存之间插入一个完整的更新数据库操作。如果真的发生了,我们前面提到过超时剔除的策略,用来保底
那么旁路缓存模式的缺陷有哪些?
缺陷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 |
|
第二个需求需要在 ShopServiceImpl.java 创建新方法 update
1 |
|
下面进行测试,测试前把redis数据先清除。启动项目选择一个商户信息,按照逻辑会在redis里进行缓存,变化是添加了一个新的超时时间
更新功能的测试,由于前端页面只有用户端,没有商家端,所以可以使用postman进行测试,如下所示,其实只是把之前的商户名“103茶餐厅”改为“102茶餐厅”
更新策略是先更新DB,再删除cache。所以首先观察数据库里的变化,更新成功。然后观察redis缓存的信息,会发现key被删除了
再次刷新商户信息时,信息发生变化
最后再去查看redis缓存,缓存信息得到更新。整体流程符合我们的设想
四、缓存穿透
1. 理论知识
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,大量无效请求打到数据库,给数据库带来巨大压力。
常见的解决方案有两种:
缓存空对象:
- 优点:实现简单,维护方便
- 缺点:额外的内存消耗(可以设置超时时间);可能造成短期的不一致(设置较短超时时间,如果完全不能接受不一致,可以插入数据时更新key)
布隆过滤:
- 优点:内存占用较少,没有多余key
- 缺点:实现复杂(Redis内部有自带的,以后再说);存在误判可能
上面两种都是被动的,就是人家已经方案来穿透你了,然后你想办法去弥补。事实上我们也有其他主动的解决方案来预防:
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验(不符合格式直接pass)
- 加强用户权限校验(比如要先登录)
- 做好热点参数的限流(限制访问频次)
2. 代码解决缓存穿透
我们采用缓存空对象的方案来解决缓存穿透,逻辑上做出对应变更,如下图所示
更改查询方法:queryById
1 |
|
测试一个不存在的店铺id,比如0:http://localhost:8080/api/shop/0,会发现报信息”店铺不存在“。
执行过程是redis缓存未命中后,先去DB里查,发现不存在,就往redis里存空值
后面再次查询(刷新网页)时,就不会再去DB里查了(可以通过清空后台信息查看)
五、缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值(很多热点数据是提前存进redis里的,设置随机的超时时间)
- 利用Redis集群提高服务的可用性
- 给缓存业务添加降级限流策略
- 给业务添加多级缓存
设置随机超时时间很简单,这里就没有代码演示了
六、缓存击穿
1. 理论知识
缓存击穿问题也叫热点Key问题(不同于缓存雪崩是大部分key失效,缓存击穿只是部分key,但这部分key很重要,是热点内容),就是一个被高并发访问并且缓存重建业务较复杂(有时需要多张表查询并进行复杂运算)的key突然失效了,无数的请求访问(因为它是热点key,有大量请求会在缓存重建过程中不断访问)会在瞬间给数据库带来巨大的冲击。比较经典的例子是商品秒杀时,大量的用户在抢某个商品时,商品的key突然过期失效了,所有请求都到数据库上了。
逻辑分析:假设线程1在查询缓存未命中之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大
2. 解决方案
常见的解决方案有两种
2.1 互斥锁
重建缓存前获取锁,其他线程来了就等着,直到释放锁
优点:没有额外的内存消耗;保证一致性;实现简单
缺点:线程需要等待,性能受影响;可能有死锁风险
这里的锁不能用 synchronized 或者 lock,它们是得到锁进行操作,没得到就等待,但我们希望是没得到锁休眠一段时间再查询缓存,所以需要用自定义的锁。Redis里 String 类型有命令 setnx 能够有这样类似的效果,只有 key 不存在时才能设置值,这样有多个线程同时 setnx 时,只有第一个能成功,其他都阻塞,直到把这个 key 删掉。
因为这个方法性能不太好,只是了解即可,这里就不用这个方法了,知道原理逻辑即可
2.2 逻辑过期
不设置真正的过期时间(TTL),让key永久生效。但我们又怎么知道到底什么时候缓存会失效呢,这里可以把时效的时间(当前时间+有效期)写到value里,让他只是逻辑上的过期时间。但其实这个key是没有TTL的,再搭配上合适的内存淘汰策略,理论上来讲是可以永远能查到的,不会存在未命中的情况。当然了,实际上我们总要判断一下key再逻辑上是否过期了,去value里取值就可以了,如果逻辑时间已过期,说明要更新一下数据。
我们把过期时间设置在 redis 的 value 中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
注意:虽然本方案也用到了互斥锁,但并不是方法一中让其他线程全部阻塞,而是开启一个异步线程。其他线程获取锁失败就失败,返回旧值即可,不影响运行。这里使用的互斥锁也是redis的setnx指令
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,其他线程查询缓存返回的都是旧数据。等缓存重建完,再来的线程,就是返回新值了
优点:线程无需等待,性能较好。相比于互斥锁方案,此方案的其他线程不会阻塞住,只会在异步线程重建缓存之前返回旧缓存而已
缺点:不保证一致性、有额外内存消耗、实现复杂
思路分析:当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据。如果过期,则判断获取锁(同样使用setnx)是否成功,成功则开启独立线程去重构缓存,自己本线程直接返回旧值,如果获取锁没成功,则表示现在锁被别人获取了,还是直接返回旧值。独立线程重构缓存完成后释放互斥锁。
如何封装数据:因为现在redis中存储的数据的value需要带上过期时间,此时要么你去修改原来的实体类,要么你新建一个实体类
步骤一、
新建一个实体类,我们采用第二个方案,这个方案,对原来代码没有侵入性。放到工具包里
1 |
|
步骤二、
在ShopServiceImpl 新增此方法,利用单元测试进行缓存预热(即提前把缓存信息写到redis里)。
注意对于缓存信息,有两种处理方式:
- 先查询,没查询到就添加缓存
- 提前添加好缓存
我们这里测试都是用后一种,即利用单元测试先添加缓存,再进行测试,如果没有先添加缓存就查询,是查不到的,没写这块的代码逻辑
1 |
|
测试完之后,查看redis,能够发现其ttl为-1,并有一个逻辑过期的时间
步骤三:正式代码,ShopServiceImpl,严格按照上面的逻辑图编写
1 |
|
这样改写后,对应的 queryById 直接调用逻辑过期方法即可
1 |
|
测试的时候我们应该测试如下内容:
- 商户信息变更之后,redis缓存能否更新成功
- 缓存一致性问题,前几个先来的线程查询到的仍然是旧值,缓存更新后,访问的才是新的值
- 多线程访问同一个店铺的场景下,是否只进行一次缓存重建。
这里使用 JMeter 模拟高并发场景,设置100个线程,同时访问商铺信息,观察最后的结果。使用 JMeter 测试时有几个注意事项,务必注意!
- 防止 jmeter HTTP请求查看结果树响应数据乱码,去配置文件里进行修改,参考:Jmeter 运行结果的查看结果树中的响应数据出现中文乱码
- jmx文件可以用黑马提供的,注意把其中的HTTP请求里的登录状态头改为自己在redis里面的登陆凭证,为了方便调试,也建议在此处把登录凭证的有效期改为永久有效
- 每次新测试之前,都要点击工具栏里的清除数据,否则会对测试有干扰,这点很重要!!!容易忽略
之前商户名是102茶餐厅,先在DB里将其改成101茶餐厅,用于观察缓存更新的变化,并且将IDEA后台消息清空,准备观察新的日志信息。随后我们在JMeter 用100个线程发起请求:localhost/shop/1,查看商户信息
测试结果,首先去redis里查看,商户信息已经发生变更,即缓存更新是成功的
随后,在 HTTP请求查看结果树的响应数据,能够发现前几个请求的查询到的缓存信息里,还是102餐厅
到后面的请求,就全是101餐厅了,足以说明缓存一致性问题如我们之前设想的一样,前几个先来的线程查询到的仍然是旧值,缓存更新后,访问的才是新的值
最后就是多线程访问同一个店铺的场景下,是否只进行一次缓存重建,这里查看IDEA后台,能够发现,只去DB里查询了一次商户信息,即那个独立的线程去DB里查询到最新的商户信息,只进行了一次缓存重建
总结:两种方案都是解决缓存重建这个过程中产生的并发问题,互斥锁保证了一致性但牺牲了可用性,而逻辑过期不能保证一致性,但可用性高。这也是分布式系统常见的可用性和一致性之间的抉择问题,根据需求选择。
七、封装缓存工具
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
- 方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
- 方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓
存击穿问题
- 方法3:根据指定类型的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
- 方法4:根据指定类型的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
方法1和3是对应普通缓存,并解决缓存穿透的;方法2和4是对应解决缓存击穿的
1 |
|
ShopServiceImpl中更新方法
1 |
|
封装完了可以按照之前的逻辑测试一下,略
注意对于缓存信息,有两种处理方式:
- 先查询,没查询到就添加缓存
- 提前添加好缓存
我们这里测试都是用后一种,即先添加缓存,再进行测试,如果没有先添加缓存就查询,是查不到的,没写这块的代码逻辑
方便以后测试,我们写个for循环把所有缓存信息都添加进去,逻辑过期时间随便设置下,按照现在quaryById的逻辑都会重建缓存的
1 |
|