本地缓存无冕之王 Caffeine Cache
本文已收录至 GitHub,推荐阅读 👉 Java随想录
微信公众号:Java 随想录
原创不易,注重版权。转载请注明原作者和原文链接
在常规的软件开发流程中,缓存的重要性日益凸显。它不仅为用户带来了更迅速的反馈时间,还能在大多数情况下有效减轻系统负荷。
本篇文章将详述一个本地缓存框架:「Caffeine Cache」。
Caffeine Cache 以其高性能和可扩展性赢得「本地缓存之王」的称号,它是一个 Java 缓存库。它的设计目标是优化计算速度、内存效率和实用性,以符合现代软件开发者的需求。
Spring Boot 1.x 版本中的默认本地缓存是 Guava Cache。在 Spring5 (SpringBoot 2.x)后,Spring 官方放弃了 Guava Cache 作为缓存机制,而是使用性能更优秀的 Caffeine 作为默认缓存组件,这对于 Caffeine 来说是一个很大的肯定。
接下来,我们会详细介绍 Caffeine Cache 的特性和应用,并将这个高效的缓存工具无缝集成到你的项目中。
淘汰算法
在解析 Caffeine Cache 之前,我们首先要理解缓存淘汰算法。一个优秀的淘汰算法能使效率大幅提升,因为缓存过程总会伴随着数据的淘汰。
FIFO(First In First Out)先进先出:
以时序为基准,先进入缓存的数据会被先淘汰。当缓存满时,把最早放入缓存的数据淘汰掉。
优点:实现简单,对于某些不常重复请求的应用效果较好。
缺点:并未考虑到数据项的访问频率和访问时间,可能淘汰的是最近和频繁访问的数据。
LRU(Least Recently Used)最近最久未使用:
此算法根据数据的历史访问记录来进行决策,最久未被访问的数据将被淘汰。LRU 通过维护一个所有缓存项的链表,新数据插入到链表的头部,如果缓存满了,就会从链表尾部开始移除数据。
优点:LRU 考虑了最近的数据访问模式,对于局部性原理的表现优秀,简单实用。
缺点:不能体现数据的访问频率,如果数据最近被访问过,即使访问频度低也不会被淘汰。比如,如果一个数据在一分钟的前 59 秒被频繁访问,而在最后一秒无任何访问,但是有一批冷门数据在最后一秒进入缓存,那么热点数据可能会被淘汰掉。
LFU(Least Frequently Used)最近最少频率使用:
其基本原理是对每个在缓存中的对象进行计数,记录其被访问的次数。当缓存满了需要淘汰某些对象时,LFU 算法会优先淘汰那些被访问次数最少的对象。
优点:LFU 能够较好地处理长期访问稳定、频率较高的情况,因为这样可以确保频繁访问的对象不容易被淘汰。
缺点:对于一些暂时高频访问但之后不再访问的对象,LFU 无法有效处理。因为这些对象的访问次数已经非常高,之后即使不再访问,也不容易被淘汰,可能造成缓存空间的浪费。并且 LFU 需要维护所有对象的访问计数,这可能会消耗比较多的存储空间和计算资源。
W-TinyLFU( Window Tiny Least Frequently Used):
Caffeine 使用的就是 Window TinyLfu 淘汰策略,此策略提供了一个近乎最佳的命中率。
看名字就能大概猜出来,它是 LFU 的变种,它在面临缓存换页(即缓存空间不足而需要替换旧缓存项)问题时,通过统计频率信息来选择最佳的候选项进行替换。
工作原理:
频率滤波:W-TinyLFU 使用一个小型的滑动窗口记录最近访问过的对象,以捕获对象的使用频率。这个窗口内的数据被插入到一个 LFU 计数器中,该计数器基于频率清除最少使用的对象。
突发性适应:W-TinyLFU 还包含一个 Admission Window,用于对新添加到缓存的项进行跟踪,以便能够处理突然出现的新热点数据。
替换策略:当缓存满了,且有新的元素需要加入时,W-TinyLFU 使用频率信息选择最少使用的条目进行替换。如果新条目的使用频率较高,那么将替换掉使用频率较低的老条目;如果新项的使用频率较低,则可能会被拒绝。
相较于传统的 LRU 和 LFU 策略,W-TinyLFU 具有以下优点:
平衡了最近性和频率:与 LRU 相比,W-TinyLFU 不仅考虑了最近使用的情况,还计算了缓存的热门程度。与 LFU 相比,它不会让长时间以前非常热门但现在很少使用的数据占据大量的空间。
计数器限制:TinyLFU 使用一个固定大小的计数滤波器来跟踪访问频率,这使得其内存占用远低于传统的 LFU 策略。
适应性强:W-TinyLFU 可以更好地适应工作负载的变化,因为它对频率的计数有一个时间窗口。这使得 W-TinyLFU 能够避免过多地重视早期的访问模式,并能更快地适应最近的访问模式。
避免缓存污染:由于它维护了一个 admission window,它可以避免一次性的、大规模的请求可能带来的缓存污染。
缺点:
需要维护额外的频率信息,增加了一些开销。
不如 LRU 算法实现简单。对于不同的使用场景,需要调整参数以获得最佳性能。
总的来说,W-TinyLFU 是一个复杂性高、灵活性强的缓存算法,对于识别和处理长期和突发的热数据表现良好,但相比于更简单的算法如 LRU,它需要更多的资源和精细的配置。
Cache 类型
Caffeine 共提供了四种类型的 Cache,对应着四种加载策略。
Cache
最普通的一种缓存,无需指定加载方式,需要手动调用put()
进行加载。需要注意的是,put()方法对于已存在的 key 将进行覆盖。
在获取缓存值时,如果想要在缓存值不存在时,原子地将值写入缓存,则可以调用get(key, k -> value)
方法,该方法将避免写入竞争。
多线程情况下,当使用get(key, k -> value)
时,如果有另一个线程同时调用本方法进行竞争,则后一线程会被阻塞,直到前一线程更新缓存完成;而若另一线程调用getIfPresent()
方法,则会立即返回 null,不会被阻塞。
Loading Cache
LoadingCache 是一种自动加载的缓存。其和普通缓存不同的地方在于,当缓存不存在或已过期时,若调用 get()方法,则会自动调用 CacheLoader.load()方法加载最新值,调用 getAll()方法将遍历所有的 key 调用 get(),除非实现了 CacheLoader.loadAll()方法。
使用 LoadingCache 时,需要指定 CacheLoader,并实现其中的 load()方法供缓存缺失时自动加载。
多线程情况下,当两个线程同时调用get()
,则后一线程将被阻塞,直至前一线程更新缓存完成。
LoadingCache 特别实用,我们可以在 load 方法里配置逻辑,缓存不存在的时候去数据库加载,可以实现多级缓存。
Async Cache
AsyncCache 是 Cache 的一个变体,其响应结果均为CompletableFuture
。
通过这种方式,AsyncCache 对异步编程进行了适配。默认情况下,缓存计算使用ForkJoinPool.commonPool()
作为线程池,如果想要指定线程池,则可以覆盖并实现Caffeine.executor(Executor)
方法。
多线程情况下,当两个线程同时调用 get(key, k -> value),则会返回「同一个 CompletableFuture 对象」。由于返回结果本身不进行阻塞,可以根据业务设计自行选择阻塞等待或者非阻塞。
Async Loading Cache
看名字就知道,显然这是 Loading Cache 和 Async Cache 的功能组合。Async Loading Cache 支持以异步的方式,对缓存进行自动加载。
以下是如何创建一个 Async Loading Cache 的缓存示例:
Async Loading Cache 也特别实用,有些业务场景我们 Load 数据的时间会比较长,这时候就可以使用 Async Loading Cache,避免 Load 数据阻塞。
驱逐策略
Caffeine 提供了 3 种回收策略:基于大小回收,基于时间回收,基于引用回收。
基于大小的过期方式
基于大小的回收策略有两种方式:一种是基于缓存大小,一种是基于权重。
注意:maximumWeight 与 maximumSize 不可以同时使用,这是因为它们都是用来限制缓存大小的机制。二者之间需要做出选择。
基于时间的过期方式
Caffeine 提供了三种定时驱逐策略:
expireAfterAccess(long, TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该 key,那么这个缓存将一直不会过期。
expireAfterWrite(long, TimeUnit):在最后一次写入缓存后开始计时,在指定的时间后过期。
expireAfter(Expiry):自定义策略,过期时间由 Expiry 实现独自计算。
基于引用的过期方式
Java 中四种引用类型
注意:AsyncLoadingCache 不支持弱引用和软引用,并且 Caffeine.weakValues()和 Caffeine.softValues()不可以一起使用。
写入外部存储
CacheWriter 方法可以将缓存中所有的数据写入到第三方,避免数据在服务重启的时候丢失。
统计
Caffeine 内置了数据收集功能,通过Caffeine.recordStats()
方法,可以打开数据收集。这样Cache.stats()
方法将会返回当前缓存的一些统计指标,例如:
hitRate:查询缓存的命中率。
evictionCount:被驱逐的缓存数量。
averageLoadPenalty:新值被载入的平均耗时。
SpringBoot 集成 Caffeine Cache
在 Caffeine Cache 的介绍结束后,接下来介绍如何在项目中顺利集成 Caffeine Cache。
话不多说,直接开始上手实践吧。
首先在 pom.xml 文件中添加 Spring Boot Starter Cache 和 Caffeine 的 Maven 依赖:
其次,创建一个配置类,并创建一个 CacheManager Bean:
Caffeine 类使用了建造者模式,主要有如下配置参数:
initialCapacity:缓存初始容量。
maximumSize:设置缓存的最大条目数。当缓存达到这个大小时,它会开始进行清除。
maximumWeight:设置缓存的最大权重。需要同时定义一个
Weigher<K,V>
来如何计算缓存条目的权重。weigher:定义了如何计算每个缓存条目的权重。
expireAfterAccess:设置在特定时间段后访问缓存项后,会使其过期。
expireAfterWrite:设置在特定时间段后写入(或修改)缓存项后,会使其过期。
此方法定义了写入缓存项后的特定时间段,之后该缓存项将被异步刷新。
refreshAfterWrite:此方法定义了写入缓存项后的特定时间段,之后该缓存项将被刷新。
weakKeys:设置缓存 key 为弱引用,在 GC 时可以直接淘汰。
weakValues:设置 value 为弱引用,在 GC 时可以直接淘汰。
softValues:设置缓存 value 为软引用,在内存溢出前可以直接淘汰。
recordStats:启用缓存的统计数据,比如命中率等。
removalListener:设置缓存淘汰监听器。当缓存中的某个条目被移除时会被调用。可以用来记录日志、触发某个操作。
除了配置 Bean 的方式,还可以用配置文件的方式:
配置完毕之后,就可以直接配合注解进行使用了:
在这个例子中,我们在 getItem 方法上添加了 @Cacheable 注解,每次调用该方法时,Spring 首先查找名item
的 cache 中是否有对应 id 的条目。如果有,就返回缓存的值,否则调用方法并将结果存入 cache。
注解使用方式
注解使用方式,相关的常用注解包括:
@Cacheable:表示该方法支持缓存。当调用被注解的方法时,如果对应的键已经存在缓存,则不再执行方法体,而从缓存中直接返回。当方法返回 null 时,将不进行缓存操作。
@CachePut:表示执行该方法后,其值将作为最新结果更新到缓存中。每次都会执行该方法。
@CacheEvict:表示执行该方法后,将触发缓存清除操作。
@Caching:用于组合前三个注解,例如:
这类注解也可以使用在类上,表示该类的所有方法都支持对应的缓存注解。
其中,最常用的是 @Cacheable,@Cacheable 注解常用的属性如下:
cacheNames/value:缓存组件的名字,即 cacheManager 中缓存的名称。
key:缓存数据时使用的 key。默认使用方法参数值,也可以使用 SpEL 表达式进行编写。
keyGenerator:和 key 二选一使用。
cacheManager:指定使用的缓存管理器。
condition:在方法执行开始前检查,在符合 condition 的情况下,进行缓存。
unless:在方法执行完成后检查,在符合 unless 的情况下,不进行缓存。
sync:是否使用同步模式。若使用同步模式,在多个线程同时对一个 key 进行 load 时,其他线程将被阻塞。
Spring Cache 还支持 Spring Expression Language (SpEL) 表达式。你可以通过 SpEL 在缓存名称或键中插入动态值。
举几个例子:
它将使用传递给 findBook
方法的 isbn
参数的值。
你也可以用更复杂的 SpEL 表达式,例如:
#root.args[0]
指的是方法调用的第一个参数,也就是 isbn
。
还有包含条件表达式的例子:
只有当 checkWarehouse
参数为 true
时,才会应用缓存。
缓存同步模式
@Cacheable
默认的行为模式是不同步的。这意味着如果你在多线程环境中使用它,并且有两个或更多的线程同时请求相同的数据,那么可能会出现缓存击穿的情况。也就是说,所有请求都会达到数据库,因为在第一个请求填充缓存之前,其他所有请求都不会发现缓存项。
Spring 4.1 引入了一个新属性sync
来解决这个问题。如果设置@Cacheable(sync=true)
,则只有一个线程将执行该方法并将结果添加到缓存,其他线程将等待。
以下是代码示例:
在这个例子中,无论有多少线程尝试使用相同的 ISBN 查找相同的书,只有一个线程会实际执行findBook
方法并将结果存储在名为"books"的缓存中。其他线程将等待,然后从缓存中获取结果,而不需要执行findBook
方法。
在不同的 Caffeine 配置下,同步模式表现不同:
本篇文章的内容至此告一段落,最后做个小总结,希望这篇文章能够给你带来收获和思考。
在这篇文章中,我们深入探讨了 Caffeine Cache 以及其淘汰算法的内部工作原理。我们还详细介绍了如何在 SpringBoot 应用程序中集成 Caffeine Cache。希望读者通过本文能深入理解 Caffeine Cache 的优势并在实践中有效应用。
总的来说,Caffeine Cache 不仅提供了强大的缓存功能,还有一个高效的淘汰策略。这使得它在处理大量数据或高并发请求时成为非常好的选择。而且,由于其与 SpringBoot 的良好兼容性,你可以方便快捷地在 SpringBoot 项目中使用它。
最后,我们需要记住,虽然 Caffeine Cache 是一个强大的工具,但正确有效的使用确实需要对其淘汰算法有深入的理解。因此,建议持续关注并研究这个领域的最新进展,以便更好地利用 Caffeine Cache 提升你的应用性能。
感谢阅读,如果本篇文章有任何错误和建议,欢迎给我留言指正。
老铁们,关注我的微信公众号「Java 随想录」,专注分享 Java 技术干货,文章持续更新,可以关注公众号第一时间阅读。
一起交流学习,期待与你共同进步!
版权声明: 本文为 InfoQ 作者【码农BookSea】的原创文章。
原文链接:【http://xie.infoq.cn/article/1863ee416fa24d08444a8200e】。文章转载请联系作者。
评论