Spring Cache注解怎么用才不踩坑

Spring Cache 注解看起来简单,真正落地时却经常遇到缓存键不对、更新后读到旧数据、同类调用不生效等问题。这篇从缓存开启和 CacheManager 配置讲起,逐个说明 Cacheable、CachePut、CacheEvict、Caching 的使用方式,再把 SpEL 和 AOP 代理陷阱一起讲清楚。

Spring 3.1 开始提供了缓存抽象。

它没有强行规定缓存必须用 Redis、Caffeine 还是本地 Map,而是通过一组注解和 CacheManager 把业务代码与具体缓存实现隔开。

我们平时最常见的就是这几个注解:

  • @Cacheable:缓存查询结果,有缓存就不再执行方法
  • @CachePut:方法一定执行,再把结果写入缓存
  • @CacheEvict:清理缓存
  • @Caching:组合多个缓存操作

看起来都是缓存,实际行为差别很大。

尤其是 @Cacheable 和 @CachePut,名字看着像,执行流程却完全不同。

另外还有一个很容易忽略的前提:Spring Cache 基于 Spring AOP 代理。

如果是同一个类内部直接调用带缓存注解的方法,可能根本不会经过代理,缓存自然也不会生效。

这篇我们把这些坑一次说清楚。

Spring Cache 怎么工作

使用 Spring Cache,大体需要做两件事:

  1. 告诉 Spring 哪些方法需要缓存
  2. 配置具体的缓存管理器

先开启缓存能力:

@Configuration
@EnableCaching
public class CacheConfig {
}
​

@EnableCaching 的作用不是创建 Redis,也不是自动决定缓存存哪里。

它主要是让 Spring 注册缓存相关的拦截器,在调用带缓存注解的方法时,先执行缓存判断。

具体使用哪种缓存,由 CacheManager 决定。

调用方
  │
  ▼
Spring AOP 代理
  │
  ├── 查询缓存
  ├── 命中则直接返回
  └── 未命中则调用目标方法,再写入缓存
​

使用 Redis 时,项目一般配置 RedisCacheManager;使用本地缓存时,可以配置 Caffeine 或其他实现。

所以注解本身只描述缓存行为,序列化方式、过期时间、缓存前缀和空值处理,通常要在 CacheManager 中配置。

@Cacheable 先查再执行

@Cacheable 是使用频率最高的缓存注解。

它的逻辑是:调用方法前先根据缓存名称和 key 查询缓存,命中就直接返回,只有没命中时才真正执行目标方法。

@Service
public class UserService {

    @Cacheable(cacheNames = CacheNames.USER)
    public User getById(Long id) {
        // 只有缓存未命中时才会执行数据库查询
        return userRepository.findById(id);
    }
}
​

第一次查询用户 1001:

调用 getById(1001)
    -> 查询缓存
    -> 没命中
    -> 执行数据库查询
    -> 返回结果并写入缓存
​

第二次查询同一个用户:

调用 getById(1001)
    -> 查询缓存
    -> 命中
    -> 直接返回缓存结果
    -> 不执行数据库查询
​

这就是 @Cacheable 的核心。

value 和 cacheNames

value 和 cacheNames 是同义属性,至少要指定一个缓存名称。

下面两种写法效果相同:

@Cacheable(value = CacheNames.USER)
public User getById(Long id) {
    return userRepository.findById(id);
}

@Cacheable(cacheNames = CacheNames.USER)
public User getById(Long id) {
    return userRepository.findById(id);
}
​

缓存名称可以理解为一个逻辑分区。

比如用户缓存、商品缓存、配置缓存分别使用不同名称,底层可能最终形成类似这样的键:

user::1001
product::2001
config::site
​

实际键格式取决于 CacheManager 配置,但缓存名称和业务 key 通常会共同决定最终位置。

也可以指定多个缓存名称。

@Cacheable(cacheNames = {CacheNames.USER, CacheNames.PLAT_USER})
public User getById(Long id) {
    return userRepository.findById(id);
}
​

不过多个缓存名称会增加理解和失效成本,除非确实需要多处共享,否则一个方法对应一个明确的缓存区域更容易维护。

key 怎么写

如果不指定 key,Spring 会根据方法参数生成默认 key。

简单方法可以直接用默认策略,但生产项目最好让关键缓存的 key 意图清楚。

key 使用 SpEL 表达式,方法参数可以写成参数名,例如 #id。

如果编译后没有保留参数名,也可以使用 #p0、#p1 这种位置写法,分别表示第一个、第二个参数。

第一个参数:#p0
第二个参数:#p1
对象属性:#user.id
全部参数:#root.args
当前方法:#root.method
当前目标对象:#root.target
​

例如一个根据用户对象查询的方法,可以让 key 取对象的 id,而不是把整个对象都作为 key:

@Cacheable(cacheNames = CacheNames.USER)
public User getByUser(UserQuery user) {
    return userRepository.findById(user.getId());
}
​

上面代码如果要使用对象 id,可以在注解中配置 SpEL #user.id。

这样生成的 key 更稳定,也避免对象的 equals、hashCode 变化导致缓存命中异常。

condition 和 unless

这两个属性都用来决定是否缓存,但判断时机不同。

  • condition:方法执行前判断,返回 true 才允许进入缓存逻辑
  • unless:方法执行后判断,返回 true 时不写入缓存

可以这样理解:

condition:这次请求要不要参与缓存?
      ↓
查询缓存 / 执行方法
      ↓
unless:这次执行结果要不要写进缓存?
​

例如只缓存正数 id,可以使用 condition;如果查询结果为 null 就不缓存,可以使用 unless。

condition = #id > 0
unless = #result == null
​

#result 代表方法返回结果,只能在执行方法之后判断,所以不能拿它写 condition。

这两个属性的时机搞反,是很常见的小坑。

@CachePut 一定会执行

@CachePut 和 @Cacheable 最大的区别是:它不会因为缓存已经存在就跳过目标方法。

每次调用 @CachePut 方法,目标方法都会执行,执行成功后再把返回结果写进缓存。

@Service
public class UserService {

    @CachePut(cacheNames = CacheNames.USER)
    public User update(User user) {
        // 每次都会执行更新
        return userRepository.update(user);
    }
}
​

它的典型用途就是更新缓存:

修改用户资料
    -> 执行数据库更新
    -> 拿到最新用户对象
    -> 用同一个 key 覆盖旧缓存
​

不过这里有一个前提:更新方法使用的 key,必须和查询方法使用的 key 对得上。

查询按用户 id 缓存,更新时也应该按用户 id 更新缓存,不能一个用 id、另一个用整个对象。

不要随便混用 Cacheable 和 CachePut

如果同一个方法同时使用 @Cacheable 和 @CachePut,往往会让执行流程变得很难读。

一个注解想在命中时跳过方法,另一个注解又要求方法一定执行,两者的意图本身就冲突。

更清晰的做法是:

  • 查询方法使用 @Cacheable
  • 新增或修改方法使用 @CachePut,或者更新数据库后使用 @CacheEvict

缓存策略宁可拆成几个职责清楚的方法,也不要把所有注解堆在一个方法上。

@CacheEvict 负责清理

数据发生变化后,最怕缓存里还留着旧数据。

@CacheEvict 就是用来删除缓存的。

@CacheEvict(cacheNames = CacheNames.USER)
public void delete(Long id) {
    userRepository.delete(id);
}
​

如果要删除指定 key,需要配置和查询方法一致的 key 表达式。

如果要清空整个缓存区域,可以使用 allEntries = true:

@CacheEvict(cacheNames = CacheNames.USER, allEntries = true)
public void refreshAll() {
    userRepository.refreshAll();
}
​

allEntries 会忽略指定的 key,清理整个缓存名称下的所有数据。

这个选项要谨慎使用,尤其是 Redis 中缓存量很大时,批量清理可能带来瞬时压力。

beforeInvocation

默认情况下,缓存清理发生在目标方法成功执行之后。

如果目标方法抛异常,默认不会执行清理。

如果希望方法执行前就清理缓存,可以设置:

@CacheEvict(
    cacheNames = CacheNames.USER,
    beforeInvocation = true
)
public void rebuild() {
    rebuildUserCache();
}
​

这样即使目标方法后面执行失败,缓存也已经被清理。

代价是:数据库更新失败时,缓存也可能先被删掉,后续请求会重新查询数据库。

到底放在执行前还是执行后,要看业务更怕旧数据,还是更怕缓存短暂失效。

@Caching 组合多个操作

一个方法有时需要同时执行多个缓存动作,这时可以使用 @Caching。

@Caching(
    put = {
        @CachePut(cacheNames = CacheNames.USER)
    },
    evict = {
        @CacheEvict(cacheNames = CacheNames.USER_LIST)
    }
)
public User update(User user) {
    return userRepository.update(user);
}
​

这个例子表达的是:更新单个用户缓存,同时清理用户列表缓存。

因为单个用户内容变了,详情缓存可以直接写入最新结果;但列表可能涉及排序、分页和筛选,直接清掉通常更安全。

@Caching 很灵活,但也容易把方法变得难以理解。

个人建议先把缓存设计简单,只有确实存在多个缓存区域需要协同时再使用它。

AOP 代理是最大陷阱

Spring Cache 依赖 Spring AOP。

通过 Spring 容器注入的对象,通常不是原始 Service,而是一个代理对象。

外部调用代理方法时,缓存拦截器才有机会工作。

Controller
    │ 调用
    ▼
Spring 代理对象
    │ 查询缓存、执行 CacheInterceptor
    ▼
真实 Service 对象
​

如果在同一个类里用 this.getById(id) 调用带 @Cacheable 的方法,就绕过了代理。

@Service
public class UserService {

    @Cacheable(cacheNames = CacheNames.USER)
    public User getById(Long id) {
        return userRepository.findById(id);
    }

    public User loadForPage(Long id) {
        // this.getById(id) 属于类内部调用,可能不会经过缓存代理
        return this.getById(id);
    }
}
​

这时你会发现注解明明写了,缓存却完全没命中。

不是 Spring Cache 失效,而是调用路径没有经过代理。

怎么解决自调用

常见解决方案有几个:

  1. 把缓存方法拆到独立的 Service 中,通过注入的 Bean 调用
  2. 从 Spring 容器中获取代理对象再调用
  3. 使用自注入,但要注意循环依赖和代码可读性
  4. 极少数场景下使用 AspectJ 等编织方式,绕开普通代理限制

最推荐第一种。

让查询 Service 负责缓存查询,让写入 Service 负责数据修改,职责清楚,也不容易出现内部调用绕过代理的问题。

还有哪些调用可能失效

下面这些场景也要留意:

  • 方法不是 Spring 容器管理的 Bean
  • 方法不是可被代理拦截的公开业务方法
  • 直接通过 new 创建 Service 对象
  • 测试代码绕过 Spring 上下文直接创建对象
  • 子类直接调用父类缓存方法,实际没有经过目标代理

遇到缓存不生效时,先确认对象是不是 Spring 注入的代理对象,再看 key 和缓存配置。

缓存键怎么设计

缓存 key 看起来只是一个字符串,实际上决定了缓存能不能命中、会不会串数据。

建议遵循几个原则:

  • 同一类数据使用统一的缓存名称
  • key 包含必要的业务维度,避免不同租户数据互相覆盖
  • key 尽量稳定,不要直接依赖可变对象的默认字符串
  • 分页、语言、租户、版本等参数需要纳入 key
  • 不同业务不要随意共用同一个缓存名称

例如多租户系统不能只用用户 id:

错误:user::1001
更稳妥:tenant-a:user:1001
​

如果用户 id 在租户之间不唯一,少了租户维度就可能读到别人的数据。

缓存 key 不是越短越好,能表达业务唯一性更重要。

缓存和数据库怎么配合

最常见的查询写法是 Cache Aside,也就是旁路缓存:

查询:先查缓存,未命中再查数据库,并写回缓存

更新:先更新数据库,再删除缓存或更新缓存
删除:删除数据库数据,再删除缓存
​

更新时到底选择 @CachePut 还是 @CacheEvict,要看返回数据是否完整、缓存结构是否复杂。

如果更新方法能返回完整且最新的实体,@CachePut 比较直接。

如果一个数据库修改会影响很多列表、统计和关联缓存,统一使用 @CacheEvict 清理相关区域,往往更稳妥。

不要只因为加了注解,就认为缓存和数据库天然一致。

缓存仍然存在并发更新、异常回滚、删除失败和过期时间等问题。

Spring Cache 帮我们减少了模板代码,但一致性策略仍然要由业务设计决定。

小结

Spring Cache 几个核心注解,可以这样记:

  • @Cacheable:先查缓存,命中就跳过方法
  • @CachePut:不查缓存,方法必执行,结果写入缓存
  • @CacheEvict:删除指定缓存或清空缓存区域
  • @Caching:把多个缓存操作组合到一起

使用时重点检查:

  • 是否添加了 @EnableCaching
  • CacheManager 是否配置正确
  • 缓存名称和 key 是否统一
  • condition 是执行前判断,unless 是执行后判断
  • @Cacheable 和 @CachePut 不要随便混用
  • 同类内部调用会绕过 Spring AOP 代理
  • 多租户、分页和多语言场景要把业务维度放进 key

一句话总结:缓存注解负责描述行为,CacheManager 负责落地实现,AOP 代理决定注解能不能真正被执行。

别只盯着注解有没有写上,真正排查缓存问题时,把调用对象、代理链、缓存名称、最终 key 和底层 CacheManager 一起看,通常很快就能找到原因。

更多推荐

章节目录