Guava Cache介绍及使用中问题排查过程分享

一、Guava Cache适用背景:

几种缓存的简介:

缓存在日常开发中举足轻重,如果你的应用对某类数据有着较高的读取频次、并且改动较小时(读远大于写),那就非常适合利用缓存来提高性能,这样可以减少对数据库的访问。但也不是什么好处都占,读取速度快了但是缓存所占用的内存更多,资源也是很宝贵的,所以我们应当缓存真正需要的数据,也就是典型的空间换时间。下面简要的列举下 Java 应用中所用到的缓存。

JVM 缓存

首先是 JVM 缓存,也可以认为是堆缓存。其实就是创建一些全局变量,如 Map、List 之类的容器用于存放数据。

这样的优势是使用简单但是也有以下问题:

  • 只能显式的写入,清除数据。
  • 不能按照一定的规则淘汰数据,如 LRU,LFU,FIFO 等。
  • 清除数据时的回调通知。
  • 其他一些定制功能等。

如果想要实现上述功能需要额外的代码来实现。

Ehcache、Guava Cache

所以出现了一些专门用作 JVM 缓存的开源工具出现了,如上述提到的 Guava Cache。

它具有上文 JVM 缓存不具有的功能,如自动清除数据、多种清除算法、清除回调等。

但也正因为有了这些功能,这样的缓存必然会多出许多东西需要额外维护,自然也就增加了系统研发维护、系统资源的消耗。

分布式缓存

刚才提到的两种缓存其实都是堆内缓存,只能在单个节点中使用,这样在分布式场景下就招架不住了。

于是也有了一些缓存中间件,如 Redis、Memcached,在分布式环境下可以共享内存。

但这些也存在网络交互带来的时效性能损耗。

Guava Cache:

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

通常来说,Guava Cache适用于:

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

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

:如果你不需要Cache中的特性,使用ConcurrentHashMap有更好的内存效率——但Cache的大多数特性都很难基于旧有的ConcurrentMap复制,甚至根本不可能做到

二、Guava Cahche核心类及方法介绍:

1.CacheBuilder:用于构建缓存并制定相关缓存规则

CacheBuilder是一个用于构建Cache的类,是建造者模式的一个例子,主要的方法有:

  • maximumSize(long maximumSize): 设置缓存存储的所有元素的最大个数。(慎重设置
  • maximumWeight(long maximumWeight): 设置缓存存储的所有元素的最大权重。
  • expireAfterAccess(long duration, TimeUnit unit): 设置元素在最后一次访问多久后过期。
  • expireAfterWrite(long duration, TimeUnit unit): 设置元素在写入缓存后多久过期。
  • concurrencyLevel(int concurrencyLevel): 设置并发水平,即允许多少线程无冲突的访问Cache,默认值是4,该值越大,LocalCache中的segment数组也会越大,访问效率越高,当然空间占用也大一些。
  • removalListener(RemovalListener<? super K1, ? super V1> listener): 设置元素删除通知器,在任意元素无论何种原因被删除时会调用该通知器。
  • setKeyStrength(Strength strength): 设置元素的key是强引用,还是弱引用,默认强引用,并且该属性也指定了EntryFactory使用是强引用还是弱引用。
  • setValueStrength(Strength strength) : 设置元素的value是强引用,还是弱引用,默认强引用。

2.CacheLoader与Callable:对缓存的加载,即“put”的动作如何实现

CacheLoader

LoadingCache是附带CacheLoader构建而成的缓存实现。创建自己的CacheLoader通常只需要简单地实现V load(K key) throws Exception方法。例如,你可以用下面的代码构建LoadingCache:

LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
        .maximumSize(1000)
        .build(
            new CacheLoader<Key, Graph>() {
                public Graph load(Key key) throws AnyException {
                    return createExpensiveGraph(key);
                }
            });


...
try {
    return graphs.get(key);
} catch (ExecutionException e) {
    throw new OtherException(e.getCause());
}

从LoadingCache查询的正规方式是使用get(K)方法。这个方法要么返回已经缓存的值,要么使用CacheLoader向缓存原子地加载新值。由于CacheLoader可能抛出异常,LoadingCache.get(K)也声明为抛出ExecutionException异常。如果你定义的CacheLoader没有声明任何检查型异常,则可以通过getUnchecked(K)查找缓存;但必须注意,一旦CacheLoader声明了检查型异常,就不可以调用getUnchecked(K)。

LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
        .expireAfterAccess(10, TimeUnit.MINUTES)
        .build(
            new CacheLoader<Key, Graph>() {
                public Graph load(Key key) { // no checked exception
                    return createExpensiveGraph(key);
                }
            });


...
return graphs.getUnchecked(key);

getAll(Iterable<? extends K>)方法用来执行批量查询。默认情况下,对每个不在缓存中的键,getAll方法会单独调用CacheLoader.load来加载缓存项。如果批量的加载比多个单独加载更高效,你可以重载CacheLoader.loadAll来利用这一点。getAll(Iterable)的性能也会相应提升。

注:CacheLoader.loadAll的实现可以为没有明确请求的键加载缓存值。例如,为某组中的任意键计算值时,能够获取该组中的所有键值,loadAll方法就可以实现为在同一时间获取该组的其他键值校注:getAll(Iterable<? extends K>)方法会调用loadAll,但会筛选结果,只会返回请求的键值对。

Callable

所有类型的Guava Cache,不管有没有自动加载功能,都支持get(K, Callable<V>)方法。这个方法返回缓存中相应的值,或者用给定的Callable运算并把结果加入到缓存中。在整个加载方法完成前,缓存项相关的可观察状态都不会更改。这个方法简便地实现了模式"如果有缓存则返回;否则运算、缓存、然后返回"。

以上两种,都实现了一种逻辑:先取缓存——取不到相关key对应的内容,再执行load 或者call方法中自己实现的逻辑来获取相关数据并缓存起来

简单比较,CacheLoader 是按key统一加载,所有取不到的统一执行一种load逻辑;而callable方法允许在get的时候指定key,即:

在使用缓存前,有没有【默认方法】来加载或计算与键关联的值?如果有的话,你应当使用CacheLoader。如果没有/或者针对于某一个key想要覆盖默认的加载运算。你应该在调用get时获取这个kye的时候传入一个Callable实例并实现加载逻辑。

3.缓存回收策略

一个残酷的现实是,我们几乎一定没有足够的内存缓存所有数据。你你必须决定:什么时候某个缓存项就不值得保留了?Guava Cache提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。

基于容量的回收(size-based eviction)

如果要规定缓存项的数目不超过固定值,只需使用CacheBuilder.maximumSize(long)。缓存将尝试回收最近没有使用或总体上很少使用的缓存项。——警告:在缓存项的数目达到限定值之前,缓存就可能进行回收操作——通常来说,这种情况发生在缓存项的数目逼近限定值时。

定时回收(Timed Eviction)

CacheBuilder提供两种定时回收的方法:

  • expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。
  • expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。

定时回收周期性地在写操作中执行,偶尔在读操作中执行。

基于引用的回收(Reference-based Eviction)

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

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

4.清理什么时候发生?

使用CacheBuilder构建的缓存不会"自动"执行清理和回收工作,也不会在某个缓存项过期后马上清理,也没有诸如此类的清理机制。相反,它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做——如果写操作实在太少的话。

这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样CacheBuilder就不可用了。

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

5.刷新

刷新和回收不太一样。正如LoadingCache.refresh(K)所声明,刷新表示为键加载新值,这个过程可以是异步的。在刷新操作进行时,缓存仍然可以向其他线程返回旧值。

如果刷新过程抛出异常,缓存将保留旧值,而异常会在记录到日志后被丢弃[swallowed]。

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

//有些键不需要刷新,并且我们希望刷新是异步完成的
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
        .maximumSize(1000)
        .refreshAfterWrite(1, TimeUnit.MINUTES)
        .build(
            new CacheLoader<Key, Graph>() {
                public Graph load(Key key) { // no checked exception
                    return getGraphFromDatabase(key);
                }


                public ListenableFuture<Key, Graph> reload(final Key key, Graph prevGraph) {
                    if (neverNeedsRefresh(key)) {
                        return Futures.immediateFuture(prevGraph);
                    }else{
                        // asynchronous!
                        ListenableFutureTask<Key, Graph> task=ListenableFutureTask.create(new Callable<Key, Graph>() {
                            public Graph call() {
                                return getGraphFromDatabase(key);
                            }
                        });
                        executor.execute(task);
                        return task;
                    }
                }
            });

CacheBuilder.refreshAfterWrite(long, TimeUnit)可以为缓存增加自动定时刷新功能。和expireAfterWrite相反,refreshAfterWrite通过定时刷新可以让缓存项保持可用,但请注意:缓存项只有在被检索时才会真正刷新(如果CacheLoader.refresh实现为异步,那么检索不会被刷新拖慢)。因此,如果你在缓存上同时声明expireAfterWrite和refreshAfterWrite,缓存并不会因为刷新盲目地定时重置,如果缓存项没有被检索,那刷新就不会真的发生,缓存项在过期时间后也变得可以回收。

三、路由系统Guava Cache使用中遇到的问题、排查过程、修复策略:

路由系统对guava cache使用是经历了一个演化过程的:从一开始完全基于guava cache到最后部分功能放弃基于其框架实现,自主编写相关实现逻辑,这一切是根据业务诉求及上下游对接口性能要求不断进行迭代的。

从整体上看,路由系统当前对于guava cache的使用基本上有限的依赖于缓存的初始化创建查询的实现、刷新单独key等,整体框架如下图:

在使用过程中遇到了一些问题,在此分享给大家排查问题的过程以及一些有效的优化action

问题一:对于cache中存储的key结构的错误使用

guava cache的设计理念个人认为更倾向于对于那些热门缓存进行本地存储,减少对数据库、对外部缓存服务器的访问,从而达到提高处理性能的目标(这种目标可能体现在tp99要求很高的服务上,路由降级接口为例,期望tp99性能20ms以内)。网络开销哪怕只有1ms,那么多次重复请求,也会降低服务响应速度。

因为存储的空间是有限的,不可能无限制的增加,所以guava cache会对那些接近“溢出”的情况做处理,根据我们配置的清楚策略去掉那些“更少”使用的key,保留下更多被触及的缓存信息。

对于路由系统,我们首先介绍地址组的概念,再来看问题出在哪:

地址组:在一个组内(每个组是独立的唯一的id),包含多个省、市、县、乡四级地址

地址组信息在系统当前缓存中的key结构:省+市+县+乡+组id

初始化时,缓存中地址组信息:来源于数据库中,但实际业务配置的能够被缓存的key远小于省数量*市数量*县数量*乡数量

但在请求过程中,传递进来的参数在经过处理后,在查询cache时,所拼装出来的key跟我们初始化的时候从库里查出来地址+地址组id相比并不是一个数量级,每天不间断的、全国各个地方的四级地址大规模查询过程中,实际查询的key组装出来的是省数量*市数量*县数量*乡数量*配置过的地址组数量,这就导致了在每一次查询中都认为key是新的,需要查询数据库,而每一个查询触发的线程又会存在多次比对不同地址组是否存在该地址的行为,这就在一次请求中,产生了多次查mysql的情况,性能也就随之下降。

当时存在的现象就是地址组配置后被使用的越多,查询性能呈现正比下降。

后来为了解决这一问题,临时采取取消load()里的对数据库的查询实现,不再要求每一次get()在缓存未查到时去数据库刷新这些key。

当然会存在一个问题,就是页面上有变更,我们通过get()就无法拿到最新的数据信息。不过当时我们有另一套机制,通过在页面变更地址组信息时触发jmq进行广播,再有全部具有该缓存的应用主机进行消费处理,刷新相关key对应的缓存。

当然,问题一的解决方式并不是针对我们发现的问题最彻底的解决方式。这里只是提出问题所在,希望如果其他伙伴使用要多关注key的设计,避免出现这种问题,真正要解决该问题,需要对这里进行key的逻辑改动,目前也在优化排期中。

问题二:get(key)与getIfPresent(key)的区别

通过问题一的修复手段,我们短暂的迎来的性能的稳定,但是当时地址组相关使用没有那么多。

在疫情期间时效降级被更广泛的使用,地址组相关的配置也越来越多,性能再次出现了上述所说的问题,虽然这一次正比曲线上升趋势没有问题一期间那么多,但是还是出现了性能下降,cpu使用偏高,吞吐量也随之下降,特别是此时请求量并没有那么大。

得益于对jstack的抓取和分析,我们发现如下

现在大部分CPU消耗,都在抢guava的分段锁。分析应该是很多线程都在并发读相同的key导致的,抢不到锁会自旋,CPU就会飙高,所以TP99就下降了。

得到上述论点后,分析源码得到结论,在使用get(key)方法获取对应数据的时候,如果从缓存中没有获取到,会内部会调用load(key)。

而guava在实现load()的逻辑中会加锁,这就导致了其他线程只能等待load()结束(哪怕load方法我们只是单纯的返回了null),所以发现上述问题后,切换了我们使用的get方法getIfPresent(key)。

经过上述优化后,性能获得比较大的提升。

源码相关:

guava/guava/src/com/google/common/cache at master · google/guava · GitHub

参考文章:

3-缓存 | Google Guava 中文教程

[Google Guava] 3-缓存 | 并发编程网 – ifeve.com

Guava 源码分析之Cache的实现原理 | 并发编程网 – ifeve.com

百度安全验证

Java并发编程-cpu的高速缓存_文白兄的博客-优快云博客_java高速缓存

Guava Cache 原理分析与最佳实践_阿里巴巴淘系技术团队官网博客的博客-优快云博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值