4. 缓存注解:@Cacheable、@CachePut、@CacheEvict、@Caching 原理与使用场景
在 Spring Boot 整合 Redis 缓存时,声明式缓存注解是核心编程模型。开发者只需在方法上添加注解,即可实现缓存读写、更新与清除,无需编写显式的缓存操作代码。本章深入剖析四个核心注解的原理、执行时机与典型使用场景。
4.1 注解体系总览
Spring 缓存抽象层(spring-context 模块)定义了以下核心注解,它们通过 AOP 切面在方法调用前后织入缓存逻辑:
| 注解 | 作用 | 执行时机 | 典型场景 |
|---|---|---|---|
| @Cacheable | 查询缓存;若缓存命中则直接返回,不执行方法体 | 方法调用前 | 读多写少的查询接口(如用户信息、商品详情) |
| @CachePut | 更新缓存;始终执行方法体,并将返回值写入缓存 | 方法调用后 | 数据更新后同步刷新缓存(如修改用户资料) |
| @CacheEvict | 清除缓存;可清除单个键或整个缓存区域 | 方法调用前/后 | 删除数据后清理缓存(如删除订单) |
| @Caching | 组合多个缓存注解,实现复杂缓存策略 | 按内部注解顺序执行 | 同时更新多个缓存区域或执行读写+清除组合 |
关键原则: @Cacheable 和 @CachePut 都使用
key 属性指定缓存键,但 @Cacheable 会先检查缓存是否存在,而 @CachePut 总是执行方法并写入。两者结合使用时需注意避免缓存不一致。
4.2 @Cacheable:缓存读取与条件控制
@Cacheable 是使用最频繁的缓存注解。其核心流程为:先查缓存 → 命中则返回 → 未命中则执行方法 → 将结果存入缓存。
@Service
public class UserService {
@Cacheable(value = "users", key = "#userId", unless = "#result == null")
public User getUserById(Long userId) {
// 模拟耗时数据库查询
return userRepository.findById(userId).orElse(null);
}
@Cacheable(value = "users", key = "#user.email", condition = "#user.age > 18")
public User getAdultUser(User user) {
return userRepository.findByEmail(user.getEmail());
}
}
重要属性说明:
- value / cacheNames:缓存区域名称,对应 Redis 中的键前缀(如
users::123)。 - key:SpEL 表达式,动态生成缓存键。默认使用方法参数组合。
- condition:满足条件才执行缓存逻辑(在方法调用前评估)。
- unless:满足条件则不缓存(在方法调用后评估,可访问返回值)。
- sync:开启同步模式,防止缓存击穿(同一键并发时只有一个线程执行方法)。
避坑提示: 使用
condition 时,若条件为 false,方法仍会执行但结果不会被缓存。使用 unless 时,若条件为 true,方法执行后结果不写入缓存。两者不要混淆。
4.3 @CachePut:强制更新缓存
@CachePut 始终执行目标方法,并用返回值更新缓存。它不会检查缓存中是否已存在该键,因此适合用于数据变更后同步刷新缓存的场景。
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
// 执行数据库更新操作
return userRepository.save(user);
}
与 @Cacheable 的核心区别:
| 对比维度 | @Cacheable | @CachePut |
|---|---|---|
| 是否执行方法体 | 缓存命中时不执行 | 始终执行 |
| 写入缓存时机 | 方法返回后(仅未命中时) | 方法返回后(总是写入) |
| 典型用途 | 查询加速 | 缓存同步更新 |
| 与数据库一致性 | 可能存在脏读(缓存未更新) | 写入后缓存与数据库一致 |
4.4 @CacheEvict:缓存清除策略
@CacheEvict 用于从缓存中移除数据。它支持清除单个键或清空整个缓存区域,是保证缓存与数据库一致性的关键工具。
@CacheEvict(value = "users", key = "#userId")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
// 清空整个 users 缓存区域
@CacheEvict(value = "users", allEntries = true)
public void clearAllUsers() {
// 批量操作后清空缓存
}
// 在方法执行前清除缓存(默认在方法后执行)
@CacheEvict(value = "users", key = "#userId", beforeInvocation = true)
public void evictBeforeMethod(Long userId) {
// 即使方法抛出异常,缓存也会被清除
}
关键属性:
- allEntries:是否清空整个缓存区域(默认 false)。设置为 true 时,key 属性被忽略。
- beforeInvocation:是否在方法执行前清除缓存(默认 false)。设置为 true 可避免方法异常导致缓存未清除的问题。
最佳实践: 在删除操作中使用
beforeInvocation = true,确保即使方法抛出异常,缓存中的脏数据也被清除,避免后续查询读到已删除的数据。
4.5 @Caching:组合复杂缓存策略
当单个方法需要同时执行多个缓存操作时(如更新主缓存、清除关联缓存),使用 @Caching 注解组合多个缓存注解。
@Caching(
put = {
@CachePut(value = "users", key = "#user.id"),
@CachePut(value = "users", key = "#user.email")
},
evict = {
@CacheEvict(value = "userLists", allEntries = true)
}
)
public User saveUser(User user) {
return userRepository.save(user);
}
执行顺序:
- 先执行 @CacheEvict(若
beforeInvocation = true)。 - 执行目标方法体。
- 执行 @CachePut 写入新数据。
- 执行 @CacheEvict(若
beforeInvocation = false,默认)。
4.6 注解执行流程与缓存生命周期
以下 SVG 图展示了 Spring 缓存切面在方法调用过程中的完整拦截流程:
4.7 使用场景对比与选择建议
| 业务场景 | 推荐注解 | 原因 |
|---|---|---|
| 查询热点数据(如商品详情) | @Cacheable | 减少数据库压力,利用缓存加速读取 |
| 更新数据后同步缓存 | @CachePut | 确保缓存与数据库一致,避免脏读 |
| 删除数据后清理缓存 | @CacheEvict | 防止查询到已删除的缓存数据 |
| 批量操作后清空整个缓存区域 | @CacheEvict(allEntries=true) | 避免逐个清除键,提高效率 |
| 同时更新主缓存和关联缓存 | @Caching | 组合多个缓存操作,保证原子性 |
| 防止缓存击穿(高并发场景) | @Cacheable(sync=true) | 同一键只允许一个线程查询数据库 |
4.8 常见陷阱与避坑指南
陷阱 1:内部方法调用导致注解失效
Spring 缓存注解基于 AOP 代理实现。当同一个类中的方法 A 调用方法 B,而方法 B 上有 @Cacheable 注解时,注解不会生效(因为调用未经过代理对象)。
解决方案: 将缓存方法抽取到另一个 Service Bean 中,或通过
Spring 缓存注解基于 AOP 代理实现。当同一个类中的方法 A 调用方法 B,而方法 B 上有 @Cacheable 注解时,注解不会生效(因为调用未经过代理对象)。
解决方案: 将缓存方法抽取到另一个 Service Bean 中,或通过
AopContext.currentProxy() 获取代理对象。
陷阱 2:@CachePut 与 @Cacheable 共用同一键导致循环
若 @CachePut 方法内部又调用了带有 @Cacheable 的同键方法,可能导致缓存写入后立即被读取,造成逻辑混乱。建议在更新方法中直接操作数据库,避免嵌套调用。
若 @CachePut 方法内部又调用了带有 @Cacheable 的同键方法,可能导致缓存写入后立即被读取,造成逻辑混乱。建议在更新方法中直接操作数据库,避免嵌套调用。
陷阱 3:缓存键冲突
默认缓存键基于方法参数生成。若不同方法使用相同的缓存区域和键,可能互相覆盖。建议显式指定 key 属性,使用业务唯一标识(如
默认缓存键基于方法参数生成。若不同方法使用相同的缓存区域和键,可能互相覆盖。建议显式指定 key 属性,使用业务唯一标识(如
#user.id、#orderNo)。
4.9 注解在冒烟测试中的验证要点
在回归冒烟测试中,需要验证缓存注解的行为是否符合预期:
- @Cacheable:首次调用后缓存应存在;第二次调用相同参数应返回缓存数据,方法体不执行。
- @CachePut:每次调用后缓存值应更新为最新返回值。
- @CacheEvict:调用后缓存键应被删除;若 allEntries=true,整个缓存区域应清空。
- @Caching:组合操作应全部执行,且顺序正确。
- 条件注解:condition 和 unless 应正确控制缓存行为。
以下 SVG 图展示了冒烟测试中验证缓存注解的典型测试用例设计:
冒烟测试核心策略: 每个缓存注解都应设计独立的正向用例(验证正常缓存行为)和负向用例(验证异常场景下的缓存状态)。特别关注 @Cacheable 的缓存命中与未命中路径、@CachePut 的强制写入行为、@CacheEvict 的清除范围,以及 @Caching 的组合执行顺序。