Caffeine 本地缓存详解与实战指南
在微服务数据补充场景中,远程调用是主要的性能瓶颈。对于变化不频繁的关联数据(如部门信息、字典数据、组织架构),每次都远程调用是一种浪费。Caffeine 是 Java 生态中性能最优的本地缓存库,本文从原理到实战,系统讲解如何用 Caffeine 优化数据补充流程。
一、为什么需要本地缓存
1.1 问题场景
假设你的分页查询接口需要补充「部门信息」:
java
Map<String, DeptDto> deptMap = departmentFeign.batchQuery(deptCodes);
但部门信息一天可能才变化一次,而接口每秒被调用 100 次。这意味着:
- 每秒 100 次相同的远程调用
- 每次耗时 50-100ms 的网络 IO
- 远程服务承受不必要的压力
1.2 本地缓存的作用
第 1 次请求:缓存未命中 -> 远程调用 -> 结果存入缓存 -> 返回
第 2~N 次请求:缓存命中 -> 直接返回(纳秒级)-> 无需远程调用
| 对比项 | 无缓存 | 有本地缓存 |
|---|---|---|
| 100 次相同查询耗时 | 100 x 50ms = 5000ms | 1 x 50ms + 99 x 0.001ms = 50ms |
| 远程服务压力 | 100 次调用 | 1 次调用 |
| 网络 IO | 100 次 | 1 次 |
1.3 为什么选 Caffeine
| 缓存方案 | 类型 | 延迟 | 适用场景 |
|---|---|---|---|
| Redis | 远程缓存 | 1-5ms | 分布式共享、大数据量 |
| Guava Cache | 本地缓存 | 纳秒级 | 已停止演进,被 Caffeine 替代 |
| Caffeine | 本地缓存 | 纳秒级 | 高性能、Spring Boot 默认推荐 |
| HashMap | 本地存储 | 纳秒级 | 无过期无淘汰,不适合做缓存 |
Caffeine 是 Guava Cache 的继任者,使用 W-TinyLFU 淘汰算法,命中率和性能都优于其他方案。
二、Caffeine 核心概念
2.1 缓存就像一个智能的 Map
java
// 普通 Map:永远不会清理,内存无限增长
Map<String, DeptDto> map = new HashMap<>();
// Caffeine Cache:自动过期、自动淘汰、容量有限
Cache<String, DeptDto> cache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
2.2 三种过期策略
java
// 策略1:写入后固定时间过期(最常用)
.expireAfterWrite(5, TimeUnit.MINUTES)
// 策略2:最后一次访问后过期
.expireAfterAccess(10, TimeUnit.MINUTES)
// 策略3:自定义过期策略(不同 Key 不同过期时间)
.expireAfter(new Expiry<String, DeptDto>() {
@Override
public long expireAfterCreate(String key, DeptDto value, long currentTime) {
return TimeUnit.MINUTES.toNanos(5);
}
@Override
public long expireAfterUpdate(String key, DeptDto value,
long currentTime, long currentDuration) {
return currentDuration;
}
@Override
public long expireAfterRead(String key, DeptDto value,
long currentTime, long currentDuration) {
return currentDuration;
}
})
2.3 淘汰策略
java
// 基于数量:最多存 500 个 Key
.maximumSize(500)
// 基于权重:适合 Value 大小不一的场景
.maximumWeight(10000)
.weigher((String key, DeptDto value) -> value.toString().length())
三、基础用法详解
3.1 Maven 依赖
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.3</version>
</dependency>
Spring Boot 2.x 已内置 Caffeine 依赖,通常不需要单独引入。
3.2 手动操作缓存(Cache)
java
Cache<String, DeptDto> deptCache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
// 写入
deptCache.put("DEPT001", new DeptDto("DEPT001", "技术部"));
// 读取(未命中返回 null)
DeptDto dept = deptCache.getIfPresent("DEPT001");
// 读取,未命中时自动加载(lambda 只在未命中时执行)
DeptDto dept2 = deptCache.get("DEPT001", key -> {
return departmentFeign.getByCode(key).getData();
});
// 删除单个
deptCache.invalidate("DEPT001");
// 批量删除
deptCache.invalidateAll(Arrays.asList("DEPT001", "DEPT002"));
// 清空所有
deptCache.invalidateAll();
3.3 自动加载缓存(LoadingCache)
java
LoadingCache<String, DeptDto> deptCache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(key -> {
// CacheLoader:未命中时自动执行
return departmentFeign.getByCode(key).getData();
});
// 直接 get,未命中自动加载
DeptDto dept = deptCache.get("DEPT001");
// 批量获取(未命中的自动加载)
Map<String, DeptDto> map = deptCache.getAll(Arrays.asList("DEPT001", "DEPT002"));
3.4 Cache vs LoadingCache
| 类型 | 适用场景 | 特点 |
|---|---|---|
| Cache | 批量查询(数据补充模式) | 手动 put/get,灵活 |
| LoadingCache | 单 Key 查询 | 自动加载,简洁 |
四、在数据补充模式中使用 Caffeine
4.1 核心思路
1. 从主查询结果提取 Key 集合
2. 用 Key 集合查本地缓存
3. 区分「命中的」和「未命中的」
4. 未命中的 Key 批量远程调用
5. 远程调用结果回填缓存
6. 合并命中 + 远程结果,返回完整 Map
4.2 完整实现
java
@Slf4j
@Service
@RequiredArgsConstructor
public class DeptCacheService {
private final DepartmentFeign departmentFeign;
private Cache<String, DeptDto> deptCache;
@PostConstruct
public void init() {
deptCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
}
public Map<String, DeptDto> batchQueryWithCache(List<String> deptCodes) {
if (CollectionUtils.isEmpty(deptCodes)) {
return Collections.emptyMap();
}
Map<String, DeptDto> resultMap = new HashMap<>(deptCodes.size());
List<String> missedCodes = new ArrayList<>();
// 第一步:查本地缓存
for (String code : deptCodes) {
DeptDto cached = deptCache.getIfPresent(code);
if (cached != null) {
resultMap.put(code, cached);
} else {
missedCodes.add(code);
}
}
log.debug("部门缓存: 总数={}, 命中={}, 未命中={}",
deptCodes.size(), resultMap.size(), missedCodes.size());
// 第二步:未命中的批量远程调用
if (!missedCodes.isEmpty()) {
Map<String, DeptDto> remoteResult = batchQueryFromRemote(missedCodes);
// 第三步:回填缓存
remoteResult.forEach((code, dto) -> {
deptCache.put(code, dto);
resultMap.put(code, dto);
});
}
return resultMap;
}
private Map<String, DeptDto> batchQueryFromRemote(List<String> deptCodes) {
try {
RestResult<List<DeptDto>> result = departmentFeign.batchQuery(deptCodes);
if (result != null && Boolean.TRUE.equals(result.getSuccess())
&& !CollectionUtils.isEmpty(result.getData())) {
return result.getData().stream()
.collect(Collectors.toMap(
DeptDto::getDeptCode, dto -> dto, (k1, k2) -> k1));
}
} catch (Exception e) {
log.warn("远程查询部门信息失败, codes={}", deptCodes, e);
}
return Collections.emptyMap();
}
public void evict(String deptCode) { deptCache.invalidate(deptCode); }
public void evictAll() { deptCache.invalidateAll(); }
public String getCacheStats() { return deptCache.stats().toString(); }
}
4.3 在 Service 中使用
java
@Service
@RequiredArgsConstructor
public class EmployeeServiceImpl implements EmployeeService {
private final EmployeeMapper employeeMapper;
private final DeptCacheService deptCacheService;
@Override
public PageInfo<EmployeeResultDto> listEmployeePager(EmployeeQueryParam param) {
List<EmployeeResultDto> employees = employeeMapper.listPager(param);
if (CollectionUtils.isEmpty(employees)) {
return new PageInfo<>(Collections.emptyList());
}
List<String> deptCodes = employees.stream()
.map(EmployeeResultDto::getDeptCode)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList());
Map<String, DeptDto> deptMap = deptCacheService.batchQueryWithCache(deptCodes);
employees.forEach(emp -> {
DeptDto dept = deptMap.get(emp.getDeptCode());
if (dept != null) {
emp.setDeptName(dept.getDeptName());
emp.setManagerName(dept.getManagerName());
}
});
return new PageInfo<>(employees);
}
}
4.4 执行流程图解
请求1(部门 A、B、C):
缓存空 -> 全部未命中 -> 远程查 A,B,C -> 回填缓存 -> 返回
请求2(部门 A、B、D):
A,B 命中, D 未命中 -> 远程只查 D -> 回填 D -> 返回
请求3(部门 A、B):
全部命中 -> 零远程调用 -> 直接返回
五、与 Spring Cache 注解集成
5.1 配置
yaml
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=500,expireAfterWrite=5m
@SpringBootApplication
@EnableCaching
public class Application { }
5.2 注解用法
java
@Service
public class DeptService {
@Autowired
private DepartmentFeign departmentFeign;
// 第一次调用执行方法体并缓存,后续直接返回缓存
@Cacheable(value = "deptCache", key = "#deptCode", unless = "#result == null")
public DeptDto getDeptByCode(String deptCode) {
RestResult<DeptDto> result = departmentFeign.getByCode(deptCode);
return (result != null && result.getSuccess()) ? result.getData() : null;
}
// 清除缓存
@CacheEvict(value = "deptCache", key = "#deptCode")
public void updateDept(String deptCode, DeptDto dto) {
// 更新逻辑
}
// 执行方法并更新缓存
@CachePut(value = "deptCache", key = "#deptCode")
public DeptDto updateAndRefresh(String deptCode, DeptDto dto) {
// 更新逻辑
return dto;
}
}
5.3 多缓存空间配置
java
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(Arrays.asList(
buildCache("deptCache", 500, 10),
buildCache("dictCache", 2000, 30),
buildCache("userCache", 1000, 5)
));
return manager;
}
private CaffeineCache buildCache(String name, int maxSize, int minutes) {
return new CaffeineCache(name,
Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(minutes, TimeUnit.MINUTES)
.recordStats()
.build());
}
}
5.4 注解 vs 手动对比
| 对比项 | @Cacheable 注解 | 手动 Cache |
|---|---|---|
| 适用场景 | 单 Key 查询 | 批量查询 |
| 代码侵入 | 低 | 中 |
| 灵活性 | 低 | 高 |
| 批量支持 | 不支持 | 支持 |
六、缓存一致性问题与解决方案
6.1 问题
本地缓存的数据可能与数据库/远程服务不一致。需要根据业务容忍度选择策略。
6.2 方案一:设置合理的过期时间
java
// 部门信息:变化少,10 分钟
.expireAfterWrite(10, TimeUnit.MINUTES)
// 库存信息:变化频繁,30 秒
.expireAfterWrite(30, TimeUnit.SECONDS)
// 字典数据:几乎不变,1 小时
.expireAfterWrite(1, TimeUnit.HOURS)
原则:数据变化越频繁,过期时间越短。
6.3 方案二:主动清除(消息驱动)
java
@Service
public class DeptCacheService {
private Cache<String, DeptDto> deptCache;
// 收到部门变更消息时主动清除
@RabbitListener(queues = "dept.update.queue")
public void onDeptUpdate(DeptUpdateMessage msg) {
deptCache.invalidate(msg.getDeptCode());
log.info("部门缓存已清除: {}", msg.getDeptCode());
}
}
6.4 方案三:定时全量刷新
java
@Service
public class DictCacheService {
private volatile Map<String, DictDto> dictMap = new HashMap<>();
@Scheduled(fixedRate = 5 * 60 * 1000)
public void refreshDictCache() {
try {
List<DictDto> allDicts = dictFeign.listAll().getData();
dictMap = allDicts.stream()
.collect(Collectors.toMap(
DictDto::getCode, dto -> dto, (k1, k2) -> k1));
log.info("字典缓存刷新完成, size={}", dictMap.size());
} catch (Exception e) {
log.error("字典缓存刷新失败", e);
}
}
}
6.5 方案对比
| 方案 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|
| 过期时间 | 最终一致 | 低 | 大多数场景 |
| 主动清除 | 准实时 | 中 | 一致性要求高 |
| 定时刷新 | 最终一致 | 低 | 数据量小 |
| 不用缓存 | 强一致 | 无 | 实时性极高 |
七、生产环境注意事项
7.1 缓存监控
java
Cache<String, DeptDto> cache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats() // 开启统计
.build();
CacheStats stats = cache.stats();
log.info("命中率: {}", stats.hitRate()); // 0.95 = 95%
log.info("命中次数: {}", stats.hitCount());
log.info("未命中次数: {}", stats.missCount());
log.info("平均加载耗时: {}ms", stats.averageLoadPenalty() / 1_000_000);
命中率应大于 80%,否则需调整配置。
7.2 避免缓存穿透
java
private static final DeptDto EMPTY_DEPT = new DeptDto();
// 远程也查不到的 Key,缓存空对象防止反复穿透
for (String code : missedCodes) {
if (remoteResult.containsKey(code)) {
deptCache.put(code, remoteResult.get(code));
resultMap.put(code, remoteResult.get(code));
} else {
deptCache.put(code, EMPTY_DEPT);
}
}
// 使用时判断
DeptDto dept = deptMap.get(deptCode);
if (dept != null && dept != EMPTY_DEPT) {
// 正常赋值
}
7.3 多实例部署
本地缓存是 JVM 级别的,多实例各自独立:
- 各实例缓存不共享
- 数据变更后短暂不一致
- 读多写少场景可接受
如需强一致:使用 Redis 或 Caffeine + Redis 二级缓存。
7.4 内存占用估算
每个对象 200B, maximumSize=1000 -> 约 200KB
每个对象 2KB, maximumSize=5000 -> 约 10MB
建议不超过 JVM 堆内存的 5%。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
八、完整示例:通用缓存批量查询模板
8.1 抽象基类
java
@Slf4j
public abstract class AbstractCacheBatchQuery<K, V> {
private final Cache<K, V> cache;
private final String cacheName;
protected AbstractCacheBatchQuery(String cacheName, int maxSize, int expireMinutes) {
this.cacheName = cacheName;
this.cache = Caffeine.newBuilder()
.maximumSize(maxSize)
.expireAfterWrite(expireMinutes, TimeUnit.MINUTES)
.recordStats()
.build();
}
public Map<K, V> batchGet(List<K> keys) {
if (CollectionUtils.isEmpty(keys)) {
return Collections.emptyMap();
}
List<K> distinctKeys = keys.stream().distinct().collect(Collectors.toList());
Map<K, V> resultMap = new HashMap<>(distinctKeys.size());
List<K> missedKeys = new ArrayList<>();
for (K key : distinctKeys) {
V cached = cache.getIfPresent(key);
if (cached != null) {
resultMap.put(key, cached);
} else {
missedKeys.add(key);
}
}
log.debug("[{}] 总数={}, 命中={}, 未命中={}",
cacheName, distinctKeys.size(), resultMap.size(), missedKeys.size());
if (!missedKeys.isEmpty()) {
try {
Map<K, V> remoteResult = doRemoteQuery(missedKeys);
remoteResult.forEach((key, value) -> {
cache.put(key, value);
resultMap.put(key, value);
});
} catch (Exception e) {
log.warn("[{}] 远程查询失败, size={}", cacheName, missedKeys.size(), e);
}
}
return resultMap;
}
protected abstract Map<K, V> doRemoteQuery(List<K> keys);
public void evict(K key) { cache.invalidate(key); }
public void evictAll(Collection<K> keys) { cache.invalidateAll(keys); }
public void evictAll() { cache.invalidateAll(); }
public String stats() { return String.format("[%s] %s", cacheName, cache.stats()); }
}
8.2 部门缓存实现
java
@Service
public class DeptCacheBatchQuery extends AbstractCacheBatchQuery<String, DeptDto> {
private final DepartmentFeign departmentFeign;
public DeptCacheBatchQuery(DepartmentFeign departmentFeign) {
super("deptCache", 1000, 10);
this.departmentFeign = departmentFeign;
}
@Override
protected Map<String, DeptDto> doRemoteQuery(List<String> deptCodes) {
RestResult<List<DeptDto>> result = departmentFeign.batchQuery(deptCodes);
if (result != null && Boolean.TRUE.equals(result.getSuccess())
&& !CollectionUtils.isEmpty(result.getData())) {
return result.getData().stream()
.collect(Collectors.toMap(
DeptDto::getDeptCode, dto -> dto, (k1, k2) -> k1));
}
return Collections.emptyMap();
}
}
8.3 用户缓存实现
java
@Service
public class UserCacheBatchQuery extends AbstractCacheBatchQuery<Long, UserDto> {
private final UserFeign userFeign;
public UserCacheBatchQuery(UserFeign userFeign) {
super("userCache", 2000, 5);
this.userFeign = userFeign;
}
@Override
protected Map<Long, UserDto> doRemoteQuery(List<Long> userIds) {
RestResult<List<UserDto>> result = userFeign.batchQueryByIds(userIds);
if (result != null && Boolean.TRUE.equals(result.getSuccess())
&& !CollectionUtils.isEmpty(result.getData())) {
return result.getData().stream()
.collect(Collectors.toMap(
UserDto::getUserId, dto -> dto, (k1, k2) -> k1));
}
return Collections.emptyMap();
}
}
8.4 业务中使用
java
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderMapper orderMapper;
private final DeptCacheBatchQuery deptCacheBatchQuery;
private final UserCacheBatchQuery userCacheBatchQuery;
@Override
public PageInfo<OrderResultDto> listOrderPager(OrderQueryParam param) {
List<OrderResultDto> orders = orderMapper.listPager(param);
if (CollectionUtils.isEmpty(orders)) {
return new PageInfo<>(Collections.emptyList());
}
List<String> deptCodes = extractKeys(orders, OrderResultDto::getDeptCode);
Map<String, DeptDto> deptMap = deptCacheBatchQuery.batchGet(deptCodes);
List<Long> buyerIds = extractIds(orders, OrderResultDto::getBuyerId);
Map<Long, UserDto> userMap = userCacheBatchQuery.batchGet(buyerIds);
orders.forEach(order -> {
DeptDto dept = deptMap.get(order.getDeptCode());
if (dept != null) {
order.setDeptName(dept.getDeptName());
}
UserDto user = userMap.get(order.getBuyerId());
if (user != null) {
order.setBuyerName(user.getNickname());
}
});
return new PageInfo<>(orders);
}
private <T> List<String> extractKeys(List<T> list, Function<T, String> extractor) {
return list.stream()
.map(extractor)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList());
}
private <T> List<Long> extractIds(List<T> list, Function<T, Long> extractor) {
return list.stream()
.map(extractor)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
}
}
九、配置参数速查表
| 参数 | 方法 | 说明 |
|---|---|---|
| 最大条数 | .maximumSize(n) |
超过后自动淘汰 |
| 最大权重 | .maximumWeight(n) |
配合 weigher |
| 写后过期 | .expireAfterWrite(t, unit) |
最常用 |
| 访问后过期 | .expireAfterAccess(t, unit) |
热点保活 |
| 写后刷新 | .refreshAfterWrite(t, unit) |
异步刷新 |
| 开启统计 | .recordStats() |
监控命中率 |
| 移除监听 | .removalListener(listener) |
移除回调 |
移除监听示例
java
Cache<String, DeptDto> cache = Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(5, TimeUnit.MINUTES)
.removalListener((String key, DeptDto value, RemovalCause cause) -> {
log.debug("缓存移除: key={}, cause={}", key, cause);
// cause: EXPIRED / SIZE / EXPLICIT
})
.build();
十、常见问题 FAQ
Q1:expireAfterWrite 和 expireAfterAccess 能同时用吗?
不能,两者互斥。需要组合逻辑时用 .expireAfter(Expiry) 自定义。
Q2:缓存过期是精确的吗?
不是。Caffeine 惰性清理 + 异步清理,但 getIfPresent 不会返回已过期数据。
Q3:Caffeine 是线程安全的吗?
是的。内部使用 ConcurrentHashMap + 分段锁,完全线程安全。
Q4:LoadingCache 的 get 会阻塞吗?
会。多线程同时 get 同一个未缓存 Key 时,只有一个线程加载,其他等待(防击穿)。
Q5:什么时候不该用本地缓存?
- 数据实时性要求极高(如库存扣减)
- 数据量极大,内存放不下
- 多实例必须强一致
- 写多读少(缓存频繁失效)
十一、总结决策流程
需要缓存吗?
+-- 数据秒级变化 -> 不缓存
+-- 数据分钟级变化 -> 短过期(1-5分钟)
+-- 数据小时/天级变化 -> 长过期(10-60分钟)
+-- 数据几乎不变 -> 超长过期或启动时加载
选哪种 API?
+-- 单 Key 查询 -> @Cacheable 或 LoadingCache
+-- 批量查询 -> 手动 Cache + batchGet
过期时间?
+-- 能容忍 N 分钟延迟 -> expireAfterWrite = N
+-- 热点要保活 -> expireAfterAccess
+-- 不同数据不同策略 -> expireAfter(Expiry)
容量?
+-- 估算:并发用户数 x 每用户涉及的不同 Key 数
+-- 兜底:宁大勿小
+-- 监控:上线后看 stats() 调整