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 在缓存命中与未命中两种场景下的完整执行流程:

@Cacheable 执行时序图 调用方 缓存代理 缓存存储 目标方法 1. 调用 2. 查询 key 命中 3a. 返回缓存值 未命中 3b. 执行方法 4b. 返回结果 写入 5b. 返回结果 说明:绿色路径为缓存命中流程,红色路径为缓存未命中流程(含方法执行与写入)

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

上述代码在更新用户信息时,同时完成三个操作:

  1. 更新 users 缓存中该用户的条目。
  2. 更新 userNames 缓存中该用户名的条目。
  3. 清除 userLists 缓存中的所有列表数据,保证列表查询结果的一致性。

2.6 缓存注解执行顺序与一致性分析

当多个缓存注解同时作用于一个方法时,它们的执行顺序直接影响缓存一致性。下图展示了 @Caching 组合注解的内部执行流程:

@Caching 组合注解执行流程 方法调用入口 检查 @CacheEvict(before=true) 执行目标方法 执行 @CachePut(写入缓存) 检查 @CacheEvict(before=false) 注意:若目标方法抛出异常,@CachePut 和 afterInvocation 的 @CacheEvict 均不会执行
一致性保障策略: 在更新数据时,推荐先执行 @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 方法中使用缓存注解时,注意缓存操作在事务提交前可能已经执行,导致事务回滚后缓存与数据库不一致。建议将缓存操作放在事务提交后的回调中执行。