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,大体需要做两件事:
- 告诉 Spring 哪些方法需要缓存
- 配置具体的缓存管理器
先开启缓存能力:
@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 失效,而是调用路径没有经过代理。
怎么解决自调用
常见解决方案有几个:
- 把缓存方法拆到独立的 Service 中,通过注入的 Bean 调用
- 从 Spring 容器中获取代理对象再调用
- 使用自注入,但要注意循环依赖和代码可读性
- 极少数场景下使用 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 一起看,通常很快就能找到原因。
