Caffeine 本地缓存详解与实战指南

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() 调整
相关推荐
Albert Edison1 小时前
【Redis】Centos7.9 安装 Redis 5 教程
数据库·redis·缓存
Steadfast_GG1 小时前
Redis中的通用命令
redis·缓存
颜笑晏晏5 小时前
长输入短输出场景下的 SGLang 推理性能实测前缀缓存、PD 分离配比与参数调优
缓存·推理优化·sglang·ai infra·pd分离
真实的菜6 小时前
Redis 从入门到精通(十四):Redis 7.x 新特性全解 —— 系列收官之作
数据库·redis·缓存
小小工匠8 小时前
Redis - 缓存与数据库一致性:问题分析与解决方案
redis·缓存·性能优化·消息队列·并发
闪电悠米8 小时前
黑马点评-Redis 消息队列-02_list_pubsub_limits
java·数据库·ide·redis·缓存·list·intellij-idea
折哥的程序人生 · 物流技术专研8 小时前
《Java 100 天进阶之路》第93篇:Redis实战应用:缓存策略与分布式锁(2026版)
java·redis·缓存·面试·架构·求职招聘
填满你的记忆8 小时前
10万QPS下,Redis缓存如何避免雪崩?
数据库·redis·缓存
10WTW019 小时前
QQ本地缓存机制初步探寻
缓存·视频·md5
2601_961194029 小时前
考研专业课在哪里参加考试|考点|流程|资料已整理
linux·考研·ubuntu·缓存·centos·负载均衡