缓存抽象:@Cacheable 代理边界、单飞与一致性责任
@Cacheable 加了却没生效,十有八九是自调用绕过了代理;并发首次加载打了十次库,是因为没开 sync。固定基线 Framework 6.1.12 / JDK 17。AOP 代理机制(JDK 动态代理/自调用/编织)由AOP 正文与类型与代理权威讲授;分布式缓存与键过期的组件语义归 Redis 票(U53),缓存回填窗口的算例见内容中心缓存段。
用法与语义
@Cacheable(cacheNames = "items", key = "'id:' + #id", sync = true, unless = "#result == null")
public Item load(long id) { return repo.find(id); }
@CacheEvict(cacheNames = "items", key = "'id:' + #id", beforeInvocation = false)
public void update(long id, Item patch) { repo.save(patch); }
// @CachePut:方法必执行并写回缓存;@Caching 组合;@CacheConfig 类级公共配置语义三件事:读穿透(命中即返回,未命中执行方法并写回)、写穿透/失效(@CacheEvict/@CachePut)、条件(condition 决定是否参与缓存,unless 按结果过滤)。key 缺省按参数生成(需要 -parameters 编译或同名语义),生产应显式 SpEL 或自定义 KeyGenerator,避免参数增删静默改变键空间。
代理边界:为什么"加了注解不生效"
缓存由 AOP 代理实现,所以反射与代理票的三条规则原样生效:类内 this.load(id) 不经过代理,注解不生效;非 public 方法(CGLIB 下)与 final 方法不可覆写同样绕过;被代理的是 Spring 管理的 Bean,自己 new 的对象没有代理。排障第一步永远是"这次调用是否经过代理对象",而不是查缓存配置。
代理还决定操作叠加顺序:多个缓存操作/事务/其他切面的顺序由 @Order 决定(AOP 票实测),缓存切面在事务内层时,"事务回滚但缓存已写"的窗口存在——对一致性敏感的写路径用"事务提交后再失效"(@TransactionalEventListener(AFTER_COMMIT) 触发 evict,见事务事件票的 U18 实测),或接受短窗口+版本化键。
sync=true:单次加载
并发未命中时默认会各自执行方法再各自写回(击穿)。sync = true 改为单飞:
原码连续摘录(仅统一公共缩进,截取方法局部):CacheAspectSupport.java,v6.1.12,L439–L461。
private Object executeSynchronized(CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {
CacheOperationContext context = contexts.get(CacheableOperation.class).iterator().next();
if (isConditionPassing(context, CacheOperationExpressionEvaluator.NO_RESULT)) {
Object key = generateKey(context, CacheOperationExpressionEvaluator.NO_RESULT);
Cache cache = context.getCaches().iterator().next();
if (CompletableFuture.class.isAssignableFrom(method.getReturnType())) {
return cache.retrieve(key, () -> (CompletableFuture<?>) invokeOperation(invoker));
}
if (this.reactiveCachingHandler != null) {
Object returnValue = this.reactiveCachingHandler.executeSynchronized(invoker, method, cache, key);
if (returnValue != ReactiveCachingHandler.NOT_HANDLED) {
return returnValue;
}
}
try {
return wrapCacheValue(method, cache.get(key, () -> unwrapReturnValue(invokeOperation(invoker))));
}
catch (Cache.ValueRetrievalException ex) {
// Directly propagate ThrowableWrapper from the invoker,
// or potentially also an IllegalArgumentException etc.
ReflectionUtils.rethrowRuntimeException(ex.getCause());
// Never reached
return null;cache.get(key, loader) 把"取值+加载"交给 Cache 实现原子完成:ConcurrentMapCache 用 computeIfAbsent,JDK 17 下同键并发只有一个 loader 执行。限制:sync 只允许一个 cache 一个 @Cacheable(其他操作/多缓存不支持),且 loader 抛异常直接向上传播(好事——失败不缓存)。单飞只防"本进程并发",多实例同 key 仍会各自回源——分布式单飞要组件层方案(Redis 锁/逻辑过期),归 Redis 票。
null、TTL 与 CacheManager 边界
- null 缓存:是否缓存 null 由 Cache 实现决定——
ConcurrentMapCache默认allowNullValues=true(null 会被缓存,本票实测);不要缓存 null 用unless = "#result == null"过滤。Redis 等组件 CacheManager 的 null 语义与序列化边界归 U53。 - 过期:抽象本身没有 TTL 概念——
ConcurrentMapCache永不过期(长期膨胀)。要 TTL 用 Caffeine(进程内,expireAfterWrite/maximumSize)或 Redis CacheManager(组件语义归 U53)。 - 本地 vs 分布式:多实例下本地缓存各自失效(evict 只发到本实例)——需要广播失效(MQ/Redis pub-sub)或接受 TTL 收敛或只用分布式缓存。这是部署形态决定的设计,不是注解能解决的。
- 序列化:跨进程缓存值必须可序列化;DTO 演进要考虑旧值反序列化(版本字段),坏值要能整体清缓存。
错误输入与排障链
| 症状 | 原因 | 验证 | 处置 |
|---|---|---|---|
| 注解不生效 | this 自调用/非代理对象/非 public | 断点看代理类 | 经代理调用;[反射代理票]三规则 |
| 并发首次打库多次 | 未开 sync | 并发计数 loader | sync=true(单缓存单操作) |
| 多实例击穿 | sync 只进程内 | 两实例压测 | 分布式单飞(U53) |
| 事务回滚但缓存脏 | 缓存切面在事务内写回 | 回滚后读缓存 | 提交后失效/版本化键 |
| 缓存无限膨胀 | ConcurrentMap 无 TTL | 堆内存/条目数 | Caffeine maximumSize+TTL |
| 旧值读不坏也读不新 | evict 只到本实例 | 两实例对比 | 广播失效或 TTL 收敛 |
| key 突然全变 | 参数签名变化+缺省 key | 缓存 miss 突增 | 显式 key SpEL |
条件变化与边界
| 条件 | 复核点 |
|---|---|
| 换 CacheManager | null/TTL/序列化/单飞实现全变(组件票核实) |
| Boot 3.3.3 | spring.cache.* 自动配置与 starter 集成(U24) |
| JDK 21+ | computeIfAbsent 语义稳定;虚拟线程下单飞仍有效 |
| AOT/Native | 代理与 SpEL 解析需配置(U28) |
实验:输入、步骤、预期与边界
run.sh schedule 组的 cache 模式(真实 ConcurrentMapCacheManager + 代理):
| 场景 | 输入 | 预期 |
|---|---|---|
| 读穿透 | 两次同 key 调用 | loader 计数 1,第二次命中 |
| 自调用反例 | 类内 this 调缓存方法两次 | loader 计数 2(注解未生效);经代理调用则 1 |
| sync 单飞 | 10 线程并发同 key(sync=true) | loader 计数 1,全部得到同值 |
| 未开 sync 反例 | 同上但 sync=false | loader 计数 10(击穿实证) |
| @CacheEvict | 写路径后读 | 命中失效重新加载 |
| null 缓存(实测) | loader 返回 null 两次 | 计数 1——ConcurrentMapCache 默认缓存 null;不缓存须 unless 过滤 |
本机已运行(2026-10-25,JDK 17.0.19,Framework 6.1.12)。未覆盖:Caffeine/Redis CacheManager(U53)、多实例广播失效、事务叠加窗口(U18 事务事件票实测过相邻场景)。
追问链
第一层:sync=true 为什么只支持一个缓存?
单飞要求"一个键的加载原子完成";多缓存/多操作时回写集与顺序无法在一条 loader 闭包里表达,框架直接拒绝而不是给弱语义。要组合先查一个再加载,用业务方法组织。
第二层:缓存与事务谁在外层?怎么选?
默认缓存切面在事务外层(读取无需事务);写路径若"先 evict 后提交",回滚后缓存已失效(安全但多一次 miss);"先提交后 evict"由提交后事件实现(一致性更好但要处理事件失败)。按业务能否接受旧窗口选,并把选择写进设计,不背口诀。
第三层:本地缓存的多实例失效方案怎么比较?
广播失效(快、复杂、丢消息要兜底)vs TTL 收敛(简单、有旧窗口)vs 只用分布式缓存(强一致、每次网络)。按读频率、旧值容忍与运维能力选;内容中心的版本化键是第四条路(键带版本,失效=换版本)。
技术案例
- 配置读取:@Cacheable+Caffeine TTL 60s,sync 防击穿,配置变更走 evict+版本。
- 商品详情:分布式缓存(U53)+ 本地短 TTL 二级,键带聚合版本,重建按内容中心水位。
- 计数器类数据:不适合 @Cacheable(写频繁),改读写穿透或直接组件计数。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Spring Framework Reference: Cache Abstraction | 6.1 | 注解语义/单飞/CacheManager 官方文档 |
| CacheAspectSupport.java | v6.1.12 | executeSynchronized 单飞(L439–L461) |
| ConcurrentMapCache javadoc | 6.1.12 | computeIfAbsent 语义 |
本地 ScheduleCacheLab | Framework 6.1.12 / JDK 17.0.19 | 穿透/自调用/单飞/evict/null 实测 |
访问日期:2026-10-25。
闭卷验收
本页 7 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。