一、前言
在高并发读写的场景下,基础的ConcurrentHashMap 可以保证并发安全,但是过期策略、淘汰策略都需要手动实现 ,Guava Cache由此诞生,但是身为"智能缓存"的Guava Cache本身不够"智能"。因为LRU缓存 在少数场景下可能无法保证高缓存命中率,因此高并发场景下,其依旧存在瓶颈。这也是Caffeine产生的原因,继承 Guava Cache 的优雅 API, 同时在性能 和命中率上实现质的飞跃。
二、和Guava Cache的对比
Caffeine和Guava Cache都是Java本地缓存的优秀实现,但是 Caffeine 在算法、并发模型和性能上做了全面革新,可以理解为 Guava Cache 的"现代化升级版"。
| 功能 | Guava Cache | Caffeine |
|---|---|---|
| 过期策略 | expireAfterWrite / expireAfterAccess | 同左 + 支持 per-entry 动态过期 |
| 异步加载 | 不支持 | ✅ AsyncLoadingCache(返回 CompletableFuture) |
| 异步刷新 | 不支持 | ✅ refreshAfterWrite(后台刷新,访问返回旧值) |
| 移除监听 | ✅ RemovalListener | ✅ RemovalListener |
| 统计监控 | ✅ recordStats | ✅ recordStats |
| 弱/软引用 | ✅ weakKeys / softValues | ✅ weakKeys / softValues |
| 权重控制 | ✅ maximumWeight | ✅ maximumWeight + weigher |
| Spring 集成 | 需手动适配 | ✅ 默认本地缓存实现,@Cacheable 原生支持 |
Caffeine 继承了 Guava Cache 的优雅 API,但在算法、并发、性能上做了全面革新,是当前 Java 本地缓存的最优解。
三、基础使用
Caffeine的API设计非常简单,结合Builder模式即可实现两种缓存 的实现(分别是手动加载 以及自动加载模式)
1. 手动加载
java
Cache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最大条目数
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后5分钟过期
.build();
cache.put("user1", new User("1", "张三"));
User user = cache.getIfPresent("user1");
// 不存在时通过函数加载
User user2 = cache.get("user2", key -> userDao.findById(key));
2. 自动加载
java
LoadingCache<String, User> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterAccess(10, TimeUnit.MINUTES)
.build(key -> userDao.findById(key));
User user = loadingCache.get("user1"); // 自动触发加载
3. Builder API介绍
3.1 容量控制
| 方法 | 参数 | 说明 |
|---|---|---|
maximumSize(long) |
最大条目数 | 缓存最多容纳的 key-value 对数量,超出后按淘汰策略驱逐 |
maximumWeight(long) |
最大总权重 | 按权重限制缓存总容量,需配合 weigher 使用 |
weigher(Weigher<K,V>) |
权重计算函数 | 自定义每个条目的权重,如 (k, v) -> v.size() |
注意:
maximumSize与maximumWeight互斥,不可同时使用。
3.2 过期策略
| 方法 | 参数 | 说明 |
|---|---|---|
expireAfterWrite(long, TimeUnit) |
时长 + 时间单位 | 条目写入后经过指定时间过期 |
expireAfterAccess(long, TimeUnit) |
时长 + 时间单位 | 条目最后一次读/写后经过指定时间过期 |
expireAfter(Expiry<K,V>) |
自定义过期策略 | 可针对每个条目动态计算过期时间(纳秒级精度) |
expireAfterWrite和expireAfterAccess可组合使用,取先到者。
3.3 刷新策略
| 方法 | 参数 | 说明 |
|---|---|---|
refreshAfterWrite(long, TimeUnit) |
时长 + 时间单位 | 条目写入后经过指定时间标记为"需刷新",下次访问时异步重新加载(仅 LoadingCache 有效) |
3.4 淘汰监听
| 方法 | 参数 | 说明 |
|---|---|---|
removalListener(RemovalListener<K,V>) |
淘汰回调函数 | 条目被驱逐/过期/手动移除时触发,可获取 key、value 和淘汰原因 |
evictionListener(RemovalListener<K,V>) |
驱逐回调函数 | 仅在因容量/权重限制被驱逐时触发(手动移除和过期不触发) |
3.5 统计与监控
| 方法 | 参数 | 说明 |
|---|---|---|
recordStats() |
无 | 开启统计,之后通过 cache.stats() 获取命中率、加载耗时、驱逐次数等 |
3.6 弱引用 / 软引用
| 方法 | 参数 | 说明 |
|---|---|---|
weakKeys() |
无 | key 使用弱引用,GC 可回收不再被外部引用的 key |
weakValues() |
无 | value 使用弱引用,GC 可回收不再被外部引用的 value |
softValues() |
无 | value 使用软引用,内存不足时 GC 可回收 |
weakValues()与softValues()互斥。
3.7 异步支持
| 方法 | 参数 | 说明 |
|---|---|---|
executor(Executor) |
线程池 | 指定用于异步加载/刷新的线程池,默认使用 ForkJoinPool.commonPool() |
scheduler(Scheduler) |
调度器 | 指定用于定时过期检查的调度器,默认使用系统调度器 |
3.8 构建方式
| 方法 | 参数 | 说明 |
|---|---|---|
build() |
无 | 构建基本 Cache,需手动加载数据 |
build(CacheLoader<K,V>) |
加载函数 | 构建 LoadingCache,缓存未命中时自动调用加载函数 |
buildAsync() |
无 | 构建异步 AsyncCache,value 为 CompletableFuture |
buildAsync(AsyncCacheLoader<K,V>) |
异步加载函数 | 构建异步 AsyncLoadingCache |
典型组合示例
java
Cache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 容量
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入过期
.refreshAfterWrite(1, TimeUnit.MINUTES) // 1分钟后异步刷新
.removalListener((key, value, cause) -> // 淘汰监听
log.info("移除: {} 原因: {}", key, cause))
.recordStats() // 开启统计
.build(key -> userDao.findById(key)); // 必须提供加载函数
四、性能测量
1. 无缓存淘汰场景
这里我们通过JMH的BenchMark来针对读、写、读写混合场景,对HashMap、ConcurrentHashMap、Guava、Caffeine分别进行性能基准测试
1.1 单线程
| 测试场景 | 实现方式 | 平均吞吐量 (ops/s) |
|---|---|---|
| 纯读 (pureRead) | CONCURRENT_HASH_MAP | 992,320,631 |
| CAFFEINE | 153,815,895 | |
| GUAVA | 16,921,348 | |
| 纯写 (pureWrite) | CONCURRENT_HASH_MAP | 121,049,344 |
| CAFFEINE | 59,932,851 | |
| GUAVA | 5,058,945 | |
| 读写混合 (readWriteMixed) | CONCURRENT_HASH_MAP | 243,496,179 |
| CAFFEINE | 88,119,973 | |
| GUAVA | 6,983,711 |
1.2 多线程(8线程)
| 测试场景 | 实现方式 | 平均吞吐量 (ops/s) |
|---|---|---|
| 纯读 (pureRead) | HASH_MAP | 184,512,874 |
| CONCURRENT_HASH_MAP | 171,627,474 | |
| CAFFEINE | 33,218,512 | |
| GUAVA | 22,276,631 | |
| 纯写 (pureWrite) | HASH_MAP | 119,833,818 |
| CONCURRENT_HASH_MAP | 55,684,573 | |
| CAFFEINE | 21,163,099 | |
| GUAVA | 16,011,427 | |
| 读写混合 (readWriteMixed) | HASH_MAP | 100,403,857 |
| CONCURRENT_HASH_MAP | 84,025,325 | |
| CAFFEINE | 23,610,020 | |
| GUAVA | 14,474,005 |
总结:
-
无并发场景 (单线程)
HashMap性能最佳 :作为非线程安全的实现,HashMap在所有单线程测试中均表现出最高的吞吐量,是性能基准的上限。ConcurrentHashMap紧随其后 :性能非常接近HashMap,在读写混合场景中达到了HashMap约 84% 的性能,展现了极高的效率。Caffeine和Guava有额外开销 :两者作为功能更丰富的缓存库,在单线程下的性能明显低于前两者。其中Caffeine的性能约为ConcurrentHashMap的 1/5 到 1/2,而Guava则更慢一些。
-
高并发场景 (8线程)
ConcurrentHashMap吞吐量最高 :在所有并发测试中,ConcurrentHashMap的绝对吞吐量均大幅领先,尤其在纯读场景下,性能是第二名Caffeine的 6倍以上。Caffeine表现稳健 :在并发场景下,Caffeine的性能显著优于Guava,吞吐量大约是Guava的 9到12倍,是功能型缓存库中的更优选择。Guava性能相对落后 :在本次测试的所有并发场景中,Guava的吞吐量均为最低。
2. 缓存淘汰策略
这个批次中,我们通过1000、5000、10000容量,测试并发场景下,Caffeine、Guava和ConcurrentHashMap的读、写、混合吞吐量
2.1 单线程
读写混合
| 容量 | 策略 | CAFFEINE | GUAVA | CONCURRENT_HASH_MAP |
|---|---|---|---|---|
| 1,000 | SIZE_ONLY | 2,186.2万 | 2,271.8万 | 4,082.8万 |
| WRITE_TTL | 1,805.5万 | 1,461.2万 | 2,636.4万 | |
| ACCESS_TTL | 2,323.8万 | 1,674.8万 | 3,984.0万 | |
| 5,000 | SIZE_ONLY | 2,265.8万 | 1,727.6万 | 2,621.8万 |
| WRITE_TTL | 1,570.7万 | 1,215.0万 | 3,881.5万 | |
| ACCESS_TTL | 1,641.6万 | 1,313.5万 | 2,813.6万 | |
| 10,000 | SIZE_ONLY | 1,462.8万 | 1,301.3万 | 2,606.4万 |
| WRITE_TTL | 1,027.5万 | 917.5万 | 3,979.9万 | |
| ACCESS_TTL | 1,096.2万 | 983.1万 | 3,983.9万 |
2.2 多线程(8线程)
读写混合
| 容量 | 策略 | CAFFEINE | GUAVA | CONCURRENT_HASH_MAP |
|---|---|---|---|---|
| 1,000 | SIZE_ONLY | 2,547.9万 | 1,033.8万 | 1.5亿 |
| WRITE_TTL | 1,804.2万 | 792.7万 | 8,499.7万 | |
| ACCESS_TTL | 2,080.6万 | 888.0万 | 8,338.9万 | |
| 5,000 | SIZE_ONLY | 2,971.3万 | 805.1万 | 1.2亿 |
| WRITE_TTL | 2,175.3万 | 656.5万 | 1.0亿 | |
| ACCESS_TTL | 3,225.2万 | 722.6万 | 7,543.1万 | |
| 10,000 | SIZE_ONLY | 3,422.0万 | 658.5万 | 8,170.6万 |
| WRITE_TTL | 2,628.2万 | 590.2万 | 9,315.9万 | |
| ACCESS_TTL | 3,447.8万 | 614.6万 | 8,222.2万 |
3. 性能对比
- ConcurrentHashMap 是吞吐量之王,但缺乏淘汰、过期等缓存管理能力,数据会无限堆积导致 OOM。
- Caffeine 是功能型缓存的最优解,性能约为 Guava 的 3~10 倍,且 W-TinyLFU 算法在命中率上显著优于 Guava 的 LRU。
- Guava Cache 已全面落后,Spring Boot 2.x 起已默认使用 Caffeine 替代,新项目应直接选用 Caffeine。
五、W-TinyLFU 算法原理
1. 简介
作为Caffeine的核心淘汰算法,W-TinyLFU 的策略非常切合真实的生产环境,其核心分为三个区域:Window 区、Probation 区、Protected 区 。通过Count-Min Sketch (频率素描)数据统计策略,实现冷热数据在三者之间流转,避免了缓存污染、数据冷启动等问题,大幅度提升了缓存命中率
2. Window区**(窗口缓存区)**
该区域约占总容量的1% ,专门接纳新写入或者新访问的数据,其采用LRU策略 管理缓存,满额时,将淘汰 候选数据到主缓存区 。这个设计能有效防止突发冷数据 (比如遍历数据)污染主缓存 ,同时维护刚刚上线的新热点数据
3. Main Cache**(主缓存区)**
1. Probation 区(试探区) :接收从窗口区淘汰过来的候选数据,属于"待观察"区域,采用 LRU 管理。
2. Protected 区(保护区) :存储高频热点数据,约占主缓存的 80%。只有 Probation 区中访问频率达标的数据才能晋升至此。
4. Count-Min Sketch(频率素描)
4.1 简单介绍
这是针对key的访问频次统计算法,传统的key访问频次 统计,是对所有key分别进行统计 ,其可以确保100%精确,但是其空间复杂度为O(n) , 在百万级别key下,其内存占用将会到达几十MB甚至上百MB
该算法则通过二维数组+多个独立的哈希函数,其空间复杂度接近O(1) ,精确度与二维数组大小呈现正相关,来确保精确度 的前提下,最大化节省空间占用。百万级别key内存占用情况大幅度降低。
4.2 核心机制
-
二维数组 :简单来说,就是固定大小的矩阵,每一行作为一个整体看待 ,当访问到某个key的时候,通过hash函数,将某个key映射到对应下标中并**+1。**
-
哈希函数 :二维数组有n行,对应哈希函数就有n个,每个哈希函数互相独立 ,为的就是让同一个key,在每一行散落到不同 的下标中,
-
取最小值计算 :获取某个key的访问频率时,将会获取每一行的频次统计 ,取最小值来作为统计结果。
4.3 为什么这样设计?
因为哈希冲突,所以某个下标可能同时统计多个key ,误差较大,也就是说,统计频次一定 >= 实际频次 ,而通过多行交叉验证,取最小值,就可以获取到最接近实际频次的数据。
4.4 Caffeine 的工程优化
除此之外,Caffeine还针对Count-Min Sketch 进行了大量的优化,在性能、内存占用、缓存命中率上进一步拔高。
- 4bit 计数器 :该算法统计频率至少占 4 字节(int),Caffeine每个单元格压缩到 4bit(最大值仅有 15)。配合频率衰减机制(定期将所有计数器 ÷ 2,避免数据超过最大值影响准确性 ),百万级 key 下内存开销进一步降低。原先的**"看频率"** ,变为了**"看趋势"**
- 频率衰减机制 :定期将所有计数器右移一位(÷2),使历史频率逐渐衰减,一方面,是避免老数据的累计频率统计过高长期霸占缓存 ,另一方面是避免4 bit 计数器超过最大值。
- Doorkeeper 优化 :先通过布隆过滤器 前置过滤,首次访问的 key 因为没加入白名单中,所以不参与计数,只有二次访问才计入,有效过滤大量一次性访问的冷数据(比如爬虫恶意扫描数据,偶然数据访问)。
- 自适应窗口大小:Window 区与 Main 区的比例并非固定 1:99,而是通过爬山算法(hill climbing)根据实际工作负载动态调整。偏向时间局部性时窗口更大,偏向频率时窗口更小。
- 快速处理模式:当缓存大小未超过总容量的 50% 且驱逐策略未触发时,Sketch 不会初始化以减小内存开销,访问也不会被记录,最大化访问性能。
- 异步驱逐:驱逐操作通过后台线程异步执行,不影响主路径的读写性能,保证 O(1) 时间复杂度的驱逐操作。
5. 小节
W-TinyLFU 的设计本质,是在缓存命中率、内存开销、并发性能三者之间取得最优平衡。它没有追求任何单一指标的极致,而是通过分层架构将复杂问题拆解为多个子问题,再用最合适的方案逐一解决。
五、多数据源下的缓存一致性
1. 前言
在生产环境中,我们经常会用到缓存,但是当引入缓存后,如何保证数据库与缓存之间的数据一致性,就成为了一个难点。
本章节我们将通过demo演示如何保障Caffeine与数据库之间的数据一致性。
2. 背景
多JVM实例,每个实例内部维护一份Caffeine缓存,需要保证数据库 ------> Caffeine 之间的数据一致性。
我们追求数据的最终一致性 ,且保障数据全链路 的可用性。同时注意版本并发控制。
3. 实现
3.1 依赖引入
XML
<dependencies>
<!-- Web 场景:提供内嵌 Tomcat、Spring MVC,支撑 REST 接口 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot 核心 starter:提供自动配置与基础能力 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- 测试场景:JUnit 5 / Mockito / AssertJ 等测试框架 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Lombok:编译期自动生成 getter/setter、构造器等样板代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- MyBatis-Plus(Spring Boot 3 适配版):简化单表 CRUD,免写 SQL -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.16</version>
</dependency>
<!-- MySQL 8 驱动:数据库连接 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- Caffeine:高性能本地缓存,承载字典数据的"一级缓存" -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- AMQP / RabbitMQ:字典变更时广播缓存刷新消息,保证多节点一致性 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- AOP:支持自定义注解 @RefreshDict + 切面实现字典缓存刷新 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
3.2 建表语句(以字典表为例)
sql
CREATE TABLE sys_dict
(
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
parent_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父级ID,0表示顶级节点',
type VARCHAR(50) NOT NULL COMMENT '字典类型,如 user_status、order_source',
level TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '层级深度,根节点为1',
name VARCHAR(100) NOT NULL COMMENT '显示名称',
code VARCHAR(100) NOT NULL COMMENT '业务编码',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
deleted BIGINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常,删除时写入id值',
PRIMARY KEY (id),
UNIQUE KEY uk_type_code (type, code, deleted),
KEY idx_parent (parent_id, deleted),
KEY idx_type (type, deleted)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='字典表(支持父子分类)';
3.3 核心Map
java
/**
* 单次引用替换发布完整快照;volatile 保证新快照及其已构造数据对读线程可见。
*/
private volatile DictCacheSnapshot snapshot;
/**
一次发布的缓存版本与数据集合。Map 结构不可变,但 {@link DictDo} 不是深度不可变对象。
*/
private record DictCacheSnapshot(long version, Map<Long, DictDo> data) {
}
3.4 缓存实现类构建
java
/**
* 基于版本快照的字典本地缓存。
* <p>新快照在内存中完整构造后才替换 {@link #snapshot},因此数据库读取失败时旧快照仍可继续提供服务。</p>
*
* @author 王玉涛
* @version 1.0
* @since 2026/8/20
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DictCacheServiceImpl implements DictCacheService {
/**
* 重建失败时额外重试一次。运行期最终失败会保留旧快照,避免短暂数据库故障直接影响读服务。
*/
private static final RetryTemplate CACHE_REBUILD_RETRY = RetryTemplate.builder()
.maxAttempts(2)
.fixedBackoff(1)
.retryOn(Exception.class)
.build();
private final DictMapper dictMapper;
private final DictCachePublisher dictCachePublisher;
/**
* 仅在当前 JVM 内串行化"查版本、构建、发布"流程,避免并发通知触发重复全量加载。
* <p>读路径不获取该锁;跨节点的一致性由广播消息和版本比较保证。</p>
*/
private final Object rebuildLock = new Object();
/**
* 单次引用替换发布完整快照;volatile 保证新快照及其已构造数据对读线程可见。
*/
private volatile DictCacheSnapshot snapshot;
/**
* 在应用启动时构建首个快照。
* <p>首次构建没有可降级的旧数据,失败时抛出异常以阻止应用以空缓存提供服务。</p>
*/
@Override
@PostConstruct
public void initializeCache() {
rebuildCache(true);
log.info("字典缓存初始化完成, version={}", snapshot.version());
}
/**
* 返回一次 volatile 读取取得的一致性快照视图,不会因并发重建而阻塞或中断遍历。
* <p>快照结构不可变,但 {@link DictDo} 本身仍可变;调用方不得修改返回元素。</p>
*/
@Override
public Collection<DictDo> getAll() {
DictCacheSnapshot currentSnapshot = snapshot;
if (currentSnapshot == null) {
throw new IllegalStateException("字典缓存尚未完成初始化");
}
return currentSnapshot.data().values();
}
/**
* 根据数据库实际版本决定是否重建。通知中的版本只用于诊断,不能作为缓存正确性的依据。
* <p>运行期重建失败时保留旧快照继续服务,等待后续通知或下一次重建恢复。</p>
*
* @param notifiedVersion 广播消息携带的版本号,仅用于日志追踪
*/
@Override
public void refreshCache(long notifiedVersion) {
log.debug("开始处理字典缓存刷新通知, notifiedVersion={}", notifiedVersion);
rebuildCache(false);
}
/**
* 查询提交后数据库的最新版本并广播刷新通知。
* <p>查询或发布失败会向调用方传播;已提交的字典变更不会因此回滚。</p>
*/
@Override
public void publishCacheRefresh() {
dictCachePublisher.broadcastCacheRefresh(queryLatestVersion());
}
/**
* 以有限重试执行重建。启动阶段没有旧快照时采用 fail-fast;运行阶段采用 fail-soft,保留旧快照。
*
* @param failOnEmptySnapshot 是否在尚无快照时将最终失败向上抛出
*/
private void rebuildCache(boolean failOnEmptySnapshot) {
try {
CACHE_REBUILD_RETRY.execute((RetryCallback<Void, Exception>) context -> {
rebuildIfVersionBehind();
return null;
});
} catch (Exception e) {
if (failOnEmptySnapshot && snapshot == null) {
throw new IllegalStateException("首次构建字典缓存失败,应用停止启动", e);
}
log.error("字典缓存重建失败,保留当前快照, version={}", currentVersion(), e);
}
}
/**
* 在单 JVM 内按版本重建:同版本跳过、数据库版本落后时拒绝回退、更高版本才发布新快照。
* <p>全量数据在锁内完成构建,防止并发刷新重复加载;发布仅是一次 volatile 引用替换。</p>
*/
private void rebuildIfVersionBehind() {
synchronized (rebuildLock) {
long databaseVersion = queryLatestVersion();
DictCacheSnapshot currentSnapshot = snapshot;
long localVersion = currentSnapshot == null ? -1L : currentSnapshot.version();
if (currentSnapshot != null && databaseVersion == localVersion) {
log.debug("字典缓存已是数据库最新版本, version={}", localVersion);
return;
}
if (databaseVersion < localVersion) {
throw new CacheRebuildException("数据库版本落后本地快照, databaseVersion=%d, localVersion=%d"
.formatted(databaseVersion, localVersion));
}
DictCacheSnapshot newSnapshot = buildSnapshot(databaseVersion);
if (currentSnapshot == null || newSnapshot.version() > currentSnapshot.version()) {
snapshot = newSnapshot;
log.info("字典缓存快照已原子替换, version={}, size={}", newSnapshot.version(), newSnapshot.data().size());
}
}
}
/**
* 由本次数据库读取构造独立快照。重复主键仅保留首次记录,避免异常数据破坏缓存结构。
* <p>版本查询与全量读取不是同一事务;若其间发生写入,后续刷新会依据新版本再次收敛。</p>
*/
private DictCacheSnapshot buildSnapshot(long version) {
List<DictDo> dictList = dictMapper.selectList(new LambdaQueryWrapper<>());
Map<Long, DictDo> data = new LinkedHashMap<>();
for (DictDo dict : dictList) {
data.putIfAbsent(dict.getId(), dict);
}
return new DictCacheSnapshot(version, Collections.unmodifiableMap(data));
}
/**
* 空表或空版本统一视为初始版本 {@code 0},以便首次构建空快照。
*/
private long queryLatestVersion() {
DictDo latest = dictMapper.selectLatestVersion();
return latest == null || latest.getVersion() == null ? 0L : latest.getVersion().longValue();
}
private long currentVersion() {
DictCacheSnapshot currentSnapshot = snapshot;
return currentSnapshot == null ? -1L : currentSnapshot.version();
}
/**
* 一次发布的缓存版本与数据集合。Map 结构不可变,但 {@link DictDo} 不是深度不可变对象。
*/
private record DictCacheSnapshot(long version, Map<Long, DictDo> data) {
}
private static class CacheRebuildException extends RuntimeException {
private CacheRebuildException(String message) {
super(message);
}
}
}
4. 流程讲述
-
缓存预热 :应用启动过程中,会自动从数据库中拉取全量数据,并构建对应缓存
-
缓存刷新 :被**@RefreshDict** 标注的方法,在事务结束后会直接发送对应的消息通知
-
缓存重建 :消费者接受到消息后,会对比版本号 ,如果版本号落后,会从数据库中拉取数据重建缓存快照,然后原子性替换引用 ,避免putAll过程中新旧缓存同时存在的问题
-
容灾兜底 :缓存重建过程中会通过Retry机制快速重试 ,避免网络抖动问题,同时当数据库完全不可用时,会跳过缓存重建 ,暂时使用旧缓存确保服务基础可用
-
消息可靠性 :针对消息队列的可靠性方案,可以通过 本地消息表 + 生产者确认 + 消费者重试机制 + 死信队列兜底 + 消息幂等性检查等方案来保障消息不丢失可追溯。
六、总结
本文从 Caffeine 与 Guava Cache 的对比出发,梳理了 Caffeine 的基础使用方式、Builder API 配置能力,并通过 JMH 基准测试验证了它在不同并发与淘汰场景下的表现,最后深入拆解了 W-TinyLFU 算法的核心机制,以及多数据源环境下基于版本快照的缓存一致性方案。
可以看到,ConcurrentHashMap 虽然吞吐量极高,但它本质上只是一个并发容器,缺少过期、淘汰、刷新等缓存治理能力;Guava Cache 功能相对完善,但 LRU 策略和并发模型已经难以满足高命中率、高吞吐量的要求。Caffeine 通过 W-TinyLFU 算法在命中率、内存开销和并发性能之间取得平衡,同时又保留了接近 Guava 的简洁 API,是当前 Java 技术栈中本地缓存的首选。
在实际选型时,可以遵循以下原则:
- 只做并发访问且不需要缓存治理 时,直接使用 ConcurrentHashMap;
- 需要过期、淘汰、刷新、统计等能力时,优先选用 Caffeine;
- 涉及多 JVM 实例与数据库缓存一致性时,在 Caffeine 基础上叠加版本快照、不可变引用替换和消息广播机制,追求最终一致性。
最后需要强调的是,缓存并不是银弹。引入缓存前应先明确热点数据特征、访问模式和可接受的一致性窗口;生产环境中还要结合监控指标持续观察命中率、驱逐频率和内存占用,才能使 Caffeine 真正发挥出它在算法和工程上的双重优势。