1.

引言
在构建高并发、高性能的应用程序时,缓存是提升系统响应速度和降低后端负载的关键技术之一。
Spring
Framework
版本开始引入了强大的基于注解的缓存抽象,允许开发者以统一的方式集成多种缓存实现,而无需关心底层的缓存细节。
在
Spring
的启发,但在此基础上进行了大量优化和创新。
它提供了近乎最佳的命中率、出色的并发性能以及灵活的策略配置,被广泛应用于各类互联网及企业级应用中。
本文将深入剖析
Caffeine
的高性能设计原理,探讨其核心算法、数据结构、并发控制及在
Spring
中的集成方式,帮助读者全面理解并合理使用这一强大的缓存框架。
2.Caffeine
虽然功能完善,但在高并发场景下存在一些性能瓶颈,例如使用
Segment
架构进行了优化,从而在读写吞吐量、内存占用等方面全面超越
Guava
主要特性
自动加载:支持同步或异步方式加载缓存条目。
基于大小的驱逐:当缓存大小超过指定阈值时,基于
W-TinyLFU
算法淘汰最不常用的条目。
基于时间的驱逐:支持多种过期策略:访问后过期、写入后过期、自定义过期时间。
异步刷新:在后台异步刷新缓存条目,避免阻塞读请求。
弱引用/软引用:允许键或值使用弱引用、软引用存储,便于与
协作。
移除监听器:在条目被移除时执行自定义逻辑。
统计信息:提供命中率、加载时间、驱逐数量等指标,便于监控。
事件分发:支持同步或异步的事件通知。
2.3
性能优势
根据官方基准测试,Caffeine
在读写吞吐量、平均延迟等方面远超
的读写性能,同时保持可控的内存占用。
其优秀性能主要得益于:
W-TinyLFU
淘汰算法:近似最优的访问频率统计,兼具
LRU
的优点。
无锁数据结构:基于
Java
操作减少锁竞争。
缓冲优化:使用环形缓冲区(RingBuffer)记录事件,减少写争用。
时间感知优化:使用系统纳秒时间戳并结合时钟缓存,降低时间获取开销。
3.Caffeine
内部的数据存储主要依赖于ConcurrentHashMap(实际上是ConcurrentHashMap的变种ConcurrentHashMapV8),将键映射到一个封装了值、元数据(如访问时间、写入时间、频率等)的节点Node上。
Node的设计是关键,它不仅存储值,还包含:
值的引用(支持强引用、弱引用、软引用)
写入时间戳(纳秒级)
访问时间戳(用于基于时间的驱逐)
频率信息(通过一个近似计数器维护)
状态标志(例如是否被淘汰、是否正在加载等)
由于直接基于
ConcurrentHashMap,Caffeine
继承了其高并发读写的特性。
同时,为了支持驱逐和过期,Caffeine
维护了额外的数据结构:访问顺序队列和写顺序队列(均为双向链表),但与传统
LRU
并不严格维护全量顺序,而是使用两个环形缓冲区记录最近的事件,再通过异步的维护操作进行批量淘汰,从而降低每次访问时的开销。
3.2算法详解
3.2.1
的局限
LRU(最近最少使用):维护一个访问顺序链表,每次访问将节点移到头部,淘汰尾部。
实现简单,但无法应对偶发性的大量扫描(一次性读取大量冷数据会将热点数据挤出缓存),导致命中率下降。
LFU(最不经常使用):记录每个条目的访问频率,淘汰频率最低的。
能更好地保留热点数据,但需要维护频率计数器,内存开销大,且对访问模式的变化反应迟钝(历史高频条目即使不再被访问也难以被淘汰)。
3.2.2TinyLFU
是一种空间高效的频率估计算法,它使用一个紧凑的频率草图(Frequency
Sketch)来近似记录每个条目的访问次数,而不是精确计数。
通过布隆过滤器风格的哈希和计数器数组,TinyLFU
可以在极低内存开销下维护大量条目的频率信息,并保证一定的误差范围。
当缓存满时,新条目与候选淘汰条目进行频率比较,保留频率较高的一个。
3.2.3W-TinyLFU
的基础上增加了窗口缓存(Window
Cache)机制,以应对突发流量和访问模式变化。
它将缓存空间划分为两部分:
窗口缓存(Window
Cache):占整个容量的
1%(可配置),使用
LRU
策略,用于捕获近期访问的热点,适应突发热数据。
主缓存(Main
Cache):占
99%,使用
策略,记录长期热点数据。
工作流程:
所有新条目首先进入窗口缓存(LRU
LRU
规则淘汰出一个候选条目。
被淘汰的候选条目进入主缓存(TinyLFU
区域)的“准入”环节:主缓存会选择一个
Victim(基于频率草图判定的最不常用条目),与候选条目进行频率比较。
如果候选条目的频率高于
Victim
淘汰),否则淘汰候选条目。
频率信息通过频率草图记录,频率草图会随时间衰减,使旧的频率权重降低,从而适应访问模式的变化。
这种设计兼顾了
LRU
对长期热点的稳定性,在多种负载下均能取得接近最优的命中率。
3.3
支持多种基于时间的过期策略,可在创建缓存时指定:
expireAfterAccess:条目在最后一次访问后经过指定时间过期。
expireAfterWrite:条目在创建或最后一次更新后经过指定时间过期。
expireAfter(自定义过期策略):可以基于条目的创建时间、最后访问时间等自定义过期时间,甚至实现动态过期时间(如根据键值对计算不同的过期时间)。
为了实现过期策略,每个
Node
记录了访问时间和写入时间(纳秒级时间戳)。
Caffeine
内部通过一个维护线程(或使用调度器)定期扫描,但更高效的方式是在读、写操作时进行惰性删除:当访问一个条目时,检查是否过期,若过期则删除并触发加载;此外,还会在缓存大小达到阈值执行驱逐时顺带清理过期条目。
为了提高时间戳获取的性能,Caffeine
使用了System.nanoTime()(相对时间,不受系统时间调整影响),并维护了一个时钟缓存(例如每秒更新一次),减少频繁调用系统调用。
3.4
提供了同步加载和异步加载两种方式:
同步加载:实现
CacheLoader接口,在缓存缺失时阻塞调用线程加载数据。异步加载:实现
AsyncCacheLoader接口,返回CompletableFuture,加载过程在异步线程池中执行,不阻塞调用线程。
此外,Caffeine
支持定时刷新:通过refreshAfterWrite指定刷新间隔。
当条目超过指定时间未被更新,在下一次访问时会触发异步刷新(如果同时设置了expireAfterWrite,刷新不会延长过期时间,过期仍会驱逐)。
刷新机制确保缓存能定期更新,避免数据过时,同时不会阻塞读请求(先返回旧值,后台加载新值)。
3.5
的并发控制大量借鉴了ConcurrentHashMap的实现,利用CAS(Compare-And-Swap)操作代替锁,减少线程阻塞。
例如:
计数器的更新:频率草图中的计数器使用
AtomicIntegerArray或类似结构,通过CAS
更新。
节点状态的变更:如标记节点为淘汰状态,使用
AtomicReferenceFieldUpdater或VarHandle进行原子更新。环形缓冲区:多个生产者(读/写事件)使用
CAS
入队,消费者(维护线程)使用
CAS
出队,无锁设计降低争用。
尽管
Caffeine
尽力减少锁,但在某些情况下仍需轻量级同步(如对链表的修改),但通过对操作的细粒度拆分和批量处理,锁竞争被控制在极低水平。
3.6
允许配置键和值的引用类型(强引用、弱引用、软引用),以便与垃圾回收机制协作,避免内存泄漏。
弱引用键允许键被
回收,适合作为二级缓存。
此外,Caffeine
内部使用对象池优化某些对象的创建(如频率草图中的计数器),减少
压力。
对时间戳等常用对象,尽量使用原始类型(long)而非包装类,节省内存。
4.Caffeine
的核心是一个频率草图,通常是一个二维数组(如
位计数器组成的矩阵),通过多个哈希函数将键映射到计数器的位置,读取时取最小值作为频率估计。
这种结构类似于布隆过滤器的变种,可以在极小的内存占用下估计数千万键的频率,误差在可接受范围内。
Caffeine
4-bit
15),当计数达到上限时不再增加,并通过定期衰减来降低历史影响。
4.2
中的频率草图实现为FrequencySketch类,内部维护一个long[]数组(将
位划分为
计数器)。
它提供了increment(key)和frequency(key)方法。
草图大小根据预估的缓存大小动态计算,通常是
的幂次方。
频率的衰减通过重置操作完成:当某个计数器的值超过阈值(例如一半的最大值)时,将所有计数器的值右移
2),实现半衰期。
衰减机制使访问频率能逐渐适应新的热点模式。
4.3
高度依赖时间戳来判断过期和刷新。
为了减少System.nanoTime()调用的开销,Caffeine
内部维护了一个时钟缓存(Ticker),可以通过配置自定义。
在默认实现中,它直接调用System.nanoTime(),但用户可以提供自己的
Ticker(例如使用java.time.Clock或固定时间用于测试)。
此外,Caffeine
会将获取到的时间戳存储在节点上,在后续操作中重复使用,避免重复调用。
4.4
设计了两个无锁的环形缓冲区(RingBuffer)来记录访问事件和写事件,称为MpscGrowableArrayQueue(多生产者单消费者队列)。
当执行读操作(命中)时,不会立即更新访问顺序(避免锁开销),而是将事件放入缓冲区。
维护线程(或下次写操作时)会消费缓冲区,批量更新节点的访问顺序和频率计数器。
写操作(如插入、更新)同样会先记录事件,再异步进行淘汰检查。
这种批量处理显著降低了每次操作的开销,提高了吞吐量。
4.5
淘汰机制与维护操作
淘汰操作不是实时进行的,而是延迟到一定条件触发。
例如:
当缓存大小接近上限时,在写操作之后触发一次淘汰。
维护线程定期执行(或在每次写操作后尝试执行一次
cleanUp)。读操作如果发现缓冲区已满,也会协助处理事件。
维护操作包括:消费事件缓冲区、更新频率草图、执行淘汰(从窗口缓存或主缓存中移除条目)、触发移除监听器等。
Caffeine
使用自旋+CAS的方式保证并发安全,维护线程通常只有一个(写操作线程会尝试充当维护者),减少了锁竞争。
5.Spring
提供了一个缓存抽象,位于org.springframework.cache包中。
核心接口是Cache和CacheManager,允许开发者通过注解(如@Cacheable、@CacheEvict)声明式地使用缓存,而无需关注底层实现。
Spring
Boot
会自动配置一个合适的CacheManager,当检测到
Caffeine
依赖时,会创建CaffeineCacheManager。
5.2Caffeine
Caffeine,需要添加依赖:
xml
<dependency><groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
然后在配置文件(application.yml)中配置缓存属性:
yaml
spring:cache:
expireAfterAccess=600s
或者通过编程方式配置Caffeine实例:
java
@Configurationpublic
CaffeineCacheManager("users",
"products");
cacheManager.setCaffeine(caffeineCacheBuilder());
return
方法上使用注解:
java
@Servicepublic
userRepository.findById(userId).orElse(null);
=
API,获得更精细的控制:
java
Cache<String,User>
userRepository.findById(k).orElse(null));
5.5
Boot
原生配置,包括:
initialCapacity:初始容量maximumSize/maximumWeight:最大条目数或权重expireAfterAccess/expireAfterWrite/expireAfterrefreshAfterWrite:定时刷新weakKeys/weakValues/softValues:引用类型recordStats:开启统计
此外,可以通过CaffeineSpec解析配置字符串,与配置文件中的spec对应。
性能调优方面,可以考虑:
根据业务访问模式合理设置过期时间和大小。
开启统计并监控命中率,调整缓存容量。
若使用异步加载,配置合适的线程池大小。
考虑是否需要使用弱引用避免内存泄漏。
6.
案例:热点数据缓存
假设有一个电商系统,商品详情页访问量大,但商品信息变化不频繁。
我们使用
Caffeine
分钟,并开启统计。
java
@Configurationpublic
productRepository.findById(id).orElse(null);
JMeter
模拟并发请求,可以对比直接查询数据库和加入缓存后的性能提升。
6.2
Ehcache、Redis、Guava
Cache
| 特性 | Caffeine | Guava Cache | Ehcache | Redis |
|---|---|---|---|---|
| 存储位置 | 堆内内存 | 堆内内存 | 堆内/堆外/磁盘 | 独立进程,网络访问 |
| 性能(读写) | 极高,接近 ConcurrentHashMap | 高,但锁竞争较多 | 较高(堆内),磁盘较慢 | 取决于网络延迟 |
| 淘汰算法 | W-TinyLFU(近最优) | LRU | LFU/LRU/FIFO | 多种(LRU/LFU/随机等) |
| 过期策略 | 访问/写入后过期,动态过期 | 访问/写入后过期 | 丰富 | 丰富 |
| 持久化 | 无 | 无 | 支持 | 支持(RDB/AOF) |
| 分布式 | 本地 | 本地 | 可配合Terracotta集群 | 分布式 |
| Spring Boot集成 | 官方推荐 | 支持(需单独配置) | 支持 | 支持(Redis) |
结论:Caffeine
适用于分布式缓存场景。
如果应用无集群共享需求,Caffeine
Caffeine
内置了统计功能,可以通过Cache.stats()获取CacheStats对象,包含:
hitCount/missCount:命中次数、未命中次数hitRate/missRate:命中率、未命中率loadSuccessCount/loadFailureCount:加载成功/失败次数totalLoadTime:总加载时间evictionCount/evictionWeight:驱逐次数和驱逐总权重
7.2
Micrometer/Actuator
Spring
Boot
Actuator。
需要添加依赖:
xml
<dependency><groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
然后在配置中开启:
java
@Beanpublic
meterRegistry.gauge("product.cache.size",
cache,
}
或直接使用CaffeineCacheManager时,它默认会注册CacheMeterBinder。
访问/actuator/metrics可以看到自定义指标。
8.
缓存穿透、雪崩、击穿
缓存穿透:查询不存在的数据,导致请求直接打到数据库。
解决方案:缓存空值(设置短暂过期)或使用布隆过滤器。
缓存雪崩:大量缓存同时过期,导致数据库压力骤增。
解决方案:设置随机过期时间,避免集体失效。
缓存击穿:热点数据过期,高并发访问同时加载。
解决方案:使用互斥锁(Caffeine
loader)本身是原子的,会阻塞其他线程直到加载完成,避免了击穿)。
8.2
配置建议
大小估算:根据业务数据量和内存限制设置
maximumSize或maximumWeight。过期时间:根据数据更新频率设置合理的过期时间,避免数据过时。
统计开启:生产环境建议开启
recordStats(),便于监控调优。异步加载:对于耗时加载操作,使用异步加载避免阻塞。
引用类型:若缓存对象占用内存大,可考虑
softValues,让JVM
是本地缓存,不适合分布式环境下的数据一致性要求高的场景。
缓存对象应不可变(或至少线程安全),避免并发修改导致数据错误。
谨慎使用弱引用/软引用,因为
行为不可预测,可能导致缓存过早失效。
如果使用
refreshAfterWrite,确保刷新间隔小于过期时间,否则条目会先过期,刷新不起作用。
9.Spring
官方推荐的缓存框架,以其卓越的性能、灵活的策略和丰富的特性赢得了广泛认可。
本文从设计原理、核心算法、并发控制、内存优化等方面深入剖析了
Caffeine
淘汰算法、无锁数据结构、异步批量处理等创新,Caffeine
在本地缓存领域达到了新的高度。


