大家好,我是老王。
聊起 Spring 的事务,很多做后端的小伙伴都会觉得:「不就一个 @Transactional 注解的事吗?」
当年我也是这么天真的。直到几年前做支付网关和订单系统重构时,半夜两点收到对账系统的严重告警:几十笔订单用户的钱扣了,但流水记录和履约状态全没落库,资金出现了单边账。排查到最后,发现就是因为踩了 Spring 声明式事务失效的坑——开发同学在一个内部方法里搞了自调用,直接绕过了代理。
今天这篇,老王不扯那些假大空的概念,咱们从底层原理把因果链串起来,彻底搞懂为什么它会失效,以及在高并发、分布式场景下该怎么避坑。
一、 核心因果链:Spring 事务到底是怎么跑起来的?
Spring 的声明式事务本质上就是基于 Spring AOP 实现的,而 AOP 的底层核心是动态代理(JDK 动态代理或 CGLIB)。
我们先把 Spring 事务调用的因果链画出来:
调用方 (Caller) $\rightarrow$ Proxy 代理对象 $\rightarrow$
TransactionInterceptor(开启事务、绑定 Connection 到 ThreadLocal) $\rightarrow$ 目标真实对象 (Target Instance) $\rightarrow$ 目标方法执行 $\rightarrow$TransactionInterceptor(捕获异常或执行成功,决定 Rollback / Commit) $\rightarrow$ 清理 ThreadLocal 资源
从这条因果链里,你可以一眼看出两个致命的依赖前提:
- 必须通过 Proxy 代理对象来发起调用,否则拦截器压根不会被触发。
- 异常必须能抛回给
TransactionInterceptor,否则它无法感知并触发回滚。
只要破坏了这两个前提中的任意一环,事务立刻就会静默失效。
二、 生产环境最常见的 5 种失效场景与源码解析
1. 经典之最:同类内部方法自调用 (Self-Invocation)
故障代码:
@Service
public class OrderService {
public void processPayment(PaymentDTO dto) {
// 前置校验逻辑...
// 直接调用同类内部的事务方法
this.completeOrder(dto);
}
@Transactional(rollbackFor = Exception.class)
public void completeOrder(PaymentDTO dto) {
orderMapper.updateStatus(dto.getOrderId(), PAID);
accountMapper.deductBalance(dto.getUserId(), dto.getAmount());
// 如果这里抛出异常,事务并不会回滚!
}
}
- 因果分析:
外部 Controller 调用的是
OrderServiceProxy.processPayment()。但是进入processPayment内部后,this.completeOrder()中的this指向的是目标对象本身(Target),而不是 Spring 容器里的代理对象(Proxy)。 - 结果:
调用链直接跳过了
TransactionInterceptor,代码就像裸跑在普通方法里一样,DB 连接直接 Auto-commit,毫无事务可言。
2. 修饰符非 public
把 @Transactional 写在 private、protected 或者 package-private 方法上。
- 底层原理:
在 Spring 的源码
AbstractFallbackTransactionAttributeSource.computeTransactionAttribute中,有这样一行硬编码判断:java// Don't allow no-public methods as required. if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) { return null; } - 结果:
Spring 扫描到非
public方法时,直接返回null,放弃为其生成事务切面,事务自然不会生效。
3. 随手一写 try-catch 吞掉了异常
故障代码:
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderDTO dto) {
try {
orderMapper.insert(dto);
paymentRpcService.pay(dto); // 假设这里超时抛了异常
} catch (Exception e) {
log.error("创建订单失败", e);
// 异常被吞掉了,没有再向外抛出!
}
}
- 因果分析:
TransactionAspectSupport里的回滚逻辑触发机制是:java如果你在业务代码里把异常吞了,Spring 认为你的方法「执行成功并正常返回」,于是欢天喜地执行了catch (Throwable ex) { completeTransactionAfterThrowing(txInfo, ex); throw ex; }commitTransactionAfterReturning(txInfo)。 - 结果: 部分数据成功写入,部分业务逻辑中断,数据直接不一致。
4. 异常类型不匹配 (默认回滚策略陷阱)
很多新手会这么写:
@Transactional // 默认配置
public void importData() throws IOException {
// 写入 DB
// 读取本地文件抛出 IOException
}
- 底层原理:
Spring 默认的事务回滚机制定义在
DefaultTransactionAttribute中: 仅在遇到RuntimeException和Error时触发回滚。 遇到受检异常(Checked Exception,如IOException、SQLException或业务自定义的 Checked Exception)时,Spring 默认执行 Commit! - 防坑实践:
代码规范必须强制加上:
@Transactional(rollbackFor = Exception.class),甚至指定rollbackFor = Throwable.class。
5. 跨线程调用:ThreadLocal 的上下文丢失
在支付场景中,为了加速响应,有时会有人把子任务丢到线程池:
@Transactional(rollbackFor = Exception.class)
public void handleCallback(CallbackData data) {
orderMapper.updateStatus(data.getOrderId());
// 异想天开:开个线程异步记日志/发积分
CompletableFuture.runAsync(() -> {
pointMapper.addPoints(data.getUserId(), data.getPoints());
// 假设这里报错,希望外层 orderMapper 也回滚
});
}
- 底层原理:
Spring 事务利用
TransactionSynchronizationManager,将当前数据库Connection绑定在当前线程的ThreadLocal变量上。 - 因果链:
新开线程 $\rightarrow$ 访问的是子线程的
ThreadLocal$\rightarrow$ 获取不到父线程绑定的 Connection $\rightarrow$ 事务管理器只能拿到全新的连接或根本脱离事务管理。 - 结果: 两个操作处于完全独立的 DB 连接中,不仅无法协同回滚,甚至子线程如果抛出异常,外层线程根本感知不到。
三、 架构师建议:如何从根本上避坑?
在订单和支付这种对数据强一致要求极高的业务中,如果全靠开发人员「小心谨慎」来防坑,团队早晚要暴雷。我们团队的避坑标准通常有这几个原则:
1. 自调用的三种解法(按推荐度排序)
- 重构代码,按业务边界拆分 Service(强烈推荐):
自调用的本质往往是类的单一职责没有做好。把
completeOrder独立抽成OrderFulfillmentService,直接利用 Spring 容器做依赖注入调用。 - 通过 ApplicationContext 或直接注入自身(次选):
java
@Service public class OrderService { @Autowired private OrderService self; // 注入自身的代理对象 public void process() { self.completeOrder(); // 走代理对象 } } AopContext.currentProxy()(不推荐): 需要在配置上开启@EnableAspectJAutoProxy(exposeProxy = true),耦合度太高,代码显得不干净。
2. 大事务拆解:拥抱编程式事务 TransactionTemplate
在很多支付业务中,我们经常要调用第三方 RPC(比如微信支付、风控接口),这类网络 I/O 耗时往往不可控。
如果你把 RPC 放在 @Transactional 修饰的大方法里:
开启事务 $\rightarrow$ 占用 DB 连接 $\rightarrow$ 调用外部 RPC (卡了 3 秒) $\rightarrow$ 数据库连接被长时间占用 $\rightarrow$ 连接池爆满 $\rightarrow$ 服务假死崩溃。
对于有网络调用的关键链路,老王强烈建议使用 TransactionTemplate 编程式事务,将事务精确控制在「纯粹的 DB 操作」上:
@Service
public class PaymentService {
@Autowired
private TransactionTemplate transactionTemplate;
public void pay(PayRequest request) {
// 1. 无事务环境:做参数校验、调用外部风控 RPC
riskClient.check(request);
// 2. 编程式事务:仅针对短平快的核心 DB 修改
transactionTemplate.execute(status -> {
try {
accountMapper.decreaseBalance(request.getAmount());
recordMapper.insertFlow(request);
return true;
} catch (Exception e) {
status.setRollbackOnly(); // 明确触发回滚
log.error("本地事务执行失败", e);
throw new BizException("扣款失败", e);
}
});
// 3. 事务提交后:发送 MQ 消息通知履约
mqProducer.send(new PaySuccessEvent(request.getOrderId()));
}
}
3. 静态检查拦截
规范落到制度没用,得落到工具。在团队的 CI/CD 流程中加入 SonarQube 或自建的 ArchUnit 规则:
- 检测
@Transactional注解是否标注在非public方法上。 - 检测
@Transactional是否漏写了rollbackFor = Exception.class。 - 检测同类中是否存在直接通过
this.调用带有事务注解的方法。
四、 老王总结
Spring 的 @Transactional 就像一辆自动挡汽车,踩油门就能走,但如果你不理解它的变速箱(AOP 代理机制与 ThreadLocal 连接绑定),在下陡坡的时候(复杂并发和异常链路)就容易刹车失灵。
记住两句话:
- 调不到代理对象,拦截器就是个摆设;
- 长链路、外部 I/O 别贪图省事用大注解,用
TransactionTemplate控制颗粒度才是保命之道。
今天先聊到这,大家在线上还踩过哪些奇葩的事务坑?欢迎在评论区盘一盘。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


