Java项目冷更新数据双缓存方案
- 应用场景
- 双缓存方案前言
- 需要考虑的点
- 1、缓存数据的大小
- 2、本地缓存的缓存时机
- 3、并发情况下,首次缓存数据的性能浪费问题
- 4、心跳检测redis是否可用
- 方案思路
- 流程
- 代码
本文章主讲思想,不限于使用什么缓存 但为了写作方便,故中间件缓存采用redis,本地缓存采用guava cache
应用场景
1、接口对缓存的需求高,不允许没有缓存的情况。 2、本地缓存临时为redis分担压力,缓存热点数据到本地 3、缓存数据一般涉及大量运算,耗时较大,而且不会频繁的更新,多用于计算后进行展示
本人以下方案着重场景1: 本人的项目遇到的问题的是,某个数据展示的接口,用户会频繁访问,因此做了redis缓存。但是redis偶尔会出现连接拿不到等不可用的情况。在这种情况下,就会直接访问数据库再进行复杂的计算,导致接口延迟过高,用户体验极差。
我认为,redis的高可用是必不可少的。但是不可避免的是,存在redis不可用的情况。而这种时候,如果是高并发的应用,轻则接口延迟过高、重则直接压垮数据库。
双缓存方案前言
考虑到中间件缓存存在不可用的可能性,因此解决方案有: (1)结果存入数据库。数据库不可用的情况基本不可能,如果出现了,首先业务肯定做不下去,一般不会造成太大影响,顶多就是用户这段时间无法享受功能。因此可以把缓存的数据结果,存到数据库中。
弊端:中间件缓存不可用时,虽然避免了耗时较高的数据库操作和计算,但是之后所有请求走数据库。我们通常做缓存的目的,就是把压力从数据库中分担出来。
(2)采用本地缓存作为备用缓存。本地缓存只要内存充足,是保证可用的。
需要考虑的点
1、缓存数据的大小
(1)本地内存资源是否足以支撑这部分数据缓存 (2)本地内存资源昂贵,缓存数据是否值得占用内存
2、本地缓存的缓存时机
主要是考虑,什么时候启用本地缓存最合适。如:双缓存共存 还是 中间件缓存不可用时,再启用本地缓存 (1)考虑中间件缓存不可用的频率 (2)如果不走缓存,接口的耗时有多少 (3)中间件缓存失效那一刻,重新获取数据的耗时是否能接受 (4)双缓存共存,需要考虑本地缓存一直占用内存,但是又基本很少用上所带来的内存浪费问题
3、并发情况下,首次缓存数据的性能浪费问题
我们传统的方案一般是 (1)有缓存,直接读缓存 (2)无缓存,走数据库,然后写入缓存。
重点出在第2步中,如果在无缓存的情况下,并发量为N(N>1),假设获取数据代码耗时10s。那么最终这N个并发请求,每个请求的耗时都是10s。而对于后台来说,一共就是10*N的开销,也会有N次的更新缓存操作。而我们知道,实际上更新缓存只需要1次即可,其余N-1次都是没有意义的。并且其余的N-1次的数据计算也是没有意义的。在那一时刻,假设运算复杂,就会给CPU带来很大的开销。如果此时还有其他不相关的业务操作,就会受到影响。
因此此处着重要考虑的是,在并发情况下,是否只允许一个线程进行数据的计算和更新缓存操作。其余线程等待该线程的处理结果即可,而不需要每个线程都做重复的事情。
上述再次举例:(此处有锁机制的情况) 假设无缓存情况下,N个请求并发,数据计算代码耗时10s。 那么:
- 只有1个线程拥有数据计算和更新缓存的权利,其余N-1个线程会被阻塞,直到缓存更新完毕。
- 那么对于每个请求来说,还是10s。(N-1个线程有可能会因为等待,而大于10s)
- 但是对于后台来说,就是单纯的10s,而不是10*N。因为只有1个线程进行了10s的计算,其余N-1个线程对于他们来说,是不参与复杂的计算的。他们直接读取结果即可。(而且这里还得结合锁的特性,比如synchronnized,当线程数大于2时就会升级为重量级锁,它在阻塞的过程中,是不占用CPU的)
- 对于更新缓存来说,只有1次,而非N次
- 假设,在这10s的期间。比如过了5s,又有一个新的请求进来。那么它的接口耗时会是5s。而如果走传统方案,它需要重新计算,那么它的接口耗时会是10s。因此在数据计算的这段时间,来多少个请求,就有多少个请求的性能开销是完全浪费的。
上述中仅仅讨论耗时问题,而实际上我们还要想到的是,线程上下文切换带来的性能开销,在这段时间内其他功能的体验效果会不会因为这个接口大打折扣。
4、心跳检测redis是否可用
当redis不可用时,本地缓存会代替redis工作,达到缓存可用的效果。但是我们肯定需要注意的是,中间件缓存何时恢复。 (1)我们做一个功能,手动触发检测redis是否可用,如果可用了,就从本地缓存切回中间件缓存 优点:开发简单 缺点:人为干预,系统不可自己恢复
(2)我们用一个线程,定期去检测redis是否可用。可用的话,就切回redis。
你可能会问,我们的代码难道不是 1、redis是否可用,可用的话直接读redis缓存。结束 2、redis不可用,走本地缓存。结束 我们直接try-catch,redis。redis不可用的话肯定会抛异常,抛异常了就走本地缓存就行了呗。
我一开始也是这么想的,但是等我实践后,我发现了一个问题。redis的参数中有一个timeout,它的作用是:redis超时。通常这里不会设置0,因为容易导致项目死掉。一般设置一个值,超过这个值redis就会报错。 1、可能redis不可用了 2、可能redis连接被拿完了,导致你拿不到连接,所以超时了 3、可能网络原因,超时了
OK,有了这个值的存在。就会导致你,在redis不可用时,走本地缓存之前,一定会经历这timeout秒。假设你的timeout设为2s。那么当redis失效时,你即使有本地缓存。 接口的延迟也会一直是2s。因此我们要把这2s给优化掉,就是设置一个标记,来标记redis是否可用,不可用的话直接走本地缓存。可用的话则走redis。这样 才能真正意义上的,走本地缓存。
因此,上述提到了标记。我们需要去维护这个标记,以达到系统能感知redis何时恢复可用,系统何时切换回redis缓存。
(1)此时,最简单的方案是,开启一个定时任务,去判断redis是否可用,去维护这个标记 但是在本篇文章中,一再强调的是,redis不可用的情况很少发生,本地缓存基本派不上用场。但是由于接口的特殊性 ,又不能脱离缓存独立存在。
上述方案的缺点:大部分情况下,redis是可用的。因此你的定时任务,在大部分情况下,都是没有意义的。
(2)因此我们更需要去考虑好性能,让心跳检测这个行为更加智能化,在有需要的时候启动,在不需要的时候不工作
方案思路
流程
1、从标记判断redis是否可用。可用的话走2,不可用走3 2、redis缓存是否存在, 存在的话直接读数据,结束; 不存在的话,从数据库获取数据进行计算,然后更新redis缓存,返回结果,结束;
3、设置标记,标记redis不可用。启动心跳检测任务,定期去判断redis是否可用,直到redis可用时,将标记恢复。 4、本地缓存是否存在, 存在的话直接读数据,结束; 不存在的话,从数据库获取数据进行计算,然后更新本地缓存,返回结果,结束;
可优化的地方有很多,结合上述"需要考虑的点"来优化步骤。
代码
暂不贴