java 缓存架构剖析–本地缓存(LoadingCache)

java 缓存架构剖析–本地缓存(LoadingCache)



缓存的使用可以大大提高程序的执行效率,但是如果缓存无法及时更新会导致脏读的情况。

1 适用场景

缓存在很多场景下都是相当有用的。例如,计算或检索一个值的代价很高,并且对同样的输入需要不止一次获取值的时候,就应当考虑使用缓存。

``Guava Cache与ConcurrentMap很相似,但也不完全一样。最基本的区别是ConcurrentMap会一直保存所有添加的元素,直到显式地移除。相对地,Guava Cache为了限制内存占用,通常都设定为自动回收元素。在某些场景下,尽管LoadingCache 不回收元素,它也是很有用的,因为它会自动加载缓存。`

``通常来说,Guava Cache适用于:

  • 你愿意消耗一些内存空间来提升速度。
  • 你预料到某些键会被查询一次以上。
  • 缓存中存放的数据总量不会超出内存容量。(Guava Cache是单个应用运行时的本地缓存。它不把数据存放到文件或外部服务器。如果这不符合你的需求,请尝试Memcached这类工具)

如果你的场景符合上述的每一条,Guava Cache就适合你。

如同范例代码展示的一样,Cache实例通过CacheBuilder生成器模式获取,但是自定义你的缓存才是最有趣的部分。
*注*:如果你不需要Cache中的特性,使用ConcurrentHashMap有更好的内存效率——但Cache的大多数特性都很难基于旧有的ConcurrentMap复制,甚至根本不可能做到。

1.1 实例理解

记得早期我呆过的一家公司有个核心服务是在启动的时候一下把常用的交易配置信息是从DB查出来放在Map里面来做缓存,先不考虑其他的,如果我想更新一下交易配置信息是不是需要每次都重启服务器呢,又或者说我开几个后门接口用来更新Map信息,这样不还得考虑线程安全的问题么。

好吧,我先上个在中小型项目中,乃至大型项目中也常用的缓存架构,如下:

img

内存架构图

我大概解释一下流程吧:

1、系统A中使用LoadingCache来维护本地缓存信息

2、当缓存刷新时(**同步、异步)**调用B系统来更新缓存信息

3、系统B接收A获取配置数据的请求,如果redis缓存中有数据就直接从redis中拿

4、当缓存中不存在请求则穿透到DB里面查询再将结果塞到redis,并返回结果

5、其实还有一步没画出来应该是有个定时job轮询DB配置信息变化时刷新redis信息(或者消息机制来实现缓存更新)

言归正传,下面来详解一下LoadingCache的使用:

2 LoadingCache的使用

详细使用示例见文档:Google Guava 3-缓存

依赖:

com.google.guava guava 27.1-jre
public static LoadingCache<String, String> cahceBuilder = CacheBuilder.newBuilder()
                //缓存池大小,在缓存项接近该大小时, Guava开始回收旧的缓存项
                .maximumSize(1)
                //对象没有被读/写访问设定时间后则对象从内存中删除(在另外的线程里面不定期维护)
                // .expireAfterAccess(GUAVA_CACHE_DAY, TimeUnit.DAYS)
                // 缓存在写入之后 设定时间 后失效
                //.expireAfterWrite(10, TimeUnit.SECONDS)
                // 设置2ms自动定时刷新,当有访问时会重新执行load方法更新缓存
                .refreshAfterWrite(2, TimeUnit.MILLISECONDS)
    //expireAfterWrite与refreshAfterWrite不能同时发生
                //移除监听器,缓存项被移除时会触发
                .removalListener(new RemovalListener() {
                    @Override
                    public void onRemoval(RemovalNotification rn) {
                        // 处理缓存键不存在缓存值时的**移除**处理逻辑
                        System.out.println(rn.getKey() + "被移除");
                    }
                })
    
                .build(new CacheLoader() {
                    @Override
                    public String load(String key) throws Exception {
                        // 处理缓存键对应的缓存值不存在时的处理逻辑
                        //即当调用get取值: null  调用 load
                        //默认的load实现return "null"
                        String strProValue = "hello " + key + "!";
                        System.out.println("%%%%%" + strProValue);
                        return strProValue;
                    }
                });

        public static void main (String[]args) throws ExecutionException, InterruptedException {
            cahceBuilder.get("jerry");
            cahceBuilder.get("peida");
            Thread.sleep(1000);
            cahceBuilder.get("jerry1");
        }

输出结果为:

%%%%%hello jerry! – 在第一次get的时候没有值会执行load方法,去取值然后塞到本地缓存

%%%%%hello peida! – 在第一次get的时候没有值会执行load方法,去取值然后塞到本地缓存

jerry被移除 – maximumSize(1) 最大值为1,当预存储第二个值的时候第一个值会被移除

%%%%%hello jerry1! – refreshAfterWrite设置2ms自动定时刷新,当有访问时会重新执行load方法更新缓存

peida被移除 – maximumSize(1) 最大值为1,当预存储第二个值的时候第一个值会被移除

3 基本特性

3.1 值操作:

get(K):这个方法要么返回已经缓存的值,要么使用CacheLoader向缓存原子地loading新值(就是上面说的当缓存没有值的时候执行Load方法)。

put(key, value):这个方法可以直接显示地向缓存中插入值,这会直接覆盖掉已有键之前映射的值。

Cache.asMap(): 使用Cache.asMap()视图提供的任何方法也能修改缓存。但请注意,asMap视图的任何方法都不能保证缓存项被原子地加载到缓存中。进一步说,asMap视图的原子运算在Guava Cache的原子加载范畴之外,所以相比于Cache.asMap().putIfAbsent(K, V)Cache.get(K, Callable<V>)应该总是优先使用。

3.2 缓存回收:

  1. 基于容量的回收

    • CacheBuilder.maximumSize(long):这个方法规定缓存项的数目不超过固定值(其实你可以理解为一个Map的最大容量),当容量超出指定值时缓存尝试回收最近没有使用或总体上很少使用的缓存项
  2. 定时回收(2种):

    • expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。

    • expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。

  3. 基于引用的回收

    通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache可以把缓存设置为允许垃圾回收:

    • CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(),使用弱引用键的缓存用而不是equals比较键。
    • CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(),使用弱引用值的缓存用而不是equals比较值。
    • CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)。使用软引用值的缓存同样用==而不是equals比较值。

    需要注意:

    • 警告:在缓存项的数目达到限定值之前,缓存就可能进行回收操作。通常来说,这种情况发生在缓存项的数目逼近限定值时。另外,不同的缓存项有不同的“权重”(weights)——例如,如果你的缓存值,占据完全不同的内存空间,你可以使用CacheBuilder.weigher(Weigher)指定一个权重函数,并且用CacheBuilder.maximumWeight(long)指定最大权重。
    • 测试定时回收:对定时回收进行测试时,不一定非得花费两秒钟去测试两秒的过期。你可以使用Ticker接口和CacheBuilder.ticker(Ticker)方法在缓存中自定义一个时间源,而不是非得用系统时钟。

3.3 显式清除:

任何时候,你都可以显式地清除缓存项,而不是等到它被回收:

3.4 移除监听器

通过CacheBuilder.removalListener(RemovalListener),你可以声明一个监听器,以便缓存项被移除时做一些额外操作。缓存项被移除时,RemovalListener会获取移除通知[RemovalNotification],其中包含移除原因[RemovalCause]、键和值。

就如我上面的例子一样,当内存回收或者定时回收都会执行removalListener

不过亲测当有数据 refresh 刷新额度时候也会触发这个监听功能

!!! 警告:默认情况下,监听器方法是在移除缓存时同步调用的。因为缓存的维护和请求响应通常是同时进行的,代价高昂的监听器方法在同步模式下会拖慢正常的缓存请求。在这种情况下,你可以使用RemovalListeners.asynchronous(RemovalListener, Executor)把监听器装饰为异步操作

3.5 移除机制

guava做cache时候数据的移除分为被动移除和主动移除两种。

  1. 被动移除分为三种:

    1).基于大小的移除:数量达到指定大小(即maximumSize(MAX_SIZE)设置值),会把不常用的键值移除

    2).基于时间的移除:

    expireAfterAccess(long, TimeUnit)根据某个键值对最后一次访问之后多少时间后移除

expireAfterWrite(long, TimeUnit)根据某个键值对被创建或值被替换后多少时间移除

​ 3).基于引用的移除:主要是基于java的垃圾回收机制,根据键或者值的引用关系决定移除

  1. 主动移除分为三种:

    1).单独移除:Cache.invalidate(key)

    2).批量移除:Cache.invalidateAll(keys)

    3).移除所有:Cache.invalidateAll()

如果配置了移除监听器RemovalListener,则在所有移除的动作时会同步执行该listener下的逻辑。

如需改成异步,使用:RemovalListeners.asynchronous(RemovalListener, Executor)

3.6 刷新:

  1. LoadingCache.refresh(K):刷新和回收不太一样。刷新表示为键加载新值,这个过程可以是异步的。在刷新操作进行时,缓存仍然可以向其他线程返回旧值,而不像回收操作,读缓存的线程必须等待新值加载完成。如果刷新过程抛出异常,缓存将保留旧值,而异常会在记录到日志后被丢弃[swallowed]。

    重载CacheLoader.reload(K, V)可以扩展刷新时的行为,这个方法允许开发者在计算新值时使用旧的值。

  2. CacheBuilder.refreshAfterWrite(long, TimeUnit):可以为缓存增加自动定时刷新功能。和expireAfterWrite相反,refreshAfterWrite通过定时刷新可以让缓存项保持可用,但请注意:

    • 缓存项只有在被检索时才会真正刷新,即只有刷新间隔时间到了你再去get(key)才会重新去执行Loading否则就算刷新间隔时间到了也不会执行loading操作。因此,如果你在缓存上同时声明expireAfterWriterefreshAfterWrite,缓存并不会因为刷新盲目地定时重置,如果缓存项没有被检索,那刷新就不会真的发生,缓存项在过期时间后也变得可以回收。
    • 还有一点比较重要的是refreshAfterWriteexpireAfterWrite两个方法设置以后,重新get会引起loading操作都是同步串行的。这其实可能会有一个隐患,当某一个时间点刚好有大量检索过来而且都有刷新或者回收的话,是会产生大量的请求同步调用loading方法,这些请求占用线程资源的时间明显变长。如正常请求也就20ms,当刷新以后加上同步请求loading这个功能接口可能响应时间远远大于20ms。为了预防这种井喷现象,可以不设置CacheBuilder.refreshAfterWrite(long, TimeUnit),改用LoadingCache.refresh(K)因为它是异步执行的,不会影响正在读的请求,同时使用ScheduledExecutorService可以帮助你很好地实现这样的定时调度,配上cache.asMap().keySet()返回当前所有已加载键,这样所有的key定时刷新就有了。*

4 其他特性

4.1 统计

CacheBuilder.recordStats()用来开启Guava Cache的统计功能。统计打开后,Cache.stats()方法会返回CacheStats对象以提供如下统计信息:

  1. hitRate():缓存命中率;

  2. averageLoadPenalty():加载新值的平均时间,单位为纳秒;

  3. evictionCount():缓存项被回收的总数,不包括显式清除

此外,还有其他很多统计信息。这些统计信息对于调整缓存设置是至关重要的,在性能要求高的应用中我们建议密切关注这些数据。

4.2 asMap视图

asMap视图提供了缓存的ConcurrentMap形式,但asMap视图缓存的交互需要注意:

  1. cache.asMap()包含当前所有加载到缓存的项。因此相应地,cache.asMap().keySet()包含当前所有已加载键;

  2. asMap().get(key)实质上等同于cache.getIfPresent(key),而且不会引起缓存项的加载。这和Map的语义约定一致。

所有读写操作都会重置相关缓存项的访问时间,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合视图上的操作。比如,遍历Cache.asMap().entrySet()不会重置缓存项的读取时间。

4.3 可能遇到的问题

1). 在put操作之前,如果已经有该键值,会先触发removalListener移除监听器,再添加

2). 配置了expireAfterAccessexpireAfterWrite,但在指定时间后没有被移除。

解决方案:CacheBuilder在文档上有说明

If expireAfterWrite or expireAfterAccess is requested entries may be evicted on each cache modification, on occasional cache accesses, or on calls to Cache.cleanUp(). Expired entries may be counted in Cache.size(), but will never be visible to read or write operations.

翻译过来大概的意思是:CacheBuilder构建的缓存不会在特定时间自动执行清理和回收工作,也不会在某个缓存项过期后马上清理,它不会启动一个线程来进行缓存维护,因为

a)线程相对较重

b)某些环境限制线程的创建。它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做。

当然,也可以创建自己的维护线程,以固定的时间间隔调用Cache.cleanUp()

4.5 清理什么时候发生?

​ 使用CacheBuilder构建的缓存不会"自动"执行清理和回收工作,也不会在某个缓存项过期后马上清理,也没有诸如此类的清理机制。相反,它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做——如果写操作实在太少的话。
​ 这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样CacheBuilder就不可用了。

​ 相反,我们把选择权交到你手里。如果你的缓存是高吞吐的,那就无需担心缓存的维护和清理等工作。如果你的 缓存只会偶尔有写操作,而你又不想清理工作阻碍了读操作,那么可以创建自己的维护线程,以固定的时间间隔调用Cache.cleanUp()ScheduledExecutorService可以帮助你很好地实现这样的定时调度。


参考:

Google Guava 3-缓存

如何基于LoadingCache实现Java本地缓存–脚本之家

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值