Spring缓存抽象详解
定位:主题第 06 篇,讲透注解式缓存抽象(@Cacheable 等)、CacheManager 体系、key 设计与工程边界
适用版本:Spring Framework 6.x(Boot 3.x)
目录
一、缓存抽象模型
1.1 四个注解
| 注解 | 行为 |
|---|---|
@Cacheable |
先查缓存,命中直接返回;未命中执行方法,结果写缓存 |
@CachePut |
总是执行方法,用结果更新缓存(不影响方法执行) |
@CacheEvict |
清除缓存条目(或整个缓存) |
@Caching |
组合多个上述操作 |
java
@Cacheable(value = "users", key = "#id")
public User getById(Long id) { /* 未命中才执行 */ }
@CachePut(value = "users", key = "#user.id")
public User update(User user) { /* 总是执行并刷新缓存 */ }
@CacheEvict(value = "users", key = "#id")
public void delete(Long id) { /* 执行后清除 */ }
1.2 实现原理
注解缓存 = AOP 代理(FW-03)
方法被代理包裹:
@Cacheable → 先查 Cache,命中短路返回,未命中调真实方法再 put
推论:自调用失效------同类内部调用绕过代理,缓存注解不生效(同事务 @Transactional 的问题)。
1.3 适用与不适用
适用:读多写少、可容忍短暂不一致的查询结果
不适用:强一致数据、写多读少、结果不可共享(每次不同的个性化数据)
二、CacheManager 体系
2.1 抽象结构
CacheManager(管理一组 Cache)
└── Cache(按名字,如 "users")
└── 键值存取(get/put/evict)
注解里 value = "users" 就是 Cache 的名字;具体存储由 CacheManager 的实现决定。
2.2 常用实现
| 实现 | 特点 |
|---|---|
| Caffeine | 本地堆内缓存,性能高,主流本地选择 |
| Redis(RedisCacheManager) | 分布式,多实例共享(配置见 SD-03) |
| Simple | 内存无过期(仅测试) |
| 多级组合 | 本地 + 远程叠加(自组装) |
Boot 自动配置:引入对应依赖后按类型选择
spring.cache.type=caffeine / redis
spring.cache.cache-names=users,orders
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m
2.3 本地与分布式的取舍
| 维度 | 本地(Caffeine) | 分布式(Redis) |
|---|---|---|
| 速度 | 极快(堆内) | 一次网络往返 |
| 一致性 | 各实例独立副本,失效不互通 | 共享一份 |
| 容量 | 受堆内存限制 | 大 |
| 适用 | 热点小数据、容忍副本不一致 | 多实例共享、大数据量 |
多级缓存:本地挡大部分读,未命中查 Redis,再无查库回填两级------热点数据的最优形态(手动组装或专用组件)。
三、Key 与条件
3.1 key 表达式(SpEL)
java
@Cacheable(value = "users", key = "#id") // 单参数
@Cacheable(value = "orders", key = "#uid + ':' + #status") // 组合
@Cacheable(value = "users", key = "#user.id") // 属性
默认(不写 key)用全部参数组合------参数对象参与 key 时,其 hashCode 稳定性决定缓存正确性(可变对象做 key 是坑)。
3.2 条件控制
condition:入参满足才走缓存逻辑(如 "#id > 0")
unless:方法结果满足则不放入缓存(如 "#result == null" 不缓存空结果------或反向利用做防穿透)
3.3 key 设计纪律
① 稳定:key 只依赖决定结果的输入
② 唯一:不同查询维度用不同缓存名或 key 前缀,防串数据
③ 可读:带业务前缀,便于排查(Redis 里能看懂)
④ 防碰撞:组合 key 用分隔符,避免拼接歧义
四、工程问题
4.1 一致性的注解级处理
写操作的标准姿势:
更新方法 → @CacheEvict(删缓存)或 @CachePut(刷新)
删除方法 → @CacheEvict
选择:删(简单,读时重建);刷新(保证读不穿透,但并发写有覆盖风险)
多实例下本地缓存的失效不互通------要么用分布式缓存,要么接受短窗口的副本不一致(本地缓存配短 TTL)。
4.2 注解缓存的隐藏限制
| 限制 | 说明 |
|---|---|
| 自调用失效 | AOP 代理限制(拆方法到别的 Bean 或注入自身) |
| 只对 public 方法 | 代理限制 |
| 无复杂失效 | 按 key 精确删;"删某用户所有缓存"要自己管(多级/通配) |
| 无击穿互斥 | 默认不防热点击穿(需底层缓存支持或自实现) |
4.3 大对象与序列化
缓存大对象:占内存、序列化开销大 → 考虑缓存轻量视图(DTO)
Redis 后端:值的序列化配置决定可读性与兼容(SD-03)
五、总结
- 抽象模型:@Cacheable/@CachePut/@CacheEvict/@Caching 四注解,AOP 实现;自调用失效。
- CacheManager:Cache 按名字管理;Caffeine 本地主流、Redis 分布式;多级组合是热点最优。
- key 设计:SpEL 表达式;稳定、唯一、可读;可变对象做 key 是坑;condition/unless 精细控制。
- 工程边界:写后删或刷新;本地缓存副本不一致靠短 TTL;注解无击穿互斥与复杂失效,复杂场景手写缓存逻辑。
六、常见高频面试题
1. @Cacheable 的工作原理是什么?
要点:基于 AOP 代理。方法被代理包裹:调用时先按缓存名 + key 查缓存,命中直接返回(方法不执行);未命中执行真实方法,把结果写入缓存后返回。key 默认由参数生成,可用 SpEL 指定。因此同类自调用绕过代理注解不生效;只对 public 方法有效。本质是"缓存即切面"------理解 AOP 就能推导出它的一切限制(与 @Transactional 同源)。
2. @Cacheable、@CachePut、@CacheEvict 的区别和用法?
要点:@Cacheable 读优化------命中短路,未命中执行并回填,用于查询;@CachePut 总是执行方法并用结果更新缓存------用于更新后刷新缓存(保证读命中);@CacheEvict 清除缓存(按 key 或 allEntries 清空)------用于删除/更新后失效。组合用 @Caching。选择:更新场景"删"(读时重建,简单)或"刷新"(读不穿透,但并发写覆盖风险);删除必配 @CacheEvict。
3. Spring 缓存抽象用什么后端?怎么切换?
要点:缓存抽象与后端解耦------CacheManager 接口统一,实现可插拔。常用:Caffeine(本地堆内,高性能,适合单机热点)、Redis(RedisCacheManager,分布式共享)、Simple(测试)。Boot 按依赖自动配置,spring.cache.type 显式指定;各后端独立配置(Caffeine 的 maximumSize/expireAfterWrite,Redis 的 TTL/序列化)。切换后端只需换实现与配置,注解代码不动------这正是抽象的价值。
4. 多级缓存怎么做?解决什么问题?
要点:本地缓存(Caffeine)+ 分布式缓存(Redis)两级:读先查本地(极快),未命中查 Redis(一次网络),再无查库并回填两级。解决:热点数据每请求都打 Redis 的网络开销------本地挡掉大部分读。代价与处理:本地副本多实例不一致------短 TTL + 可接受的最终一致;失效广播(如 Redis pub/sub 通知各实例清本地)可选。适合读多、热点集中、容忍秒级不一致的场景。
5. @Cacheable 的 key 怎么设计?有哪些坑?
要点:key 决定缓存命中与正确性。设计:用决定结果的输入组合(如 "#uid + ':' + #status"),带业务前缀可读,分隔符防拼接歧义;不同维度用不同缓存名。坑:① 不写 key 时默认全参数------可变参数对象参与时,其 hashCode 不稳定会导致命中错乱;② key 冲突串数据(两个不同查询同 key);③ 参数含无关字段导致缓存命中率低。纪律:显式写 key,只用必要且稳定的输入。
6. 注解缓存如何保证与数据库的一致性?
要点:最终一致策略。写路径:更新/删除方法配 @CacheEvict 删缓存(读时重建,避免并发刷新覆盖)或 @CachePut 刷新(读不穿透)。注意:① 方法执行成功才应失效(默认方法后执行,异常不失效,可用 beforeInvocation);② 多实例本地缓存失效不互通------用分布式缓存或短 TTL 兜底;③ 数据库事务与缓存操作的一致性------事务回滚但缓存已删影响小(重建即可),反之"缓存已刷新事务回滚"才危险,@CachePut 要注意时机。强一致需求的数据不适合缓存。
7. 为什么 @Cacheable 自调用会失效?怎么解决?
要点:注解缓存靠 AOP 代理实现,代理只拦截"从外部经代理对象"的调用;同类内部 this.method() 直接调原始对象,绕过代理,缓存逻辑不执行------与 @Transactional 自调用失效同源。解法:① 把被调方法拆到另一个 Bean;② 注入自身代理再调用;③ AopContext.currentProxy()(需 exposeProxy)。设计层面:缓存方法应是服务的对外入口,内部逻辑不调用它。
8. unless 和 condition 的区别?
要点:作用时机与目的不同。condition 在方法执行前评估入参------为 false 则完全不走缓存逻辑(不查缓存、结果也不缓存),如 "#id > 0" 只对有效 id 启用缓存。unless 在方法执行后评估结果------为 true 则结果不放入缓存(但之前仍查了缓存),如 "#result == null" 避免缓存空结果。组合示例:防穿透反而要缓存空值(短 TTL),此时不用 unless 排除 null 而是允许缓存。
9. 本地缓存(Caffeine)要注意什么?
要点:① 容量与淘汰------配 maximumSize 与过期策略(expireAfterWrite/access),防无限增长撑爆堆;② 多实例副本不一致------各实例独立缓存,失效不互通,靠短 TTL 收敛窗口,一致性敏感场景慎用本地或加失效广播;③ 统计------开启 recordStats 观察命中率,命中率低说明 key 设计或容量有问题;④ 大对象------堆内存占用高,缓存轻量对象;⑤ 重启即失------冷启动穿透压力,可预热。
10. 什么数据不适合用注解缓存?
要点:① 强一致数据------缓存必然有滞后窗口,账务类实时数据不适合;② 写多读少------频繁失效使命中率低,白增复杂度;③ 结果不可共享------每次请求结果都不同(个性化、与调用者强相关),key 无法复用;④ 大对象/大集合------内存与序列化开销大;⑤ 命中率天然低的查询(条件组合爆炸)。判断标准:读多、结果可共享、容忍短暂滞后,三者同时满足才值得缓存。
