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

Spring 框架从 3.1 版本开始引入了声明式缓存抽象,它通过一组注解将缓存操作与业务逻辑解耦。开发者无需手动编写缓存读写代码,只需在方法上添加注解,即可实现缓存数据的存取、更新与失效。本章将深入剖析三个核心注解 @Cacheable、@CachePut 和 @CacheEvict 的工作原理、配置细节及最佳实践。

2.1 缓存抽象核心概念

在深入注解之前,需要理解 Spring 缓存抽象中的几个关键组件:

组件 说明 类比
CacheManager 缓存管理器,管理多个 Cache 实例,负责缓存的创建、获取和生命周期管理 数据库连接池
Cache 缓存容器,存储键值对,提供 get、put、evict 等原子操作 数据库中的一张表
Cache Key 缓存键,默认基于方法参数生成,可通过 key 属性自定义 数据库主键
Cache Value 缓存值,即方法的返回结果 数据库行记录
关键理解: Spring 缓存抽象不关心底层存储实现(Redis、Caffeine、EhCache 等),它通过 CacheManager 接口统一管理。切换缓存中间件只需更换配置,业务代码无需改动。

2.2 @Cacheable:缓存读取与存储

@Cacheable 是最常用的缓存注解,用于标记需要缓存结果的方法。当方法被调用时,Spring 会先检查缓存中是否存在对应键的值:

  • 缓存命中: 直接返回缓存值,不执行方法体。
  • 缓存未命中: 执行方法体,将返回值存入缓存,再返回结果。

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 String[] 无(必填) 缓存名称,对应 CacheManager 中的缓存区域
key String SpEL 表达式,默认基于所有参数 自定义缓存键,支持 Spring Expression Language (SpEL)
condition String ""(无条件) SpEL 条件表达式,为 true 时才缓存
unless String ""(无条件) SpEL 条件表达式,为 true 时不缓存
sync boolean false 是否启用同步模式,防止缓存击穿

2.2.2 缓存键生成策略

Spring 使用 KeyGenerator 接口生成缓存键。默认实现 SimpleKeyGenerator 的规则如下:

  • 无参数: 使用 SimpleKey.EMPTY(空键)。
  • 一个参数: 直接使用该参数作为键。
  • 多个参数: 使用 SimpleKey 对象包装所有参数。
避坑指南: 当方法有多个参数但只希望以部分参数作为缓存键时,必须显式指定 key 属性。例如 key = "#userId",否则默认使用所有参数组合,可能导致缓存键不符合预期。

2.2.3 condition 与 unless 的区别

属性 评估时机 作用 示例
condition 方法执行前 满足条件才检查并缓存 condition = "#id > 0"
unless 方法执行后 满足条件则不缓存结果 unless = "#result == null"
@Cacheable(value = "users", key = "#id",
           condition = "#id != null",
           unless = "#result == null")
public User getUserById(Long id) {
    return userRepository.findById(id).orElse(null);
}

2.2.4 sync 同步模式

在高并发场景下,多个线程同时请求同一个未缓存的 key 会导致缓存击穿。设置 sync = true 后,Spring 会对缓存键加锁,确保只有一个线程执行方法体,其他线程等待结果:

@Cacheable(value = "users", key = "#id", sync = true)
public User getUserById(Long id) {
    // 高并发下只有一个线程执行此方法
    return userRepository.findById(id).orElse(null);
}
性能提示: sync 模式使用局部锁,仅对同一个 JVM 实例有效。在分布式环境下,仍需结合分布式锁或缓存中间件自身的原子操作来防止击穿。

2.3 @CachePut:缓存更新

@CachePut 注解用于强制更新缓存。与 @Cacheable 不同,它始终执行方法体,并将返回值更新到缓存中。常用于数据更新操作后刷新缓存。

@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
    return userRepository.save(user);
}
对比维度 @Cacheable @CachePut
方法是否执行 缓存命中时不执行 始终执行
缓存写入时机 仅未命中时写入 每次调用都写入
典型场景 查询操作 更新/插入操作
与数据库一致性 可能读到旧数据 保证缓存与数据库同步
最佳实践: 在更新数据库的方法上同时使用 @CachePut 和 @CacheEvict 的组合,可以灵活控制缓存更新策略。例如:先更新缓存(@CachePut),再清理关联查询的缓存(@CacheEvict)。

2.4 @CacheEvict:缓存失效

@CacheEvict 用于从缓存中移除数据,通常在删除或更新操作后调用,避免脏数据残留。

2.4.1 基本用法

@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
    userRepository.deleteById(id);
}

2.4.2 关键属性

属性 类型 默认值 说明
allEntries boolean false 是否清空整个缓存区域
beforeInvocation boolean false 是否在方法执行前清除缓存

2.4.3 allEntries:批量清空

当需要清空某个缓存区域的所有数据时,设置 allEntries = true:

@CacheEvict(value = "users", allEntries = true)
public void clearAllUserCache() {
    // 方法体可以为空,仅用于触发缓存清理
}
使用注意: allEntries = true 会清空整个缓存区域,影响所有缓存键。在生产环境中应谨慎使用,避免大量缓存同时失效导致数据库压力骤增(缓存雪崩)。

2.4.4 beforeInvocation:执行时机

默认情况下,@CacheEvict 在方法执行成功返回后才清除缓存。如果方法抛出异常,缓存不会被清除。设置 beforeInvocation = true 可以在方法执行前清除缓存:

@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void deleteUserWithException(Long id) {
    // 即使此方法抛出异常,缓存也会被清除
    userRepository.deleteById(id);
    throw new RuntimeException("模拟异常");
}

2.5 注解组合与缓存一致性闭环

在实际业务中,单一注解往往无法满足复杂的缓存一致性需求。下图展示了典型的缓存一致性闭环流程:

缓存一致性闭环:查询 → 更新 → 失效 用户请求 @Cacheable 查询 缓存命中 查询数据库 @CachePut 更新 @CacheEvict 失效 写入缓存 更新缓存 清除缓存 返回

2.6 缓存配置与启用

2.6.1 启用缓存抽象

在 Spring Boot 中,只需在配置类或启动类上添加 @EnableCaching 注解即可启用缓存抽象:

@SpringBootApplication
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

2.6.2 配置缓存管理器

Spring Boot 自动配置了 CacheManager,但可以通过配置文件自定义缓存行为。以 Caffeine 缓存为例:

# application.yml
spring:
  cache:
    type: caffeine
    cache-names: users,products,orders
    caffeine:
      spec: maximumSize=500,expireAfterWrite=10m
配置项 说明 示例值
spring.cache.type 缓存类型(caffeine, redis, simple 等) caffeine
spring.cache.cache-names 预定义的缓存区域名称列表 users, products
spring.cache.caffeine.spec Caffeine 缓存规范 maximumSize=500,expireAfterWrite=10m

2.6.3 自定义 KeyGenerator

当默认的键生成策略不满足需求时,可以自定义 KeyGenerator:

@Configuration
public class CacheConfig {

    @Bean("customKeyGenerator")
    public KeyGenerator customKeyGenerator() {
        return (target, method, params) -> {
            StringBuilder sb = new StringBuilder();
            sb.append(target.getClass().getSimpleName());
            sb.append(".");
            sb.append(method.getName());
            sb.append("(");
            for (Object param : params) {
                sb.append(param).append(",");
            }
            sb.append(")");
            return sb.toString();
        };
    }
}

// 使用自定义 KeyGenerator
@Cacheable(value = "users", keyGenerator = "customKeyGenerator")
public User getUserById(Long id) {
    return userRepository.findById(id).orElse(null);
}

2.7 常见问题与最佳实践

问题1:@Cacheable 在同一个类中方法调用失效
原因:Spring 缓存基于 AOP 代理实现,同一个类中的方法调用(this.method())不会经过代理,导致缓存注解不生效。
解决方案:将缓存方法抽取到另一个 Service Bean 中,或通过 AopContext.currentProxy() 获取代理对象。
问题2:缓存穿透与空值缓存
当查询数据库返回 null 时,默认不会缓存。这会导致每次请求都穿透到数据库。解决方案:使用 unless = "#result == null" 配合 Optional 或默认值,或者缓存一个特殊标记值。
最佳实践清单:
  • 始终为缓存键指定有意义的 SpEL 表达式,避免默认键冲突。
  • 更新操作使用 @CachePut 保证缓存与数据库同步。
  • 删除操作使用 @CacheEvict 避免脏数据。
  • 高并发查询场景开启 sync = true 防止缓存击穿。
  • 合理设置缓存过期时间,避免数据长期不一致。
  • 使用 allEntries = true 时注意缓存雪崩风险。