事务、缓存与异步注解的失效场景
@Transactional、@Cacheable 和 @Async 通常依赖 Spring 代理。注解没有产生预期行为时,先检查对象是否由容器管理、调用是否经过代理、方法能否被代理,以及异常和线程边界是否符合协议。
1. 同类自调用绕过代理
public void createOrder() {
this.saveOrder();
}
@Transactional
public void saveOrder() {}
外部调用 createOrder 进入代理后,目标对象内部的 this.saveOrder() 直接调用自身。事务、缓存和异步拦截器都没有第二次进入机会。
优先把 saveOrder 的边界移动到另一个 Bean,或让外部入口方法本身承担完整语义。自注入或 AopContext.currentProxy() 会把结构问题变成运行时约束。
2. 方法与类必须可被代理
- 对象必须由当前 Spring 容器创建或处理。
- JDK 代理只能从代理接口暴露调用。
- CGLIB 不能覆盖 final、private 方法。
- 方法上的注解要能被事务或缓存属性解析器找到。
当前 Spring 版本对受支持的方法可见性已有演进,不应只背“必须 public”。最稳妥的业务入口仍是可覆盖的公开方法,并用所部署版本的集成测试确认。
3. 异常必须到达事务拦截器
@Transactional
public void importData() {
try {
repository.saveAll(rows);
} catch (DataAccessException exception) {
log.warn("ignored", exception);
}
}
方法正常返回时,代理会尝试提交。默认回滚规则也只自动匹配 RuntimeException 与 Error。不要通过吞异常伪造成功;需要转换异常时保留 cause 并让回滚规则匹配。
4. 新线程没有原事务上下文
@Async 让方法在另一个执行器线程运行。调用线程的 JDBC 事务通常不会跟过去,异步方法若自己声明事务,会创建属于异步线程的新边界。
调用方已经返回后,异步失败也不能通过原调用栈传播。需要 Future/CompletableFuture、事件结果、重试和监控来承接。
5. 缓存边界还受 key 与提交时机影响
@Cacheable 只在 key、condition 等规则匹配时读取或写入缓存。可变参数、不稳定 toString() 或租户字段遗漏会造成错误命中。
数据库事务尚未提交时就更新缓存,之后本地回滚会留下不存在的数据。可以在事务提交后失效或更新缓存,并明确失败重试;缓存注解的顺序需要从实际拦截链验证。
同类调用、非容器对象等代理边界同样会让缓存注解失效。
6. 多事务管理器需要明确选择
应用含多个 DataSource 或事务技术时,默认 TransactionManager 可能不是目标资源。通过限定名称、类型或事务管理配置明确选择,并测试一条方法究竟绑定了哪些连接。
本地事务无法原子覆盖两个独立数据库,除非使用相应全局事务能力;多个 @Transactional 注解不会自动合并资源。
7. 一套排查顺序
- 用
AopUtils确认注入对象是否为代理、是哪种代理。 - 从调用入口确认是否经过代理,检查自调用。
- 检查方法可见性、final、注解位置和切点匹配。
- 打开事务、缓存或异步框架的诊断日志。
- 记录线程名、事务状态、缓存 key 和实际 SQL。
- 用集成测试复现成功、异常和并发路径。
8. 面试题
8.1 @Transactional 常见的失效原因有哪些
出现公司:美团、浩鲸科技
考察重点
- 容器对象、代理入口和自调用。
- 方法可代理性与异常传播。
- 异步线程和事务管理器边界。
相关内容:第 1 节“同类自调用绕过代理”至第 7 节“一套排查顺序”。
参考回答
先看调用是否经过 Spring 管理的代理。同类 this 调用、手工 new 的对象、JDK 代理接口未暴露的方法,以及 CGLIB 无法覆盖的 final/private 方法,都可能绕过拦截。异常被方法内部吞掉或不匹配回滚规则时,代理也会按正常返回提交。
@Async 切到新线程后不会继承普通 JDBC 事务,多数据源还可能选错 TransactionManager。排查时确认代理类型和入口,记录线程、事务状态与 SQL,再用异常路径集成测试证明边界。