SpringBoot 集成 Caffeine 实现本地缓存详解
- 发布时间:2026-03-28 20:54:30
- 本文热度:浏览 4 赞 0 评论 0
- 文章标签: SpringBoot Caffeine Spring Cache
- 全文共1字,阅读约需1分钟
为什么在 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 依赖。
<!-- 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:删除缓存
- @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 方法应该尽量保持“纯查询”属性,不要夹带必须执行的副作用逻辑。
⸻
- @CachePut
@CachePut(cacheNames = "userCache", key = "#user.id") public UserDTO updateUser(UserDTO user) { System.out.println("更新数据库..."); return saveToDb(user); }
这个注解和 @Cacheable 最大的区别是:方法一定会执行。 适合更新数据库成功后,把最新结果同步写回缓存。
常见场景: • 修改用户资料后刷新用户缓存 • 修改商品价格后刷新商品缓存
⸻
- @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
如果 UserQuery 没有正确实现 equals 和 hashCode,或者字段过多、可变,缓存 key 很容易失控。
更稳妥的做法是显式挑选关键字段:
@Cacheable( cacheNames = "userCache", key = "#query.name + ':' + #query.status + ':' + #query.pageNo + ':' + #query.pageSize" ) public List
常见错误三:把高基数参数直接拿来做 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 往往更合适。
- 手动读写缓存
@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 或做批量操作
- 使用 get(key, mappingFunction) 防止重复加载
public UserDTO getUser(Long userId) { return userCache.get(userId, this::loadFromDb); }
这个写法的价值不只是代码简洁,更重要的是对并发加载更友好。 对于同一个 key,并发场景下可以减少重复回源。相比“先查缓存,再手动查库再 put”的两段式写法,它更不容易产生竞争窗口。
- 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) {
}
}
这套写法适合大多数“读多写少”的基础业务缓存场景。
⸻
最容易踩的坑
- 同类方法内部调用导致缓存不生效
这是 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 注解缓存普遍问题。
⸻
- 把缓存对象设计成可变对象并在外部修改
缓存里存的是对象引用。如果取出后在业务侧直接修改,可能会污染缓存内容。
UserDTO user = userService.getUserById(1L); user.setName("临时修改");
如果这个对象就是缓存中的实例,那么后续其他请求看到的也可能是被改过的值。
应对方式: • 缓存不可变对象 • 返回副本 • 避免调用方直接修改缓存对象
⸻
- 缓存大对象或深层对象图
本地缓存非常快,所以很多人会不自觉把“大而全”的对象直接塞进去。 问题是: • 占堆内存 • 增加 GC 压力 • 降低缓存容量利用率 • 命中一个字段却要背整个对象图
更好的思路是缓存“够用的数据”,而不是“全部数据”。
⸻
- 没有监控就谈缓存优化
上线前只看到“接入缓存了”,看不到: • 命中率 • 回源次数 • 淘汰次数 • 平均加载耗时 • 缓存区大小变化
这样的缓存优化很容易沦为心理安慰。 本地缓存尤其如此,因为它不像 Redis 那样天然有独立监控界面,很多问题更容易被忽略。
⸻
- 误把本地缓存当分布式一致缓存
这是架构层面的误判。 只要服务是多实例部署,本地缓存就一定要接受“实例之间天然不一致”这个事实。 不能一边用本地缓存,一边默认所有节点会自动同步,这是很多线上脏数据问题的根源。
⸻
一个更合理的落地思路
如果项目是普通的 Spring Boot 单体或少量实例服务,可以这样分层使用:
第一层:只缓存真正高复用的数据
先挑这些: • 字典表 • 地区树 • 配置项 • 商品基础信息 • 用户基础资料 • 权限元数据
不要一开始就把搜索、分页、组合查询全缓存。
第二层:每个缓存区独立定策略
不要一个全局 10m + 1000条 打天下。 缓存区要按数据特征分: • 变更频率 • 对新鲜度的敏感程度 • 对象大小 • 访问热点程度
第三层:更新路径优先保证一致性
宁可多删几次缓存,也不要为了少一次数据库访问而长期返回脏数据。 在多数后台系统里,一致性通常比那一点点命中率更重要。
第四层:结合监控逐步扩展
先上线少量缓存区,观察真实命中率和内存表现,再逐步扩大范围。 缓存最怕一上来铺太大,最后不知道问题出在哪。
⸻
什么时候不建议用 Caffeine
虽然它很好用,但也不是所有场景都该上。
以下情况通常不适合作为首选:
- 强依赖多实例一致性
例如库存、余额、强实时权限控制这类数据,如果不能容忍短时间不一致,就不该只依赖本地缓存。
- 数据体积大且种类多
如果缓存对象巨大、key 数量又多,本地内存成本会很高。这类场景更适合集中式缓存或其他数据加速方案。
- 读流量不稳定、命中率天然低
例如高度离散的个性化查询,请求之间几乎不复用,缓存价值就很小。
- 团队没有监控和治理能力
缓存不是加一个注解就结束了。没有淘汰策略、容量规划、监控观测、失效机制,后面迟早出问题。
⸻
总结
Spring Boot 集成 Caffeine 本身并不复杂,真正有价值的部分不在“能不能跑起来”,而在于是否理解它的边界和适用面。
把它用好,关键点并不多: • 明确它是本地缓存,不是分布式共享缓存 • 优先缓存高频读取、变化不快、回源成本高的数据 • 合理设计 key、TTL、容量上限 • 更新路径上优先考虑失效策略和一致性 • 不要忽略 命中率、淘汰次数、内存占用、回源压力 的监控 • 简单场景用 Spring Cache 注解,复杂场景直接用 Caffeine 原生 API
很多项目接入本地缓存后效果不好,不是因为 Caffeine 不行,而是因为缓存目标不清楚、配置过于随意、把不该缓存的数据硬塞进去,或者把本地缓存误当成一致性方案。
真正成熟的做法从来不是“哪里慢就都加缓存”,而是先判断数据特征,再决定要不要缓存、缓存多久、谁来失效、出了不一致怎么兜底。 把这些问题想清楚之后,Caffeine 才会从“一个依赖包”变成真正能带来收益的性能工具。