手上这个订单系统出过一次让我头皮发麻的事故:凌晨支付回调的并发一上来,同一笔订单被两个线程同时处理,结果库存扣了两次,订单状态却还停在“待支付”。排查到最后,问题就出在事务边界上——一个方法里既有声明式事务,又混着手动提交,边界完全失控。那次教训让我把 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 的自动装配,事务的使用门槛被大大降低。你只需要三样东西:
- 一个数据源(DataSource)
- 一个事务管理器(PlatformTransactionManager)
- 一个
@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之后抛异常,数据不会回滚。
解决方式有几种:
- 把需要事务的方法拆到另一个 Bean 中,让外部 Bean 调用,这会走代理,是最干净的方案。
- 注入自身代理,比如通过
@Resource注入本类,再调用注入对象的innerMethod()。 - 使用
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。比如下单、支付回调、创建订单、修改用户资料。这些操作自然就是一个原子操作。
遇到这些情况,考虑编程式:
- 一个方法里有多个独立的数据库操作,每个操作需要独立事务。
- 需要根据运行时的复杂条件来决定回滚范围,而这个条件不好用“抛出异常”来表达。
- 流程中包含事务后置动作,比如事务提交后发消息、调外部接口。
- 需要手动处理非数据库资源(比如消息队列)的回滚或补偿。
- 多数据源环境下,需要分别控制、分别提交。
如果只是偶尔需要部分回滚,也可以保留声明式事务,在方法内部用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隔离级别,两个事务几乎同时开启,第二个事务读取时第一个还没提交,所以看不到状态变化,于是继续往下走。
排查步骤:
- 先看日志,确认两个线程都进入了下单逻辑。
- 查看数据库锁和事务持续时间。
- 检查事务隔离级别。
- 确认
@Transactional是否真的生效(自调用检查)。 - 发现问题根源后,在“读取订单状态”时加了
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,并且会在代码里写明事务边界。已经两年多没有因为事务问题半夜爬起来处理线上事故了。
如果你正在重构一个事务混乱的老项目,不妨从这两个问题开始盘查:第一,当前哪些方法的事务边界是模糊的?第二,有多少方法调用没有经过代理?把这两个问题查清楚,项目的事务稳定性就能上一个台阶。