前言:一个惨痛的教训
话说那年,我写了一个"用户信息查询"接口,代码简洁优雅,自我感觉良好。上线后 QPS 一路飙升,数据库 CPU 直接拉满,DBA 小哥深夜给我打电话,语气中透着一丝疲惫和杀意。
"兄弟,你这接口一秒钟查同一个用户几百次,图啥呢?"
那一刻,我顿悟了:缓存,是程序员对数据库最基本的尊重。
一、@Cacheable 是什么?
@Cacheable 是 Spring Cache 提供的声明式缓存注解,属于"我不想写重复代码"系列的终极解决方案。
它的核心思想很朴素:
- 第一次调用方法 → 执行方法,结果存缓存
- 后续相同参数调用 → 直接返回缓存,方法体压根不执行
简单说就是:"这活我干过了,答案在这,别烦我。"
二、快速上手
2.1 添加依赖
<!-- Spring Boot Starter Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- 如果用 Redis 作为缓存实现 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>2.2 开启缓存功能
在启动类上加一个注解,仪式感要有:
@SpringBootApplication
@EnableCaching // 就这一行,缓存世界的大门向你敞开
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}2.3 使用 @Cacheable
@Service
public class UserService {
@Cacheable(value = "userCache", key = "#userId")
public User getUserById(Long userId) {
// 这行日志是关键,用来验证缓存是否生效
System.out.println(">>> 真的去查数据库了,userId: " + userId);
return userRepository.findById(userId).orElse(null);
}
}测试一下:
userService.getUserById(1L); // 控制台输出日志,查了数据库
userService.getUserById(1L); // 静悄悄,缓存命中
userService.getUserById(1L); // 依然静悄悄
userService.getUserById(2L); // 输出日志,新用户要查库恭喜,你的数据库松了一口气。
三、核心属性详解
@Cacheable 的属性不多,但每个都值得细品。
3.1 value / cacheNames —— 缓存的"收纳盒"
@Cacheable(value = "userCache")
@Cacheable(cacheNames = "userCache") // 两种写法等价
@Cacheable(value = {"cache1", "cache2"}) // 可以同时放多个盒子这玩意就是给缓存分类,就像你家的收纳盒:袜子放一个、内裤放一个,别混了。
3.2 key —— 缓存的"门牌号"
默认情况下,Spring 会用方法的所有参数生成 key。但很多时候我们需要自定义:
// 使用 SpEL 表达式
@Cacheable(value = "userCache", key = "#userId")
public User getUserById(Long userId) { ... }
// 多参数组合
@Cacheable(value = "userCache", key = "#userId + '_' + #type")
public User getUserByIdAndType(Long userId, String type) { ... }
// 使用对象的属性
@Cacheable(value = "userCache", key = "#user.id")
public User updateUser(User user) { ... }
// 使用方法名 + 参数(防止不同方法 key 冲突)
@Cacheable(value = "userCache", key = "'getUser_' + #userId")
public User getUserById(Long userId) { ... }踩坑预警:key 冲突是缓存的头号杀手。想象一下,getUserById(1L) 和 getOrderById(1L) 用了同一个 key,返回数据直接串台,这 bug 够你查三天。
3.3 condition —— 有条件地缓存
不是所有数据都值得缓存,比如查出来是 null 的情况:
// 只有 userId > 0 才缓存
@Cacheable(value = "userCache", key = "#userId", condition = "#userId > 0")
public User getUserById(Long userId) { ... }
// 参数长度大于 3 才缓存
@Cacheable(value = "userCache", condition = "#name.length() > 3")
public User getUserByName(String name) { ... }condition 在方法执行前判断,决定要不要走缓存逻辑。
3.4 unless —— 有条件地不缓存
和 condition 相反,unless 在方法执行后判断,可以根据返回值决定是否缓存:
// 结果为 null 不缓存
@Cacheable(value = "userCache", key = "#userId", unless = "#result == null")
public User getUserById(Long userId) { ... }
// 结果状态异常不缓存
@Cacheable(value = "userCache", unless = "#result.status != 1")
public User getUserById(Long userId) { ... }划重点:condition 是"要不要查缓存",unless 是"查完了要不要存"。
3.5 keyGenerator —— 自定义 key 生成器
当 SpEL 表达式满足不了你的时候,可以自己写生成器:
@Configuration
public class CacheConfig {
@Bean("myKeyGenerator")
public KeyGenerator keyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName());
sb.append(":");
sb.append(method.getName());
sb.append(":");
for (Object param : params) {
sb.append(param.toString()).append("_");
}
return sb.toString();
};
}
}
// 使用
@Cacheable(value = "userCache", keyGenerator = "myKeyGenerator")
public User getUserById(Long userId) { ... }四、先搞清楚:查一次数据要多久?
在聊缓存实现之前,我们先看一组数据,感受一下不同存储介质的速度差异:
| 存储类型 | 单次查询耗时 | 相对速度 | 备注 |
|---|---|---|---|
| 本地缓存(Caffeine) | 几十纳秒 ~ 几微秒 | ⚡⚡⚡⚡⚡ | 内存直接读取,快到离谱 |
| Redis(本机) | 0.1ms ~ 0.5ms | ⚡⚡⚡⚡ | 走网络 + 序列化 |
| Redis(同机房) | 0.5ms ~ 2ms | ⚡⚡⚡ | 网络延迟是大头 |
| Redis(跨机房) | 2ms ~ 10ms | ⚡⚡ | 跨机房网络延迟 |
| MySQL(简单查询) | 1ms ~ 10ms | ⚡⚡ | 走网络 + SQL 解析 + 磁盘 IO |
| MySQL(复杂查询) | 10ms ~ 100ms+ | ⚡ | 关联、排序、没走索引... |
划重点:本地缓存比 Redis 快 100~1000 倍,比 MySQL 快 1000~10000 倍。
这意味着什么?
- 如果一个接口 QPS 是 1000,每次查 MySQL 耗时 10ms,光数据库查询就要消耗 10 秒的 CPU 时间
- 换成本地缓存,同样的请求只需要 0.01 秒
所以,能用本地缓存就别用 Redis,能用 Redis 就别查数据库。
五、本地缓存:Caffeine 才是真的快
很多同学一提缓存就想到 Redis,但其实对于单机场景或者读多写少的热点数据,本地缓存才是性能天花板。
5.1 为什么选 Caffeine?
Spring Boot 2.x 之后,官方钦定 Caffeine 作为本地缓存的首选实现(之前是 Guava Cache)。Caffeine 号称"地表最强本地缓存",采用 W-TinyLFU 淘汰算法,命中率吊打一众竞品。
5.2 引入依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>5.3 配置方式一:简单配置(application.yml)
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=5mspec 参数说明:
maximumSize=10000:最多缓存 10000 条数据expireAfterWrite=5m:写入后 5 分钟过期expireAfterAccess=5m:最后一次访问后 5 分钟过期
5.4 配置方式二:精细控制(配置类)
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100) // 初始容量
.maximumSize(10000) // 最大条目数
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后过期时间
.recordStats()); // 开启统计(生产环境可关闭)
return cacheManager;
}
}5.5 不同缓存不同配置
用户信息缓存 30 分钟,配置信息缓存 24 小时?安排:
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
List<CaffeineCache> caches = new ArrayList<>();
// 用户缓存:最多 5000 条,30 分钟过期
caches.add(new CaffeineCache("userCache",
Caffeine.newBuilder()
.maximumSize(5000)
.expireAfterWrite(30, TimeUnit.MINUTES)
.build()));
// 配置缓存:最多 1000 条,24 小时过期
caches.add(new CaffeineCache("configCache",
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(24, TimeUnit.HOURS)
.build()));
// 热点数据:最多 100 条,按访问时间过期
caches.add(new CaffeineCache("hotDataCache",
Caffeine.newBuilder()
.maximumSize(100)
.expireAfterAccess(10, TimeUnit.MINUTES)
.build()));
cacheManager.setCaches(caches);
return cacheManager;
}
}5.6 本地缓存的局限性
本地缓存虽然快,但也有硬伤:
| 问题 | 说明 |
|---|---|
| 多实例数据不一致 | A 机器更新了,B 机器还是旧数据 |
| 内存有限 | 缓存太多会 OOM |
| 重启即失效 | 应用重启,缓存清空 |
适用场景:
- 单机应用
- 数据量小、更新不频繁的配置信息
- 可以容忍短时间不一致的场景
- 极致性能要求的热点数据(配合 Redis 做二级缓存)
六、配合 Redis 使用
光有注解没有存储实现,就像光有嘴没有胃。下面配置 Redis 作为缓存后端:
6.1 配置文件
spring:
redis:
host: localhost
port: 6379
password: your_password # 没密码就删掉这行
database: 0
cache:
type: redis
redis:
time-to-live: 3600000 # 缓存过期时间,单位毫秒(1小时)
key-prefix: "app:" # key 前缀
use-key-prefix: true
cache-null-values: false # 是否缓存 null 值6.2 自定义 Redis 缓存配置
想要更精细的控制?上配置类:
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
// 默认配置
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 默认过期时间 1 小时
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues(); // 不缓存 null
// 针对不同缓存名称设置不同配置
Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
configMap.put("userCache", defaultConfig.entryTtl(Duration.ofMinutes(30))); // 用户缓存 30 分钟
configMap.put("configCache", defaultConfig.entryTtl(Duration.ofHours(24))); // 配置缓存 24 小时
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(configMap)
.build();
}
}七、缓存三兄弟:@Cacheable、@CachePut、@CacheEvict
| 注解 | 作用 | 适用场景 |
|---|---|---|
@Cacheable | 查缓存,没有就执行方法并缓存结果 | 查询操作 |
@CachePut | 无论如何都执行方法,并更新缓存 | 更新操作 |
@CacheEvict | 删除缓存 | 删除/更新操作 |
7.1 @CachePut:更新必备
@CachePut(value = "userCache", key = "#user.id")
public User updateUser(User user) {
// 一定会执行,然后把返回值更新到缓存
return userRepository.save(user);
}7.2 @CacheEvict:清理门户
// 删除单个缓存
@CacheEvict(value = "userCache", key = "#userId")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
// 清空整个缓存(慎用!)
@CacheEvict(value = "userCache", allEntries = true)
public void clearAllUserCache() {
// 核弹级操作
}
// 方法执行前就删除缓存
@CacheEvict(value = "userCache", key = "#userId", beforeInvocation = true)
public void deleteUser(Long userId) {
// 即使方法抛异常,缓存也已经删了
}7.3 @Caching:组合技
一个方法需要操作多个缓存?用 @Caching 打包:
@Caching(
cacheable = {
@Cacheable(value = "userCache", key = "#userId")
},
evict = {
@CacheEvict(value = "userListCache", allEntries = true)
}
)
public User getUserById(Long userId) { ... }八、血泪踩坑实录
坑 1:同类方法调用,缓存不生效
@Service
public class UserService {
@Cacheable(value = "userCache", key = "#userId")
public User getUserById(Long userId) { ... }
public User getUser(Long userId) {
// 这样调用,缓存不生效!
return this.getUserById(userId);
}
}原因:Spring Cache 基于 AOP 代理,this.xxx() 是直接调用,不走代理。
解决方案:
- 注入自己(有点魔幻但有效)
- 使用
AopContext.currentProxy() - 拆分到不同的 Service
@Service
public class UserService {
@Autowired
@Lazy
private UserService self; // 注入自己
@Cacheable(value = "userCache", key = "#userId")
public User getUserById(Long userId) { ... }
public User getUser(Long userId) {
return self.getUserById(userId); // 通过代理调用
}
}坑 2:缓存对象没实现 Serializable
用 Redis 缓存时,对象必须可序列化:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}不然你会收获一个 SerializationException,并且怀疑人生。
坑 3:缓存穿透
查一个不存在的数据,每次都穿透到数据库:
// 错误示范:null 不缓存
@Cacheable(value = "userCache", key = "#userId", unless = "#result == null")
public User getUserById(Long userId) {
return userRepository.findById(userId).orElse(null);
}解决方案:缓存空对象或使用布隆过滤器
// 缓存空对象,但设置较短过期时间
@Cacheable(value = "userCache", key = "#userId")
public User getUserById(Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user == null) {
// 返回一个空对象标记,而不是 null
return User.EMPTY;
}
return user;
}坑 4:缓存雪崩
大量缓存同时过期,请求全打到数据库。
解决方案:过期时间加随机值
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
return RedisCacheManager.builder(factory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1).plusMinutes(new Random().nextInt(30))))
.build();
}九、最佳实践总结
- 缓存命名要有意义:
userCache、orderCache,别用cache1、cache2 - key 设计要唯一:加上业务前缀,避免冲突
- 合理设置过期时间:热数据短一点,冷数据长一点
- null 值要处理:要么缓存空对象,要么用布隆过滤器
- 更新和删除要同步:数据变了,缓存也要跟着变
- 监控要到位:缓存命中率、过期淘汰数都要关注
结语
缓存用得好,下班回家早。@Cacheable 是 Spring 送给我们的礼物,用好它,你的数据库会感谢你,你的 DBA 会感谢你,你的用户也会感谢你(虽然他们并不知道发生了什么)。
记住:每一次无意义的数据库查询,都是对服务器资源的犯罪。
如果这篇文章帮你少写了几行代码,少挨了几次 DBA 的骂,记得点个赞。毕竟,独乐乐不如众乐乐,大家一起优雅,才是真的优雅。