2. Spring Boot 缓存抽象:@Cacheable、@CachePut、@CacheEvict、@Caching 注解详解

Spring 框架从 3.1 版本开始引入了声明式缓存抽象,Spring Boot 在此基础上提供了零配置的自动装配能力。本章深入剖析四个核心注解的语义、执行时机、返回值处理以及组合使用场景,帮助你在服务边界划分时做出正确的缓存注解决策。

2.1 缓存抽象核心架构

Spring 缓存抽象并非具体的缓存实现(如 Redis、Caffeine),而是一套基于 AOP 的拦截器体系。核心接口 CacheManager 管理多个 Cache 实例,注解通过 CacheInterceptor 在方法调用前后织入缓存逻辑。

架构要点: 注解只定义缓存行为,不绑定具体存储;通过配置切换 CacheManager 实现即可更换缓存中间件。
Spring 缓存抽象架构图 @Cacheable / @CachePut @CacheEvict / @Caching CacheInterceptor (AOP) CacheManager 抽象接口 RedisCacheManager CaffeineCacheManager JCacheCacheManager SimpleCacheManager 注解定义缓存行为 → AOP 拦截 → CacheManager 委派 → 具体 Cache 实现 切换 CacheManager 实现即可无缝迁移缓存中间件

2.2 @Cacheable:缓存查询与条件写入

@Cacheable 是最常用的缓存注解,用于标记查询方法。执行流程遵循“先查缓存,命中则直接返回;未命中则执行方法,将结果存入缓存”。

核心属性

属性类型默认值说明
value / cacheNamesString[]—缓存名称,对应 CacheManager 中的缓存区域
keyString (SpEL)方法参数组合缓存键的 SpEL 表达式,如 #id
conditionString (SpEL)""满足条件才缓存(方法执行前评估)
unlessString (SpEL)""满足条件则不缓存(方法执行后评估)
syncbooleanfalse是否同步加锁,防止缓存击穿
最佳实践: 使用 sync = true 应对高并发场景下的缓存击穿;unless 适合过滤 null 结果或错误码。
// 基础用法:按用户 ID 缓存
@Cacheable(value = "users", key = "#userId", sync = true)
public User getUserById(Long userId) {
    return userRepository.findById(userId).orElse(null);
}

// 条件缓存:仅缓存活跃用户,且排除 null 结果
@Cacheable(value = "users", key = "#userId",
           condition = "#userId > 0",
           unless = "#result == null")
public User getActiveUser(Long userId) {
    return userRepository.findActiveById(userId);
}

// 多缓存区域:同时写入两个缓存
@Cacheable(cacheNames = {"users", "userCache"}, key = "#userId")
public User getUserWithMultiCache(Long userId) {
    return userRepository.findById(userId).orElse(null);
}

2.3 @CachePut:强制更新缓存

@CachePut 总是执行目标方法,并将返回值更新到缓存中。它与 @Cacheable 的核心区别在于:不跳过方法执行,适用于数据更新后同步刷新缓存的场景。

对比维度@Cacheable@CachePut
方法是否执行缓存命中时不执行始终执行
缓存写入时机方法返回后(未命中时)方法返回后(总是写入)
典型场景查询接口更新/创建接口
与 #result 关系可用 #result 在 unless 中可用 #result 在 key/condition 中
// 更新用户后同步刷新缓存
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
    return userRepository.save(user);
}

// 创建用户并缓存,使用 #result 引用返回结果
@CachePut(value = "users", key = "#result.id")
public User createUser(User user) {
    return userRepository.save(user);
}
注意陷阱: @CachePut 与 @Cacheable 同时标注在同一方法上时,@Cacheable 会先检查缓存,若命中则跳过方法执行,导致 @CachePut 失效。应使用 @Caching 组合或拆分方法。

2.4 @CacheEvict:缓存驱逐策略

@CacheEvict 用于删除缓存条目,在数据删除或批量失效场景中保证一致性。支持单条删除和清空整个缓存区域。

关键属性

  • allEntries:是否清空整个缓存区域,默认 false。设为 true 时忽略 key 属性。
  • beforeInvocation:是否在方法执行前驱逐缓存,默认 false(方法执行后)。设为 true 可防止方法异常导致缓存未清除。
// 删除单条缓存
@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 unsafeDeleteUser(Long userId) {
    // 即使这里抛出异常,缓存也会被清除
    userRepository.deleteById(userId);
}
性能建议: allEntries = true 在 Redis 等分布式缓存中会触发批量删除操作,可能引起缓存雪崩。建议配合 @Scheduled 定时刷新或使用缓存名称前缀模式进行渐进式清理。

2.5 @Caching:复杂缓存操作组合

当单个方法需要同时执行多种缓存操作(如更新缓存 + 驱逐其他相关缓存)时,@Caching 注解可以将多个缓存注解组合在一起。它内部包含 cacheable、put、evict 三个数组属性。

// 用户角色变更:更新用户缓存 + 驱逐角色缓存 + 驱逐权限缓存
@Caching(
    put = { @CachePut(value = "users", key = "#user.id") },
    evict = {
        @CacheEvict(value = "roles", key = "#user.roleId"),
        @CacheEvict(value = "permissions", key = "#user.id")
    }
)
public User updateUserRole(User user) {
    return userRepository.save(user);
}

// 复杂查询:先查缓存A,未命中则查缓存B,同时驱逐过期缓存
@Caching(
    cacheable = { @Cacheable(value = "primary", key = "#id") },
    evict = { @CacheEvict(value = "stale", key = "#id") }
)
public Data getDataWithFallback(Long id) {
    return dataRepository.findById(id).orElse(null);
}

执行顺序规则

  1. @CacheEvict(beforeInvocation=true) — 方法执行前驱逐
  2. @Cacheable — 检查缓存,命中则直接返回
  3. 方法体执行 — 若未命中或未使用 @Cacheable
  4. @CachePut — 方法返回后写入缓存
  5. @CacheEvict(beforeInvocation=false,默认) — 方法返回后驱逐
设计原则: 当组合操作超过 3 个时,建议拆分为独立方法并显式调用,避免 @Caching 注解过于臃肿难以维护。服务边界划分时,应将同一实体的缓存操作收敛到一个 Service 中。

2.6 注解对比与选型矩阵

注解方法是否执行缓存操作典型场景一致性风险
@Cacheable命中时不执行读取 + 写入查询接口缓存与数据库可能不一致(更新未触发)
@CachePut始终执行写入更新/创建接口低(强制同步写入)
@CacheEvict始终执行删除删除接口、批量失效中(删除后下次查询重建缓存)
@Caching取决于内部组合混合复杂业务变更需关注组合顺序

2.7 常见陷阱与避坑指南

  • 内部方法调用失效: 同一个类中的方法直接调用(非 AOP 代理)会导致缓存注解不生效。应通过注入自身代理或拆分到不同 Service。
  • SpEL 表达式错误: key = "#user.id" 要求参数名为 user,若参数名未编译保留(-parameters 选项),应使用 @Param("user") 或 key = "#a0.id"。
  • 缓存穿透: 查询不存在的数据时,#result == null 会导致缓存未写入,每次请求都穿透到数据库。应使用 unless = "#result == null" 配合空值占位对象。
  • 缓存雪崩: 大量缓存同时过期,导致数据库压力激增。应设置随机过期时间(TTL 抖动)或使用本地 + 分布式二级缓存。
服务边界划分启示: 缓存注解应放在 Service 层而非 Controller 层,因为 Service 是业务逻辑的边界。跨服务调用时,调用方不应直接操作被调用方的缓存,而应通过 API 触发 @CacheEvict 或 @CachePut 来保证一致性。