ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot事务控制:声明式与编程式事务选型与避坑指南

Spring Boot事务控制:声明式与编程式事务选型与避坑指南

手上这个订单系统出过一次让我头皮发麻的事故:凌晨支付回调的并发一上来,同一笔订单被两个线程同时处理,结果库存扣了两次,订单状态却还停在“待支付”。排查到最后,问题就出在事务边界上——一个方法里既有声明式事务,又混着手动提交,边界完全失控。那次教训让我把 Spring Boot 的事务控制从头到尾梳理了一遍。今天这篇就把两种方式的原理、用法和踩坑点一次讲清楚。

Spring Boot 项目中,事务处理大概是让新手最想回避、又最绕不开的话题。很多同学第一天学的时候只知道在方法上丢一个@Transactional,以为加上就万事大吉,直到某一天线上数据错乱、订单重复入账,才发现自己连事务是怎么生效的都没搞明白。而另外一部分人则会走向另一个极端:不信任注解,到处用TransactionTemplate手动包业务逻辑,代码里塞满 try/catch 和setRollbackOnly(),看着很控制欲,实际上反而把事务边界搅得一团糟。

这篇文章只聊一件事:在 Spring Boot 里,声明式事务和编程式手动事务到底怎么选、怎么用、怎么避开那些让人头大的坑。我会把自己在几个真实项目里的实操经验、排查过程、参数调优思路都写出来,适合用过@Transactional但被各种诡异行为折磨过的人,也适合刚接触 Spring 事务、想一步到位搞懂底层逻辑的新手。

1. 先理清楚:事务到底在管什么

1.1 事务的四个基本特性

不管用什么方式控制事务,第一个绕不开的概念就是 ACID。这四个字母分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。用大白话解释:

  • 原子性:一个事务里的所有操作,要么全部成功,要么全部失败,不许出现“做了一半”的情况。
  • 一致性:事务执行前后,数据整体状态是合法的,比如转账前后两边金额总和一样。
  • 隔离性:多个事务同时跑的时候,彼此不能乱干扰,A 事务没提交的数据,B 事务不能随便看。
  • 持久性:事务提交了,数据就真的落盘了,不会因为重启而丢。

这四个特性里,日常开发最容易感知到的是原子性和隔离性。你会经常遇到“前一步插入成功了,后一步报错,结果第一条数据还在”这种问题,这就是原子性被破坏。而隔离性控制不好,就会出现脏读、不可重复读、幻读这些经典问题。

1.2 Spring 事务的核心抽象

Spring 对事务做了很好的封装,核心就是PlatformTransactionManager接口。它定义了三个关键方法:获取事务、提交事务、回滚事务。Spring Boot 会根据你引入的持久层框架自动选择对应的实现类:

如果用 JPA / Hibernate,自动装配JpaTransactionManager;如果用 MyBatis 或原生 JDBC,自动装配DataSourceTransactionManager。大多数情况下你不需要手动指定事务管理器,Spring Boot 会自动发现DataSource并完成配置。

值得注意的一点是:DataSourceTransactionManager管理的是单个DataSource上的连接事务。也就是说,如果你的项目里配了多个数据源,光靠默认事务管理器是不够的,需要额外配置多数据源事务方案。这一点我在文末问题排查章节会再提。

Spring 的事务控制还有一个核心概念:事务传播行为。它指的是当一个事务方法调用另一个事务方法时,该怎么处理事务边界。默认是REQUIRED:如果当前没有事务就新建一个,如果已经有事务就加入当前事务。这个在大多数业务场景里都够用,但也容易踩坑,后面专门聊。

1.3 为什么 Spring Boot 对事务这么友好

Java EE 时代,事务往往要靠应用服务器和复杂的 XML 配置来完成,写起来很痛苦。Spring 出现以后,先用 XML AOP 配置,后来演进到注解驱动,再到 Spring Boot 的自动装配,事务的使用门槛被大大降低。你只需要三样东西:

  1. 一个数据源(DataSource)
  2. 一个事务管理器(PlatformTransactionManager)
  3. 一个@EnableTransactionManagement(Spring Boot 会自动开启,不用手写)

这种“零配置”的体验很容易让人把事务当成理所当然,也就忽略了背后代理、传播机制、回滚时机这些细节。但恰恰是这些细节决定了线上稳定性。

2. 声明式自动事务:注解背后的魔法

2.1 最基本的用法

声明式事务的标志就是@Transactional注解。最常见的姿势是直接标在 Service 方法上:

@Service public class OrderService { private final OrderDao orderDao; private final StockService stockService; public OrderService(OrderDao orderDao, StockService stockService) { this.orderDao = orderDao; this.stockService = stockService; } @Transactional public void createOrder(Order order) { orderDao.insert(order); stockService.deduct(order.getProductId(), order.getCount()); // 这里如果抛出异常,insert 操作也会回滚 } }

这个例子很典型:一个下单操作要同时写订单表、扣库存、加积分。任一环节失败,前面成功写入的数据都要撤销。@Transactional声明在方法上,Spring 就会在这个方法执行前开启事务,方法正常执行完就提交,方法抛出异常就回滚。

这里有一个默认行为必须牢记:@Transactional默认只在抛出RuntimeException(运行时异常)或Error时才回滚,受检异常(比如IOException)默认不会回滚。这是 Spring 的设计哲学,认为事务回滚应该针对程序错误,而不是业务上可预期的异常。

实际开发中,业务异常通常你都会自定义成RuntimeException的子类,所以这个默认规则多数时候是成立的。但如果你在抛受检异常时也期望回滚,就要显式指定:

@Transactional(rollbackFor = Exception.class) public void createOrder(Order order) throws IOException { // 即使是 IOException,也会触发回滚 }

rollbackFor还可以写成rollbackForClassName,但我个人很少用字符串方式,直接用 Class 类型更安全,编译期就能发现问题。

2.2 关键属性逐个拆解

@Transactional注解里真正需要你花心思理解的,除了rollbackFor,还有下面这几个:

propagation(传播行为)

默认REQUIRED。除了它,日常比较多见的是REQUIRES_NEW和NESTED。

REQUIRES_NEW意味着不管当前有没有事务,都新开一个独立事务。这个很适用于“无论主流程成功与否,某些日志或审计记录必须写进去”的场景。比如订单创建成功与否,都要记录一条完整操作日志,而日志写失败不能影响主流程。此时日志记录的 Service 方法就适合标REQUIRES_NEW。

NESTED则是嵌套事务,依赖数据库的 savepoint 机制,实现部分回滚。外层事务回滚时内层也会跟着回滚,但内层回滚不影响外层。要注意的是,NESTED并不是所有事务管理器都支持的,MySQL 的 InnoDB 支持 savepoint,但如果你用的数据库或连接池不支持,Spring 会退化成普通事务。

isolation(隔离级别)

默认是数据库自身的隔离级别,通常是READ_COMMITTED或REPEATABLE_READ。你不指定时,Spring 不会干预,直接交给数据库。一般建议不要轻易把隔离级别调成SERIALIZABLE,因为性能代价很高,除非你真的对数据一致性有极端要求。

readOnly(只读事务)

设置readOnly = true能让底层执行时走只读路径,某些数据库会做性能优化,也避免了不小心写入数据。但要注意,这只是一个提示,并不是强制约束。你设置readOnly = true之后如果还是执行了 update 语句,数据库不会拦你,Spring 也不会拦。它更重要的价值是在代码层面提醒后人这个方法是只读的。

timeout(超时时间)

单位是秒,默认-1表示不限制。设置超时时间后,如果事务执行超过该时间,Spring 会强制回滚。这是一个很有用的兜底策略,能防止慢 SQL 把连接池占满。

2.3 代理机制:为什么自调用会失效

这是声明式事务最大的坑,没有之一。

@Transactional之所以能生效,是因为 Spring 给目标方法所在的 Bean 创建了一个代理对象(Proxy)。你在外部调用这个 Bean 的方法时,实际执行顺序是:调用代理 → 代理开启事务 → 调用目标方法 → 提交/回滚 → 返回结果。

但如果你在同一个类里,this调用另一个@Transactional方法,就不经过代理了,相当于直接调用目标方法本体。事务注解形同虚设。经典的例子:

@Service public class UserService { public void outerMethod() { // this 调用,内部方法上的 @Transactional 不生效 this.innerMethod(); } @Transactional public void innerMethod() { userDao.update(...); } }

outerMethod外面如果没有任何事务包裹,innerMethod里的数据库操作就没有事务保护。一旦update之后抛异常,数据不会回滚。

解决方式有几种:

  1. 把需要事务的方法拆到另一个 Bean 中,让外部 Bean 调用,这会走代理,是最干净的方案。
  2. 注入自身代理,比如通过@Resource注入本类,再调用注入对象的innerMethod()。
  3. 使用AopContext.currentProxy()获得当前代理,然后调用。前提是配置了exposeProxy = true,Spring Boot 默认没开,需要额外设置。

我个人最推荐第一种,职责更清晰,也方便后续做事务边界管理。后面在问题排查里我会再给一个场景实例。

2.4 私有方法、内部调用和事务边界

@Transactional只能作用于 public 方法。这一点在 Spring 官方文档里有明确说明,因为代理机制无法拦截私有方法。你要是把注解标在 private 方法上,编不过也不会报错,运行的时候就是静默不生效,很容易被忽略。

还有一类场景是事务方法里调用了另一个事务方法,但两个方法的传播级别不同。比如外层方法 A 是REQUIRED,内层方法 B 是REQUIRES_NEW,那么 A 抛异常回滚时,B 因为已经自己提交了,不会跟着回滚。很多业务事故就出在这里:你以为内层也回滚了,实际上它早就独立提交了。所以看到REQUIRES_NEW一定要警觉,确认内层事务是不是真的应该独立于外层。

3. 编程式手动事务:把控制权握在自己手里

3.1 TransactionTemplate:优雅的手动事务姿势

编程式事务最推荐的姿势是用TransactionTemplate,它兼顾了控制力和代码可读性。先看代码:

@Service public class PaymentService { private final TransactionTemplate transactionTemplate; private final PayOrderDao payOrderDao; private final AccountDao accountDao; public PaymentService(PlatformTransactionManager transactionManager, PayOrderDao payOrderDao, AccountDao accountDao) { this.transactionTemplate = new TransactionTemplate(transactionManager); this.payOrderDao = payOrderDao; this.accountDao = accountDao; } public void reconcile(String payOrderId, String accountId) { transactionTemplate.execute(status -> { try { PayOrder order = payOrderDao.findById(payOrderId); if (order.getStatus() == 1) { // 业务条件不满足,直接设置回滚 status.setRollbackOnly(); return null; } accountDao.deduct(accountId, order.getAmount()); payOrderDao.updateStatus(payOrderId, 2); return null; } catch (DataAccessException e) { // 记录一下特殊日志 log.error("reconcile failed, orderId: {}", payOrderId, e); status.setRollbackOnly(); throw e; } }); } }

这段代码里,execute方法接收一个TransactionCallback,回调本身最外层包着事务。回调正常返回即提交,回调里往外抛异常即回滚。如果你不想抛异常,但业务上判断需要回滚,就调用status.setRollbackOnly()把当前事务标记为只能回滚。

和声明式事务相比,编程式事务的好处在于:事务边界完全由代码控制,你可以在事务中间任意位置决定“到这里就不要继续了”。这在一些复杂业务里非常有用,比如有两个数据源需要同时控制,或者你只想对某一段 SQL 做事务保护,而不是整个方法。

3.2 PlatformTransactionManager 直接操作方式

除了TransactionTemplate,你还可以直接操作PlatformTransactionManager。这种方式更底层,也更繁琐。

@Service public class LegacyMigrateService { private final PlatformTransactionManager transactionManager; private final DataDao dataDao; public LegacyMigrateService(PlatformTransactionManager transactionManager, DataDao dataDao) { this.transactionManager = transactionManager; this.dataDao = dataDao; } public void migrate(List<Long> ids) { DefaultTransactionDefinition def = new DefaultTransactionDefinition(); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); def.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT); TransactionStatus status = transactionManager.getTransaction(def); try { for (Long id : ids) { dataDao.updateSomething(id); } transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; } } }

这种方式最大的问题是容易忘记释放资源、容易把事务搞脏。你看代码里transactionManager.getTransaction(def)返回的TransactionStatus必须被正确提交或回滚,一旦中间逻辑分支多了,很容易漏掉某个分支的 commit/rollback,导致连接不释放、事务悬挂。所以我一般不建议直接用这种方式,除非你是在写底层框架,或者需要对事务进行非常精细的编排。

3.3 编程式事务的典型场景

编程式事务不是用来替代@Transactional的,而是在声明式不够用的地方做补充。我实际项目中遇到过这么几类场景:

场景一:同一个方法里要控制多个数据源。TransactionTemplate可以塞不同的PlatformTransactionManager,分别开启两个独立事务,分别提交回滚。注意这里是两个事务,不是分布式事务,你要做好最终一致性或者补偿方案。

**场景二:需要在事务中调用远程接口。**传统做法是事务里调用第三方 HTTP 接口,等待返回后继续。这种“长事务”非常危险,会一直占用数据库连接,也会锁大量行。改成编程式事务后,你可以把远程调用放到事务提交之后执行,或者用TransactionSynchronizationManager.registerSynchronization注册事务同步回调,在事务提交后去做远程调用。

**场景三:批量任务里,希望每 N 条记录提交一次,而不是整体一个大事务。**大事务导致锁范围大、回滚代价高,Spring Batch 这类框架有自己的 chunk 机制,但如果你手写循环处理,用TransactionTemplate包住单条处理的逻辑,效率和安全都能兼顾。

3.4 编程式事务的注意清单

这里列几个我踩过的坑:

第一,TransactionTemplate不是线程安全的,但它的实例本身是可重复使用的,你可以把它注入为 Bean,不要每个方法 new 一个。

第二,回调里面如果调用了status.setRollbackOnly(),但之后又没有抛异常,最终 Spring 会执行回滚而不是提交。这个逻辑要清晰,不要以为只是“标记一下”。标记之后,事务结果是确定的回滚。

第三,如果你在编程式事务里再嵌套另一个TransactionTemplate,传播行为会起作用,默认REQUIRED会复用外层事务。这在某些时候是你要的,但也可能导致内层setRollbackOnly把外层事务也标记成回滚,外层明明想提交都提交不了。

第四,不要让事务跨越未知耗时的网络调用。编程式事务给了你自由,但也考验你的边界设计能力。原则是:能短则短,能小则小。

4. 两种方式的对比与选型

4.1 一张表看懂核心差异

对比维度声明式事务(@Transactional)编程式事务(TransactionTemplate)
侵入性低,一个注解搞定高,需要显式包裹业务代码
事务边界方法级,天然一个方法一个事务自由控制,可大可小
可读性简洁,代码清洁逻辑复杂时容易读不懂
异常处理靠异常类型决定回滚,不够精细手动控制,任意点可回滚
动态条件回滚需要抛异常或标记 rollbackOnly直接判断后可 setRollbackOnly
性能开销有 AOP 代理开销,但很小同样有代理和回调开销
适合场景80% 的增删改查业务多数据源、复杂补偿、重大任务
理解门槛低,但底层原理容易踩坑较高,需要理解事务状态

从上表可以看出,声明式事务最大的优势是“省心”,让代码专注于业务逻辑,而不被事务样板代码干扰。编程式事务最大的优势是“可控”,特别适合事务边界不明确、业务流程跨越多个步骤的情况。

4.2 选型建议:什么时候用哪个

我的经验性选择逻辑很简单:

**默认用声明式。**只要你的业务能够用一个方法天然表达为一个完整单元,就优先考虑@Transactional。比如下单、支付回调、创建订单、修改用户资料。这些操作自然就是一个原子操作。

遇到这些情况,考虑编程式:

  1. 一个方法里有多个独立的数据库操作,每个操作需要独立事务。
  2. 需要根据运行时的复杂条件来决定回滚范围,而这个条件不好用“抛出异常”来表达。
  3. 流程中包含事务后置动作,比如事务提交后发消息、调外部接口。
  4. 需要手动处理非数据库资源(比如消息队列)的回滚或补偿。
  5. 多数据源环境下,需要分别控制、分别提交。

如果只是偶尔需要部分回滚,也可以保留声明式事务,在方法内部用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()来标记回滚。不过这个做法要谨慎,因为它把事务状态和业务代码耦合了,阅读的人可能不理解为什么突然回滚。

4.3 混合使用的经验

很多项目里会出现“声明式为主,编程式为辅”的局面。我自己在写代码时有个原则:一个事务的边界要尽量单一,不要混搭。

比如这种写法是不推荐的:

@Transactional public void process(Order order) { transactionTemplate.execute(status -> { // 业务逻辑 return null; }); }

外层声明式事务已经开启了一个事务,内层TransactionTemplate默认REQUIRED会加入这个事务。表面上能跑,但一旦有人把内层改成REQUIRES_NEW,传播行为就乱了。更隐蔽的问题是,你很难一眼看出整个事务的边界到底在哪——是方法级别,还是回调级别?

混合使用不是不行,但一定要在代码注释里写清楚事务边界和意图。我见过因为混用导致两个事务管理器切换混乱的线上事故,排查过程极其痛苦。

5. 高频踩坑与排查手记

5.1 常见问题速查表

问题现象可能原因解决方案
加了@Transactional但数据没回滚方法自调用,代理没生效拆 Bean / 注入代理
受检异常导致不回滚默认只回滚 RuntimeException设置rollbackFor = Exception.class
内层事务提交了但外层回滚REQUIRES_NEW导致独立事务确认业务是否需要独立事务
事务方法里调远程接口,连接被耗尽长事务占连接用编程式把事务缩短/后置
多线程并发下数据错乱事务隔离级别不够/无锁合理使用悲观锁或SELECT FOR UPDATE
一个事务方法内部调别的 Service 方法,内层回滚外层没回滚内层捕获异常未抛出如果想一起回滚,不要吞异常
事务方法在 Controller 层调用 Service 层,事务不生效Service 层才有代理,Controller 没有把事务方法写在 Service 层
@Transactional写在 private 方法代理无法拦截私有方法改成 public 拆分 Bean
数据库连接池爆掉事务内做了耗时操作缩短事务、使用异步

这个表里的问题,我基本都亲自踩过。最有意思的是“内层事务提交了但外层回滚”这个,我最初还以为只要外层回滚,内层就一定能跟着回滚,实际上要看传播行为和事务是否共享。

5.2 一次真实的事务排查过程

去年我们做一个交易系统时,出了这么一起故障:用户支付成功后,有一次回调因为网络抖动被重发了两次。两个线程同时处理同一个支付回调,结果两个线程都读取到了订单状态为“待支付”,然后各自执行了“扣款”和“改状态”,导致同一笔钱被扣了两次。

从架构上看,问题出在并发控制上,但事务在其中扮演了帮凶。当时方法上标了@Transactional,按理说一个事务提交后,另一个事务应该能看到新状态。问题在于我们用了READ_COMMITTED隔离级别,两个事务几乎同时开启,第二个事务读取时第一个还没提交,所以看不到状态变化,于是继续往下走。

排查步骤:

  1. 先看日志,确认两个线程都进入了下单逻辑。
  2. 查看数据库锁和事务持续时间。
  3. 检查事务隔离级别。
  4. 确认@Transactional是否真的生效(自调用检查)。
  5. 发现问题根源后,在“读取订单状态”时加了SELECT ... FOR UPDATE悲观锁,确保只有一个线程能进入后续处理。

这个案例给我们的教训是:事务解决的是原子性,解决不了并发冲突。如果你需要防重、防并发,要用锁、唯一索引或幂等性机制,而不是指望事务替你做所有事。

5.3 细节排查技巧

排查事务问题,我有几个固定套路:

第一步,确认事务是否真的开启。在application.yml里把日志级别调成 DEBUG,看DataSourceTransactionManager的日志。如果看到 “Initiating transaction commit” 或 “Rolling back transaction” 就说明事务代理生效了。

logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG

第二步,查自调用。在项目里全局搜索同一类中this.xxx()的调用,逐一排查是否有@Transactional方法被 this 调用。

第三步,查异常有没有被吞。事务方法内如果有 try/catch,并且 catch 后没有重新抛出异常,那么事务不会回滚。这是一个很常见的误用。

第四步,查传播行为。内层方法如果用了REQUIRES_NEW,要仔细看是否需要。很多时候只是想“一个方法一个事务”,结果错误地用了REQUIRES_NEW反而让内外事务分离。

5.4 连接耗尽与事务时间问题

除了正确性问题,事务还会带来性能问题。最典型的就是大事务。一个事务里如果做了一百次批量插入,或者调用了一个耗时三秒的外部接口,那数据库连接会被占用很长时间,连接池很快耗尽。

这种问题的识别方式就是观察HikariCP的连接池监控,看活跃连接数和事务执行时间。如果很多连接长时不被释放,优先怀疑长事务。解决思路并不是简单地把事务拆掉,而是先问自己:这段逻辑真的需要在一个事务里完成吗?

如果可以缩短,就缩短;如果部分逻辑不需要跟主事务一起提交回滚,就拆出去。

6. 事务设计的三条经验

写了这么多,最后分享几条我在实际工作中沉淀下来的设计经验。

**第一条:事务最小化原则。**一个事务只做它必须做的事。不要把无关查询、外部调用、耗时的计算都塞进事务里。事务里的操作越少,锁的时间越短,并发能力越强,出问题的概率也越低。

**第二条:回滚边界要显式。**不管是声明式还是编程式,都要让代码阅读者一眼看出哪些异常会导致回滚。声明式里注意rollbackFor,编程式里注意setRollbackOnly和异常抛出时机。宁可多写点注释,不要让后来者猜。

**第三条:事务不是银弹。**它解决的是一致性,不解决并发、不解决网络、不解决业务幂等。真实场景里,要考虑用乐观锁、悲观锁、唯一索引、消息队列等互补手段。掌握事务只是第一步,学会在正确的地方使用正确的并发控制手段,才能把系统做稳。

我个人现在的习惯是:常规 CRUD 用@Transactional,复杂结算、多数据源、定时任务用TransactionTemplate,并且会在代码里写明事务边界。已经两年多没有因为事务问题半夜爬起来处理线上事故了。

如果你正在重构一个事务混乱的老项目,不妨从这两个问题开始盘查:第一,当前哪些方法的事务边界是模糊的?第二,有多少方法调用没有经过代理?把这两个问题查清楚,项目的事务稳定性就能上一个台阶。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进