本地缓存 - Guava Cache

Guava Cache简介

Guava 是Google提供的一套JAVA的工具包,而Guava Cache则是该工具包中提供的一套完善的JVM****级别的高并发缓存框架。其实现机制类似

ConcurrentHashMap

,但是进行了众多的封装与能力扩展。

Guava Cache适用场景

Guava Cache具备本地缓存该有的优势 ,也无可避免的存在着本地缓存的弊端

  • 优势:基于空间换时间的策略,利用内存的高速处理效率,提升机器的处理性能,减少大量对外的

    IO

    请求

    交互,比如读取DB、请求外部网络、读取本地磁盘数据等等操作。

  • 弊端:整体容量受限 ,可能对本机内存造成压力。此外,对于分布式多节点集群部署的场景,缓存更新场景会出现缓存漂移问题,导致各个节点之间的缓存

    数据不一致

鉴于上述优劣综合判断,可以大致圈定Guava Cache的实际适用场合:

  • 数据读多写少 且对一致性要求不高的场景

这类场景中,会将数据缓存到本地内存中,采用定时触发(或者事件推送)的策略重新加载到内存中。这样业务处理逻辑直接从内存读取需要的数据,修改系统配置项之后,需要等待一定的时间后方可生效。

很多的配置中心采用的都是这个缓存策略。统一配置中心中管理配置数据,然后各个业务节点会从统一配置中心拉取配置并存储在自己本地的内存中然后使用本地内存中的数据。这样可以有效规避配置中心的单点故障问题,降低了配置中心的请求压力,也提升了业务节点自身的业务处理性能(减少了与配置中心之间的网络交互请求)。

  • 性能 要求极其严苛的场景

对于分布式系统而言,集中式缓存是一个常规场景中很好的选项。但是对于一些超大并发量且读性能要求严苛的系统而言,一个请求流程中需要频繁的去与Redis交互,其网络开销也是不可忍受的。所以可以采用将数据本机内存缓存的方式,分散redis的压力,降低对外请求交互的次数,提升接口响应速度。

Guava Cache使用

引入对应的依赖包:

xml 复制代码
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>

容器创建 ------ CacheBuilder

借助CacheBuilder以一种优雅的方式来构建出合乎我们诉求的Cache实例。对

CacheBuilder

中常见的属性方法,归纳说明如下:

  • **newBuilder:**构造出一个Builder实例类

  • initialCapacity:待创建的缓存容器的初始容量大小(记录条数

  • maximumSize:指定此缓存容器的最大容量(最大缓存记录条数)

  • maximumWeight:指定此缓存容器的最大容量(最大比重 值),需结合weighter方可体现出效果expireAfterWrite:设定过期策略,按照数据写入时间进行计算

  • expireAfterAccess:设定过期策略,按照数据最后访问时间来计算

  • **weighter:**入参为一个函数式接口,用于指定每条存入的缓存数据的权重占比情况。这个需要与maximumWeight结合使用

  • **refreshAfterWrite:**缓存写入到缓存之后

  • concurrencyLevel:用于控制缓存的并发处理能力,同时支持多少个线程并发写入操作

  • **recordStats:**设定开启此容器的数据加载与缓存命中情况统计

基于CacheBuilder及其提供的各种方法,可以轻松的进行缓存容器的构建、并指定容器的各种约束条件:

scss 复制代码
public LoadingCache<String, User> createUserCache() {
    return CacheBuilder.newBuilder()
            .initialCapacity(1000) // 初始容量
            .maximumSize(10000L)   // 设定最大容量
            .expireAfterWrite(30L, TimeUnit.MINUTES) // 设定写入过期时间
            .concurrencyLevel(8)  // 设置最大并发写操作线程数
            .refreshAfterWrite(1L, TimeUnit.MINUTES) // 设定自动刷新数据时间
            .recordStats() // 开启缓存执行情况统计
            .build(new CacheLoader<String, User>() {
                @Override
                public User load(String key) throws Exception {
                    return userDao.getUser(key);
                }
            });
}

业务层使用

Guava Cache容器对象创建完成后,可以基于其提供的对外接口完成相关缓存的具体操作。首先可以了解下Cache提供的对外操作接口:

get:查询指定key对应的value值,如果缓存中没匹配,则基于给定的Callable逻辑去获取数据回填缓存中并返回getIfPresent如果缓存中存在指定的key值,则返回对应的value值,否则返回null(此方法不会触发自动回源与回填操作)

**getAllPresent:**针对传入的key列表,返回缓存中存在的对应value值列表(不会触发自动回源与回填操作)

**put:**往缓存中添加key-value键值对

**putAll:**批量往缓存中添加key-value键值对

**invalidate:**从缓存中删除指定的记录

**invalidateAll:**从缓存中批量删除指定记录,如果无参数,则清空所有缓存

**size:**获取缓存容器中的总记录数

stats**:**获取缓存容器当前的统计数据

**asMap:**将缓存中的数据转换为ConcurrentHashMap格式返回

**cleanUp:**清理所有的已过期的数据

在项目中,可以基于上述接口,实现各种缓存操作功能:

csharp 复制代码
public static void main(String[] args) {
    CacheService cacheService = new CacheService();
    LoadingCache<String, User> cache = cacheService.createUserCache6();
    cache.put("122", new User("122"));
    cache.put("122", new User("122"));
    System.out.println("put操作后查询:" + cache.getIfPresent("122"));
    cache.invalidate("122");
    System.out.println("invalidate操作后查询:" + cache.getIfPresent("122"));
    System.out.println(cache.stats());
}

当然,上述示例代码中这种使用方式有个明显的弊端就是业务层面对Guava Cache的私有API依赖过深 ,后续如果需要替换Cache组件的时候会比较痛苦,需要对业务调用的地方进行大改。所以真正项目里面,最好还是对其适当封装,以实现业务层面的解耦 。如果项目是使用Spring框架,也可以基于Spring Cache统一规范来集成并使用Guava Cache,降低对业务逻辑的侵入

Guava Cache淘汰策略

将数据从缓存容器中移除的操作统称数据淘汰。按照触发形态不同可以将数据的清理与淘汰策略分为被动淘汰主动淘汰两种。

被动淘汰

1)基于数据量限制

当容器内的缓存数量接近(注意是接近、而非达到)设定的最大阈值的时候,会触发guava cache的数据清理机制,会基于LRU或FIFO删除一些不常用的key-value键值对。这种方式需要在创建容器的时候指定其maximumSize或者maximumWeight,然后基于size或者weight进行判断并执行上述的清理操作;以确保 缓存容器中的数据量始终是在可控范围内。

  • **FIFO:**根据缓存记录写入的顺序,先写入的先淘汰

  • **LRU:**根据访问顺序,淘汰最久没有访问的记录

2)基于过期时间

Guava Cache支持根据创建时间或者根据访问时间来设定数据过期处理,实际使用的时候可以根据具体需要来选择对应的方式。

  • **创建过期:**基于缓存记录的插入时间判断。比如设定10分钟过期,则记录加入缓存之后,

    不管有没有访问

    ,10分钟时间到则缓存失效。

  • **访问过期:**基于最后一次的访问时间来判断是否过期。比如设定10分钟过期,如果缓存记录被访问到,则以最后一次访问时间重新计时;只有连续10分钟没有被访问的时候才会过期,否则将一直存在缓存中不会被过期。

  • 基于创建时间过期

    public Cache<String, User> createUserCache() { return CacheBuilder.newBuilder() .expireAfterWrite(30L, TimeUnit.MINUTES) .build(); }

  • 基于访问时间过期

    public Cache<String, User> createUserCache() { return CacheBuilder.newBuilder() .expireAfterAccess(30L, TimeUnit.MINUTES) .build(); }

注意:Guava Cache的过期数据淘汰是一种被动触发 技能。getIfPresent方法对应的实现源码,可以很明显的看出每次get请求的时候都会触发一次cleanUp操作:

为了实现高效的多线程并发控制,Guava Cache采用了类似ConcurrentHashMap一样的分段锁机制,数据被分为了不同分片,每个分片同一时间只允许有一个线程执行写操作,这样降低并发锁争夺的竞争压力。而上面代码中也可以看出,执行清理的时候,仅针对当前查询的记录所在的Segment分片执行清理操作,而其余的分片的过期数据并不会触发清理逻辑。

3)基于引用

基于引用回收的策略,核心是利用JVM虚拟机的GC****机制来达到数据清理的目的。按照JVM的GC原理,当一个对象不再被引用之后,便会执行一系列的标记清除逻辑,并最终将其回收释放。这种实际使用的较少,此处不多展开。

主动淘汰

实际使用的时候有很多情况需要从缓存中立即将指定的记录给删除掉。比如执行删除或者更新操作的时候就需要删除已有的历史缓存记录,这种情况下就需要主动调用 Guava Cache提供的相关删除操作接口,来达到对应诉求。

  • **invalidate(key):**删除指定的记录

  • **invalidateAll(keys):**批量删除给定的记录

  • **invalidateAll():**清空整个缓存容器

缓存容量限制

Guava Cache提供了对缓存总量 的限制,并且支持从两个维度进行限制,首先要厘清sizeweight两个概念的区别与联系。

  • 限制缓存条数size

    public Cache<String, User> createUserCache() { return CacheBuilder.newBuilder().maximumSize(10000L).build(); }

  • 限制缓存权重weight

    public Cache<String, String> createUserCache() { return CacheBuilder.newBuilder() .maximumWeight(50000) .weigher((key, value) -> (int) Math.ceil(value.length() / 1000)) .build(); }

一般而言,限制容器的容量的初衷 ,是为了防止内存占用过大导致内存溢出,所以本质上是限制

内存的占用量

。从实现层面,往往会根据总内存占用量与预估每条记录字节数进行估算,将其转换为对缓存记录条数的限制。这种做法相对简单易懂,但是对于单条缓存记录占用字节数差异较大的情况下,会导致基于条数控制的结果不够精准

比如:

需要限制缓存最大占用500M总量,缓存记录可能大小范围是1k~100k,按照每条50k进行估算,设定缓存容器最大容量为限制最大容量1w条。如果存储的都是1k大小的记录,则内存总占用量才10M(内存没有被有效利用起来);若都存储的是100k大小的记录,又会导致内存占用为1000M,远大于预期的内存占用量(容易造成内存溢出)。

为了解决这个问题,Guava Cache中提供了一种相对精准 的控制策略,即基于权重的总量控制,根据一定的规则,计算出每条value记录所占的权重值,然后以权重值进行总量的计算。

还是上面的例子,我们按照权重进行设定,假定1k对应基础权重1,则100k可转换为权重100。这样一来:

限制缓存最大占用500M1k对应权重1,Nk代表权重N,则我们可以限制总权重为50w。这样假如存储的都是1k的记录,则最多可以缓存5w条记录;而如果都是100k大小的记录,则最多仅可以缓存5000条记录。根据存储数据的大小不同,最大存储的记录条数也不相同,但是最终占用的总体量可以实现基本吻合。

所以,基于weight权重的控制方式,比较适用于这种对容器体量控制精度严格诉求的场景,可以在创建容器的时候指定每条记录的权重计算策略(比如基于字符串长度或者基于bytes数组长度进行计算权重)。

使用约束说明

在实际使用中,这几个参数之间有一定的使用约束,需要特别注意一下:

  • 如果

    没有指定

    weight实现逻辑,则使用maximumSize来限制最大容量,按照容器中缓存记录的条数进行限制;这种情况下,即使设定了maximumWeight也不会生效。

  • 如果

    指定

    了weight实现逻辑,则必须使用 maximumWeight 来限制最大容量,按照容器中每条缓存记录的weight值累加后的总weight值进行限制。

  • 按照权重进行限制缓存容量的时候必须要指定weighter属性才可以生效。

看下面的一个反面示例,指定了weighter和maximumSize,却没有指定 maximumWeight属性:

typescript 复制代码
public static void main(String[] args) {
    try {
        Cache<String, String> cache = CacheBuilder.newBuilder()
            .weigher((key, value) -> 2)
            .maximumSize(2)
            .build();
        cache.put("key1", "value1");
        cache.put("key2", "value2");
        System.out.println(cache.size());
    } catch (Exception e) {
        e.printStackTrace();
    }
}

执行的时候,会报错,提示weighter和maximumSize不可以混合使用:

css 复制代码
java.lang.IllegalStateException: maximum size can not be combined with weigher
        at com.google.common.base.Preconditions.checkState(Preconditions.java:502)
        at com.google.common.cache.CacheBuilder.maximumSize(CacheBuilder.java:484)
        at com.veezean.skills.cache.guava.CacheService.main(CacheService.java:205)

Guava Cache数据回源与回填策略

在前面介绍过缓存的三种模型 ,分别是旁路型穿透型异步型

Guava Cache提供的是一种穿透型缓存 模式,当缓存中没有获取到对应记录的时候,支持自动去源端获取数据并回填到缓存中。这里回源 获取数据的策略有两种,即CacheLoader方式与Callable方式,两种方式适用于不同的场景,实际使用中可以按需选择。

CacheLoader

CacheLoader适用于数据加载方式比较固定且统一的场景,在缓存容器创建的时候就需要指定此具体的加载逻辑。常见的使用方式如下:

typescript 复制代码
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容器。

默认情况下,我们需要继承CacheLoader类并实现其load抽象方法即可。

当然,CacheLoader类中还有一些其它的方法,我们也可以选择性的进行覆写来实现自己的自定义诉求。比如我们需要设定refreshAfterWrite来支持定时刷新 的时候,就推荐覆写reload方法,提供一个异步数据加载能力,避免数据刷新操作对业务请求造成阻塞。

另外,有一点需要注意下,如果创建缓存的时候使用refreshAfterWrite指定了需要定时更新缓存数据内容,则必须在创建的时候指定CacheLoader实例,否则执行的时候会报错 。因为在执行refresh操作的时候,必须调用CacheLoader对象的reload方法去执行数据的回源操作。

Callable

与CacheLoader不同,Callable的方式在每次数据获取请求中进行指定,可以在不同的调用场景中,分别指定并使用不同的数据获取策略,更加的灵活

ini 复制代码
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回源策略。

不回源查询

前面介绍了基于CacheLoader方式自动回源,或者基于Callable方式显式回源的两种策略。但是实际使用缓存的时候,并非是缓存中获取不到数据时就一定需要去执行回源操作。

为了支持这种场景的访问,Guava cache也提供了一种不会触发回源操作的访问方式。如下:

getIfPresent:从内存中查询,如果存在则返回对应值,不存在则返回null

getAllPresent:批量从内存中查询,如果存在则返回存在的键值对,不存在的key则不出现在结果集里

Guava Cache的数据清理与加载刷新机制

在前面的CacheBuilder类中有提供了几种expirerefresh的方法,即expireAfterAccessexpireAfterWrite以及refreshAfterWrite

数据过期

对于数据有过期时效诉求的场景,可以通过几种方式设定缓存的过期时间:

  • expireAfterAccess

  • expireAfterWrite

在Guava Cache中,为了最大限度的保证并发性,采用的是惰性删除 的策略。在get等操作访问缓存记录时触发过期数据的删除操作。

下图为关键环节:

在执行get请求的时候,会先判断下当前查询的数据是否过期,如果已经过期,则会触发对当前操作的Segment的过期数据清理操作。

数据刷新

除了上述的2个过期时间设定方法,Guava Cache还提供了个refreshAfterWrite方法,用于设定定时自动refresh操作。项目中可能会有这么个情况:

为了提升性能,将最近访问系统的用户信息缓存起来,设定有效期30分钟。如果用户的角色出现变更,或者用户昵称、个性签名之类的发生更改,则需要最长等待30分钟缓存失效重新加载后才能够生效。

这种情况下,我们就可以在设定了过期时间的基础上,再设定一个每隔1分钟重新refresh的逻辑。这样既可以保证数据在缓存中的留存时长,又可以尽可能的缩短缓存变更生效的时间。这种情况,便该refreshAfterWrite登场了。

与expire清理逻辑相同,refresh操作依旧是采用一种被动触发的方式来实现。当get操作执行的时候会判断下如果创建时间已经超过了设定的刷新间隔,则会重新去执行一次数据的加载逻辑(前提是数据并没有过期)。

鉴于缓存读多写少 的特点,Guava Cache在数据refresh操作执行的时候,采用了一种非阻塞式的加载逻辑,尽可能的保证并发场景下对读取线程的性能影响。

具体而言,可以概括为如下几点:

  • 同一时刻仅允许一个线程执行数据重新加载操作,并阻塞等待重新加载完成之后该线程的查询请求才会返回对应的新值作为结果。

  • 当一个线程正在阻塞执行refresh数据刷新操作的时候,其它线程此时来执行get请求的时候,会判断下数据需要refresh操作,但是因为没有获取到refresh执行锁,这些其它线程的请求不会被阻塞 等待refresh完成,而是立刻返回 当前refresh前的旧值

  • 当执行refresh的线程操作完成后,此时另一个线程再去执行get请求的时候,会判断无需refresh,直接返回当前内存中的当前值即可。

上述的过程,按照时间轴的维度来看,可以囊括成如下的执行过程:

数据expire与refresh关系

expire操作就是采用的一种严苛 的更新锁定机制,当一个线程执行数据重新加载的时候,其余的线程则阻塞等待。refresh操作执行过程中不会阻塞其余线程的get查询操作,会直接返回旧值。这样的设计各有利弊

expire:

  • **优势:**有效防止缓存击穿问题,且阻塞等待的方式可以保证业务层面获取到的缓存数据的强一致性。

  • **弊端:**高并发场景下,如果回源的耗时较长,会导致多个读线程被阻塞等待,影响整体的并发效率。

refresh:

  • **优势:**可以最大限度的保证查询操作的执行效率,避免过多的线程被阻塞等待。

  • **弊端:**多个线程并发请求同一个key对应的缓存值拿到的结果可能不一致,在对于一致性要求特别严苛的业务场景下可能会引发问题。

Guava Cache中的expire与fefresh两种机制,给人的另一个困惑点在于:两者都是被动触发的数据加载逻辑,不管是expire还是refresh,只要超过指定的时间间隔,其实都是依旧存在与内存中,等有新的请求查询的时候,都会执行自动的最新数据加载操作。那这两个实际使用而言,仅仅只需要依据是否需要阻塞加载这个维度来抉择?

并非如此。expire存在的意义更多的是一种数据生命周期终结 的意味,超过了expire有效期的数据,虽然依旧会存在于内存中,但是在一些触发了cleanUp操作的情况下,是会被释放掉以减少内存占用的。而refresh则仅仅只是执行数据更新,框架无法判断是否需要从内存释放掉,会始终占据内存。

所以在具体使用时,需要根据场景综合判断:

  • 数据需要永久存储 ,且不会变更 ,这种情况下expirerefresh都并不需要设定

  • 数据极少变更 ,或者对变更的感知诉求不强,且并发请求同一个key的竞争压力不大,直接使用expire即可

  • 数据无需过期 ,但是可能会被修改 ,需要及时感知并更新缓存数据,直接使用refresh

  • 数据需要过期 (避免不再使用的数据始终留在内存中)、也需要在有效期内尽可能保证数据的更新一致性 ,则采用expirerefresh两者结合

对于expire与refresh结合使用的场景,两者的时间间隔设置,需要注意:

expire时间设定要大于refresh时间,否则的话refresh将永远没有机会执行

Guava Cache并发能力

在高并发场景下,如果某个key值没有命中缓存,大量的请求同步打到下游模块处理的时候,很容易造成缓存击穿问题。

为了防止缓存击穿问题,可以通过加锁 的方式来规避。当缓存不可用时,仅持锁的线程负责从数据库中查询数据并写入缓存中,其余请求重试时先尝试从缓存中获取数据,避免所有的并发请求全部同时打到数据库上。

作为穿透型缓存的保护策略之一,

Guava

Cache

自带了并发锁定机制,同一时刻仅允许一个请求去回源获取数据并回填到缓存中,而其余请求则阻塞等待,不会造成数据源的压力过大。

采用分段锁降低锁争夺

下面介绍下Guava Cache支持多线程环境下的安全访问。锁的粒度越大,多线程请求的时候对锁的竞争压力越大,对性能的影响越大。而如果将锁的粒度拆分小一些,这样同时请求到同一把锁的概率就会降低,这样线程间争夺锁的竞争压力就会降低。

Guava Cache中采用的也就是这种分段锁 策略来降低锁的粒度,可以在创建缓存容器的时候使用concurrencyLevel来指定允许的最大并发****线程数,使得线程安全的前提下尽可能的减少锁争夺。而

concurrencyLevel

值与分段

Segment

的数量之间也存在一定的关系,这个关系相对来说会比较复杂、且受是否限制总容量等因素的影响,源码中这部分的计算逻辑可以看下:

ini 复制代码
int segmentShift = 0;
int segmentCount = 1;
while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) {
    ++segmentShift;
    segmentCount <<= 1;
}

根据上述的控制逻辑代码,可以将segmentCount的取值约束概括为下面几点:

  • segmentCount 是 2 的整数倍

  • segmentCount 最大可能为(concurrencyLevel -1)*2

  • 如果有按照权重设置容量,则segmentCount不得超过总权重值的1/20

从源码中可以比较清晰的看出这一点,Guava Cache在put写操作的时候,会首先计算出key对应的hash值,然后根据hash值来确定数据应该写入到哪个Segment中,进而对该Segment加锁执行写入操作。

scss 复制代码
@Override
public V put(K key, V value) {
    // ... 省略部分逻辑
    int hash = hash(key);
    return segmentFor(hash).put(key, hash, value, false);
}
@Nullable
V put(K key, int hash, V value, boolean onlyIfAbsent) {
  lock();
    try {
        // ... 省略具体逻辑
    } finally {
        unlock();
        postWriteCleanup();
    }
}

根据上述源码也可以得出一个结论,concurrencyLevel只是一个理想状态下的最大同时并发数,也取决于同一时间的操作请求是否会平均的分散在各个不同的Segment中。极端情况下,如果多个线程操作的目标对象都在同一个分片中时,那么只有1个线程可以持锁执行,其余线程都会阻塞等待。

实际使用中,比较推荐的是将concurrencyLevel设置为CPU****核数的2倍 ,以获得较优的并发性能。当然,concurrencyLevel也不是可以随意设置的,其源码设置里允许的最大值为65536

佛系抢锁策略

在put等写操作场景下,Guava Cache采用的是上述分段锁的策略,通过优化锁的粒度,来提升并发的性能。但是加锁毕竟还是对性能有一定的影响的,为了追求更加极致的性能表现,在get等读操作自身并没有发现加锁操作 ------ 但是Guava Cache的get等处理逻辑也并非是纯粹的只读操作,它还兼具触发数据淘汰清理操作的删除逻辑,所以只在判断需要执行清理的时候才会尝试去佛系抢锁

那么它是如何减少抢锁的几率的呢?从源码中可以看出,并非是每次请求都会去触发cleanUp操作,而是会尝试积攒一定次数之后再去尝试清理:

scss 复制代码
static final int DRAIN_THRESHOLD = 0x3F;
void postReadCleanup() {
  if ((readCount.incrementAndGet() & DRAIN_THRESHOLD) == 0) {
    cleanUp();
  }
}

在高并发场景下,如果查询请求量巨大的情况下,即使按照上述的情况限制每次达到一定请求数量之后才去执行清理操作,依旧可能会出现多个get操作线程同时去抢锁执行清理操作的情况,这样岂不是依旧会阻塞这些读取请求的处理吗?

scss 复制代码
void cleanUp() {
  long now = map.ticker.read();
  runLockedCleanup(now);
  runUnlockedCleanup();
}
void runLockedCleanup(long now) {
    // 尝试请求锁,请求到就处理,请求不到就放弃
  if (tryLock()) {
    try {
      // ... 省略部分逻辑
      readCount.set(0);
    } finally {
      unlock();
    }
  }
}

可以看到源码中采用的是tryLock方法来尝试去抢锁,如果抢到锁就继续后续的操作,如果没抢到锁就不做任何清理操作,直接返回 ------ 这也是为什么前面将其形容为"佛系抢锁"的缘由。

Guava Cache缓存监控

引入缓存的一个初衷是希望缓存能够提升系统的处理性能,而有限缓存容量中仅存储部分数据的时候,我们会希望存储的有限数据可以尽可能的覆盖并抗住大部分的请求流量,所以对缓存的命中率会非常关注。

Guava Cache深知这一点,所以提供了stat统计日志,支持查看缓存数据的

加载

或者

命中

情况统计。我们可以基于命中情况,不断的去优化代码中缓存的数据策略,以发挥出缓存的最大价值。

Guava Cache的统计信息封装为CacheStats对象进行承载,主要包含以下几个关键指标项:

  • **hitCount:**命中缓存次数

  • **missCount:**没有命中缓存次数(查询的时候内存中没有)

  • **loadSuccessCount:**回源加载的时候加载成功次数

  • **loadExceptionCount:**回源加载但是加载失败的次数

  • **totalLoadTime:**回源加载操作总耗时

  • **evictionCount:**删除记录的次数

缓存容器创建的时候,可以通过recordStats()开启缓存行为的统计记录:

csharp 复制代码
public static void main(String[] args) {
    LoadingCache<String, User> cache = CacheBuilder.newBuilder()
            .recordStats()
            .build(new CacheLoader<String, User>() {
                @Override
                public User load(String key) throws Exception {
                    System.out.println(key + "用户缓存不存在,尝试CacheLoader回源查找并回填...");
                    User user = userDao.getUser(key);
                    if (user == null) {
                        System.out.println(key + "用户不存在");
                    }
                    return user;
                }
            });

    try {
        System.out.println(cache.get("123");
        System.out.println(cache.get("124"));
        System.out.println(cache.get("123"));
        System.out.println(cache.get("126"));

    } catch (Exception e) {
    } finally {
        CacheStats stats = cache.stats();
        System.out.println(stats);
    }
}

代码执行结果:

ini 复制代码
123用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=123, userName=铁柱, department=研发部)
124用户缓存不存在,尝试CacheLoader回源查找并回填...
User(userId=124, userName=翠花, department=测试部)
User(userId=123, userName=铁柱, department=研发部)
126用户缓存不存在,尝试CacheLoader回源查找并回填...
126用户不存在
CacheStats{hitCount=1, missCount=3, loadSuccessCount=2, loadExceptionCount=1, totalLoadTime=1972799, evictionCount=0}

一共执行了4次请求,其中1次命中,3次回源处理,2次回源加载成功,1次回源没找到数据,与打印出来的CacheStats统计结果完全吻合。

承前启后 ------ Caffeine Cache

技术的更新迭代始终没有停歇的时候,Guava工具包作为Google家族的优秀成员,在很多方面提供了非常优秀的能力支持。随着JAVA8的普及,Google也基于语言的新特性,对Guava Cache部分进行了重新实现,形成了后来的Caffeine Cache,并在SpringBoot2.x中取代了Guava Cache。

相关推荐
世俗ˊ18 分钟前
Spring Boot 的 WebClient 实践教程
java·spring boot·后端
西域编娃1 小时前
探索Scala:文本分析与文件操作的艺术
开发语言·后端·scala
西域编娃1 小时前
解锁Scala编程:深入文本分析与数据处理的艺术
大数据·开发语言·后端·scala
ahauedu2 小时前
Spring Boot的JdbcTemplate实现“不存在即插入,存在即更新”
spring boot·后端·python
爱吃香菜---www2 小时前
Scala中字符串
开发语言·后端·scala
睎zyl2 小时前
scala统计词频
开发语言·后端·scala
小蒜学长2 小时前
springboot视频网站系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·spring
用户99045017780094 小时前
真需求永远是第一位
后端
Python私教5 小时前
Docker化部署Flask:轻量级Web应用的快速部署方案
后端
创码小奇客5 小时前
《Lock 锁与 AQS 的 “家族秘史”:继承那些事儿,代码来揭秘》
java·后端·架构