2. Spring Boot 缓存抽象:@Cacheable、@CachePut、@CacheEvict、@Caching 注解详解
Spring 框架从 3.1 版本开始引入了声明式缓存抽象,Spring Boot 在此基础上提供了零配置的自动装配能力。本章深入剖析四个核心注解的语义、执行时机、返回值处理以及组合使用场景,帮助你在服务边界划分时做出正确的缓存注解决策。
2.1 缓存抽象核心架构
Spring 缓存抽象并非具体的缓存实现(如 Redis、Caffeine),而是一套基于 AOP 的拦截器体系。核心接口 CacheManager 管理多个 Cache 实例,注解通过 CacheInterceptor 在方法调用前后织入缓存逻辑。
架构要点: 注解只定义缓存行为,不绑定具体存储;通过配置切换
CacheManager 实现即可更换缓存中间件。
2.2 @Cacheable:缓存查询与条件写入
@Cacheable 是最常用的缓存注解,用于标记查询方法。执行流程遵循“先查缓存,命中则直接返回;未命中则执行方法,将结果存入缓存”。
核心属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value / cacheNames | String[] | — | 缓存名称,对应 CacheManager 中的缓存区域 |
key | String (SpEL) | 方法参数组合 | 缓存键的 SpEL 表达式,如 #id |
condition | String (SpEL) | "" | 满足条件才缓存(方法执行前评估) |
unless | String (SpEL) | "" | 满足条件则不缓存(方法执行后评估) |
sync | boolean | false | 是否同步加锁,防止缓存击穿 |
最佳实践: 使用
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);
}
执行顺序规则
- @CacheEvict(beforeInvocation=true) — 方法执行前驱逐
- @Cacheable — 检查缓存,命中则直接返回
- 方法体执行 — 若未命中或未使用 @Cacheable
- @CachePut — 方法返回后写入缓存
- @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 来保证一致性。