Caffeine 缓存及其应用相关总结

Caffeine 缓存及其应用相关总结

版本:基于 Caffeine 3.2.x

定位:Java 高性能本地缓存库

官网:https://github.com/ben-manes/caffeine

1. 概述

Caffeine 是一个基于 JVM 堆内存的高性能本地缓存库 ,由 Ben Manes 开发。它沿用了 Google Guava Cache 的 API 风格与设计思想,但在内部实现上做了大量优化,在命中率、吞吐量、内存占用三个维度上都显著优于 Guava Cache,被视为 Guava Cache 的"精神续作"与事实上的替代者。

核心特点:

  • 近乎最优的命中率:采用 Window TinyLFU 淘汰算法,兼顾"最近性"与"频率"。
  • 极低的开销 :读路径基于 ConcurrentHashMap,近乎无锁;高频事件通过环形缓冲异步处理。
  • 丰富的策略:支持容量、权重、时间、引用等多种淘汰维度。
  • 同步与异步 :同时提供 Cache / LoadingCache 与基于 CompletableFuture 的异步版本。

注意:Caffeine 是本地(单机)缓存,不是分布式缓存。它常与 Redis 等分布式缓存配合,作为"本地 L1 + 远程 L2"架构中的 L1 层。


2. 特性总览

特性 说明
容量控制 maximumSize(条数)、maximumWeight(权重,可自定义 weigher
过期策略 expireAfterWriteexpireAfterAccessexpireAfter(自定义 Expiry
引用类型 weakKeysweakValuessoftValues
加载缓存 LoadingCache + CacheLoader;支持 getAll 批量加载
异步缓存 AsyncCache / AsyncLoadingCache,基于 CompletableFuture
自动刷新 refreshAfterWrite,异步刷新、返回旧值、不阻塞读取
统计信息 recordStats(),命中率、加载耗时、淘汰次数等
移除监听 removalListener,监听移除原因
淘汰算法 Window TinyLFU(基于频率草图与准入策略)

3. 环境要求与依赖

  • Caffeine 2.x:要求 Java 8+
  • Caffeine 3.x:要求 Java 11+

Maven:

xml 复制代码
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.2.3</version>
</dependency>

Gradle:

groovy 复制代码
implementation 'com.github.ben-manes.caffeine:caffeine:3.2.3'

4. 快速开始

4.1 手动加载(Cache)

java 复制代码
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import java.time.Duration;

Cache<String, User> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .build();

// 若不存在,则用映射函数加载并放入缓存
User user = cache.get("uid:123", key -> loadUserFromDB(key));

// 若不存在,返回 null(不触发加载)
User maybe = cache.getIfPresent("uid:123");

4.2 自动加载(LoadingCache)

java 复制代码
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.LoadingCache;

LoadingCache<String, User> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .build(new CacheLoader<String, User>() {
            @Override
            public User load(String key) {
                return loadUserFromDB(key);
            }
        });

User user = cache.get("uid:123"); // 不存在时自动调用 load()

5. 核心接口体系

Caffeine 提供四个核心缓存接口:

接口 说明 获取方式
Cache<K,V> 手动加载缓存 builder.build()
LoadingCache<K,V> 自动加载缓存(继承 Cache builder.build(loader)
AsyncCache<K,V> 异步手动加载缓存 builder.buildAsync()
AsyncLoadingCache<K,V> 异步自动加载缓存 builder.buildAsync(loader)

5.1 Cache 常用方法

java 复制代码
V get(K key, Function<? super K, ? extends V> mappingFunction);
V getIfPresent(Object key);
Map<K, V> getAllPresent(Iterable<? extends K> keys);
void put(K key, V value);
void putAll(Map<? extends K, ? extends V> map);
void invalidate(Object key);
void invalidateAll(Iterable<? extends K> keys);
void invalidateAll();
long estimatedSize();          // 估算大小(近似值)
CacheStats stats();            // 统计信息
ConcurrentMap<K, V> asMap();   // 底层 ConcurrentHashMap 视图
void cleanUp();                // 显式执行一次维护

5.2 LoadingCache 额外方法

java 复制代码
V get(K key);                          // 不存在则触发加载
Map<K, V> getAll(Iterable<? extends K> keys); // 批量加载
void refresh(K key);                   // 异步刷新

5.3 CacheLoader 接口

java 复制代码
public interface CacheLoader<K, V> {
    V load(K key) throws Exception;                  // 必须实现
    default Map<K, V> loadAll(Iterable<? extends K> keys); // 批量加载,默认逐个 load
    default V reload(K key, V oldValue);             // 刷新逻辑,默认走 load
    // 异步变体(用于 AsyncLoadingCache)
    default CompletableFuture<V> asyncLoad(K key, Executor executor);
    default CompletableFuture<Map<K, V>> asyncLoadAll(...);
    default CompletableFuture<V> asyncReload(K key, V oldValue, Executor executor);
}

6. 缓存配置详解

6.1 容量控制

按条数

java 复制代码
Caffeine.newBuilder().maximumSize(10_000).build();

按权重 (需同时指定 weigher):

java 复制代码
Caffeine.newBuilder()
        .maximumWeight(100_000)
        .weigher((String key, User value) -> value.getWeight())
        .build();

注意:maximumSizemaximumWeight 只能二选一。

6.2 过期策略

方法 语义
expireAfterWrite(Duration) 条目写入后固定时长过期,期间无论访问多少次都会过期
expireAfterAccess(Duration) 条目最后一次访问后计时,每次访问都会"续期"
expireAfter(Expiry) 完全自定义过期时间

自定义 Expiry(返回值单位为纳秒):

java 复制代码
import com.github.benmanes.caffeine.cache.Expiry;

Expiry<String, User> expiry = new Expiry<>() {
    @Override
    public long expireAfterCreate(String key, User value, long currentTime) {
        return TimeUnit.MINUTES.toNanos(10);
    }

    @Override
    public long expireAfterUpdate(String key, User value, long currentTime, long currentDuration) {
        return currentDuration; // 更新后保持原过期时间
    }

    @Override
    public long expireAfterRead(String key, User value, long currentTime, long currentDuration) {
        return currentDuration; // 读取后不续期
    }
};

Cache<String, User> cache = Caffeine.newBuilder().expireAfter(expiry).build();

6.3 引用类型

java 复制代码
Caffeine.newBuilder()
        .weakKeys()     // key 使用弱引用,GC 时回收(注意用 == 比较)
        .weakValues()   // value 使用弱引用
        .softValues()   // value 使用软引用(内存紧张时回收)
        .build();

weakKeys 底层用 ==(引用相等)比较 key,而非 equals,需谨慎使用自定义对象作为 key。


7. 缓存加载

7.1 同步加载(LoadingCache)

java 复制代码
LoadingCache<String, List<Item>> cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .build(key -> loadItemsFromRemote(key));

List<Item> items = cache.get("hot-items"); // 未命中则阻塞加载

7.2 批量加载(getAll)

实现 CacheLoader.loadAll 可一次批量加载多个 key,减少 IO 次数:

java 复制代码
LoadingCache<String, User> cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .build(new CacheLoader<String, User>() {
            @Override
            public User load(String key) {
                return loadSingle(key);
            }

            @Override
            public Map<String, User> loadAll(Iterable<? extends String> keys) {
                return loadBatch(keys); // 一次批量查询
            }
        });

Map<String, User> users = cache.getAll(Arrays.asList("1", "2", "3"));

7.3 异步加载(AsyncLoadingCache)

java 复制代码
import com.github.benmanes.caffeine.cache.AsyncLoadingCache;

AsyncLoadingCache<String, User> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .buildAsync(key -> loadUserAsync(key)); // 返回 CompletableFuture<User>

CompletableFuture<User> future = cache.get("uid:123");
User user = future.join(); // 或 future.thenAccept(...)

异步缓存默认使用 ForkJoinPool.commonPool(),可通过 executor(...) 自定义线程池。


8. 缓存刷新

refreshAfterWrite 让条目在写入一段时间后,下次访问时触发异步刷新,刷新期间仍返回旧值,不阻塞读取:

java 复制代码
LoadingCache<String, List<Item>> cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .refreshAfterWrite(Duration.ofMinutes(1))
        .build(key -> loadItemsFromRemote(key));

List<Item> items = cache.get("hot-items"); // 过期后异步刷新,返回旧值

刷新与过期的区别:

机制 过期后读取 是否阻塞
expireAfterWrite 重新加载新值 阻塞(同步加载)
refreshAfterWrite 返回旧值,后台异步加载 不阻塞

refreshAfterWrite 依赖后续访问触发,不保证到点立即刷新;需配置 CacheLoader(或 reload)。


9. 缓存淘汰算法:Window TinyLFU

9.1 背景:LRU 与 LFU 的局限

  • LRU(最近最少使用):只看"最近性",容易被一次稀疏的大范围扫描"冲垮"(扫描型流量会淘汰真正热的数据)。
  • LFU(最不经常使用):只看"频率",但历史高频条目会长期霸占缓存,且无法快速反映热度变化。

9.2 TinyLFU 与频率草图

TinyLFU 用近似频率(而非精确计数)来做"准入决策":当新条目要进入缓存时,将其频率与"受害者"(被淘汰候选)的频率比较,只有新条目更"热"才允许进入,否则直接丢弃。

频率通过 Count-Min Sketch(CMS) 估计:

  • 用多个哈希函数 + 一个二维计数器数组近似统计频率,内存占用极小。
  • 每个计数器为 4-bit(最大计数值 15)。
  • 周期性减半(aging / reset),避免历史高频条目永久压制新条目,使缓存能适应访问模式的变化。

9.3 Window TinyLFU 结构

Caffeine 在 TinyLFU 基础上加入一个"准入窗口",解决突发流量被误杀的问题,整体结构为:

区域 比例(约) 作用
Window(窗口区) 1% 新加入的条目先进入的小型 LRU,保护"稀疏突发"数据
Probation(试用区) 主区 20% 淘汰候选区
Protected(保护区) 主区 80% 被判定为"热"的条目

主区(Main)占约 99%,内部按 SLRU 思想分为 Protected(热)与 Probation(测试)两段。

9.4 淘汰流程

  1. 新条目先进入 Window 区。
  2. 当主区满时,从 Probation 区选择 LRU 受害者。
  3. 候选(来自 Window 或 Probation 的晋升者)与受害者通过频率草图比较频率。
  4. 候选更热 → 准入 Protected 区,受害者被淘汰;否则候选被丢弃,受害者保留。

这套机制使 Caffeine 在多种真实访问模式下达到接近最优的命中率,同时内存开销低于纯 LRU。


10. 缓存移除与监听

通过 removalListener 监听条目被移除的原因:

java 复制代码
import com.github.benmanes.caffeine.cache.RemovalCause;
import com.github.benmanes.caffeine.cache.RemovalListener;

Cache<String, User> cache = Caffeine.newBuilder()
        .maximumSize(1000)
        .removalListener((String key, User value, RemovalCause cause) -> {
            System.out.printf("移除 key=%s, cause=%s%n", key, cause);
        })
        .build();

RemovalCause 枚举值:

枚举 含义
EXPLICIT 显式调用 invalidate / put 覆盖等手动移除
REPLACED 条目被新值替换
COLLECTED key/value 被 GC 回收(弱引用/软引用)
EXPIRED 到期过期
SIZE 因容量限制被淘汰

11. 缓存统计

开启 recordStats() 后可获取 CacheStats

java 复制代码
Cache<String, User> cache = Caffeine.newBuilder()
        .recordStats()
        .build();

CacheStats stats = cache.stats();
System.out.println("命中次数:   " + stats.hitCount());
System.out.println("未命中次数: " + stats.missCount());
System.out.println("命中率:     " + stats.hitRate());
System.out.println("加载成功:   " + stats.loadSuccessCount());
System.out.println("加载失败:   " + stats.loadFailureCount());
System.out.println("平均加载耗时(纳秒): " + stats.averageLoadPenalty());
System.out.println("淘汰次数:   " + stats.evictionCount());

统计会带来少量性能开销,生产环境按需开启。


12. 内部实现与性能设计

Caffeine 高性能的关键在于以下几点:

12.1 基于 ConcurrentHashMap 的近乎无锁读

底层存储直接使用 ConcurrentHashMap,读操作在多数情况下无锁竞争,吞吐量极高。

12.2 环形缓冲(Ring Buffer)

访问/写入事件通过 MPSC(多生产者单消费者)环形缓冲异步记录,由后台线程统一消费并更新频率草图和淘汰策略,避免每次读写都直接争用淘汰策略的锁,降低热路径开销。

12.3 分层时间轮(Timing Wheel)

过期事件用分层时间轮管理,插入/删除为 O(1) 均摊复杂度,优于 Guava Cache 的 O(log n) 队列实现。

12.4 内存友好

  • 频率草图用 4-bit 计数器近似统计,避免为每个条目维护大对象。
  • 异步事件通过缓冲批量处理,减少对象分配。

13. 分布式多节点缓存一致性

13.1 问题的本质

Caffeine 是进程内本地缓存,每个 JVM 实例各自维护一份,彼此独立、无法共享。在多节点部署下会出现:

  • 同一 key 在不同节点缓存了不同版本的值;
  • 某节点更新了数据库,其它节点的本地缓存仍是旧值,导致脏读。

因此核心矛盾是:本地缓存的高性能(无网络开销)与分布式的一致性(需要跨节点协调)。这是所有"本地缓存 + 多节点"架构绕不开的问题。

13.2 一致性策略概览

策略 一致性强度 实现成本 适用场景
短 TTL 兜底 弱(最终一致) 对实时性要求不高的场景
主动失效广播(Pub/Sub 等) 绝大多数业务场景(推荐)
版本号 / 时间戳校验 中高 需要明确数据新鲜度
两级缓存(L1 Caffeine + L2 Redis) 高频读、需要跨节点共享
强一致(分布式锁 / 禁用本地缓存) 金额、库存等强一致场景

13.3 方案一:主动失效广播(最常用)

写入方更新 DB 后,通过消息中间件广播"key 失效"事件,各节点订阅后调用 invalidate(key) 删除本地缓存。

基于 Redis Pub/Sub 的示例:

java 复制代码
// 发布端:更新 DB 后广播失效
public void updateUser(String id, User user) {
    db.update(user);
    redisTemplate.convertAndSend("cache-invalid", id);
}

// 订阅端:收到消息后删除本地缓存
public class CacheInvalidListener implements MessageListener {
    private final Cache<String, User> cache;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = new String(message.getBody());
        cache.invalidate(key);
    }
}

注意点:

  • 失效消息可能丢失 → 必须配合 TTL 兜底,保证最坏情况下最终一致。
  • 失效操作要幂等(重复删除无副作用)。
  • 选型:Redis Pub/Sub 简单但会丢消息;Kafka / RocketMQ 更可靠但更重。

13.4 方案二:两级缓存 L1 + L2

Caffeine 作为 L1 本地缓存,Redis 作为 L2 分布式缓存:

  • 读:先查 L1 → miss 查 L2 → 再 miss 查 DB 并回填 L2、L1。
  • 写:更新 DB → 删除 L2 → 广播删除各节点 L1。
java 复制代码
public User getUser(String id) {
    // 1. 查本地缓存
    User u = localCache.getIfPresent(id);
    if (u != null) return u;

    // 2. 查分布式缓存
    u = redisCache.get(id);
    if (u != null) {
        localCache.put(id, u);
        return u;
    }

    // 3. 查 DB 并回填
    u = db.get(id);
    if (u != null) {
        redisCache.set(id, u, ttl);
        localCache.put(id, u);
    }
    return u;
}

两级缓存同时获得"本地高性能"与"一定程度跨节点共享",但也引入"多级一致性"问题,复杂度更高。建议 L1 的 TTL 设置得比 L2 更短,以缩小脏数据窗口。

13.5 缓存与数据库的一致性(Cache-Aside)

Caffeine 通常与数据库一起构成"缓存-数据库"体系,最经典的一致性模式是 Cache-Aside(旁路缓存)

  1. 读:先查缓存,命中直接返回;未命中查 DB 并回填缓存。
  2. 写:先更新 DB,再删除缓存(而不是更新缓存)。

为什么"先更新 DB 再删缓存"优于"先删缓存再更新 DB":

  • 先删缓存:删除缓存 → 更新 DB 期间,有读请求进来,查 DB 拿到旧值回填缓存 → 缓存又脏了。
  • 先更新 DB 再删缓存:即使删除前有读请求拿到旧值,删除操作也会把脏值清掉,最终一致。

延迟双删(进一步压缩并发窗口):

text 复制代码
删除缓存 → 更新 DB → 延迟 N 毫秒 → 再次删除缓存

第二次删除用来兜底"更新 DB 前读到旧值并回填"的并发请求。

13.6 方案三:Canal + MQ 广播失效(业务无侵入)

流程:

text 复制代码
数据库变更 → Canal 监听 binlog → 发送 MQ 消息 → 所有节点消费 → 各自失效本地缓存

原理:

  • Canal 伪装成 MySQL 的从库(Slave),订阅并解析 binlog,捕获每一行数据的 INSERT / UPDATE / DELETE。
  • 数据变更后,Canal 将变更的「表名 + 主键」打包成消息,投递到 MQ(Kafka / RocketMQ / RabbitMQ)。
  • 各业务节点订阅 MQ 消息,解析出对应的缓存 key,调用 invalidate(key) 失效本地缓存。

优点:

  • 业务代码零侵入:增删改代码里完全不用写缓存失效逻辑,彻底解耦。
  • 覆盖所有写路径:不依赖开发者自觉,无论哪个入口改库(接口、定时任务、甚至手工 SQL),都能感知到变更。
  • 失效面更全:基于 binlog,天然覆盖全部数据变更。

缺点:

  • 链路较长、有轻微延迟:binlog → Canal → MQ → 消费端,通常有几十毫秒到秒级的延迟。
  • 组件多、运维成本高:需要额外部署并维护 Canal 与 MQ。
  • 需要映射规则:binlog 给出的是「表 + 主键」,需自行维护「主键 → 缓存 key」的映射。

适用场景: 表多、写入口分散、希望彻底解耦缓存逻辑的中大型系统;对一致性要求较高且能接受秒级延迟的场景。

消费端示例:

java 复制代码
@Component
@Slf4j
public class BinlogCacheInvalidConsumer {

    private final Cache<String, Object> localCache;

    // 消费由 Canal 投递到 MQ 的 binlog 变更消息
    @KafkaListener(topics = "binlog-cache-invalid")
    public void onBinlogChange(String msg) {
        // msg 形如: {"table":"user","id":123,"type":"UPDATE"}
        JSONObject obj = JSON.parseObject(msg);
        String table = obj.getString("table");
        Long id = obj.getLong("id");

        String key = table + ":" + id;   // 主键 -> 缓存 key 映射
        localCache.invalidate(key);
        log.info("binlog 失效缓存, key={}", key);
    }
}

建议将「主键 → 缓存 key」的映射规则单独收敛到一个工具类,避免散落在各处。

13.7 一致性与性能的权衡

  • Caffeine 本地缓存的核心优势就是无网络开销,任何跨节点同步都会削弱这一点。
  • 通常应选择最终一致而非强一致:允许短暂不一致(如几百毫秒 ~ 几秒),换取性能与可用性。
  • 强一致场景(金额、库存等)应放弃本地缓存,或配合分布式锁 / 事务,Caffeine 不适合。

13.8 实践建议

  1. 默认采用 Cache-Aside + 主动失效广播 + 短 TTL 兜底,性价比最高。
  2. 热点数据用两级缓存,并让 L1 的 TTL 短于 L2。
  3. 失效消息优先用可靠队列(Kafka / RocketMQ),仅 Pub/Sub 时须接受丢消息风险。
  4. 对写入口分散、追求彻底解耦的系统,采用 Canal + MQ 方案。
  5. 所有失效 / 删除操作保证幂等
  6. recordStats() 监控命中率,判断本地缓存是否真的带来收益。

14. Spring / Spring Boot 集成

14.1 引入依赖

xml 复制代码
<!-- 缓存抽象 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- 本地缓存 -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.2.3</version>
</dependency>
<!-- Redis:分布式缓存 + 失效广播 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

14.2 application.yml 配置

yaml 复制代码
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  data:
    redis:
      host: localhost
      port: 6379
      # password: your-password

14.3 Config 类

CacheConfig:本地 Caffeine 缓存 Bean

java 复制代码
@Configuration
public class CacheConfig {

    /** 本地 Caffeine 缓存,与 Redis 组成 L1 + L2 */
    @Bean
    public Cache<String, Object> localCache() {
        return Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(10))
                .recordStats()
                .build();
    }
}

RedisConfig:失效广播频道 + 监听容器

java 复制代码
@Configuration
public class RedisConfig {

    /** 缓存失效广播频道 */
    public static final String CACHE_INVALID_CHANNEL = "cache-invalid-channel";

    /** 订阅频道,收到消息后交给 CacheInvalidListener 处理 */
    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
            RedisConnectionFactory connectionFactory,
            CacheInvalidListener listener) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listener, new ChannelTopic(CACHE_INVALID_CHANNEL));
        return container;
    }
}

14.4 缓存一致性处理:Redis Pub/Sub 失效监听

本地缓存 + Redis 组成两级缓存后,数据更新时通过 Redis Pub/Sub 广播失效消息,各节点收到后删除自己的本地缓存,从而实现多节点间的最终一致。

订阅端 CacheInvalidListener:

java 复制代码
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheInvalidListener implements MessageListener {

    private final Cache<String, Object> localCache;

    /** 当前节点唯一标识,进程内固定 */
    private final String nodeId = UUID.randomUUID().toString();

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String body = new String(message.getBody());
        // 消息格式: nodeId:key
        int idx = body.indexOf(':');
        if (idx <= 0) return;
        String srcNode = body.substring(0, idx);
        String key = body.substring(idx + 1);

        // 本节点自己发的消息,已在本地清理过,跳过
        if (nodeId.equals(srcNode)) {
            return;
        }
        localCache.invalidate(key);
        log.info("收到缓存失效通知, key={}, src={}", key, srcNode);
    }

    public String getNodeId() {
        return nodeId;
    }
}

发布端(业务更新时广播):

java 复制代码
String nodeId = cacheInvalidListener.getNodeId();
redisTemplate.convertAndSend(RedisConfig.CACHE_INVALID_CHANNEL, nodeId + ":" + key);

14.5 业务使用示例

java 复制代码
@Service
@RequiredArgsConstructor
@Slf4j
public class UserCacheService {

    private final Cache<String, Object> localCache;
    private final StringRedisTemplate redisTemplate;
    private final UserRepository userRepository;
    private final CacheInvalidListener cacheInvalidListener;

    /**
     * 查询:先查本地缓存,再查 Redis,最后查 DB
     */
    public User getUser(Long userId) {
        String key = "user:" + userId;

        // 1. 查本地 Caffeine
        User user = (User) localCache.getIfPresent(key);
        if (user != null) {
            log.info("命中本地缓存, key={}", key);
            return user;
        }

        // 2. 查 Redis
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            user = JSON.parseObject(json, User.class);
            localCache.put(key, user); // 回填本地缓存
            log.info("命中 Redis 缓存, key={}", key);
            return user;
        }

        // 3. 查数据库
        user = userRepository.findById(userId).orElse(null);
        if (user != null) {
            redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 5, TimeUnit.MINUTES);
            localCache.put(key, user);
            log.info("命中数据库, key={}", key);
        }
        return user;
    }

    /**
     * 更新:先更 DB,再删 Redis,最后广播失效通知
     */
    @Transactional
    public void updateUser(User user) {
        // 1. 更新数据库
        userRepository.save(user);
        String key = "user:" + user.getId();

        // 2. 删除 Redis
        redisTemplate.delete(key);

        // 3. 广播失效通知给所有节点(携带本节点 ID)
        String nodeId = cacheInvalidListener.getNodeId();
        redisTemplate.convertAndSend(RedisConfig.CACHE_INVALID_CHANNEL, nodeId + ":" + key);

        // 4. 清理本节点本地缓存
        localCache.invalidate(key);

        log.info("已广播缓存失效, key={}", key);
    }
}

说明:

  • JSON 可为 fastjson2 或 Jackson 的 ObjectMapper,按项目习惯替换即可。
  • updateUser 中 DB 与 Redis 的操作并非严格原子(Redis 不参与 DB 事务),这是 Cache-Aside 的常见取舍;若需更强保证,可配合延迟双删或分布式事务框架。

14.6 补充:@Cacheable 注解方式

若想用 Spring 声明式缓存注解,可改用 CaffeineCacheManager

java 复制代码
@Configuration
public class CacheManagerConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(10)));
        return manager;
    }
}

然后在方法上使用 @Cacheable / @CacheEvict 等注解。需要注意:注解方式下,跨节点的缓存一致性仍需靠 13 节所述的失效广播机制(在 @CacheEvict 或更新方法里广播失效消息)来保障。


15. 最佳实践

  1. 区分过期与刷新 :需要"不阻塞、返回旧值、后台更新"时用 refreshAfterWrite;需要严格时效性时用 expireAfterWrite
  2. 合理设置容量maximumSize 过小会导致频繁淘汰、命中率低;过大则浪费内存。结合 recordStats() 观测命中率调优。
  3. 批量加载 :热点 key 批量查询场景实现 loadAll,减少 IO 往返。
  4. 本地 + 分布式两级缓存:Caffeine 作为 L1 拦截高频读,Redis 作为 L2 提供跨实例共享,降低网络与 Redis 压力。
  5. 避免缓存穿透 :对不存在的 key 缓存空值(短过期),或使用布隆过滤器;Caffeine 层面可通过 get 的映射函数返回哨兵值 + 短过期实现。
  6. 谨慎使用弱/软引用:GC 行为不可控,可能导致"诡异"的未命中,仅在确有需求时使用。
  7. 统计按需开启 :生产环境可在观测期开启 recordStats(),压测稳定后可考虑关闭。

16. 常见问题与避坑指南

16.1 expireAfterAccessexpireAfterWrite 混淆

  • expireAfterWrite:写入后固定时间过期,与访问无关。
  • expireAfterAccess:每次访问都重置计时,频繁访问的条目会"永生"。

16.2 maximumSize 是近似值

底层基于估算权重,极端情况下缓存条目可能短暂超过设定值,estimatedSize() 也只是一个估算。

16.3 weakKeys 使用 == 比较

weakKeys 下 key 用引用相等(==)而非 equals,自定义对象的 hashCode/equals 可能失效,务必注意。

16.4 递归加载导致死锁

get(key, mappingFunction) 的映射函数中若再次加载同一个 key,可能引发死锁或异常:

java 复制代码
// 反例:映射函数内部又去 get 同一个 key
cache.get("k", key -> cache.get("k")); // 危险!

16.5 refreshAfterWrite 不保证立即刷新

它依赖后续访问触发,且是异步的;若某 key 长时间无访问,不会自动刷新。

16.6 移除监听可能异步/延迟执行

removalListener 的回调可能在后台维护线程执行,不要在回调里做重逻辑或依赖严格时序。

16.7 统计与监听的开销

recordStats() 与复杂的 removalListener 都会带来性能损耗,按需使用。


17. 与其他缓存方案对比

维度 Caffeine Guava Cache Ehcache Redis
类型 本地内存 本地内存 本地/堆外/持久化 分布式
性能 最高 受网络限制
命中率 近最优(W-TinyLFU) LRU 多种 近似 LRU
持久化
集群共享 可选
JCache/JSR-107 需适配 需适配 原生支持 需适配
适用场景 单机高速缓存 旧项目遗留 需标准化/持久化 分布式共享缓存

选型建议:新项目的本地缓存默认选 Caffeine;需要 JCache 标准接口、持久化或堆外内存时考虑 Ehcache;跨实例共享数据用 Redis(可叠加 Caffeine 作为本地 L1)。


18. 参考资料


本文档基于 Caffeine 3.2.x 编写,具体 API 以实际引入版本的官方文档为准。

相关推荐
xiaojiaohuazi1 小时前
国产安陆EG4S20 FPGA实现千兆以太网TCP/IP:AD7606C 8通道1 MSPS采集、SDRAM缓存与Python上位机
tcp/ip·缓存·fpga开发
一只叫煤球的猫2 小时前
Spring AI 2.0 源码解析(三):ChatClient 的 Fluent API 如何构建请求?
java·后端·面试
软件开发JR2 小时前
基于Web的足球青训俱乐部管理后台系统的设计与开发
java·前端·spring boot·毕业设计
重生之我是Java开发战士2 小时前
【Java EE】Spring AOP :面向切面编程
java·spring·java-ee
凤山老林2 小时前
基于 Spring Batch 的海量数据迁移与批处理架构:分片、容错与断点续跑
java·spring boot·spring·架构·spring batch
Javatutouhouduan2 小时前
Java初学者如何高效学习JVM?
java·jvm·java虚拟机·java面试·后端开发·java程序员·java八股文
Nil2083 小时前
leetcode 146LRU缓存
算法·leetcode·缓存
李可以量化3 小时前
Redis 从了解到精通(三)上:量化交易场景下的数据备份与安全配置
redis·python·安全·qmt·ptrade
ZJU_统一阿萨姆3 小时前
【算子开发】全局内存访问与合并访存
java·服务器·网络·人工智能·语言模型