2. Spring Boot 缓存抽象:@Cacheable、@CachePut、@CacheEvict、@Caching 注解详解
Spring 从 3.1 版本开始引入了声明式缓存抽象,通过一组注解将缓存操作与业务逻辑解耦。在 Spring Boot 中,这一抽象被进一步简化,开发者只需在方法上添加注解即可完成数据的缓存写入、更新与驱逐。本章将深入剖析 @Cacheable、@CachePut、@CacheEvict 以及组合注解 @Caching 的核心机制、执行时序与最佳实践。
2.1 缓存抽象核心注解总览
下表列出了 Spring 缓存抽象中四个核心注解的职责与典型使用场景:
| 注解 | 核心职责 | 触发时机 | 典型场景 |
|---|---|---|---|
@Cacheable |
缓存查询结果,避免重复执行 | 方法调用前检查缓存,命中则直接返回 | 读多写少的查询接口 |
@CachePut |
强制更新缓存,方法始终执行 | 方法执行后,将返回值写入缓存 | 数据新增或全量更新后同步缓存 |
@CacheEvict |
清除缓存中的指定条目或全部条目 | 方法执行前或执行后触发清除 | 数据删除、失效或批量重置 |
@Caching |
组合多个缓存注解,实现复杂策略 | 按内部注解各自规则执行 | 同时需要更新和清除多个缓存 |
关键认知: 缓存抽象基于 AOP 代理实现。注解必须添加在
public 方法上,且不能在同一类内部通过 this 调用,否则代理失效,注解不会生效。
2.2 @Cacheable:缓存查询结果
@Cacheable 是使用频率最高的缓存注解。当方法被调用时,Spring 会根据缓存名称和键(key)在缓存中查找;如果找到则直接返回缓存值,不再执行方法体;如果未命中,则执行方法并将返回值存入缓存。
2.2.1 基本用法与参数
@Service
public class UserService {
@Cacheable(value = "users", key = "#id")
public User getUserById(Long id) {
// 模拟耗时数据库查询
simulateSlowQuery();
return userRepository.findById(id).orElse(null);
}
}
核心参数说明:
- value / cacheNames:缓存名称,可以指定多个,如
{"users", "profiles"}。 - key:SpEL 表达式,用于动态生成缓存键。默认使用所有方法参数组合。
- condition:SpEL 条件,满足条件时才缓存。例如
#result != null。 - unless:SpEL 条件,满足条件时不缓存。例如
#result == null。 - sync:是否启用同步模式,防止缓存击穿。默认
false。
性能调优要点: 对于高并发场景,建议设置
sync = true。此时 Spring 会对相同 key 的并发请求加锁,只允许一个线程执行方法体,其余线程等待缓存结果,有效避免缓存击穿。
2.2.2 执行时序图
下图展示了 @Cacheable 在缓存命中与未命中两种场景下的完整执行流程:
2.3 @CachePut:强制更新缓存
@CachePut 与 @Cacheable 最大的区别在于:方法始终会被执行,且执行完成后将返回值写入缓存。它不会在方法执行前检查缓存,因此适用于数据新增或全量更新后必须同步缓存的场景。
@CachePut(value = "users", key = "#user.id")
public User createUser(User user) {
// 执行数据库插入操作
return userRepository.save(user);
}
常见陷阱: 如果
@CachePut 的 key 表达式依赖方法参数中的某个字段(如 #user.id),而该字段在方法执行后才被赋值(例如数据库自增 ID),则缓存键会为 null,导致缓存写入失败或覆盖错误。解决方案:在方法返回对象中通过 #result.id 获取自增 ID。
2.4 @CacheEvict:精准清除缓存
@CacheEvict 用于从缓存中移除数据。它支持两种清除模式:
- 单条目清除:通过 key 指定要移除的缓存条目。
- 全量清除:设置
allEntries = true,清除缓存中所有条目。
2.4.1 清除时机控制
@CacheEvict 通过 beforeInvocation 属性控制清除操作的执行时机:
| 属性值 | 清除时机 | 适用场景 |
|---|---|---|
beforeInvocation = false(默认) |
方法执行成功后清除 | 方法可能抛出异常时,避免误清除缓存 |
beforeInvocation = true |
方法执行前清除 | 无论方法执行成功与否,都必须清除缓存 |
// 单条目清除:删除用户后清除对应缓存
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
// 全量清除:重置所有用户缓存
@CacheEvict(value = "users", allEntries = true)
public void refreshAllUsers() {
// 执行批量刷新逻辑
}
2.5 @Caching:组合复杂缓存策略
当单个方法需要同时执行多个缓存操作(例如更新一个缓存并清除另一个缓存)时,可以使用 @Caching 注解进行组合。
@Caching(
put = {
@CachePut(value = "users", key = "#user.id"),
@CachePut(value = "userNames", key = "#user.name")
},
evict = {
@CacheEvict(value = "userLists", allEntries = true)
}
)
public User updateUser(User user) {
return userRepository.save(user);
}
上述代码在更新用户信息时,同时完成三个操作:
- 更新
users缓存中该用户的条目。 - 更新
userNames缓存中该用户名的条目。 - 清除
userLists缓存中的所有列表数据,保证列表查询结果的一致性。
2.6 缓存注解执行顺序与一致性分析
当多个缓存注解同时作用于一个方法时,它们的执行顺序直接影响缓存一致性。下图展示了 @Caching 组合注解的内部执行流程:
一致性保障策略: 在更新数据时,推荐先执行
@CacheEvict(beforeInvocation = true) 清除旧缓存,再执行 @CachePut 写入新数据。这样可以避免在方法执行期间,其他线程读到脏数据。如果业务允许短暂的不一致,也可以先 @CachePut 再 @CacheEvict,但需注意并发下的缓存回滚问题。
2.7 最佳实践与避坑指南
- key 设计原则:尽量使用业务唯一标识(如数据库 ID、业务流水号),避免使用整个对象作为 key,防止内存浪费和 hashCode/equals 问题。
- 条件缓存:对于查询结果为空的情况,使用
unless = "#result == null"避免缓存空值;若业务允许缓存空值,则使用condition = "#id != null"防止无效 key。 - 避免内部调用:同一类中的方法互相调用时,缓存注解不会生效。解决方案:将缓存方法抽取到另一个
@Service中,或通过AopContext.currentProxy()获取代理对象。 - 缓存名称规范:使用有业务含义的缓存名称,如
users、orders,避免使用模糊的cache1、cache2。 - 事务与缓存协同:在
@Transactional方法中使用缓存注解时,注意缓存操作在事务提交前可能已经执行,导致事务回滚后缓存与数据库不一致。建议将缓存操作放在事务提交后的回调中执行。