重新认识下JVM级别的本地缓存框架Guava Cache(3)——探寻实现细节与核心机
https://pics.codingcoder.cn/pics/202210050807355.png大家好,又见面了。
本文是笔者作为掘金技术社区签约作者的身份输出的缓存专栏系列内容,将会通过系列专题,讲清楚缓存的方方面面。如果感兴趣,欢迎关注以获取后续更新。
通过《重新认识下JVM级别的本地缓存框架Guava Cache——优秀从何而来》一文,我们知道了Guava Cache作为JVM级别的本地缓存组件的诸多暖心特性,也一步步地学习了在项目中集成并使用Guava Cache进行缓存相关操作。Guava Cache作为一款优秀的本地缓存组件,其内部很多实现机制与设计策略,同样值得开发人员深入的掌握与借鉴。
作为系列专栏,本篇文章我们将进一步探讨下Guava Cache 实现层面的一些逻辑与设计策略,让我们可以对Guava Cache整体有个更加明朗的认识。
https://pics.codingcoder.cn/pics/202207102124124.gif
数据回源与回填策略
前面我们介绍过,Guava Cache提供的是一种穿透型缓存模式,当缓存中没有获取到对应记录的时候,支持自动去源端获取数据并回填到缓存中。这里回源获取数据的策略有两种,即CacheLoader方式与Callable方式,两种方式适用于不同的场景,实际使用中可以按需选择。
下面一起看下这两种方式。
CacheLoader
CacheLoader适用于数据加载方式比较固定且统一的场景,在缓存容器创建的时候就需要指定此具体的加载逻辑。常见的使用方式如下:
public LoadingCache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
System.out.println(key + "用户缓存不存在,尝试CacheLoader回源查找并回填...");
return userDao.getUser(key);
}
});
}上述代码中,在使用CacheBuilder创建缓存容器的时候,如果在build()方法中传入一个CacheLoader实现类的方式,则最终创建出来的是一个LoadingCache具体类型的Cache容器:
https://pics.codingcoder.cn/pics/202210312136285.png
默认情况下,我们需要继承CacheLoader类并实现其load抽象方法即可。
https://pics.codingcoder.cn/pics/202210312150011.png
当然,CacheLoader类中还有一些其它的方法,我们也可以选择性的进行覆写来实现自己的自定义诉求。比如我们需要设定refreshAfterWrite来支持定时刷新的时候,就推荐覆写reload方法,提供一个异步数据加载能力,避免数据刷新操作对业务请求造成阻塞。
https://pics.codingcoder.cn/pics/202210312216077.png
另外,有一点需要注意下,如果创建缓存的时候使用refreshAfterWrite指定了需要定时更新缓存数据内容,则必须在创建的时候指定CacheLoader实例,否则执行的时候会报错。因为在执行refresh操作的时候,必须调用CacheLoader对象的reload方法去执行数据的回源操作。
https://pics.codingcoder.cn/pics/202210310831521.png
https://pics.codingcoder.cn/pics/202207102124124.gif
Callable
与CacheLoader不同,Callable的方式在每次数据获取请求中进行指定,可以在不同的调用场景中,分别指定并使用不同的数据获取策略,更加的灵活。
public static void main(String[] args) {
try {
GuavaCacheService cacheService = new GuavaCacheService();
Cache<String, User> cache = cacheService.createCache();
String userId = "123";
// 获取userId, 获取不到的时候执行Callable进行回源
User user = cache.get(userId, () -> cacheService.queryUserFromDb(userId));
System.out.println("get对应用户信息:" + user);
} catch (Exception e) {
e.printStackTrace();
}
}通过提供Callable实现函数并作为参数传递的方式,可以根据业务的需要,在不同业务调用场景下,指定使用不同的Callable回源策略。
https://pics.codingcoder.cn/pics/202207102124124.gif
不回源查询
前面介绍了基于CacheLoader方式自动回源,或者基于Callable方式显式回源的两种策略。但是实际使用缓存的时候,并非是缓存中获取不到数据时就一定需要去执行回源操作。
比如下面这个场景:
用户论坛中维护了个黑名单列表,每次用户登录的时候,需要先判断下是否在黑名单中,如果在则禁止登录。
因为论坛中黑名单用户占整体用户量的比重是极少的,也就是几乎绝大部分登录的时候去查询缓存都是无法命中黑名单缓存的。这种时候如果每次查询缓存中没有的情况下都去执行回源操作,那几乎等同于每次请求都需要去访问一次DB了,这显然是不合理的。
所以,为了支持这种场景的访问,Guava cache也提供了一种不会触发回源操作的访问方式。如下:
接口功能说明getIfPresent从内存中查询,如果存在则返回对应值,不存在则返回nullgetAllPresent批量从内存中查询,如果存在则返回存在的键值对,不存在的key则不出现在结果集里上述两种接口,执行的时候仅会从当前内存中已有的缓存数据里面进行查询,不会触发回源的操作。
public static void main(String[] args) {
try {
GuavaCacheService cacheService = new GuavaCacheService();
Cache<String, User> cache = cacheService.createCache();
cache.put("123", new User("123", "123"));
cache.put("124", new User("124", "124"));
System.out.println(cache.getIfPresent("125"));
ImmutableMap<String, User> allPresentUsers =
cache.getAllPresent(Stream.of("123", "124", "125").collect(Collectors.toList()));
System.out.println(allPresentUsers);
} catch (Exception e) {
e.printStackTrace();
}
}执行后,输入结果如下:
null
{123=User(userName=123, userId=123), 124=User(userName=124, userId=124)}Guava Cache的数据清理与加载刷新机制
在前面的CacheBuilder类中有提供了几种expire与refresh的方法,即expireAfterAccess、expireAfterWrite以及refreshAfterWrite。
这里我们对几个方法进行一些探讨。
数据过期
对于数据有过期时效诉求的场景,我们可以通过几种方式设定缓存的过期时间:
[*]expireAfterAccess
[*]expireAfterWrite
现在我们思考一个问题:数据过期之后,会立即被删除吗?在前面的文章中,我们自己构建本地缓存框架的时候,有介绍过缓存数据删除的几种机制:
删除机制具体说明主动删除搞个定时线程不停的去扫描并清理所有已经过期的数据。惰性删除在数据访问的时候进行判断,如果过期则删除此数据。两者结合采用惰性删除为主,低频定时主动删除为兜底,兼顾处理性能与内存占用。在Guava Cache中,为了最大限度的保证并发性,采用的是惰性删除的策略,而没有设计独立清理线程。所以这里我们就可以回答前面的问题,也即过期的数据,并非是立即被删除的,而是在get等操作访问缓存记录时触发过期数据的删除操作。
在get执行逻辑中进行数据过期清理以及重新回源加载的执行判断流程,可以简化为下图中的关键环节:
https://pics.codingcoder.cn/pics/202211062119555.png
在执行get请求的时候,会先判断下当前查询的数据是否过期,如果已经过期,则会触发对当前操作的Segment的过期数据清理操作。
数据刷新
除了上述的2个过期时间设定方法,Guava Cache还提供了个refreshAfterWrite方法,用于设定定时自动refresh操作。项目中可能会有这么个情况:
为了提升性能,将最近访问系统的用户信息缓存起来,设定有效期30分钟。如果用户的角色出现变更,或者用户昵称、个性签名之类的发生更改,则需要最长等待30分钟缓存失效重新加载后才能够生效。
这种情况下,我们就可以在设定了过期时间的基础上,再设定一个每隔1分钟重新refresh的逻辑。这样既可以保证数据在缓存中的留存时长,又可以尽可能的缩短缓存变更生效的时间。这种情况,便该refreshAfterWrite登场了。
https://pics.codingcoder.cn/pics/202211062104479.png
与expire清理逻辑相同,refresh操作依旧是采用一种被动触发的方式来实现。当get操作执行的时候会判断下如果创建时间已经超过了设定的刷新间隔,则会重新去执行一次数据的加载逻辑(前提是数据并没有过期)。
鉴于缓存读多写少的特点,Guava Cache在数据refresh操作执行的时候,采用了一种非阻塞式的加载逻辑,尽可能的保证并发场景下对读取线程的性能影响。
看下面的代码,模拟了两个并发请求同时请求一个需要刷新的数据的场景:
public static void main(String[] args) {
try {
LoadingCache<String, User> cache =
CacheBuilder.newBuilder().refreshAfterWrite(1L, TimeUnit.SECONDS).build(new MyCacheLoader());
cache.put("123", new User("123", "ertyu"));
Thread.sleep(1100L);
Runnable task = () -> {
try {
System.out.println(Thread.currentThread().getId() + "线程开始执行查询操作");
User user = cache.get("123");
System.out.println(Thread.currentThread().getId() + "线程查询结果:" + user);
} catch (Exception e) {
e.printStackTrace();
}
};
CompletableFuture.allOf(
CompletableFuture.runAsync(task), CompletableFuture.runAsync(task)
).thenRunAsync(task).join();
} catch (Exception e) {
e.printStackTrace();
}
}执行后,结果如下:
14线程开始执行查询操作
13线程开始执行查询操作
13线程查询结果:User(userName=ertyu, userId=123)
14线程执行CacheLoader.reload(),oldValue=User(userName=ertyu, userId=123)
14线程执行CacheLoader.load()...
14线程执行CacheLoader.load()结束...
14线程查询结果:User(userName=97qx6, userId=123)
15线程开始执行查询操作
15线程查询结果:User(userName=97qx6, userId=123)从执行结果可以看出,两个并发同时请求的线程只有1个执行了load数据操作,且两个线程所获取到的结果是不一样的。具体而言,可以概括为如下几点:
[*]同一时刻仅允许一个线程执行数据重新加载操作,并阻塞等待重新加载完成之后该线程的查询请求才会返回对应的新值作为结果。
[*]当一个线程正在阻塞执行refresh数据刷新操作的时候,其它线程此时来执行get请求的时候,会判断下数据需要refresh操作,但是因为没有获取到refresh执行锁,这些其它线程的请求不会被阻塞等待refresh完成,而是立刻返回当前refresh前的旧值。
[*]当执行refresh的线程操作完成后,此时另一个线程再去执行get请求的时候,会判断无需refresh,直接返回当前内存中的当前值即可。
上述的过程,按照时间轴的维度来看,可以囊括成如下的执行过程:
https://pics.codingcoder.cn/pics/202211061655253.png
数据expire与refresh关系
expire与refresh在某些实现逻辑上有一定的相似度,很多的文章中介绍的时候甚至很直白的说refresh比expire更好,因为其不会阻塞业务端的请求。个人认为这种看法有点片面,从单纯的字面含义上也可以看出这两种机制不是互斥的、而是一种相互补充的存在,并不存在谁比谁更好这一说,关键要看具体是应用场景。
https://pics.codingcoder.cn/pics/202211062023920.png
expire操作就是采用的一种严苛的更新锁定机制,当一个线程执行数据重新加载的时候,其余的线程则阻塞等待。refresh操作执行过程中不会阻塞其余线程的get查询操作,会直接返回旧值。这样的设计各有利弊:
操作优势弊端expire有效防止缓存击穿问题,且阻塞等待的方式可以保证业务层面获取到的缓存数据的强一致性。高并发场景下,如果回源的耗时较长,会导致多个读线程被阻塞等待,影响整体的并发效率。refresh可以最大限度的保证查询操作的执行效率,避免过多的线程被阻塞等待。多个线程并发请求同一个key对应的缓存值拿到的结果可能不一致,在对于一致性要求特别严苛的业务场景下可能会引发问题。Guava Cache中的expire与fefresh两种机制,给人的另一个困惑点在于:两者都是被动触发的数据加载逻辑,不管是expire还是refresh,只要超过指定的时间间隔,其实都是依旧存在与内存中,等有新的请求查询的时候,都会执行自动的最新数据加载操作。那这两个实际使用而言,仅仅只需要依据是否需要阻塞加载这个维度来抉择?
并非如此。
expire存在的意义更多的是一种数据生命周期终结的意味,超过了expire有效期的数据,虽然依旧会存在于内存中,但是在一些触发了cleanUp操作的情况下,是会被释放掉以减少内存占用的。而refresh则仅仅只是执行数据更新,框架无法判断是否需要从内存释放掉,会始终占据内存。
所以在具体使用时,需要根据场景综合判断:
[*]数据需要永久存储,且不会变更,这种情况下expire和refresh都并不需要设定
[*]数据极少变更,或者对变更的感知诉求不强,且并发请求同一个key的竞争压力不大,直接使用expire即可
[*]数据无需过期,但是可能会被修改,需要及时感知并更新缓存数据,直接使用refresh
[*]数据需要过期(避免不再使用的数据始终留在内存中)、也需要在有效期内尽可能保证数据的更新一致性,则采用expire与refresh两者结合。
对于expire与refresh结合使用的场景,两者的时间间隔设置,需要注意:
expire时间设定要大于refresh时间,否则的话refresh将永远没有机会执行
Guava Cache并发能力支持
采用分段锁降低锁争夺
前面我们提过Guava Cache支持多线程环境下的安全访问。我们知道锁的粒度越大,多线程请求的时候对锁的竞争压力越大,对性能的影响越大。而如果将锁的粒度拆分小一些,这样同时请求到同一把锁的概率就会降低,这样线程间争夺锁的竞争压力就会降低。
https://pics.codingcoder.cn/pics/165924683167139344194.png
Guava Cache中采用的也就是这种分段锁策略来降低锁的粒度,可以在创建缓存容器的时候使用concurrencyLevel来指定允许的最大并发线程数,使得线程安全的前提下尽可能的减少锁争夺。而concurrencyLevel值与分段Segment的数量之间也存在一定的关系,这个关系相对来说会比较复杂、且受是否限制总容量等因素的影响,源码中这部分的计算逻辑可以看下:
int segmentShift = 0; int segmentCount = 1; while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20
页:
[1]