简单总结下文内容:
- expireAfterAccess表示多久没有访问(读或写)就失效
- expireAfterWrite表示多久没有更新就失效
expireAfterAccess和expireAfterWrite注意点如下:
使用场景:
业务非常注重缓存的时效性
缺点:
性能较差,缓存过期后,所有线程都要等待和锁争用,尽管guava可以保证只有一个线程load缓存(很好地防止缓存失效的瞬间大量请求穿透到后端引起雪崩效应),但是其他线程也要等待和锁争用 - refreshAfterWrite表示继上次更新后多久刷新
优点与缺点:
优点是refresh性能要比load好很多,guava保证只有一个线程refresh缓存,缺点是其他缓存返回旧值,这个旧值可能是很多之前的旧值(原因refresh动作不是自动轮询执行的,而是在get请求的时候才会检查是否需要refresh、如需要在refresh,其他线程直接返回旧值可能是很久之前的,有效减少等待和锁的争用,性能较好) - 另外补充一下,load时value不能是null,否则get时会抛出异常,如果value值可能是null,则要用Optional包一下,避免通过try-catch处理null
通过上面可以看到expireAfterWrite和refreshAfterWrite都有优缺点,只配置一个属性不是性能差、就是获取到很久之前的旧值引发业务问题,因此2个属性可以搭配使用,例如expireAfterWrite=2s、refreshAfterWrite=1s,比如说控制缓存每1s进行refresh,如果超过2s没有访问,那么则让缓存失效,下次访问时不会得到旧值,而是必须得待新值加载
一、思考和猜想
Guava Cache是本地缓存的不二之选,用起来真不错呵,可是你真的知道怎么使用才能满足需求?今天我们深入探讨一下Expire和Refresh。(废话少说)
首先看一下三种基于时间的清理或刷新缓存数据的方式:
-
expireAfterAccess: 当缓存项在指定的时间段内
没有被读或写
就会被回收,即多久没有访问就过期
。 -
expireAfterWrite:当缓存项在指定的时间段内
没有更新
就会被回收,即多久没有写入就过期
。 -
refreshAfterWrite:当缓存项上一次更新操作之后的
多久会被刷新
。
考虑到时效性
,我们可以使用expireAfterWrite
,使每次更新之后的指定时间让缓存失效,然后重新加载缓存。guava cache会严格限制只有1个加载操作
,这样会很好地防止缓存失效的瞬间大量请求穿透到后端引起雪崩效应
。
然而,通过分析源码,guava cache在限制只有1个加载操作时进行加锁
,其他请求必须阻塞等待这个加载操作完成
;而且,在加载完成之后,其他请求的线程会逐一获得锁,去判断是否已被加载完成
,每个线程必须轮流地走一个“”获得锁,获得值,释放锁“”的过程,这样性能会有一些损耗
。这里由于我们计划本地缓存1秒
,所以频繁的过期和加载
,锁等待等过程会让性能有较大的损耗
。
因此我们考虑使用refreshAfterWrite。refreshAfterWrite的特点是,在refresh的过程中,严格限制只有1个重新加载操作
,而其他查询先返回旧值
,这样有效地可以减少等待和锁争用
,所以refreshAfterWrite会比expireAfterWrite性能好
。但是它也有一个缺点,因为到达指定时间后,它不能严格保证所有的查询都获取到新值。了解过guava cache的定时失效(或刷新)原来的同学都知道,guava cache并没使用额外的线程去做定时清理和加载的功能
,而是依赖于查询请求
。在查询的时候去比对上次更新的时间,如超过指定时间则进行加载或刷新
。所以,如果使用refreshAfterWrite,在吞吐量很低的情况下,如很长一段时间内没有查询之后,发生的查询有可能会得到一个旧值
(这个旧值可能来自于很长时间之前
),这将会引发问题。
可以看出refreshAfterWrite和expireAfterWrite两种方式各有优缺点,各有使用场景。那么能否在refreshAfterWrite和expireAfterWrite找到一个折中?比如说控制缓存每1s进行refresh,如果超过2s没有访问,那么则让缓存失效,下次访问时不会得到旧值,而是必须得待新值加载
。由于guava官方文档没有给出一个详细的解释,查阅一些网上资料也没有得到答案,因此只能对源码进行分析
,寻找答案。经过分析,当同时使用两者的时候,可以达到预想的效果,这真是一个好消息呐!
二、源码分析
通过追踪LoadingCache的get方法源码,发现最终会调用以下核心方法,下面贴出源码:
com.google.common.cache.LocalCache.Segment.get方法:
这个缓冲的get方法,编号1是判断是否有存活值,即根据expireAfterAccess和expireAfterWrite进行判断是否过期
,如果过期,则value为null,执行编号3,。编号2指不过期的情况下,根据refreshAfterWrite判断是否需要refresh
。而编号3是需要进行加载(load而非reload)
,原因是没有存活值,可能因为过期,可能根本就没有过该值。
从段代码来看,在get的时候,是先判断过期,再判断refresh
,所以我们可以通过设置refreshAfterWrite为1s,将expireAfterWrite 设为2s
,当访问频繁的时候,会在每秒都进行refresh,而当超过2s没有访问,下一次访问必须load新值。
我们继续顺藤摸瓜,顺带看看load和refresh分别都做了什么事情
,验证以下上面说的理论。
下面看看 com.google.common.cache.LocalCache.Segment.lockedGetOrLoad方法:
load方法有点长,限于篇幅,没有贴出全部代码,关键步骤有7步。
-
获得锁
-
获得key对应的valueReference
-
判断是否该缓存值正在loading,如果loading,则不再进行load操作(通过设置createNewEntry为false),后续会等待获取新值。
-
如果不是在loading,判断是否已经有新值了(被其他请求load完了),如果是则返回新值
-
准备loading,设置为loadingValueReference。loadingValueReference 会使其他请求在步骤3的时候会发现正在loding。
-
释放锁。
-
如果真的需要load,则进行load操作。
通过分析发现,只会有1个load操作,其他get会先阻塞住
,验证了之前的理论。
下面看看com.google.common.cache.LocalCache.Segment.scheduleRefresh方法:
refresh步骤如下:
-
判断是否需要refresh,且当前非loading状态
,如果是则进行refresh操作,并返回新值。 -
步骤2是我加上去的,为后面的测试做准备。
如果需要refresh,但是有其他线程正在对该值进行refreshing,则打印,最终会返回旧值
。
继续深入步骤1中调用的refresh方法:
-
插入loadingValueReference,表示该值正在loading,其他请求根据此判断是需要进行refresh还是返回旧值。insertLoadingValueReference里有加锁操作,
确保只有1个refresh穿透到后端
。限于篇幅,这里不再展开。但是,这里加锁的范围比load时候加锁的范围要小
,在expire->load的过程,所有的get一旦知道expire,则需要获得锁,直到得到新值为止,阻塞的影响范围会是从expire到load到新值为止
;而refresh->reload的过程,一旦get发现需要refresh,会先判断是否有loading,再去获得锁,然后释放锁之后再去reload,阻塞的范围只是insertLoadingValueReference的一个小对象的new和set操作,几乎可以忽略不计
,所以这是之前说refresh比expire高效的原因之一。 -
进行refresh操作,这里不对loadAsync进行展开,它调用了CacheLoader的reload方法,reload方法支持重载去实现异步的加载,而当前线程返回旧值,这样性能会更好,其默认是同步地调用了CacheLoader的load方法实现。
到这里,我们知道了refresh和expire的区别了吧!refresh执行reload,而expire后会重新执行load,和初始化时一样
。