一、简介

Caffeine 是基于Java 8 开发的、提供了近乎最佳命中率的高性能本地缓存组件,Spring5 开始不再支持 Guava Cache,改为使用 Caffeine。

下面是 Caffeine 官方测试报告。

由上面三幅图可见:不管在并发读、并发写还是并发读写的场景下,Caffeine 的性能都大幅领先于其他本地开源缓存组件。

本文先介绍 Caffeine 实现原理,再讲解如何在项目中使用 Caffeine 。

二、Caffeine 原理

2.1 淘汰算法

2.1.1 常见算法

对于 Java 进程内缓存我们可以通过 HashMap 来实现。不过,Java 进程内存是有限的,不可能无限地往里面放缓存对象。这就需要有合适的算法辅助我们淘汰掉使用价值相对不高的对象,为新进的对象留有空间。常见的缓存淘汰算法有 FIFO、LRU、LFU。

**FIFO(First In First Out):先进先出。**它是优先淘汰掉最先缓存的数据、是最简单的淘汰算法。缺点是如果先缓存的数据使用频率比较高的话,那么该数据就不停地进进出出,因此它的缓存命中率比较低。

**LRU(Least Recently Used):最近最久未使用。**它是优先淘汰掉最久未访问到的数据。缺点是不能很好地应对偶然的突发流量。比如一个数据在一分钟内的前59秒访问很多次,而在最后1秒没有访问,但是有一批冷门数据在最后一秒进入缓存,那么热点数据就会被冲刷掉。

**LFU(Least Frequently Used):**最近最少频率使用。它是优先淘汰掉最不经常使用的数据,需要维护一个表示使用频率的字段。

主要有两个缺点:

一、如果访问频率比较高的话,频率字段会占据一定的空间;

二、无法合理更新新上的热点数据,比如某个歌手的老歌播放历史较多,新出的歌如果和老歌一起排序的话,就永无出头之日。

2.1.2 W-TinyLFU 算法

Caffeine 使用了 W-TinyLFU 算法,解决了 LRU 和LFU上述的缺点。W-TinyLFU 算法由论文《TinyLFU: A Highly Efficient Cache Admission Policy》提出。

它主要干了两件事:

一、采用 Count–Min Sketch 算法降低频率信息带来的内存消耗;

二、维护一个PK机制保障新上的热点数据能够缓存。

如下图所示,Count–Min Sketch 算法类似布隆过滤器 (Bloom filter)思想,对于频率统计我们其实不需要一个精确值。存储数据时,对key进行多次 hash 函数运算后,二维数组不同位置存储频率(Caffeine 实际实现的时候是用一维 long 型数组,每个 long 型数字切分成16份,每份4bit,默认15次为最高访问频率,每个key实际 hash 了四次,落在不同 long 型数字的16份中某个位置)。读取某个key的访问次数时,会比较所有位置上的频率值,取最小值返回。对于所有key的访问频率之和有个最大值,当达到最大值时,会进行reset即对各个缓存key的频率除以2。

如下图缓存访问频率存储主要分为两大部分,即 LRU 和 Segmented LRU 。新访问的数据会进入第一个 LRU,在 Caffeine 里叫 WindowDeque。当 WindowDeque 满时,会进入 Segmented LRU 中的 ProbationDeque,在后续被访问到时,它会被提升到 ProtectedDeque。当 ProtectedDeque 满时,会有数据降级到 ProbationDeque 。数据需要淘汰的时候,对 ProbationDeque 中的数据进行淘汰。具体淘汰机制:取ProbationDeque 中的队首和队尾进行 PK,队首数据是最先进入队列的,称为受害者,队尾的数据称为攻击者,比较两者 频率大小,大胜小汰。

总的来说,通过 reset 衰减,避免历史热点数据由于频率值比较高一直淘汰不掉,并且通过对访问队列分成三段,这样避免了新加入的热点数据早早地被淘汰掉。

2.2 高性能读写

Caffeine 认为读操作是频繁的,写操作是偶尔的,读写都是异步线程更新频率信息。

2.2.1 读缓冲

传统的缓存实现将会为每个操作加锁,以便能够安全的对每个访问队列的元素进行排序。一种优化方案是将每个操作按序加入到缓冲区中进行批处理操作。读完把数据放到环形队列 RingBuffer 中,为了减少读并发,采用多个 RingBuffer,每个线程都有对应的 RingBuffer。环形队列是一个定长数组,提供高性能的能力并最大程度上减少了 GC所带来的性能开销。数据丢到队列之后就返回读取结果,类似于数据库的WAL机制,和ConcurrentHashMap 读取数据相比,仅仅多了把数据放到队列这一步。异步线程并发读取 RingBuffer 数组,更新访问信息,这边的线程池使用的是下文实战小节讲的 Caffeine 配置参数中的 executor。

2.2.2 写缓冲

与读缓冲类似,写缓冲是为了储存写事件。读缓冲中的事件主要是为了优化驱逐策略的命中率,因此读缓冲中的事件完整程度允许一定程度的有损。但是写缓冲并不允许数据的丢失,因此其必须实现为一个安全的队列。Caffeine 写是把数据放入MpscGrowableArrayQueue 阻塞队列中,它参考了JCTools里的MpscGrowableArrayQueue ,是针对 MPSC- 多生产者单消费者(Multi-Producer & Single-Consumer)场景的高性能实现。多个生产者同时并发地写入队列是线程安全的,但是同一时刻只允许一个消费者消费队列。

三、Caffeine 实战

3.1 配置参数

Caffeine 借鉴了Guava Cache 的设计思想,如果之前使用过 Guava Cache,那么Caffeine 很容易上手,只需要改变相应的类名就行。构造一个缓存 Cache 示例代码如下:

//动态调整缓存容量
cache.policy().eviction().ifPresent(eviction -> {eviction.setMaximum(2 * eviction.getMaximum());
});//动态调整缓存有效时间
cache.policy().expireAfterAccess().ifPresent(expiration -> ...);
cache.policy().expireAfterWrite().ifPresent(expiration -> ...);
cache.policy().expireVariably().ifPresent(expiration -> ...);
cache.policy().refreshAfterWrite().ifPresent(expiration -> ...);

Caffeine 类相当于建造者模式的 Builder 类,通过 Caffeine 类配置 Cache,配置一个Cache 有如下参数:

  • expireAfterWrite:写入间隔多久淘汰;
  • expireAfterAccess:最后访问后间隔多久淘汰;
  • refreshAfterWrite:写入后间隔多久刷新,该刷新是基于访问被动触发的,支持异步刷新和同步刷新,如果和 expireAfterWrite 组合使用,能够保证即使该缓存访问不到、也能在固定时间间隔后被淘汰,否则如果单独使用容易造成OOM;
  • expireAfter:自定义淘汰策略,该策略下 Caffeine 通过时间轮算法来实现不同key 的不同过期时间;
  • maximumSize:缓存 key 的最大个数;
  • weakKeys:key设置为弱引用,在 GC 时可以直接淘汰;
  • weakValues:value设置为弱引用,在 GC 时可以直接淘汰;
  • softValues:value设置为软引用,在内存溢出前可以直接淘汰;
  • executor:选择自定义的线程池,默认的线程池实现是 ForkJoinPool.commonPool();
  • maximumWeight:设置缓存最大权重;
  • weigher:设置具体key权重;
  • recordStats:缓存的统计数据,比如命中率等;
  • removalListener:缓存淘汰监听器;
  • writer:缓存写入、更新、淘汰的监听器。

3.2 项目实战

Caffeine 支持解析字符串参数,参照 Ehcache 的思想,可以把所有缓存项参数信息放入配置文件里面,比如有一个 caffeine.properties 配置文件,里面配置参数如下:

users=maximumSize=10000,expireAfterWrite=180s,softValues
goods=maximumSize=10000,expireAfterWrite=180s,softValues

针对不同的缓存,解析配置文件,并加入 Cache 容器里面,代码如下:

@Component
@Slf4j
public class CaffeineManager {private final ConcurrentMap<String, Cache> cacheMap = new ConcurrentHashMap<>(16);@PostConstructpublic void afterPropertiesSet() {String filePath = CaffeineManager.class.getClassLoader().getResource("").getPath() + File.separator + "config"+ File.separator + "caffeine.properties";Resource resource = new FileSystemResource(filePath);if (!resource.exists()) {return;}Properties props = new Properties();try (InputStream in = resource.getInputStream()) {props.load(in);Enumeration propNames = props.propertyNames();while (propNames.hasMoreElements()) {String caffeineKey = (String) propNames.nextElement();String caffeineSpec = props.getProperty(caffeineKey);CaffeineSpec spec = CaffeineSpec.parse(caffeineSpec);Caffeine caffeine = Caffeine.from(spec);Cache manualCache = caffeine.build();cacheMap.put(caffeineKey, manualCache);}}catch (IOException e) {log.error("Initialize Caffeine failed.", e);}}
}

当然也可以把 caffeine.properties 里面的配置项放入配置中心,如果需要动态生效,可以通过如下方式:

private <T> T getTFromRedis(String redisKey, Class targetClass, Supplier supplier) {String data;T value;String redisValue = UUID.randomUUID().toString();if (tryGetDistributedLockWithRetry(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue, 30)) {try {data = getFromRedis(redisKey);if (StringUtils.isEmpty(data)) {value = (T) supplier.get();setToRedis(redisKey, JackSonParser.bean2Json(value));}else {value = json2Bean(targetClass, data);}}finally {releaseDistributedLock(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue);}}else {value = json2Bean(targetClass, getFromRedis(redisKey));}return value;
}

至于是否利用 Spring 的 EL 表达式通过注解的方式使用,仁者见仁智者见智,笔者主要考虑三点:

一、EL 表达式上手需要学习成本;

二、引入注解需要注意动态代理失效场景;

三、EL 表达式对于静态变量、静态类、静态方法支持不友好,需要写明类的全路径,不够简洁。

获取缓存时通过如下方式:

caffeineManager.getCache(cacheName).get(redisKey, value -> getTFromRedis(redisKey, targetClass, supplier));

Caffeine 这种带有回源函数的 get 方法最终都是调用 ConcurrentHashMap 的 compute 方法,它能确保高并发场景下,如果对一个热点 key 进行回源时,单个进程内只有一个线程回源,其他都在阻塞。业务需要确保回源的方法耗时比较短,防止线程阻塞时间比较久,系统可用性下降。

笔者实际开发中用了 Caffeine 和 Redis 两级缓存。Caffeine 的 cache 缓存 key 和 Redis 里面一致,都是命名为 redisKey。targetClass 是返回对象类型,从 Redis 中获取字符串反序列化成实际对象时使用。supplier 是函数式接口,是缓存回源到数据库的业务逻辑。

getTFromRedis 方法实现如下:

private <T> T getTFromRedis(String redisKey, Class targetClass, Supplier supplier) {String data;T value;String redisValue = UUID.randomUUID().toString();if (tryGetDistributedLockWithRetry(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue, 30)) {try {data = getFromRedis(redisKey);if (StringUtils.isEmpty(data)) {value = (T) supplier.get();setToRedis(redisKey, JackSonParser.bean2Json(value));}else {value = json2Bean(targetClass, data);}}finally {releaseDistributedLock(redisKey + RedisKey.DISTRIBUTED_SUFFIX, redisValue);}}else {value = json2Bean(targetClass, getFromRedis(redisKey));}return value;
}

由于回源都是从 MySQL 查询,虽然 Caffeine 本身解决了进程内同一个 key 只有一个线程回源,需要注意多个业务节点的分布式情况下,如果 Redis 没有缓存值,并发回源时会穿透到 MySQL ,所以回源时加了分布式锁,保证只有一个节点回源。

注意一点:从本地缓存获取对象时,如果业务要对缓存对象做更新,需要深拷贝一份对象,不然并发场景下多个线程取值会相互影响。

笔者项目之前都是使用 Ehcache 作为本地缓存,切换成 Caffeine 后,涉及本地缓存的接口,同样 TPS 值时,CPU 使用率能降低 10% 左右,接口性能都有一定程度提升,最多的提升了 25%。上线后观察调用链,平均响应时间降低24%左右。

四、总结

Caffeine 是目前比较优秀的本地缓存解决方案,通过使用 W-TinyLFU 算法,实现了缓存高命中率、内存低消耗。如果之前使用过 Guava Cache,看下接口名基本就能上手。如果之前使用的是 Ehcache,笔者分享的使用方式可以作为参考。

五、参考文献

  1. TinyLFU: A Highly Efficient Cache Admission Policy
  2. Design Of A Modern Cache
  3. Caffeine Github

作者: Zhang Zhenglin

来源:微信公众号:vivo互联网技术

总结

面试前的“练手”还是很重要的,所以开始面试之前一定要准备好啊,不然也是耽搁面试官和自己的时间。

我自己是刷了不少面试题的,所以在面试过程中才能够做到心中有数,基本上会清楚面试过程中会问到哪些知识点,高频题又有哪些,所以刷题是面试前期准备过程中非常重要的一点。

下面我就把我整理的面试资料分享给有需要的读者朋友——戳这里免费获取

面试题及解析总结

大厂面试场景

知识点总结

有数,基本上会清楚面试过程中会问到哪些知识点,高频题又有哪些,所以刷题是面试前期准备过程中非常重要的一点。

下面我就把我整理的面试资料分享给有需要的读者朋友——戳这里免费获取

面试题及解析总结

[外链图片转存中…(img-oedGWgif-1623749016421)]

大厂面试场景

[外链图片转存中…(img-rXtefup4-1623749016422)]

知识点总结

膜拜大佬!高性能缓存-Caffeine-原理及实战相关推荐

  1. 高性能缓存 Caffeine 原理及实战

    一.简介 Caffeine 是基于Java 8 开发的.提供了近乎最佳命中率的高性能本地缓存组件,Spring5 开始不再支持 Guava Cache,改为使用 Caffeine. 下面是 Caffe ...

  2. Redis缓存设计原理及实战

    缓存是什么? 一个系统中的不同层之间的访问速度不一样,所以我们才需要缓存,这样就可以把一些需要频繁访问的数据放在缓存中,以加快它们的访问速度. 为了让你能更好地理解,我以计算机系统为例,来解释一下.下 ...

  3. Java本地高性能缓存的几种实现方式

    Java缓存技术可分为远端缓存和本地缓存,远端缓存常用的方案有著名的redis和memcache,而本地缓存的代表技术主要有HashMap,Guava Cache,Caffeine和Encahche. ...

  4. 京东架构师最新出品《分布式缓存原理到实战剖析手册》,限时开源

    分布式缓存所带来的诸多问题 当下互联网领域的软件系统,毫不夸张地说已经成为分布式系统的天下. 随着现代应用对速度的要求越来越高,对缓存机制的使用也越来越常见.越来越频繁.分布式的语境给系统的开发与设计 ...

  5. Redis一通百通~P8架构师带你玩转Redis高性能缓存设计实战

    前言 高并发十分考验架构师功底,它也是分布式架构设计中必须考虑的因素之一.要知道,光靠服务器堆是没有出路的. 想看看大牛是怎么面对高并发的?想知道BATJ大厂是怎么设计高可用架构的?这里有可参考的实践 ...

  6. 来自未来的缓存 Caffeine,带你揭开它的神秘面纱

    作者 | Garnett 来源 | Garnett的Java之路(ID:gh_009246af52d4) 头图 |  CSDN 下载自东方IC caffeine是什么,它和redis什么区别,有哪些作 ...

  7. Spring Boot进阶:原理、实战与面试题分析

    在当下的互联网应用中,业务体系日益复杂,业务功能也在不断地变化.以典型的电商类应用为例,其背后的业务功能复杂度以及快速迭代要求的开发速度,与5年前的同类业务系统相比,面临着诸多新的挑战. 这些挑战中核 ...

  8. Linux 系统调用深思:从原理到实战

    Linux 系统调用揭秘:从原理到实战(Demystifying Linux System Calls: From Principles to Practice) 引言(Introduction) 系 ...

  9. 来!带你深入理解分布式事务:原理与实战!

    随着互联网的不断发展,互联网企业的业务在飞速变化,推动着系统架构也在不断地发生变化.总体来说,系统架构大致经历了 单体应用架构→垂直应用架构→分布式架构→SOA架构→微服务架构的演变. 如今微服务技术 ...

最新文章

  1. C#与Excel互操作
  2. 征途猎魔mysql数据库_猎魔征途-猎魔征途官方首测版 - 超好玩
  3. python卸载pip重新安装_pip无法正常使用卸载并重新安装
  4. sorry for yesterday
  5. java-2018-01-17计划
  6. 百度移动应用安卓_昔日明星91助手和安卓市场“下线” 百度那19亿美元值不值?...
  7. 河南彩民中奖3亿5千万
  8. oracle expdp数据到txt,[20130727]ORACLE 12C使用expdp导出view数据.txt
  9. 为什么单片机的代码在Flash中运行,单片机的代码运行位置跟电脑有什么不同?
  10. 内网监控利器——Nagios
  11. 微分方程数值解法(2)——椭圆型方程的有限差分法
  12. disc性格测试cs适合职业_DISC职业性格测试
  13. 从《目标》、《凤凰项目》到《持续交付》:DevOps 的过去、现在及未来
  14. 一键美化python代码
  15. 中南大学工商管理考研考情与难度、参考书及上岸前辈备考经验
  16. 百宝,神烦云免费网络验证autojs实例代码
  17. matlab等高线线型设置命令,MATLAB等高线命令
  18. 辩证法——自然观、自然科学方法论和科学观
  19. Microchip最新推出的ATMEGA4809-XPRO开发板简介
  20. 保险公司如何为数字化转型做准备

热门文章

  1. 外语转录与翻译的音频
  2. 5.6.2. Removing a Column
  3. 基于Amos路径分析的模型拟合参数详解
  4. 使用 xlsx 下载 xlsx 文件
  5. 基于微信小程序springboot粤味早茶店微信扫码点餐系统源码和论文
  6. c语言之判断电压最大值最小值,《单片机和C语言编程》..ppt
  7. 计算机科学双一流大,南京市不起眼的4所好大学,第3所跻身“双一流”板上钉钉...
  8. js判断某个变量是否等于undefined
  9. 超宽带技术的定位原理(转载)
  10. 融云android聊天界面,Android 融云IM集成以及使用详解(一)