Caffeine 缓存及其应用相关总结
版本:基于 Caffeine 3.2.x
定位:Java 高性能本地缓存库
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) |
| 过期策略 | expireAfterWrite、expireAfterAccess、expireAfter(自定义 Expiry) |
| 引用类型 | weakKeys、weakValues、softValues |
| 加载缓存 | 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();
注意:
maximumSize与maximumWeight只能二选一。
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 淘汰流程
- 新条目先进入 Window 区。
- 当主区满时,从 Probation 区选择 LRU 受害者。
- 候选(来自 Window 或 Probation 的晋升者)与受害者通过频率草图比较频率。
- 候选更热 → 准入 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(旁路缓存):
- 读:先查缓存,命中直接返回;未命中查 DB 并回填缓存。
- 写:先更新 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 实践建议
- 默认采用 Cache-Aside + 主动失效广播 + 短 TTL 兜底,性价比最高。
- 热点数据用两级缓存,并让 L1 的 TTL 短于 L2。
- 失效消息优先用可靠队列(Kafka / RocketMQ),仅 Pub/Sub 时须接受丢消息风险。
- 对写入口分散、追求彻底解耦的系统,采用 Canal + MQ 方案。
- 所有失效 / 删除操作保证幂等。
- 用
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. 最佳实践
- 区分过期与刷新 :需要"不阻塞、返回旧值、后台更新"时用
refreshAfterWrite;需要严格时效性时用expireAfterWrite。 - 合理设置容量 :
maximumSize过小会导致频繁淘汰、命中率低;过大则浪费内存。结合recordStats()观测命中率调优。 - 批量加载 :热点 key 批量查询场景实现
loadAll,减少 IO 往返。 - 本地 + 分布式两级缓存:Caffeine 作为 L1 拦截高频读,Redis 作为 L2 提供跨实例共享,降低网络与 Redis 压力。
- 避免缓存穿透 :对不存在的 key 缓存空值(短过期),或使用布隆过滤器;Caffeine 层面可通过
get的映射函数返回哨兵值 + 短过期实现。 - 谨慎使用弱/软引用:GC 行为不可控,可能导致"诡异"的未命中,仅在确有需求时使用。
- 统计按需开启 :生产环境可在观测期开启
recordStats(),压测稳定后可考虑关闭。
16. 常见问题与避坑指南
16.1 expireAfterAccess 与 expireAfterWrite 混淆
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 官方 GitHub:https://github.com/ben-manes/caffeine
- W-TinyLFU 论文:Gil Einziger, Roy Friedman, Ben Manes, "TinyLFU: A Highly Efficient Cache Admission Policy"
- Caffeine 性能基准:https://github.com/ben-manes/caffeine/wiki/Benchmarks
- Caffeine 官方 Wiki:https://github.com/ben-manes/caffeine/wiki
本文档基于 Caffeine 3.2.x 编写,具体 API 以实际引入版本的官方文档为准。