一次caffeine引起的CPU飙升问题

背景

背景是上游服务接入了博主团队提供的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));

思考每个参数的含义

  1. maximumSize:指定缓存可以包含的最大条目数。请注意,缓存可能会在超过此限制之前逐出某个条目,或者在逐出时暂时超过阈值。当缓存大小增长到接近最大值时,缓存会逐出不太可能再次使用的条目。例如,缓存可能会逐出某个条目,因为它最近未使用或非常经常使用。
  2. expireAfterAccess:指定在条目创建、最近替换其值或最后一次读取条目后经过固定持续时间后,应自动从缓存中删除每个条目
  3. 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资源

可能的优化方案有:

  1. 降低缓存kv的大小,比如缓存v的大小从1k降低到20byte
  2. 将缓存的过期时间从10分钟调整到1分钟,加速缓存淘汰速度
  3. 当前缓存获取属于IO密集型业务,可以适当调高线程池最大线程数,以便有更多线程资源被拿来进行缓存驱逐
  4. 使用专门的Scheduler,与put缓存的线程隔离,专门用来维护缓存的过期刷新等
  5. 碍于内存压力,考虑使用引用驱逐策略,在内存不足时优先GC缓存
  6. 如果以上方案都不适用,使用别的方案代替caffeine,比如本地内存、分布式缓存redis等等

参考:https://github.com/ben-manes/caffeine/wiki/Eviction

  • 15
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值