3. Spring Boot 缓存抽象:@Cacheable、@CachePut、@CacheEvict 注解详解
Spring 框架自 3.1 版本起提供了声明式缓存抽象,允许开发者通过注解以极低的侵入性为业务方法添加缓存能力。Spring Boot 在此基础上进一步简化了配置,使得开发者只需关注缓存语义本身。本章将深入剖析三个核心注解:@Cacheable、@CachePut 和 @CacheEvict,并探讨它们在缓存一致性场景下的正确使用方式。
3.1 缓存抽象核心概念
在深入注解之前,需要理解 Spring 缓存抽象的三个核心角色:
| 角色 | 接口/注解 | 职责说明 |
|---|---|---|
| 缓存管理器 | CacheManager |
管理一组 Cache 实例,负责缓存的创建、获取和生命周期管理 |
| 缓存对象 | Cache |
实际存储键值对的容器,提供 get、put、evict、clear 等操作 |
| 缓存注解 | @Cacheable / @CachePut / @CacheEvict |
声明式缓存操作入口,通过 AOP 拦截方法调用并委托给 Cache 接口 |
关键认知:Spring 缓存抽象是一个“门面模式”的实现。注解只负责声明缓存行为,实际的存储后端(Redis、Caffeine、EhCache 等)由
CacheManager 的实现类决定。这种设计使得切换缓存中间件时,业务代码无需任何修改。
3.2 @Cacheable:读缓存的核心入口
@Cacheable 用于标记需要缓存结果的方法。其执行流程遵循“先查缓存,再执行方法”的原则:
- 根据缓存名称和键生成策略计算缓存键。
- 尝试从缓存中获取值。
- 如果命中缓存,直接返回缓存值,不执行方法体。
- 如果未命中,执行方法体,将返回值存入缓存,然后返回结果。
@Service
public class UserService {
@Cacheable(value = "users", key = "#userId")
public User getUserById(Long userId) {
// 模拟耗时数据库查询
simulateSlowQuery();
return userRepository.findById(userId).orElse(null);
}
}
3.2.1 核心属性详解
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value / cacheNames |
String[] |
必填 | 缓存名称,对应 CacheManager 中的缓存区域 |
key |
String (SpEL) |
方法参数组合 | 缓存键的 SpEL 表达式,支持 #paramName、#root.args 等 |
keyGenerator |
String (Bean 名称) |
— | 自定义键生成器 Bean,与 key 属性互斥 |
cacheManager |
String (Bean 名称) |
默认管理器 | 指定使用的 CacheManager Bean |
condition |
String (SpEL) |
— | 满足条件才缓存(在方法执行前评估) |
unless |
String (SpEL) |
— | 满足条件不缓存(在方法执行后评估) |
sync |
boolean |
false |
是否启用同步模式,防止缓存击穿 |
缓存穿透陷阱:当方法返回
null 时,@Cacheable 默认不会将 null 存入缓存。如果业务允许返回 null 且需要缓存该结果以防止缓存穿透,应使用 @Cacheable(unless = "#result == null") 的否定形式,或配置 cacheNullValues 属性(取决于具体缓存实现)。
3.3 @CachePut:主动更新缓存
@CachePut 总是执行方法体,并将返回值更新到缓存中。它与 @Cacheable 的核心区别在于:不跳过方法执行。适用于写操作后需要同步更新缓存的场景。
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
3.3.1 与 @Cacheable 的对比
| 对比维度 | @Cacheable | @CachePut |
|---|---|---|
| 方法执行 | 缓存命中时不执行 | 始终执行 |
| 缓存写入 | 仅在未命中时写入 | 每次执行后都写入 |
| 典型场景 | 查询操作 | 更新/新增操作 |
| 返回值要求 | 必须与缓存类型一致 | 必须与缓存类型一致 |
| 事务敏感性 | 低(读操作) | 高(写操作需考虑回滚) |
3.4 @CacheEvict:缓存失效与清理
@CacheEvict 用于从缓存中移除条目,是维护缓存一致性的关键工具。它支持两种失效模式:单条失效和批量清空。
// 单条缓存失效
@CacheEvict(value = "users", key = "#userId")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
// 批量清空整个缓存区域
@CacheEvict(value = "users", allEntries = true)
public void clearAllUsers() {
// 清空 users 缓存区域中的所有条目
}
3.4.1 关键属性
| 属性 | 默认值 | 说明 |
|---|---|---|
allEntries |
false |
是否清空整个缓存区域,而非仅移除单个键 |
beforeInvocation |
false |
是否在方法执行前执行失效操作。默认在方法执行后,若方法抛出异常则失效操作不会执行 |
最佳实践:在删除操作中,建议使用
@CacheEvict(beforeInvocation = true)。这样即使方法执行失败,缓存也会被提前清理,避免因异常导致缓存中残留已删除的数据。这种“先失效,后操作”的策略能有效降低缓存与数据库之间的不一致窗口。
3.5 组合使用:缓存一致性策略
在实际业务中,读多写少的场景需要将三个注解组合使用,形成完整的缓存生命周期管理。以下是一个典型的最佳实践模式:
@Service
public class ProductService {
// 读操作:缓存结果
@Cacheable(value = "products", key = "#productId")
public Product getProduct(Long productId) {
return productRepository.findById(productId).orElse(null);
}
// 写操作:更新缓存
@CachePut(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}
// 写操作:失效缓存
@CacheEvict(value = "products", key = "#productId", beforeInvocation = true)
public void deleteProduct(Long productId) {
productRepository.deleteById(productId);
}
// 批量操作:清空缓存区域
@CacheEvict(value = "products", allEntries = true)
public void refreshAllProducts() {
// 触发全量数据刷新
}
}
3.6 注解执行流程与 AOP 原理
Spring 通过 AOP(面向切面编程)拦截带有缓存注解的方法。下图展示了 @Cacheable 注解的完整执行流程:
3.7 常见陷阱与避坑指南
| 陷阱 | 现象 | 解决方案 |
|---|---|---|
| 内部方法调用失效 | 同一个类中的方法 A 调用方法 B,B 上的缓存注解不生效 | 将缓存方法抽取到独立的 @Service Bean 中,或通过 AopContext.currentProxy() 获取代理对象 |
| 键冲突 | 不同业务使用相同的缓存名称和键,导致数据覆盖 | 使用 cacheNames 区分业务域,或在键中加入业务前缀,如 "user:" + #userId |
| 事务与缓存不同步 | 事务回滚后缓存已被 @CachePut 更新,导致脏数据 |
使用 @Transactional 结合 @CacheEvict 而非 @CachePut,让缓存失效而非更新 |
| 缓存穿透 | 大量请求查询不存在的 key,每次都穿透到数据库 | 使用 unless = "#result == null" 配合空值缓存,或使用布隆过滤器 |
| 缓存击穿 | 热点 key 失效瞬间,大量请求同时回源 | 设置 @Cacheable(sync = true) 启用同步模式,只允许一个线程回源 |
3.8 注解组合与缓存一致性策略矩阵
针对不同的业务操作类型,推荐采用以下注解组合策略来保证缓存与数据库的一致性:
| 操作类型 | 推荐注解 | 一致性保证 | 适用场景 |
|---|---|---|---|
| 纯查询 | @Cacheable |
弱一致性(允许短暂不一致) | 商品详情、用户信息 |
| 新增 | @CachePut |
强一致性(立即更新缓存) | 创建订单、注册用户 |
| 更新 | @CachePut 或 @CacheEvict |
取决于策略:更新缓存 vs 失效后下次查询再加载 | 修改资料、更新状态 |
| 删除 | @CacheEvict(beforeInvocation=true) |
强一致性(先失效后删除) | 删除商品、注销账号 |
| 批量更新 | @CacheEvict(allEntries=true) |
最终一致性(清空区域,下次查询重建) | 批量导入、全量刷新 |
架构建议:在微服务架构中,缓存注解应仅作用于服务内部的方法调用。跨服务的数据变更应通过事件驱动(如消息队列)或分布式缓存同步机制来通知其他服务失效缓存,避免因本地缓存注解无法感知远程变更而导致的数据不一致。