Spring缓存抽象详解

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)

五、总结

  1. 抽象模型:@Cacheable/@CachePut/@CacheEvict/@Caching 四注解,AOP 实现;自调用失效。
  2. CacheManager:Cache 按名字管理;Caffeine 本地主流、Redis 分布式;多级组合是热点最优。
  3. key 设计:SpEL 表达式;稳定、唯一、可读;可变对象做 key 是坑;condition/unless 精细控制。
  4. 工程边界:写后删或刷新;本地缓存副本不一致靠短 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 无法复用;④ 大对象/大集合------内存与序列化开销大;⑤ 命中率天然低的查询(条件组合爆炸)。判断标准:读多、结果可共享、容忍短暂滞后,三者同时满足才值得缓存。

相关推荐
贾伟康1 小时前
【共创稿事节】一个 UserDataManager 管八类数据:Preferences 持久化的克制用法
java·服务器·前端·harmonyos·arkts·鸿蒙
李纲明1 小时前
WordPress 前台改完还是旧页:浏览器、CDN、页面缓存、对象缓存怎么分层清
缓存·cdn·wordpress·devdiy
卓豪终端管理1 小时前
【无标题】
java·服务器·前端
智_永无止境1 小时前
又找到一个纯Java的OCR项目,识别的格式还挺多
java·开发语言·ocr
学编程就要猛1 小时前
MCP(Model Context Protocol)
java·springai
用户072367066602 小时前
Apollo 配置热更新落地:让 Spring Boot @ConfigurationProperties Bean 不重启即时生效
java
2601_970703832 小时前
中国除湿机与湿度控制行业研究报告
java·大数据·人工智能
深入云栈2 小时前
Netty 4.2.x 源码深度解析 (二十一):Pipeline 事件传播 —— Inbound 与 Outbound 的双向流转
java·后端·架构