背景
背景是上游服务接入了博主团队提供的sdk,已经长达3年,运行稳定无异常,随着最近冲业绩,流量越来越大,直至某一天,其中一个接入方(流量很大)告知CPU在慢慢上升且没有回落的迹象,dump文件能看到缓存的holder占用4个G,那不用说了,责无旁贷
打开他们的内存监控,G1的老年代占用大概是这个样子
可以看到,老年代每次gc后使用量都在上升,说明每次能gc的内存越来越少,而cpu也是蹭蹭往上追,直至崩掉
原因
查看dump,能看到我们的缓存对象cacheHoler占用高达4g,其中cacehKey的数量更是高达900w!
首先是惊呆了,这个缓存当初设置的maximumSize可只有2w啊,这900w什么鬼!
caffeine介绍
官方是这么说的,一句话,目前最牛逼的本地java缓存库
Caffeine 是一个高性能Java 缓存库,提供接近最佳的命中率
我们先弄清楚caffeine的原理,是不是使用姿势有问题?
官方的一个小demo
LoadingCache<Key, Graph> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
对比一下我们的适用方式
Caffeine.newBuilder()
.executor(executorService)
.refreshAfterWrite(12000, TimeUnit.SECONDS)
.expireAfterWrite(600, TimeUnit.SECONDS)
.maximumSize(20000)
.buildAsync(key -> {
return callForDemo("demo");
});
不同之处也就是我们用了异步的cache,定义了自己的线程池,指定了refreshAfterWrite参数为1200秒
好像姿势没什么问题?
这不咱也找到了官方的异步用法
AsyncLoadingCache<Key, Graph> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
// Either: Build with a synchronous computation that is wrapped as asynchronous
.buildAsync(key -> createExpensiveGraph(key));
// Or: Build with a asynchronous computation that returns a future
.buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));
思考每个参数的含义
- maximumSize:指定缓存可以包含的最大条目数。请注意,缓存可能会在超过此限制之前逐出某个条目,或者在逐出时暂时超过阈值。当缓存大小增长到接近最大值时,缓存会逐出不太可能再次使用的条目。例如,缓存可能会逐出某个条目,因为它最近未使用或非常经常使用。
- expireAfterAccess:指定在条目创建、最近替换其值或最后一次读取条目后经过固定持续时间后,应自动从缓存中删除每个条目
- refreshAfterWrite:指定在创建条目或最近替换其值后经过固定持续时间后,活动条目就有资格进行自动刷新。当出现对条目的第一个过时请求时,将执行自动刷新。触发刷新的请求将进行异步调用,并立即返回旧值。
一个重要信息:maximumSize的缓存不会立即驱逐
那为题是不是就出现在这里?
多线程模拟
那我们用1k个线程模拟一下从maximumSize为20的缓存实例获取缓存
for (int i = 0; i < 1000; i++) {
int finalI = i;
new Thread(() -> {
UserCharacter uc = new UserCharacter();
uc.setUid(finalI +"");
uc.setStationId(finalI +"111");
configHolder.getAbResultCache(uc, "demo")
.getAbResults();
}).start();
}
//Thread.sleep(2000);
configHolder.getStats();
public void getStats() {
System.out.println( abResultCache.synchronous().asMap());
}
第一次调用,多线程获取缓存后立即查看缓存中的快照map数量为1000,明显超过maximumSize定义的20
第二次调用,添加代码Thread.sleep(5000)
;查看缓存中的快照map数量为20,正好是maximumSize定义的20
第三次调用,添加代码Thread.sleep(2000)
;查看缓存中的快照map数量为100-300不等,说明大于20的缓存条目正在被驱逐
结论
caffeine的缓存驱逐速度在高并发情况下跟不上缓存添加速度,造成内存gc不下来
且旧的缓存会被超过maximumSize的新缓存驱逐,所以20000个缓存其实根本没起到缓存的作用,很快就会被新缓存驱逐,10个线程一直被抢着来进行缓存的添加和驱逐,这也是为什么CPU快要被干爆了
那要怎么优化呢?
驱逐策略
caffeine提供了三类驱逐策略
基于size或者weigh
// Evict based on the number of entries in the cache
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> createExpensiveGraph(key));
// Evict based on the number of vertices in the cache
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumWeight(10_000)
.weigher((Key key, Graph graph) -> graph.vertices().size())
.build(key -> createExpensiveGraph(key));
maximumSize:指定缓存可以包含的最大条目数。请注意,缓存可能会在超过此限制之前逐出某个条目,或者在逐出时暂时超过阈值。当缓存大小增长到接近最大值时,缓存会逐出不太可能再次使用的条目。例如,缓存可能会逐出某个条目,因为它最近未使用或非常经常使用
weigher:如果不同的服务器空间具有不同的“权重”——例如,如果您的服务器值具有不同的内存占用——您可以指定一个权重函数Caffeine.weigher(Weigher)和一个最大的服务器权重Caffeine.maximumWeight(long)
基于时间
// Evict based on a fixed expiration policy
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
// Evict based on a varying expiration policy
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfter(new Expiry<Key, Graph>() {
public long expireAfterCreate(Key key, Graph graph, long currentTime) {
// Use wall clock time, rather than nanotime, if from an external resource
long seconds = graph.creationDate().plusHours(5)
.minus(System.currentTimeMillis(), MILLIS)
.toEpochSecond();
return TimeUnit.SECONDS.toNanos(seconds);
}
public long expireAfterUpdate(Key key, Graph graph,
long currentTime, long currentDuration) {
return currentDuration;
}
public long expireAfterRead(Key key, Graph graph,
long currentTime, long currentDuration) {
return currentDuration;
}
})
.build(key -> createExpensiveGraph(key));
咖啡因提供了快速定时驱动方法:
expireAfterAccess:(long, TimeUnit):指定在条目创建、最近替换其值或最后一次读取条目后经过固定持续时间后,应自动从缓存中删除每个条目。所有缓存读写操作(Cache.asMap().put(K, V)和Cache.asMap().get(Object))都会重置访问时间,但不会通过对 Cache#asMap 的集合视图的操作来重置访问时间。
expireAfterWrite:(long, TimeUnit):指定在创建条目或最近替换其值后经过固定持续时间后,活动条目就有资格进行自动刷新
expireAfter(Expiry):分别定义缓存创建、更新、读取多久后过期
Scheduler: Caffeine.scheduler(Scheduler)使用接口和方法指定调度线程,而不是依赖其他服务器活动来触发实例行维护。提供的调度器可能无法提供实时保证。该计划是尽最大努力的,并且不会对何时删除过期的条目做出任何硬性保证。
基于引用 weakKeys/weakValues/softValues
指定存储在缓存中的每个键或值都应包装在 {@link WeakReference} 中或{@link SoftReference} (默认情况下,使用强引用)。使用以上方法时,生成的缓存将使用标识 ({@code ==}) 比较来确定键的相等性
注意值value支持weakValues和softValues,而key只支持weakKeys
WeakReference:gc就会被回收
SoftReference:gc时如果没有足够的内存时会被回收,如何量化这个内存是否充足,点这里
以上驱逐策略官方建议优先采用maximumSize,除非你对WeakReference和SoftReference的适用相当熟悉并清楚由此产生的后果,不然不建议使用引用驱逐策略。
优化
回归本案例,高峰期我们的cacheHolder里面有900w个缓存实例,而maximumSize设置仅为20000,由于cacheKey是用户维度的,显然20000个key对一下c端服务来说太少了,但是调高maximumSize又会引起cacheHolderi自身占用过多内存,调高线程池的最大线程数又会对争抢正常业务的CPU资源
可能的优化方案有:
- 降低缓存kv的大小,比如缓存v的大小从1k降低到20byte
- 将缓存的过期时间从10分钟调整到1分钟,加速缓存淘汰速度
- 当前缓存获取属于IO密集型业务,可以适当调高线程池最大线程数,以便有更多线程资源被拿来进行缓存驱逐
- 使用专门的Scheduler,与put缓存的线程隔离,专门用来维护缓存的过期刷新等
- 碍于内存压力,考虑使用引用驱逐策略,在内存不足时优先GC缓存
- 如果以上方案都不适用,使用别的方案代替caffeine,比如本地内存、分布式缓存redis等等
参考:https://github.com/ben-manes/caffeine/wiki/Eviction