一次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

bash 复制代码
LoadingCache<Key, Graph> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> createExpensiveGraph(key));

对比一下我们的适用方式

bash 复制代码
Caffeine.newBuilder()
 			.executor(executorService)
            .refreshAfterWrite(12000, TimeUnit.SECONDS)
            .expireAfterWrite(600, TimeUnit.SECONDS)
            .maximumSize(20000)
            .buildAsync(key -> {
                 return callForDemo("demo");
            });

不同之处也就是我们用了异步的cache,定义了自己的线程池,指定了refreshAfterWrite参数为1200秒

好像姿势没什么问题?

这不咱也找到了官方的异步用法

bash 复制代码
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的缓存实例获取缓存

bash 复制代码
        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();
bash 复制代码
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

bash 复制代码
// 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)

基于时间

bash 复制代码
// 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

相关推荐
c++之路11 分钟前
C++20概述
java·开发语言·c++20
Championship.23.2415 分钟前
Linux Top 命令族深度解析与实战指南
java·linux·服务器·top·linux调试
橘子海全栈攻城狮30 分钟前
【最新源码】养老院系统管理A013
java·spring boot·后端·web安全·微信小程序
逻辑驱动的ken36 分钟前
Java高频面试考点18
java·开发语言·数据库·算法·面试·职场和发展·哈希算法
冷雨夜中漫步1 小时前
Claude Code源码分析——Claude Code Agent Loop 详细设计文档
java·开发语言·人工智能·ai
直奔標竿1 小时前
Java开发者AI转型第二十六课!Spring AI 个人知识库实战(五)——联网搜索增强实战
java·开发语言·人工智能·spring boot·后端·spring
快乐非自愿1 小时前
Redis--SDS字符串与集合的底层实现原理
数据库·redis·缓存
one_love_zfl2 小时前
java面试-微服务组件篇
java·微服务·面试
一只大袋鼠2 小时前
Java进阶:CGLIB动态代理解析
java·开发语言
环流_2 小时前
HTTP 协议的基本格式
java·网络协议·http