SpringBoot 集成 Caffeine 实现本地缓存实战详解
- 发布时间:2026-03-27 21:34:24
- 本文热度:浏览 4 赞 0 评论 0
- 文章标签: SpringBoot Caffeine Spring Cache
- 全文共1字,阅读约需1分钟
在高并发系统中,缓存几乎是后端性能优化绕不开的一环。很多业务场景并不适合一上来就引入 Redis 这一类分布式缓存,例如单体应用、低延迟接口、热点数据读取、配置类数据查询、短生命周期对象复用等。这类场景更适合使用进程内本地缓存。对于 Spring Boot 应用而言,Caffeine 是当前非常成熟且性能优异的本地缓存方案之一。
Caffeine 是 Java 生态中广泛使用的高性能本地缓存库,设计目标是用更低的延迟、更高的命中效率以及更灵活的淘汰策略,替代传统的 Guava Cache。在 Spring Boot 中集成 Caffeine,既可以直接使用原生 API,也可以结合 Spring Cache 抽象,以声明式方式完成缓存管理。这种组合兼顾了开发效率、可维护性以及运行性能。
一、本地缓存的适用场景与价值
在讨论 Spring Boot 集成 Caffeine 之前,首先要明确本地缓存究竟适合解决什么问题。
本地缓存的核心特点是数据存储在当前应用进程的堆内存中,因此读取路径非常短,不需要网络通信,不依赖外部中间件,访问延迟通常远低于远程缓存。对于以下场景,本地缓存通常有较高价值:
- 读多写少的数据,例如地区字典、系统配置、权限映射、商品类目、页面模板。
- 热点数据频繁访问,但对强一致性要求不高。
- 单节点内部高频重复计算结果缓存,例如复杂 DTO 组装结果、规则引擎中间结果、接口聚合数据。
- 需要降低数据库查询次数,减轻数据库压力。
- 服务启动简单,不希望引入 Redis、Memcached 等外部依赖。
但是,本地缓存也有天然局限:
- 缓存数据仅当前节点可见,多实例部署时数据不共享。
- 节点之间的数据一致性维护复杂。
- 占用 JVM 堆内存,配置不合理容易引发 Full GC 或内存压力。
- 服务重启后缓存全部丢失。
- 不适合大数据量、跨节点共享、强一致性场景。
因此,本地缓存从来不是“万能缓存”,而是针对合适问题的高效工具。对于单节点高频读取、允许短暂数据不一致的业务,Caffeine 往往可以用极低的成本带来明显收益。
二、为什么选择 Caffeine
在 Java 本地缓存领域,常见方案包括手写 ConcurrentHashMap、Guava Cache、Ehcache、Caffeine 等。之所以在 Spring Boot 中优先推荐 Caffeine,主要有以下几个原因。
1. 性能优秀
Caffeine 在读写性能、并发控制以及命中率优化方面都做得非常出色。它对热点数据场景的支持较好,适合高并发 Web 服务。
2. 淘汰策略更先进
简单的 LRU 只能粗粒度地表示“最近最少使用”,但在真实业务访问中,历史访问频率与近期访问热度往往共同决定一个缓存项是否值得保留。Caffeine 在淘汰算法设计上比传统 LRU 更合理,能够在有限容量下取得更好的命中率。
3. API 设计简洁
无论是直接构建 Cache,还是使用 LoadingCache、AsyncLoadingCache,Caffeine 的使用方式都比较清晰,学习成本低。
4. 与 Spring Cache 集成自然
Spring Boot 对 Caffeine 有良好的支持,可以通过配置类快速接入,业务代码中通过 @Cacheable、@CachePut、@CacheEvict 等注解即可完成缓存逻辑。
5. 功能完整
Caffeine 支持如下常见能力:
- 最大容量控制
- 基于访问时间过期
- 基于写入时间过期
- 刷新机制
- 称重淘汰
- 移除监听
- 统计信息
- 同步与异步加载
这使它不仅适合简单缓存,也适合中等复杂度的业务缓存场景。
三、Spring Cache 与 Caffeine 的关系
很多开发者在接入缓存时容易混淆 Spring Cache 和 Caffeine 的职责边界。
Spring Cache 本质上是缓存抽象,它定义了一套统一的缓存操作模型,屏蔽底层实现差异。开发者在业务层通过注解声明缓存行为,至于底层究竟使用 ConcurrentMap、Caffeine、Redis 还是其他缓存实现,则由具体的 CacheManager 决定。
Caffeine 则是具体的缓存实现,它负责缓存数据存储、过期淘汰、容量控制、命中统计等底层行为。
两者组合之后的职责划分如下:
- Spring Cache 负责声明式编程模型。
- Caffeine 负责本地缓存的真实执行逻辑。
这也是在 Spring Boot 项目中推荐使用 Spring Cache + Caffeine 的根本原因。业务代码不直接依赖底层实现,后期即使要切换为 Redis 或构建多级缓存,改造成本也更低。
四、项目依赖配置
以 Maven 项目为例,Spring Boot 集成 Caffeine 通常需要引入如下依赖:
<dependencies>
<!-- Spring Cache 抽象 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Caffeine 本地缓存实现 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Web 示例依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
如果使用的是较新的 Spring Boot 3.x,依赖坐标不变,但底层基于 Spring Framework 6,JDK 通常要求 17 及以上。若是 Spring Boot 2.x,则常见运行环境是 JDK 8 或 11。实际开发中应以项目当前版本约束为准。
这里有一个版本区别需要明确:
- Spring Boot 2.x 与 Spring Boot 3.x 在 Caffeine 集成方式上总体一致,核心差异主要体现在 Jakarta 命名空间迁移、JDK 基线版本以及周边生态依赖兼容性上。
spring-boot-starter-cache与caffeine的使用方式没有本质变化,注解模型仍然是@EnableCaching、@Cacheable、@CachePut、@CacheEvict。
五、开启 Spring Cache 功能
在 Spring Boot 项目中使用缓存注解,必须显式开启缓存支持。
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@EnableCaching
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@EnableCaching 的作用是启用 Spring 的缓存注解驱动。当容器扫描到 @Cacheable、@CachePut、@CacheEvict 等注解时,会通过 AOP 代理机制对目标方法进行增强,从而在方法调用前后织入缓存处理逻辑。
需要注意的是,Spring Cache 基于代理机制,因此存在一个常见限制:同类内部方法直接调用不会触发缓存注解。这是很多项目中缓存“失效”的根源之一,后文会专门分析。
六、基础配置方式
Spring Boot 集成 Caffeine 常见有两种配置思路:
- 在配置文件中定义简单参数。
- 通过 Java Config 自定义
CacheManager与不同缓存实例。
对于简单项目,可以先采用配置文件方式。
application.yml 示例
spring:
cache:
type: caffeine
cache-names:
- userCache
- productCache
- configCache
caffeine:
spec: initialCapacity=100,maximumSize=1000,expireAfterAccess=600s
这个配置含义如下:
type: caffeine:指定底层缓存实现为 Caffeine。cache-names:预定义缓存名称,避免运行时动态创建不受控缓存。spec:使用字符串方式配置 Caffeine 参数。
上述配置表示:
- 初始容量 100
- 最大缓存条目 1000
- 访问后 600 秒过期
这种方式适合所有缓存策略都比较统一的项目。如果不同缓存需要不同过期时间、不同容量限制,就更适合使用 Java Config 精细化配置。
七、使用 Java Config 自定义 CacheManager
在实际项目中,不同业务缓存的生命周期和容量通常不一致。例如:
- 用户基础信息缓存 30 分钟即可
- 商品信息缓存 10 分钟
- 配置字典缓存 2 小时
- 权限路由缓存 5 分钟
此时,建议通过代码显式配置不同的缓存实例。
package com.example.demo.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
CaffeineCache userCache = new CaffeineCache(
"userCache",
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()
.build()
);
CaffeineCache productCache = new CaffeineCache(
"productCache",
Caffeine.newBuilder()
.initialCapacity(200)
.maximumSize(5000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build()
);
CaffeineCache configCache = new CaffeineCache(
"configCache",
Caffeine.newBuilder()
.initialCapacity(50)
.maximumSize(200)
.expireAfterWrite(2, TimeUnit.HOURS)
.recordStats()
.build()
);
cacheManager.setCaches(Arrays.asList(userCache, productCache, configCache));
return cacheManager;
}
}
这种方式有几个显著优势:
- 每个缓存实例可以独立设置参数。
- 配置可读性更强。
- 后续扩展移除监听器、权重函数、定时刷新等能力更方便。
八、Caffeine 核心参数详解
要真正用好 Caffeine,不能只会抄配置,而应理解几个关键参数的含义和差异。
1. initialCapacity
用于设置初始容量,作用类似 HashMap 初始化容量。合理设置可以减少扩容带来的开销,但它并不是容量上限。
.initialCapacity(100)
2. maximumSize
设置最大缓存条目数。当缓存项达到上限时,Caffeine 会根据淘汰策略清除部分旧数据。
.maximumSize(1000)
对于大多数业务场景,这是最常用的容量控制方式。
3. maximumWeight 与 weigher
如果缓存对象大小差异很大,仅按条目数量控制并不合理。例如同样是一条缓存,A 对象只占几 KB,B 对象可能占几 MB。此时可以采用权重淘汰。
.maximumWeight(10_000)
.weigher((String key, Object value) -> 10)
权重不是实际内存字节数,而是业务自定义的“成本指标”。使用不当会增加维护复杂度,因此一般只在对象体积差异显著时考虑。
4. expireAfterWrite
缓存项在写入或更新之后,经过指定时间自动过期。
.expireAfterWrite(10, TimeUnit.MINUTES)
适用于写入后固定生命周期的数据,例如商品快照、接口聚合结果、统计摘要。
5. expireAfterAccess
缓存项在最后一次访问之后,经过指定时间过期。
.expireAfterAccess(10, TimeUnit.MINUTES)
适用于热点访问场景。只要持续被访问,缓存项就可以一直保留;长期不访问的数据会被自然淘汰。
6. refreshAfterWrite
到达指定时间后,缓存项在下一次访问时会触发刷新。它与 expireAfterWrite 最大差别在于,刷新并不一定让缓存立即失效,而是尽量在保留旧值可用性的前提下更新数据。
.refreshAfterWrite(5, TimeUnit.MINUTES)
这一能力更适合配合 LoadingCache 使用,而不是简单 @Cacheable 场景。
7. recordStats
启用统计信息,可用于观察缓存命中率、加载次数、淘汰次数等指标。
.recordStats()
在生产环境中,缓存没有监控就很容易变成“黑盒”。统计信息对于调参和故障排查非常重要。
8. removalListener
缓存项移除时触发监听,可记录日志或做清理动作。
.removalListener((key, value, cause) -> {
System.out.println("key=" + key + ", cause=" + cause);
})
注意,这里更适合做轻量动作,不宜执行复杂阻塞逻辑,否则可能影响缓存性能。
九、基于注解的缓存实践
在 Spring Boot 中,大多数业务会选择通过注解使用缓存,这也是最符合 Spring 风格的方式。
下面以用户查询为例说明。
1. 实体类
package com.example.demo.model;
public class User {
private Long id;
private String username;
private Integer age;
public User() {
}
public User(Long id, String username, Integer age) {
this.id = id;
this.username = username;
this.age = age;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
2. Repository 模拟层
这里为了演示缓存效果,直接使用内存数据模拟数据库。
package com.example.demo.repository;
import com.example.demo.model.User;
import org.springframework.stereotype.Repository;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class UserRepository {
private final Map<Long, User> database = new ConcurrentHashMap<>();
public UserRepository() {
database.put(1L, new User(1L, "alice", 18));
database.put(2L, new User(2L, "bob", 20));
database.put(3L, new User(3L, "charlie", 22));
}
public User findById(Long id) {
System.out.println("查询数据库,id=" + id);
return database.get(id);
}
public User save(User user) {
database.put(user.getId(), user);
System.out.println("更新数据库,id=" + user.getId());
return user;
}
public void deleteById(Long id) {
database.remove(id);
System.out.println("删除数据库,id=" + id);
}
}
3. Service 层缓存注解
package com.example.demo.service;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Cacheable(cacheNames = "userCache", key = "#id")
public User getById(Long id) {
return userRepository.findById(id);
}
@CachePut(cacheNames = "userCache", key = "#user.id")
public User update(User user) {
return userRepository.save(user);
}
@CacheEvict(cacheNames = "userCache", key = "#id")
public void delete(Long id) {
userRepository.deleteById(id);
}
}
4. Controller 层
package com.example.demo.controller;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public User getById(@PathVariable Long id) {
return userService.getById(id);
}
@PutMapping
public User update(@RequestBody User user) {
return userService.update(user);
}
@DeleteMapping("/{id}")
public String delete(@PathVariable Long id) {
userService.delete(id);
return "ok";
}
}
十、缓存注解的工作机制与区别
1. @Cacheable
@Cacheable 表示先查缓存,如果命中则直接返回,不执行方法体;如果未命中,则执行方法并将返回值放入缓存。
@Cacheable(cacheNames = "userCache", key = "#id")
这是最常用的注解,适合查询方法。
2. @CachePut
@CachePut 不会跳过方法执行。它一定会执行方法体,并将返回结果写入缓存。适合更新方法。
@CachePut(cacheNames = "userCache", key = "#user.id")
如果把更新接口也写成 @Cacheable,就会出现逻辑错误,因为缓存命中时方法根本不会执行。
3. @CacheEvict
@CacheEvict 用于删除缓存。通常配合删除操作或某些会导致缓存失效的写操作使用。
@CacheEvict(cacheNames = "userCache", key = "#id")
它还支持清空整个缓存区域:
@CacheEvict(cacheNames = "userCache", allEntries = true)
但要谨慎使用,因为清空整个缓存会瞬间降低命中率,并可能带来数据库短时流量冲击。
4. @Caching
当一个方法需要组合多个缓存动作时,可以使用 @Caching。
@Caching(
put = {
@CachePut(cacheNames = "userCache", key = "#result.id")
},
evict = {
@CacheEvict(cacheNames = "userPermissionCache", key = "#result.id")
}
)
这在复杂业务中比较常见,例如更新用户信息后,同时刷新用户基础信息缓存并删除权限缓存。
十一、key 设计是缓存成败关键之一
很多缓存问题并不是出在框架本身,而是出在 key 设计不合理。
1. key 必须唯一且稳定
例如根据用户 ID 查询:
@Cacheable(cacheNames = "userCache", key = "#id")
这种设计是合理的,因为用户 ID 唯一、稳定。
但如果是多条件查询,必须保证 key 能正确表达查询条件。比如:
@Cacheable(cacheNames = "userListCache", key = "#page + ':' + #size + ':' + #status")
2. 避免 key 过于复杂
不要直接把整个对象序列化结果当 key,原因包括:
- 可读性差
- 稳定性差
- 对象字段变化可能导致 key 不一致
- 调试困难
3. 注意 null 参数与默认值
如果方法参数允许为空,必须确认 key 表达式是否会导致冲突。比如多个查询都被拼接成相同 key,这会产生严重缓存污染。
4. 使用前缀区分业务域
虽然 Spring Cache 已经通过 cacheNames 分区,但在复杂场景中,依然建议业务语义清晰。例如:
userCacheproductCacheconfigCache
不要全部塞进一个通用 commonCache 中,否则后期维护和排查会很痛苦。
十二、缓存穿透、缓存击穿、缓存雪崩在本地缓存中的表现
很多人认为这些问题只出现在 Redis 场景,其实本地缓存同样会遇到,只是表现形式不同。
1. 缓存穿透
缓存和数据库中都不存在某个数据,请求每次都会落到数据库。
例如恶意请求持续查询不存在的用户 ID,@Cacheable 默认不会缓存 null 值时,数据库压力仍然存在。
解决思路:
- 缓存空对象或特殊占位对象
- 对非法参数前置校验
- 结合布隆过滤器进行拦截
- 针对高频不存在 key 进行短期空值缓存
在 Spring Cache 中,是否缓存空值要结合具体实现和配置方式确定。实际项目中建议显式设计“空值对象”或统一包装结果,而不是含糊依赖默认行为。
2. 缓存击穿
某个热点 key 在高并发下恰好失效,大量请求同时穿透到数据库。
本地缓存同样存在这个问题。虽然本地访问速度快,但一旦热点数据过期,大量线程并发访问同一个 key 仍然可能同时触发数据库查询。
解决方式包括:
- 为热点 key 增加互斥加载控制
- 使用加载缓存模式
- 适当提高热点数据过期时间
- 使用逻辑过期加异步刷新
3. 缓存雪崩
大量缓存在同一时刻集中失效,导致后端资源瞬时承压。
如果很多缓存统一设置为 expireAfterWrite(10, TimeUnit.MINUTES),并且服务启动后同一时间加载,就可能在 10 分钟后集中过期。
解决思路:
- 不同缓存设置不同过期时间
- 增加随机过期偏移
- 对核心热点缓存采用刷新而非硬过期
- 建立降级策略,必要时返回兜底结果
十三、直接使用 Caffeine 原生 API
Spring Cache 注解适合大多数标准查询场景,但在需要更细粒度控制时,可以直接使用 Caffeine 原生 API。
1. 手动构建 Cache
package com.example.demo.service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class LocalCacheService {
private final Cache<String, String> cache = Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
public String get(String key) {
return cache.getIfPresent(key);
}
public void put(String key, String value) {
cache.put(key, value);
}
public void remove(String key) {
cache.invalidate(key);
}
}
2. 使用 get 形式避免重复加载
public String getValue(String key) {
return cache.get(key, k -> loadFromDb(k));
}
private String loadFromDb(String key) {
System.out.println("从数据库加载:" + key);
return "value-" + key;
}
这种写法可以把“查不到则加载”的逻辑集中管理,比手动 getIfPresent 后再判断更简洁。
3. LoadingCache
package com.example.demo.service;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class UserLoadingCacheService {
private final LoadingCache<Long, String> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(this::loadUserName);
private String loadUserName(Long userId) {
System.out.println("加载用户名 userId=" + userId);
return "user-" + userId;
}
public String getUserName(Long userId) {
return cache.get(userId);
}
}
这种方式更适合底层组件或基础服务,而不是 Controller 直接调用。
十四、如何监控缓存效果
缓存不是加上就结束,真正有价值的是命中率和资源收益。
启用 recordStats() 后,可以获取统计指标:
Cache<String, String> cache = Caffeine.newBuilder()
.maximumSize(1000)
.recordStats()
.build();
cache.put("a", "1");
cache.getIfPresent("a");
cache.getIfPresent("b");
System.out.println(cache.stats());
常见关注指标包括:
- hitCount:命中次数
- missCount:未命中次数
- hitRate:命中率
- evictionCount:淘汰次数
- loadSuccessCount:成功加载次数
- loadFailureCount:加载失败次数
在生产环境中,建议将这些指标接入 Micrometer,再上报到 Prometheus、Grafana 等监控系统。否则缓存配置是否合理只能靠猜测。
一个常见误区是盲目追求极高命中率。实际上,缓存命中率必须结合业务类型分析:
- 如果是高频热点接口,命中率低说明缓存价值有限或配置不合理。
- 如果是大量长尾请求,命中率不高并不一定是问题。
- 若淘汰频率很高,可能说明
maximumSize太小。 - 若堆内存压力大,则可能说明缓存容量配置过大。
十五、生产环境中的常见问题
1. 同类内部调用导致缓存不生效
例如:
@Service
public class UserService {
@Cacheable(cacheNames = "userCache", key = "#id")
public User getById(Long id) {
return userRepository.findById(id);
}
public User queryUser(Long id) {
return this.getById(id);
}
}
queryUser() 中通过 this.getById(id) 调用本类方法,不会走 Spring 代理,因此缓存注解不会生效。
解决办法通常有三种:
- 将缓存方法拆到独立 Bean 中。
- 通过代理对象调用。
- 调整业务结构,避免自调用。
其中最推荐的是拆分职责,把带缓存的方法放入独立服务类,结构更清晰。
2. 更新了数据库但没有更新缓存
很多项目只对查询做了 @Cacheable,却忘了在更新和删除时同步刷新缓存,导致读到旧数据。
解决原则很明确:
- 更新操作使用
@CachePut或@CacheEvict - 删除操作使用
@CacheEvict - 涉及列表页、聚合页时,要考虑相关联缓存是否需要同步失效
3. 缓存对象被外部修改
如果缓存的是可变对象,调用方拿到对象后进行了修改,而这个对象又是缓存中的同一个引用,就可能导致缓存数据被污染。
解决思路:
- 返回不可变对象
- 做深拷贝
- 避免把带状态可变对象直接放入缓存
4. 缓存容量过大引发内存问题
本地缓存和 Redis 最大的不同之一,就是它直接占用应用堆内存。如果盲目增大 maximumSize,可能导致:
- GC 频繁
- Old 区压力上升
- Full GC 时间变长
- 甚至 OOM
因此,本地缓存容量设计一定要结合堆内存大小、对象体积、QPS、命中率综合评估,不能凭经验拍脑袋。
5. 多节点缓存不一致
在集群部署时,一个节点更新了本地缓存,其他节点并不会自动同步。
常见解决方式包括:
- 对一致性要求高的数据不用本地缓存。
- 使用 Redis 作为统一缓存,本地缓存只做一级缓存。
- 借助消息队列、发布订阅机制通知各节点清理本地缓存。
- 缩短本地缓存过期时间,降低不一致窗口。
这也是很多中大型系统采用“本地缓存 + Redis 二级缓存”架构的重要原因。
十六、Caffeine 与 Redis 如何配合
很多系统不是“二选一”,而是同时使用本地缓存和分布式缓存。
一个典型设计是:
- 一级缓存:Caffeine,本地内存,追求极低延迟
- 二级缓存:Redis,跨节点共享,容量更大
- 三级存储:MySQL 或其他持久化数据库
读取流程通常为:
- 先查 Caffeine
- 未命中则查 Redis
- Redis 未命中再查数据库
- 数据回填 Redis 与 Caffeine
这样做的好处是:
- 本地热点请求绝大多数不出进程
- 减少 Redis 压力
- 多节点之间仍具备共享缓存能力
- 在一定程度上兼顾性能与一致性
当然,这会带来更高的实现复杂度,包括双层失效、一致性控制、回源并发控制等。如果业务规模不大,不建议过早引入多级缓存架构。
十七、一个更贴近真实项目的实践建议
在 Spring Boot 项目中集成 Caffeine,不建议一开始就把所有查询接口全部缓存化。更稳妥的落地方式通常是:
第一步:识别热点读场景
先通过接口日志、SQL 统计、APM 监控找出最频繁、最适合缓存的查询,例如:
- 用户详情
- 商品详情
- 配置字典
- 首页推荐结果
- 分类导航
第二步:从只读、低一致性要求场景入手
优先缓存静态程度高、变更频率低的数据,而不是交易库存、余额、订单状态这类敏感信息。
第三步:为每个缓存单独命名与配置
不要所有缓存共享一个过期时间。要根据业务特点区分:
- 高变更数据:短过期
- 静态数据:长过期
- 热点数据:适当加大容量
第四步:补齐失效策略
缓存最难的从来不是“查的时候怎么加”,而是“更新的时候怎么删、怎么刷”。业务上线前必须把写路径理清楚。
第五步:上线后持续监控命中率和内存占用
缓存是需要调优的。第一次上线的参数通常只是起点,而不是终点。
十八、完整示例配置汇总
下面给出一个较完整的 Spring Boot + Caffeine 最小可用配置思路。
application.yml
server:
port: 8080
spring:
cache:
type: caffeine
启动类
@EnableCaching
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
CacheConfig
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
CaffeineCache userCache = new CaffeineCache(
"userCache",
Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.recordStats()
.build()
);
cacheManager.setCaches(Arrays.asList(userCache));
return cacheManager;
}
}
Service
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Cacheable(cacheNames = "userCache", key = "#id")
public User getById(Long id) {
return userRepository.findById(id);
}
@CachePut(cacheNames = "userCache", key = "#user.id")
public User update(User user) {
return userRepository.save(user);
}
@CacheEvict(cacheNames = "userCache", key = "#id")
public void delete(Long id) {
userRepository.deleteById(id);
}
}
这个实现已经能够覆盖典型的查询、更新、删除场景,适合作为项目落地的起点。
十九、结语
Spring Boot 集成 Caffeine 的价值,不只是“多加一个缓存注解”这么简单。它本质上是在系统吞吐、响应时间、数据库压力与实现复杂度之间做平衡。对于单体应用、读多写少业务、热点数据访问以及对极致低延迟有要求的接口,本地缓存往往能以非常低的成本带来明显收益。
但要真正把 Caffeine 用好,关键不在于知道几个注解,而在于理解缓存策略背后的设计逻辑:缓存哪些数据、缓存多久、如何控制容量、何时更新、何时删除、是否允许短暂不一致、集群下如何协调。这些问题如果没有想清楚,再好的缓存框架也只能停留在“看起来用了缓存”的阶段。
从工程实践角度看,Spring Cache + Caffeine 是 Spring Boot 本地缓存最推荐的组合之一。它既保留了 Spring 声明式开发的简洁性,又具备 Caffeine 在性能和淘汰策略上的优势。对于中小型项目,它完全可以胜任大部分本地缓存需求;对于更复杂的系统,它也可以作为多级缓存体系中的一级缓存组件继续发挥价值。
真正成熟的缓存设计从来不是简单堆技术,而是围绕业务一致性、性能目标和系统边界做出的工程选择。理解这一点,Spring Boot 中的 Caffeine 才不是一个“依赖包”,而是一种可控、可靠、可演进的性能优化手段。