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);
}

执行顺序:

  1. 先执行 @CacheEvict(若 beforeInvocation = true)。
  2. 执行目标方法体。
  3. 执行 @CachePut 写入新数据。
  4. 执行 @CacheEvict(若 beforeInvocation = false,默认)。

4.6 注解执行流程与缓存生命周期

以下 SVG 图展示了 Spring 缓存切面在方法调用过程中的完整拦截流程:

Spring 缓存注解执行流程 方法调用 有 @Cacheable? (检查缓存) 缓存命中 → 直接返回 执行目标方法 有 @CachePut? (写入缓存) 写入 Redis 缓存 有 @CacheEvict? (清除缓存) 清除缓存键/区域 返回方法执行结果 说明: @CacheEvict 若设置 beforeInvocation=true, 则在方法执行前清除缓存。

4.7 使用场景对比与选择建议

业务场景 推荐注解 原因
查询热点数据(如商品详情) @Cacheable 减少数据库压力,利用缓存加速读取
更新数据后同步缓存 @CachePut 确保缓存与数据库一致,避免脏读
删除数据后清理缓存 @CacheEvict 防止查询到已删除的缓存数据
批量操作后清空整个缓存区域 @CacheEvict(allEntries=true) 避免逐个清除键,提高效率
同时更新主缓存和关联缓存 @Caching 组合多个缓存操作,保证原子性
防止缓存击穿(高并发场景) @Cacheable(sync=true) 同一键只允许一个线程查询数据库

4.8 常见陷阱与避坑指南

陷阱 1:内部方法调用导致注解失效
Spring 缓存注解基于 AOP 代理实现。当同一个类中的方法 A 调用方法 B,而方法 B 上有 @Cacheable 注解时,注解不会生效(因为调用未经过代理对象)。
解决方案: 将缓存方法抽取到另一个 Service Bean 中,或通过 AopContext.currentProxy() 获取代理对象。
陷阱 2:@CachePut 与 @Cacheable 共用同一键导致循环
若 @CachePut 方法内部又调用了带有 @Cacheable 的同键方法,可能导致缓存写入后立即被读取,造成逻辑混乱。建议在更新方法中直接操作数据库,避免嵌套调用。
陷阱 3:缓存键冲突
默认缓存键基于方法参数生成。若不同方法使用相同的缓存区域和键,可能互相覆盖。建议显式指定 key 属性,使用业务唯一标识(如 #user.id、#orderNo)。

4.9 注解在冒烟测试中的验证要点

在回归冒烟测试中,需要验证缓存注解的行为是否符合预期:

  • @Cacheable:首次调用后缓存应存在;第二次调用相同参数应返回缓存数据,方法体不执行。
  • @CachePut:每次调用后缓存值应更新为最新返回值。
  • @CacheEvict:调用后缓存键应被删除;若 allEntries=true,整个缓存区域应清空。
  • @Caching:组合操作应全部执行,且顺序正确。
  • 条件注解:condition 和 unless 应正确控制缓存行为。

以下 SVG 图展示了冒烟测试中验证缓存注解的典型测试用例设计:

缓存注解冒烟测试验证用例 @Cacheable 验证 1. 首次调用 → 执行方法 2. 检查 Redis 存在缓存键 3. 第二次调用 → 不执行方法 4. 返回结果与首次一致 5. 验证 unless/condition 生效 @CachePut 验证 1. 调用更新方法 2. 方法体始终执行 3. 检查 Redis 缓存值已更新 4. 再次查询返回新值 5. 验证 key 表达式正确 @CacheEvict 验证 1. 调用清除方法 2. 检查 Redis 键已删除 3. allEntries=true 清空区域 4. beforeInvocation 测试 5. 异常场景缓存仍被清除 @Caching 验证 1. 组合注解全部生效 2. 多个 @CachePut 写入 3. @CacheEvict 清除关联缓存 4. 验证执行顺序正确 5. 事务回滚时缓存状态 边界条件验证 1. 缓存键为 null 的处理 2. 返回值为 null 的缓存 3. 并发场景缓存一致性 4. sync 模式防击穿验证 5. 缓存过期时间验证 异常场景验证 1. 方法抛出异常时缓存状态 2. Redis 连接超时降级 3. 缓存序列化异常处理 4. 缓存穿透场景验证 5. 缓存雪崩防护验证
冒烟测试核心策略: 每个缓存注解都应设计独立的正向用例(验证正常缓存行为)和负向用例(验证异常场景下的缓存状态)。特别关注 @Cacheable 的缓存命中与未命中路径、@CachePut 的强制写入行为、@CacheEvict 的清除范围,以及 @Caching 的组合执行顺序。