原创

SpringBoot 集成 Caffeine 实现本地缓存详解

为什么在 Spring Boot 里使用 Caffeine

很多项目一提到缓存,第一反应就是 Redis。这个方向没有问题,但并不是所有缓存都适合放到远程服务里。对于一些典型场景,本地缓存反而更合适: • 单机内高频读取的数据 • 变化频率不高、允许短时间不一致的数据 • 查询代价高,但结果体积小的数据 • 用来削峰、挡热点、减少数据库和远程调用压力的数据

Caffeine 就是这类场景里很成熟的一种本地缓存实现。它不是简单的 Map 包装,而是一个专门为高并发、本地高性能缓存设计的库。和早期常见的 Guava Cache 相比,Caffeine 在命中效率、淘汰策略、并发性能、统计能力和扩展性上都更适合现代 Java 服务端项目。

在 Spring Boot 里集成 Caffeine,本质上有两条路线: • 使用 Spring Cache 抽象,通过注解方式接入 • 直接使用 Caffeine 原生 API,做更细粒度的控制

这两种方式不是谁替代谁,而是面向不同复杂度。简单业务优先用 Spring Cache;涉及单飞加载、异步缓存、动态过期、自定义加载逻辑时,再考虑直接使用 Caffeine API。

Caffeine 适合解决什么问题

先把边界讲清楚,比一上来贴配置更重要。

Caffeine 适合的是进程内缓存。缓存数据只存在当前应用实例的内存里,因此它天然有几个特点:

优势 1. 访问速度极快 不经过网络,不序列化,不依赖外部中间件,读延迟通常远低于 Redis。 2. 部署简单 不需要新增基础设施,不需要考虑 Redis 连接池、网络抖动、序列化协议等问题。 3. 适合热点数据就地复用 同一个实例反复读取相同数据时,本地缓存收益非常直接。 4. 可以作为一级缓存 在“本地缓存 + Redis + DB”多级缓存架构里,Caffeine 很适合做最前面的本地一级缓存。

局限 1. 多实例之间不共享 A 实例缓存了数据,B 实例并不知道。分布式环境下天然会出现实例间不一致。 2. 应用重启缓存丢失 它不是持久化缓存,重启后全部失效。 3. 内存占用受 JVM 限制 缓存数据本质上占的是堆内存,配置不当容易引发 GC 压力,甚至 OOM。 4. 不适合作为唯一事实来源 它只能加速读取,不能代替数据库,也不适合作为跨实例一致的数据存储。

所以,最常见的正确定位是: • 把 Caffeine 当作性能优化层 • 不把 Caffeine 当作一致性保障层 • 不把 Caffeine 当作分布式共享缓存

Spring Cache 与 Caffeine 的关系

Spring Boot 集成 Caffeine 时,经常会看到三层概念混在一起: • Spring Cache:Spring 提供的缓存抽象 • CacheManager:Spring 管理缓存实例的入口 • Caffeine:具体的缓存实现

这三者关系很简单:

Spring Cache 负责统一编程模型,比如 @Cacheable、@CachePut、@CacheEvict;Caffeine 负责真正把数据存在内存里;CaffeineCacheManager 则把两者连接起来。

也就是说,业务层通常只写注解:

@Cacheable(cacheNames = "userCache", key = "#userId") public UserDTO getUserById(Long userId) { return userRepository.findById(userId) .map(this::toDTO) .orElse(null); }

真正使用的底层缓存实现,是通过配置决定的。今天选 Caffeine,明天换 Redis,从业务代码角度不一定要大改,这就是 Spring Cache 抽象的价值。

但这层抽象也带来一个现实问题:它会屏蔽一些底层实现特性。 例如 Caffeine 的 refreshAfterWrite、LoadingCache、AsyncLoadingCache、自定义 Expiry 等能力,并不能总是优雅地通过注解直接表达出来。

所以在项目中要有一个判断: • 只是做常规方法级缓存:用 Spring Cache • 要用 Caffeine 的高级能力:直接使用原生 API

引入依赖

如果使用 Spring Boot 3.x 或 2.x,通常只需要引入缓存启动器和 Caffeine 依赖。

org.springframework.boot spring-boot-starter-cache
<!-- Caffeine -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

如果项目本来就已经引入了 spring-boot-starter 体系,真正需要关注的是两点: • spring-boot-starter-cache 用于启用 Spring Cache 体系 • caffeine 是具体缓存实现

启用缓存功能

在 Spring Boot 启动类或配置类上加 @EnableCaching:

@SpringBootApplication @EnableCaching public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }

这个注解的作用不是“开启 Caffeine”,而是开启 Spring 的缓存注解能力。没有它,@Cacheable、@CachePut、@CacheEvict 都不会生效。

最基础的集成方式

application.yml 配置方式

Spring Boot 支持直接通过配置文件声明使用 Caffeine。

spring: cache: type: caffeine cache-names: - userCache - productCache caffeine: spec: initialCapacity=100,maximumSize=1000,expireAfterWrite=10m,recordStats

这段配置做了几件事: • 指定缓存类型为 caffeine • 预定义两个缓存区域:userCache、productCache • 使用 Caffeine 的 spec 字符串定义缓存参数

这种方式的优点是简单,适合统一规则场景。缺点也很明显:所有 cache-names 默认会共用同一套 spec 规则。如果你希望 userCache 过期 10 分钟,而 productCache 过期 1 小时,这种配置就不够用了。

使用配置类自定义 CacheManager

实际项目里,更常见的是通过 Java Config 显式创建 CacheManager。

@Configuration public class CacheConfig {

@Bean
public CacheManager cacheManager() {
    CaffeineCacheManager cacheManager = new CaffeineCacheManager();

    cacheManager.setCaffeine(Caffeine.newBuilder()
            .initialCapacity(100)
            .maximumSize(1000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .recordStats());

    return cacheManager;
}

}

这比直接写 spec 更清晰,原因有三个: • IDE 有类型提示,不容易写错 • 配置可以复用常量、条件判断、环境变量 • 后续更容易扩展到不同缓存策略

如果项目中所有缓存的规则都差不多,这已经够用了。

注解方式的核心用法

Spring Cache 最常用的三个注解分别是: • @Cacheable:先查缓存,没有再执行方法并写入缓存 • @CachePut:总是执行方法,并把结果写入缓存 • @CacheEvict:删除缓存

  1. @Cacheable

@Service public class UserService {

@Cacheable(cacheNames = "userCache", key = "#userId")
public UserDTO getUserById(Long userId) {
    System.out.println("查询数据库...");
    return loadFromDb(userId);
}

private UserDTO loadFromDb(Long userId) {
    UserDTO user = new UserDTO();
    user.setId(userId);
    user.setName("用户" + userId);
    return user;
}

}

调用第一次会执行方法体;后续相同 key 的调用,直接从缓存返回,不再进入方法体。

这里要注意一个基础但高频的误区: 缓存命中时,方法根本不会执行。 很多人把日志、埋点、校验、权限判断写在 @Cacheable 方法内部,结果命中缓存时这些逻辑全部被跳过。

所以,@Cacheable 方法应该尽量保持“纯查询”属性,不要夹带必须执行的副作用逻辑。

  1. @CachePut

@CachePut(cacheNames = "userCache", key = "#user.id") public UserDTO updateUser(UserDTO user) { System.out.println("更新数据库..."); return saveToDb(user); }

这个注解和 @Cacheable 最大的区别是:方法一定会执行。 适合更新数据库成功后,把最新结果同步写回缓存。

常见场景: • 修改用户资料后刷新用户缓存 • 修改商品价格后刷新商品缓存

  1. @CacheEvict

@CacheEvict(cacheNames = "userCache", key = "#userId") public void deleteUser(Long userId) { System.out.println("删除数据库记录..."); deleteFromDb(userId); }

用于在数据删除或更新后清理缓存。 如果是大范围失效,也可以这样:

@CacheEvict(cacheNames = "userCache", allEntries = true) public void clearUserCache() { }

但这个操作要慎用。allEntries = true 看起来方便,实际上在高并发系统里可能导致瞬时缓存雪崩,尤其是热点缓存区域。

key 的设计比注解本身更重要

很多缓存问题,根源不是 Caffeine,也不是 Spring Cache,而是 key 设计混乱。

常见错误一:key 维度不完整

@Cacheable(cacheNames = "orderCache", key = "#orderId") public OrderDTO getOrder(Long orderId, Long tenantId) { ... }

如果系统是多租户的,仅用 orderId 作为 key 就有风险。不同租户下相同 orderId 可能产生串数据。

更合理的写法:

@Cacheable(cacheNames = "orderCache", key = "#tenantId + ':' + #orderId") public OrderDTO getOrder(Long orderId, Long tenantId) { ... }

常见错误二:使用对象作为 key,但对象 equals/hashCode 语义不稳定

@Cacheable(cacheNames = "userCache", key = "#query") public List queryUsers(UserQuery query) { ... }

如果 UserQuery 没有正确实现 equals 和 hashCode,或者字段过多、可变,缓存 key 很容易失控。

更稳妥的做法是显式挑选关键字段:

@Cacheable( cacheNames = "userCache", key = "#query.name + ':' + #query.status + ':' + #query.pageNo + ':' + #query.pageSize" ) public List queryUsers(UserQuery query) { ... }

常见错误三:把高基数参数直接拿来做 key

比如搜索词、时间范围、动态过滤条件组合特别多,这种查询天然 key 爆炸。 结果是缓存命中率很低,内存占用却很高。

缓存不是“所有查询都缓存”。 更准确的原则是:只缓存高复用、低变动、计算代价高的数据。

unless 与 condition 的差异

这是使用 Spring Cache 时很容易混淆的地方。

condition

在方法执行前判断是否启用缓存。

@Cacheable(cacheNames = "userCache", key = "#userId", condition = "#userId > 0") public UserDTO getUserById(Long userId) { return loadFromDb(userId); }

只有 userId > 0 时才使用缓存。

unless

在方法执行后判断结果是否需要放入缓存。

@Cacheable(cacheNames = "userCache", key = "#userId", unless = "#result == null") public UserDTO getUserById(Long userId) { return loadFromDb(userId); }

这个配置很常见,表示查询结果为 null 时不缓存。

两者区别要记牢: • condition:方法执行前决定“查不查缓存” • unless:方法执行后决定“存不存缓存”

是否应该缓存 null

这是个典型的边界问题,没有统一答案,取决于业务。

不缓存 null 的优点 • 逻辑简单 • 避免把“不存在”长期固化在缓存中 • 适合数据可能稍后创建的场景

不缓存 null 的问题 • 对于不存在的数据,每次都会打到数据库 • 容易被缓存穿透拖垮后端

缓存 null 的适用场景 • 某类 key 大量不存在 • 数据不存在本身就是稳定事实 • 需要抵御恶意请求或高频空查

Spring Cache 默认对 null 的处理要结合具体配置和底层实现看。在很多项目中,更可控的做法不是直接缓存 null,而是缓存一个明确的“空对象”或“占位对象”。

例如:

public class NullUser extends UserDTO { }

或者定义统一返回包装对象,再缓存“查询到/未查询到”的状态。

这样比隐式处理 null 更可维护。

过期策略怎么选

Caffeine 常见的时间策略主要有两类: • expireAfterWrite • expireAfterAccess

expireAfterWrite

写入后一段时间过期,不管中间有没有访问。

expireAfterWrite(Duration.ofMinutes(10))

适合: • 数据更新频率相对明确 • 希望定期刷新 • 缓存寿命按“生成时间”计算

比如商品分类、配置项、地区列表这类数据,通常更适合写后过期。

expireAfterAccess

最后一次访问后一段时间过期。频繁访问的数据会一直保留。

expireAfterAccess(Duration.ofMinutes(30))

适合: • 热点数据明显 • 希望冷数据自动淘汰 • 数据量较大,按访问热度保留

比如用户会话上下文、热点详情页数据。

不要机械理解“时间越长越好”

过期时间本质上是在平衡两件事: • 命中率 • 数据新鲜度

时间太短,缓存形同虚设;时间太长,脏数据窗口过大。 这个值不能靠感觉拍,最好结合业务更新频率、读写比例、容忍时效来定。

一个很实用的经验是: • 变化快的数据:尽量依赖主动失效,不依赖长时间 TTL • 变化慢的数据:可以用较长 TTL 提高命中率 • 热点高读数据:TTL 不宜太短,否则回源抖动明显

容量控制比 TTL 更容易被忽略

很多人一接入缓存,只配置过期时间,不配置大小上限。这是危险的。

Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(Duration.ofMinutes(10));

maximumSize 的意义不是“最多 10000 条就够了”这么简单,而是告诉缓存:当容量逼近上限时,需要基于淘汰策略把低价值数据移除。

如果不做大小控制,理论上缓存 key 会持续增长,只要业务 key 组合足够多,内存迟早会出问题。

什么时候用 maximumWeight

如果缓存对象大小差异很大,仅靠条数控制不够准确,这时可以使用权重。

LoadingCache<Long, String> cache = Caffeine.newBuilder() .maximumWeight(10_000) .weigher((Long key, String value) -> value.length()) .build(key -> "value-" + key);

这适合对象体积差异特别明显的场景。 不过大多数业务系统里,先把 maximumSize 设计合理,已经能解决 80% 问题。

统计能力非常值得打开

很多项目上了缓存,但根本不知道它有没有带来效果。 Caffeine 提供了命中统计能力,建议在调优阶段至少开启。

Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats();

开启后可以获取统计信息:

Cache<Object, Object> nativeCache = Caffeine.newBuilder() .maximumSize(1000) .recordStats() .build();

CacheStats stats = nativeCache.stats(); System.out.println(stats.hitRate()); System.out.println(stats.evictionCount());

需要注意: 命中率不是越高越好,而是要结合业务看。

例如: • 命中率高,但缓存对象很大,导致 GC 压力大,也未必划算 • 命中率一般,但刚好挡住了最昂贵的数据库查询,这个缓存仍然有价值 • 命中率低且回源代价小,这种缓存可能根本没必要存在

不要为了“指标好看”而缓存一切。

多个缓存区域使用不同配置

这是实际项目里很常见的需求。 比如: • userCache:10 分钟过期,最大 1000 条 • configCache:1 小时过期,最大 100 条 • productCache:5 分钟过期,最大 5000 条

这时不能只靠一个全局 spec,更适合手动注册多个 CaffeineCache。

@Configuration public class CacheConfig {

@Bean
public CacheManager cacheManager() {
    SimpleCacheManager cacheManager = new SimpleCacheManager();

    List<org.springframework.cache.Cache> caches = new ArrayList<>();

    caches.add(new CaffeineCache("userCache",
            Caffeine.newBuilder()
                    .initialCapacity(100)
                    .maximumSize(1000)
                    .expireAfterWrite(Duration.ofMinutes(10))
                    .recordStats()
                    .build()));

    caches.add(new CaffeineCache("configCache",
            Caffeine.newBuilder()
                    .initialCapacity(10)
                    .maximumSize(100)
                    .expireAfterWrite(Duration.ofHours(1))
                    .recordStats()
                    .build()));

    caches.add(new CaffeineCache("productCache",
            Caffeine.newBuilder()
                    .initialCapacity(500)
                    .maximumSize(5000)
                    .expireAfterWrite(Duration.ofMinutes(5))
                    .recordStats()
                    .build()));

    cacheManager.setCaches(caches);
    return cacheManager;
}

}

这种方式虽然代码多一点,但对于中大型项目更可控。 缓存不是一个统一参数能打天下的组件,不同数据应该有不同策略。

直接使用 Caffeine 原生 API 的场景

Spring Cache 注解方式很方便,但它解决的是“方法结果缓存”。 当你的需求开始偏向缓存本身的行为控制时,原生 API 往往更合适。

  1. 手动读写缓存

@Service public class LocalCacheService {

private final Cache<Long, UserDTO> userCache = Caffeine.newBuilder()
        .initialCapacity(100)
        .maximumSize(1000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .build();

public UserDTO getUser(Long userId) {
    return userCache.getIfPresent(userId);
}

public void putUser(Long userId, UserDTO user) {
    userCache.put(userId, user);
}

public void evictUser(Long userId) {
    userCache.invalidate(userId);
}

}

这种方式适合: • 不是基于方法返回值缓存 • 需要更明确控制缓存生命周期 • 需要在一个方法里组合多个 key 或做批量操作

  1. 使用 get(key, mappingFunction) 防止重复加载

public UserDTO getUser(Long userId) { return userCache.get(userId, this::loadFromDb); }

这个写法的价值不只是代码简洁,更重要的是对并发加载更友好。 对于同一个 key,并发场景下可以减少重复回源。相比“先查缓存,再手动查库再 put”的两段式写法,它更不容易产生竞争窗口。

  1. LoadingCache

private final LoadingCache<Long, UserDTO> userCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(10)) .build(this::loadFromDb);

public UserDTO getUser(Long userId) { return userCache.get(userId); }

适合缓存加载逻辑高度统一的场景。 但在业务系统里,如果查询过程涉及权限、上下文、条件分支很多,LoadingCache 未必比显式方法更清晰。

refreshAfterWrite 和 expireAfterWrite 不是一回事

这是 Caffeine 使用中非常容易被误解的一点。

expireAfterWrite

到期后,缓存项失效。下一次访问会触发重新加载。 在重新加载完成前,请求可能需要等待。

refreshAfterWrite

到达刷新时间后,下一次访问会触发刷新,但旧值可以继续返回;刷新完成后再替换成新值。

这意味着: • expireAfterWrite 更像“过期后失效” • refreshAfterWrite 更像“后台更新旧值”

示例:

LoadingCache<Long, UserDTO> userCache = Caffeine.newBuilder() .maximumSize(1000) .refreshAfterWrite(Duration.ofMinutes(5)) .expireAfterWrite(Duration.ofMinutes(30)) .build(this::loadFromDb);

这种配置表达的意思通常是: • 5 分钟后可刷新 • 30 分钟后强制失效

它适合对数据新鲜度有要求,但又不希望请求线程频繁阻塞等待回源的场景。

不过要注意,refreshAfterWrite 不是“定时任务自动刷新”。 它依然是访问驱动的:到了刷新时间,只有后续访问发生时才会触发刷新。

异步缓存适合哪些场景

Caffeine 还支持异步加载缓存,比如 AsyncLoadingCache。

private final AsyncLoadingCache<Long, UserDTO> asyncCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(Duration.ofMinutes(10)) .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> loadFromDb(key), executor));

适合: • 加载逻辑本身就是异步的 • 希望结合异步编程模型减少阻塞 • 批量并发读取同类资源

但在典型 Spring MVC 同步接口里,异步缓存并不会自动让系统“更快”。 如果业务线程最终还是要 join() 等结果,那只是把同步等待换了个地方。 所以异步缓存更适合本身就使用 CompletableFuture、响应式编程或异步聚合查询的场景。

缓存一致性怎么处理

本地缓存最现实的问题,不是“怎么写入”,而是“什么时候失效”。

常见策略一:更新数据库后主动删除缓存

这是最常见、也最稳妥的方式。

@CacheEvict(cacheNames = "userCache", key = "#user.id") public UserDTO updateUser(UserDTO user) { return saveToDb(user); }

或者先更新数据库,再清理缓存。

其核心思想是: 宁可短时间没有缓存,也不要让脏缓存长期存在。

常见策略二:更新后回填缓存

@CachePut(cacheNames = "userCache", key = "#user.id") public UserDTO updateUser(UserDTO user) { return saveToDb(user); }

这种方式在单机场景下没有问题,但多实例时要清楚边界: 当前实例更新了自己的本地缓存,其他实例的本地缓存不会同步更新。

常见策略三:基于消息通知多实例失效

在分布式部署下,如果本地缓存必须尽量保持一致,通常做法是: • 更新数据库 • 发送缓存失效消息 • 各实例收到消息后删除自己的本地缓存

例如配合 Kafka、RabbitMQ、Redis Pub/Sub 等机制广播失效事件。 这已经不只是“集成 Caffeine”问题,而是“分布式本地缓存一致性”问题。

结论很明确: Caffeine 能解决性能问题,但不能独立解决多节点一致性问题。

缓存穿透、击穿、雪崩在本地缓存场景下怎么理解

这些词经常被机械套用,但在本地缓存场景里要具体分析。

缓存穿透

查询一个根本不存在的数据,缓存中也没有,每次都打到数据库。

应对手段: • 缓存空结果 • 做布隆过滤器 • 对非法参数提前拦截

缓存击穿

某个热点 key 过期瞬间,大量请求同时回源。

应对手段: • 热点 key 延长 TTL • 使用 cache.get(key, mappingFunction) 或 LoadingCache 减少并发重复加载 • 热点数据提前刷新 • 必要时结合互斥锁或单飞机制

缓存雪崩

大量 key 在短时间集中失效,造成后端瞬时压力暴涨。

应对手段: • 不要让大批 key 使用完全相同的失效时间 • 分批预热 • 不要频繁 allEntries = true • 多级缓存兜底

在使用 Caffeine 时,真正容易发生的往往不是概念上的“雪崩”,而是热点 key 同时回源和某个缓存区被整体清空后瞬间重建。

版本差异需要注意什么

Spring Boot 2.x 与 3.x

从 Caffeine 的基础集成思路看,Spring Boot 2.x 和 3.x 区别不大,核心仍然是: • 引入 spring-boot-starter-cache • 引入 caffeine • 配置 CacheManager • 使用 @EnableCaching 与缓存注解

真正需要注意的是周边生态差异: • Spring Boot 3.x 基于 Spring Framework 6,要求 Java 17+ • 旧项目升级时要关注 javax.* 到 jakarta.* 的迁移影响 • 某些历史配置类、第三方 starter 在升级到 Boot 3.x 后可能需要适配

Guava Cache 与 Caffeine

如果项目里原来用的是 Guava Cache,迁移到 Caffeine 时需要注意: • API 风格相似,但不是完全等价替换 • Caffeine 在性能和淘汰策略上更优 • 新项目优先选 Caffeine,不建议再新接入 Guava Cache

实战中的典型配置建议

下面给一套比较稳妥的思路,不是固定答案,但适合作为落地起点。

配置类示例

@Configuration @EnableCaching public class CacheConfig {

@Bean
public CacheManager cacheManager() {
    SimpleCacheManager cacheManager = new SimpleCacheManager();

    List<org.springframework.cache.Cache> caches = new ArrayList<>();

    caches.add(new CaffeineCache("userCache",
            Caffeine.newBuilder()
                    .initialCapacity(100)
                    .maximumSize(2000)
                    .expireAfterWrite(Duration.ofMinutes(10))
                    .recordStats()
                    .build()));

    caches.add(new CaffeineCache("dictCache",
            Caffeine.newBuilder()
                    .initialCapacity(20)
                    .maximumSize(200)
                    .expireAfterWrite(Duration.ofHours(1))
                    .recordStats()
                    .build()));

    caches.add(new CaffeineCache("productCache",
            Caffeine.newBuilder()
                    .initialCapacity(500)
                    .maximumSize(5000)
                    .expireAfterAccess(Duration.ofMinutes(30))
                    .recordStats()
                    .build()));

    cacheManager.setCaches(caches);
    return cacheManager;
}

}

Service 示例

@Service public class ProductService {

@Cacheable(cacheNames = "productCache", key = "#productId", unless = "#result == null")
public ProductDTO getProductById(Long productId) {
    return loadFromDb(productId);
}

@CachePut(cacheNames = "productCache", key = "#product.id")
public ProductDTO updateProduct(ProductDTO product) {
    return saveToDb(product);
}

@CacheEvict(cacheNames = "productCache", key = "#productId")
public void deleteProduct(Long productId) {
    deleteFromDbInternal(productId);
}

private ProductDTO loadFromDb(Long productId) {
    ProductDTO dto = new ProductDTO();
    dto.setId(productId);
    dto.setName("商品-" + productId);
    return dto;
}

private ProductDTO saveToDb(ProductDTO product) {
    return product;
}

private void deleteFromDbInternal(Long productId) {
}

}

这套写法适合大多数“读多写少”的基础业务缓存场景。

最容易踩的坑

  1. 同类方法内部调用导致缓存不生效

这是 Spring AOP 机制带来的经典问题。

@Service public class UserService {

public UserDTO getUser(Long userId) {
    return findUser(userId); // 内部调用
}

@Cacheable(cacheNames = "userCache", key = "#userId")
public UserDTO findUser(Long userId) {
    return loadFromDb(userId);
}

}

由于是同类内部调用,没有经过 Spring 代理,@Cacheable 不会生效。

解决思路通常有三种: • 把缓存方法拆到另一个 Bean • 通过代理对象调用 • 重构调用路径,避免自调用

这个问题不是 Caffeine 特有的,是 Spring 注解缓存普遍问题。

  1. 把缓存对象设计成可变对象并在外部修改

缓存里存的是对象引用。如果取出后在业务侧直接修改,可能会污染缓存内容。

UserDTO user = userService.getUserById(1L); user.setName("临时修改");

如果这个对象就是缓存中的实例,那么后续其他请求看到的也可能是被改过的值。

应对方式: • 缓存不可变对象 • 返回副本 • 避免调用方直接修改缓存对象

  1. 缓存大对象或深层对象图

本地缓存非常快,所以很多人会不自觉把“大而全”的对象直接塞进去。 问题是: • 占堆内存 • 增加 GC 压力 • 降低缓存容量利用率 • 命中一个字段却要背整个对象图

更好的思路是缓存“够用的数据”,而不是“全部数据”。

  1. 没有监控就谈缓存优化

上线前只看到“接入缓存了”,看不到: • 命中率 • 回源次数 • 淘汰次数 • 平均加载耗时 • 缓存区大小变化

这样的缓存优化很容易沦为心理安慰。 本地缓存尤其如此,因为它不像 Redis 那样天然有独立监控界面,很多问题更容易被忽略。

  1. 误把本地缓存当分布式一致缓存

这是架构层面的误判。 只要服务是多实例部署,本地缓存就一定要接受“实例之间天然不一致”这个事实。 不能一边用本地缓存,一边默认所有节点会自动同步,这是很多线上脏数据问题的根源。

一个更合理的落地思路

如果项目是普通的 Spring Boot 单体或少量实例服务,可以这样分层使用:

第一层:只缓存真正高复用的数据

先挑这些: • 字典表 • 地区树 • 配置项 • 商品基础信息 • 用户基础资料 • 权限元数据

不要一开始就把搜索、分页、组合查询全缓存。

第二层:每个缓存区独立定策略

不要一个全局 10m + 1000条 打天下。 缓存区要按数据特征分: • 变更频率 • 对新鲜度的敏感程度 • 对象大小 • 访问热点程度

第三层:更新路径优先保证一致性

宁可多删几次缓存,也不要为了少一次数据库访问而长期返回脏数据。 在多数后台系统里,一致性通常比那一点点命中率更重要。

第四层:结合监控逐步扩展

先上线少量缓存区,观察真实命中率和内存表现,再逐步扩大范围。 缓存最怕一上来铺太大,最后不知道问题出在哪。

什么时候不建议用 Caffeine

虽然它很好用,但也不是所有场景都该上。

以下情况通常不适合作为首选:

  1. 强依赖多实例一致性

例如库存、余额、强实时权限控制这类数据,如果不能容忍短时间不一致,就不该只依赖本地缓存。

  1. 数据体积大且种类多

如果缓存对象巨大、key 数量又多,本地内存成本会很高。这类场景更适合集中式缓存或其他数据加速方案。

  1. 读流量不稳定、命中率天然低

例如高度离散的个性化查询,请求之间几乎不复用,缓存价值就很小。

  1. 团队没有监控和治理能力

缓存不是加一个注解就结束了。没有淘汰策略、容量规划、监控观测、失效机制,后面迟早出问题。

总结

Spring Boot 集成 Caffeine 本身并不复杂,真正有价值的部分不在“能不能跑起来”,而在于是否理解它的边界和适用面。

把它用好,关键点并不多: • 明确它是本地缓存,不是分布式共享缓存 • 优先缓存高频读取、变化不快、回源成本高的数据 • 合理设计 key、TTL、容量上限 • 更新路径上优先考虑失效策略和一致性 • 不要忽略 命中率、淘汰次数、内存占用、回源压力 的监控 • 简单场景用 Spring Cache 注解,复杂场景直接用 Caffeine 原生 API

很多项目接入本地缓存后效果不好,不是因为 Caffeine 不行,而是因为缓存目标不清楚、配置过于随意、把不该缓存的数据硬塞进去,或者把本地缓存误当成一致性方案。

真正成熟的做法从来不是“哪里慢就都加缓存”,而是先判断数据特征,再决定要不要缓存、缓存多久、谁来失效、出了不一致怎么兜底。 把这些问题想清楚之后,Caffeine 才会从“一个依赖包”变成真正能带来收益的性能工具。

正文到此结束
评论插件初始化中...
Loading...