Java Caffeine 快速入门

一、前言

在高并发读写的场景下,基础的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()

注意: maximumSizemaximumWeight 互斥,不可同时使用。

3.2 过期策略

方法 参数 说明
expireAfterWrite(long, TimeUnit) 时长 + 时间单位 条目写入后经过指定时间过期
expireAfterAccess(long, TimeUnit) 时长 + 时间单位 条目最后一次读/写后经过指定时间过期
expireAfter(Expiry<K,V>) 自定义过期策略 可针对每个条目动态计算过期时间(纳秒级精度)

expireAfterWriteexpireAfterAccess 可组合使用,取先到者。

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

总结:

  1. 无并发场景 (单线程)

    • HashMap 性能最佳 :作为非线程安全的实现,HashMap 在所有单线程测试中均表现出最高的吞吐量,是性能基准的上限。
    • ConcurrentHashMap 紧随其后 :性能非常接近 HashMap,在读写混合场景中达到了 HashMap 约 84% 的性能,展现了极高的效率。
    • CaffeineGuava 有额外开销 :两者作为功能更丰富的缓存库,在单线程下的性能明显低于前两者。其中 Caffeine 的性能约为 ConcurrentHashMap 的 1/5 到 1/2,而 Guava 则更慢一些。
  2. 高并发场景 (8线程)

    • ConcurrentHashMap 吞吐量最高 :在所有并发测试中,ConcurrentHashMap 的绝对吞吐量均大幅领先,尤其在纯读场景下,性能是第二名 Caffeine6倍以上
    • Caffeine 表现稳健 :在并发场景下,Caffeine 的性能显著优于 Guava,吞吐量大约是 Guava9到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 核心机制

  1. 二维数组 :简单来说,就是固定大小的矩阵,每一行作为一个整体看待 ,当访问到某个key的时候,通过hash函数,将某个key映射到对应下标中并**+1。**

  2. 哈希函数 :二维数组有n行,对应哈希函数就有n个,每个哈希函数互相独立 ,为的就是让同一个key,在每一行散落到不同 的下标中,

  3. 取最小值计算 :获取某个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 内串行化"查版本、构建、发布"流程,避免并发通知触发重复全量加载。
 * &lt;p&gt;读路径不获取该锁;跨节点的一致性由广播消息和版本比较保证。&lt;/p&gt;
 */
private final Object rebuildLock = new Object();

/**
 * 单次引用替换发布完整快照;volatile 保证新快照及其已构造数据对读线程可见。
 */
private volatile DictCacheSnapshot snapshot;

/**
 * 在应用启动时构建首个快照。
 * &lt;p&gt;首次构建没有可降级的旧数据,失败时抛出异常以阻止应用以空缓存提供服务。&lt;/p&gt;
 */
@Override
@PostConstruct
public void initializeCache() {
    rebuildCache(true);
    log.info("字典缓存初始化完成, version={}", snapshot.version());
}

/**
 * 返回一次 volatile 读取取得的一致性快照视图,不会因并发重建而阻塞或中断遍历。
 * &lt;p&gt;快照结构不可变,但 {@link DictDo} 本身仍可变;调用方不得修改返回元素。&lt;/p&gt;
 */
@Override
public Collection&lt;DictDo&gt; getAll() {
    DictCacheSnapshot currentSnapshot = snapshot;
    if (currentSnapshot == null) {
        throw new IllegalStateException("字典缓存尚未完成初始化");
    }
    return currentSnapshot.data().values();
}

/**
 * 根据数据库实际版本决定是否重建。通知中的版本只用于诊断,不能作为缓存正确性的依据。
 * &lt;p&gt;运行期重建失败时保留旧快照继续服务,等待后续通知或下一次重建恢复。&lt;/p&gt;
 *
 * @param notifiedVersion 广播消息携带的版本号,仅用于日志追踪
 */
@Override
public void refreshCache(long notifiedVersion) {
    log.debug("开始处理字典缓存刷新通知, notifiedVersion={}", notifiedVersion);
    rebuildCache(false);
}

/**
 * 查询提交后数据库的最新版本并广播刷新通知。
 * &lt;p&gt;查询或发布失败会向调用方传播;已提交的字典变更不会因此回滚。&lt;/p&gt;
 */
@Override
public void publishCacheRefresh() {
    dictCachePublisher.broadcastCacheRefresh(queryLatestVersion());
}

/**
 * 以有限重试执行重建。启动阶段没有旧快照时采用 fail-fast;运行阶段采用 fail-soft,保留旧快照。
 *
 * @param failOnEmptySnapshot 是否在尚无快照时将最终失败向上抛出
 */
private void rebuildCache(boolean failOnEmptySnapshot) {
    try {
        CACHE_REBUILD_RETRY.execute((RetryCallback&lt;Void, Exception&gt;) context -&gt; {
            rebuildIfVersionBehind();
            return null;
        });
    } catch (Exception e) {
        if (failOnEmptySnapshot &amp;&amp; snapshot == null) {
            throw new IllegalStateException("首次构建字典缓存失败,应用停止启动", e);
        }
        log.error("字典缓存重建失败,保留当前快照, version={}", currentVersion(), e);
    }
}

/**
 * 在单 JVM 内按版本重建:同版本跳过、数据库版本落后时拒绝回退、更高版本才发布新快照。
 * &lt;p&gt;全量数据在锁内完成构建,防止并发刷新重复加载;发布仅是一次 volatile 引用替换。&lt;/p&gt;
 */
private void rebuildIfVersionBehind() {
    synchronized (rebuildLock) {
        long databaseVersion = queryLatestVersion();
        DictCacheSnapshot currentSnapshot = snapshot;
        long localVersion = currentSnapshot == null ? -1L : currentSnapshot.version();

        if (currentSnapshot != null &amp;&amp; databaseVersion == localVersion) {
            log.debug("字典缓存已是数据库最新版本, version={}", localVersion);
            return;
        }
        if (databaseVersion &lt; localVersion) {
            throw new CacheRebuildException("数据库版本落后本地快照, databaseVersion=%d, localVersion=%d"
                    .formatted(databaseVersion, localVersion));
        }

        DictCacheSnapshot newSnapshot = buildSnapshot(databaseVersion);
        if (currentSnapshot == null || newSnapshot.version() &gt; currentSnapshot.version()) {
            snapshot = newSnapshot;
            log.info("字典缓存快照已原子替换, version={}, size={}", newSnapshot.version(), newSnapshot.data().size());
        }
    }
}

/**
 * 由本次数据库读取构造独立快照。重复主键仅保留首次记录,避免异常数据破坏缓存结构。
 * &lt;p&gt;版本查询与全量读取不是同一事务;若其间发生写入,后续刷新会依据新版本再次收敛。&lt;/p&gt;
 */
private DictCacheSnapshot buildSnapshot(long version) {
    List&lt;DictDo&gt; dictList = dictMapper.selectList(new LambdaQueryWrapper&lt;&gt;());
    Map&lt;Long, DictDo&gt; data = new LinkedHashMap&lt;&gt;();
    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&lt;Long, DictDo&gt; data) {
}

private static class CacheRebuildException extends RuntimeException {
    private CacheRebuildException(String message) {
        super(message);
    }
}
}

4. 流程讲述

  1. 缓存预热 :应用启动过程中,会自动从数据库中拉取全量数据,并构建对应缓存

  2. 缓存刷新 :被**@RefreshDict** 标注的方法,在事务结束后会直接发送对应的消息通知

  3. 缓存重建 :消费者接受到消息后,会对比版本号 ,如果版本号落后,会从数据库中拉取数据重建缓存快照,然后原子性替换引用 ,避免putAll过程中新旧缓存同时存在的问题

  4. 容灾兜底 :缓存重建过程中会通过Retry机制快速重试 ,避免网络抖动问题,同时当数据库完全不可用时,会跳过缓存重建 ,暂时使用旧缓存确保服务基础可用

  5. 消息可靠性 :针对消息队列的可靠性方案,可以通过 本地消息表 + 生产者确认 + 消费者重试机制 + 死信队列兜底 + 消息幂等性检查等方案来保障消息不丢失可追溯。

六、总结

本文从 Caffeine 与 Guava Cache 的对比出发,梳理了 Caffeine 的基础使用方式、Builder API 配置能力,并通过 JMH 基准测试验证了它在不同并发与淘汰场景下的表现,最后深入拆解了 W-TinyLFU 算法的核心机制,以及多数据源环境下基于版本快照的缓存一致性方案。

可以看到,ConcurrentHashMap 虽然吞吐量极高,但它本质上只是一个并发容器,缺少过期、淘汰、刷新等缓存治理能力;Guava Cache 功能相对完善,但 LRU 策略和并发模型已经难以满足高命中率、高吞吐量的要求。Caffeine 通过 W-TinyLFU 算法在命中率、内存开销和并发性能之间取得平衡,同时又保留了接近 Guava 的简洁 API,是当前 Java 技术栈中本地缓存的首选。

在实际选型时,可以遵循以下原则:

  • 只做并发访问且不需要缓存治理 时,直接使用 ConcurrentHashMap
  • 需要过期、淘汰、刷新、统计等能力时,优先选用 Caffeine;
  • 涉及多 JVM 实例与数据库缓存一致性时,在 Caffeine 基础上叠加版本快照、不可变引用替换和消息广播机制,追求最终一致性。

最后需要强调的是,缓存并不是银弹。引入缓存前应先明确热点数据特征、访问模式和可接受的一致性窗口;生产环境中还要结合监控指标持续观察命中率、驱逐频率和内存占用,才能使 Caffeine 真正发挥出它在算法和工程上的双重优势。

相关推荐
Java成神之路-1 小时前
Java 并发灵魂拷问:notify/signal 唤醒顺序是规范还是实现?
java
Java牛马1 小时前
Caffeine 缓存及其应用相关总结
java·redis·缓存·caffeine·数据一致性·本地缓存
2kπ1 小时前
QT开发笔记
开发语言·笔记·qt
青 春 记 忆1 小时前
零基础入门Python15|关联、聚合、索引与事务:订单数据库
开发语言·python·后端开发
东风破_2 小时前
NestJS CRUD 实战:用 Todos 模块打通 Controller-Service-Module 三层
后端
东风破_2 小时前
NestJS 框架入门:从工厂模式到模块化架构
后端
江屿风2 小时前
【STM32基础篇】【嵌入式生态问题及历史追溯】流食般投喂
大数据·开发语言·人工智能·笔记·stm32·嵌入式硬件
灯澜忆梦2 小时前
【基于GO的Web开发4】html/template 模板语法
后端·golang·html
一只叫煤球的猫2 小时前
Spring AI 2.0 源码解析(三):ChatClient 的 Fluent API 如何构建请求?
java·后端·面试