1. 没加 @Transactional 的时候,线上到底发生过什么
1.1 一个让我半夜爬起来修数据的真实事故
先讲一件真事。前几年我维护过一个订单中心服务,核心逻辑是:用户在 App 上下单,后端要完成三件事——扣减库存、生成订单记录、给用户增加积分。三个操作分布在不同的 Service 方法里,当时代码写得比较随意,createOrder()方法里按顺序调用了stockService.reduce()、orderMapper.insert()、pointsService.add(),方法上干干净净,一个事务注解都没有。
上线初期流量小,一切正常。等有一天大促压测一开,问题就炸了:库存扣了,订单表里没有记录;或者订单生成了,积分没到账。用户投诉截图一张接一张,运营那边对着 Excel 手工补单补了整整两天。我半夜爬起来查日志,发现是pointsService.add()里调了一个外部接口,那个接口超时抛了异常,导致后面的逻辑中断,但前面已经执行完的库存扣减和订单插入却都已经提交到数据库了。
这就是没加事务的典型后果:方法里的多个数据库操作各自为战,每个操作执行完就自动提交,任何一步出错都不会回滚其他步骤。你可能觉得这是初学者才会犯的错,但说实话,我在不少生产环境的旧代码里都见过类似写法,很多还是当初从别的同事手里接过来的。
1.2 @Transactional 到底帮我们做了什么
@Transactional注解的核心价值在于,它把 Spring 的声明式事务管理能力绑定到一个方法上。你只需要在方法上方加一行注解,Spring 就会在这个方法执行前开启事务,方法内所有的数据库操作都纳入同一个事务上下文,全部成功才统一提交,任何一个 RuntimeException 冒出来就整体回滚。
这里要澄清一个最常见的误区:很多人以为@Transactional是"让方法支持事务",其实它的准确含义是"让方法内的所有操作原子化"。区别在于,前者让你以为事务是方法自带的默认属性,后者才说清楚了事务边界在哪里——方法的入口是事务的开始,方法的出口是提交或回滚的决定点。
数据库层面的原理也值得说一句。Spring 的事务管理是基于数据库连接(Connection)的,默认情况下Connection的 autoCommit 是 true,也就是每条 SQL 执行完马上提交。Spring 在开启事务时,会把这个连接的 autoCommit 临时改成 false,等业务方法执行完毕,再统一执行 commit 或 rollback。理解这一点,后面很多诡异问题你都能自己推导出来。
2. 传播行为:你以为很简单,其实它就是事务边界的规则
2.1 7 种传播行为,真正常用的就那几个
@Transactional注解里有个propagation属性,用来定义事务的传播行为。这个东西听起来抽象,但你可以把它理解成:当一个带事务的方法调用另一个带事务的方法时,两者的事务边界如何合并。
Spring 定义了 7 种传播行为,我直接说结论,日常开发中绝大多数情况只需要关注这几种:
| 传播行为 | 含义 | 使用频率 |
|---|---|---|
| REQUIRED | 如果当前有事务则加入,没有则新建 | 最常用 |
| REQUIRES_NEW | 无论如何都挂起当前事务,新建一个独立事务 | 偶尔用 |
| NESTED | 嵌套事务,内层回滚不影响外层已提交的 | 少用 |
| SUPPORTS | 有事务则加入,没有就以非事务方式执行 | 少用 |
| NOT_SUPPORTED | 以非事务方式执行,挂起当前事务 | 极少用 |
| MANDATORY | 必须有事务,否则抛异常 | 几乎不用 |
| NEVER | 必须没有事务,否则抛异常 | 几乎不用 |
REQUIRED是默认值。绝大多数业务场景,比如创建订单、更新用户资料、批量导入数据,你只需要保持默认就行。两个方法 A 和 B 都是 REQUIRED,A 调用 B,那 B 会直接加入 A 的事务,共用一个事务连接,同生共死。这也是最容易理解的协作模式。
2.2 REQUIRES_NEW 的典型场景:日志不能跟着业务一起回滚
REQUIRES_NEW是我在实战中第二个用得多的传播行为。它的语义是:调用方的事务先挂起,被调用方法自己新建一个独立事务,两者互不干扰。被调用方法提交了,即使调用方后面回滚了,它也不受影响。
最典型的场景是操作日志记录。我在一个支付系统里遇到过这么个需求:支付成功后需要写一条审计日志,不管后续业务流程怎么失败,这条日志都必须留下来。如果日志方法和业务方法共用同一个 REQUIRED 事务,业务一失败回滚,日志也没了,审计等于白做。把日志方法改成REQUIRES_NEW后,各管各的,日志稳稳落库。
还有消息发送场景也类似:订单状态更新失败要回滚,但通知管理员的消息必须发出去。这类需求用REQUIRES_NEW是合理的,但也要清楚代价——被挂起的事务持有数据库连接,而连接池里的连接是有限的。如果外层事务很久不结束,内层事务又频繁创建,连接池可能被耗尽。我见过因为在高并发路径上滥用 REQUIRES_NEW,导致连接池活跃连接数飙升到上限,整个服务雪崩的案例。
2.3 NESTED 和 REQUIRES_NEW 的区别,90% 的人说不清
很多文章把这两个混为一谈,实际上它们完全不同。NESTED是嵌套事务,底层利用数据库的 savepoint(保存点)机制,内层事务回滚时只回滚到 savepoint 的位置,不影响外层事务已经执行的操作。但内层事务并不是独立提交的,它依然跟随外层事务统一提交。也就是说:NESTED 是"整体提交、局部回滚",REQUIRES_NEW 是"各自提交、互不影响"。
同样拿日志场景举例,用 NESTED 的话,如果外层后续真的抛异常回滚了,内层那个 savepoint 之后的日志照样也会被回滚掉。所以如果你的需求是"无论如何日志都要留下",NESTED 不行,只有 REQUIRES_NEW 能满足。如果你的需求是"批量处理一批数据,其中某一条失败时只回滚这一条,不影响其他条",那 NESTED 比 REQUIRES_NEW 更合适,因为外层事务的公共服务(比如更新批次状态)不会跟着某一条数据的失败而回滚。
3. 那些让你怀疑人生的 @Transactional 失灵场景
3.1 this 调用:同一个类里的方法互调,事务直接失效
这应该是 @Transactional 翻车率最高的一个坑。代码看起来没什么问题:
@Service public class OrderService { public void createOrder(Order order) { // 业务逻辑 updateStock(order); insertOrder(order); } @Transactional public void updateStock(Order order) { stockMapper.reduce(order.getProductId(), order.getCount()); } @Transactional public void insertOrder(Order order) { orderMapper.insert(order); } }updateStock和insertOrder上都标注了@Transactional,但调用方是同一个类里的createOrder方法,这段代码不会开启任何事务。
原因在于 Spring 事务是通过 AOP 动态代理实现的。Spring 容器里实际放着的不是你的OrderService实例,而是一个代理对象。当外部调用orderService.createOrder()时,调用的是代理对象的方法,代理逻辑里先开启事务,再调用真实目标对象的方法。但当createOrder()方法内部通过this.updateStock()调用时,这个this指向的是原始目标对象,根本不是代理对象,所以直接绕过了代理逻辑,@Transactional自然就不生效。
解决方案有几种,我按推荐程度排序:
- 拆分 Service:把需要事务的方法放到另一个 Service 类里,注入进来再调用,这是最干净的做法。
- 注入自身:在类里注入
OrderService自己,用self.updateStock()调用,让调用路径经过代理。 - 用 AopContext 获取代理对象:通过
((OrderService) AopContext.currentProxy()).updateStock(order)调用,需要额外配置exposeProxy = true。 - 用 TransactionTemplate:在方法内部用编程式事务包住需要原子操作的代码,这个我后面细说。
3.2 方法不是 public?事务同样不生效
@Transactional只对public 方法生效,这是 Spring 官方文档写死的规则。放在 private、protected 或者包级可见的方法上时,Spring 不会报错,但会无提示地忽略掉。
为什么?因为 Spring AOP 的默认代理方式是 JDK 动态代理,它只能代理接口方法;即使你用 CGLIB 代理,它也是通过生成子类来覆写方法,private 方法无法被覆写,自然无法拦截。所以你在 private 方法上加注解,相当于写了一句注释。
这个坑尤其容易出现在"把公共逻辑抽到私有方法里再统一加事务"这类重构中。我自己也踩过:当时把批量保存的逻辑抽到一个 private 方法里,看方法上没加注解总觉得不踏实,就补了一个@Transactional,还跟同事说"这块肯定是事务保护的"。结果后来某条数据落库失败,前面的数据却都在,排查了半天才发现注解放在 private 方法上根本没起作用。
3.3 吃掉了异常,事务怎么可能回滚
另一个高频问题:事务方法内部自己 try-catch 了异常,异常没抛出去,Spring 根本不知道出错了,于是事务就正常提交了。
@Transactional public void transfer(Account from, Account to, BigDecimal amount) { try { accountMapper.decrease(from.getId(), amount); accountMapper.increase(to.getId(), amount); } catch (Exception e) { log.error("转账失败", e); // 没有重新抛出异常——事务认为一切正常 } }这段代码的问题在 catch 块里,异常被吞掉了,Spring 事务监听器无法感知异常,事务最终以 commit 收场。结果是钱扣了但没到账,数据就脏了。
正确的做法是:如果确实需要捕获异常做额外处理,处理完之后一定要重新抛出 RuntimeException。比如记录日志后throw new RuntimeException(e)。如果你既想拿到异常做业务处理,又不想因此回滚整个事务,可以考虑在 catch 里调用一个REQUIRES_NEW的方法去记录错误信息,然后再重新抛出异常。
3.4 checked 异常默认不回滚,这件事要知道
Spring 默认只对RuntimeException和Error进行回滚,对于 checked 异常(比如IOException、SQLException这种必须捕获或声明的异常)默认不回滚。
这个设计思路是:Spring 认为 checked 异常通常代表可以恢复的业务预判错误,不应该直接让事务回滚;而运行时异常才代表不可预期的严重问题。
但实际开发中,很多业务异常就是自定义的 checked 异常。你需要在注解上显式声明:
@Transactional(rollbackFor = Exception.class)甚至更精确一点:
@Transactional(rollbackFor = BizException.class)rollbackFor = Exception.class是很多公司规范里的标配,因为它的语义是"任何异常都回滚",基本符合绝大多数业务诉求。不过也别无脑堆,线程池里处理任务时如果异常被包装成 checked 异常,而你声明的回滚规则不匹配,照样不会回滚,这点在写异步任务时要留意。
4. 事务失效之外,隔离级别和锁才是真正的隐雷
4.1 隔离级别不是 SQL 课上的概念,它会直接影响你的订单金额
@Transactional的isolation属性对应数据库的四种隔离级别。在 Spring 中默认用的是数据库自己的默认隔离级别,MySQL InnoDB 默认是REPEATABLE_READ,PostgreSQL 和 Oracle 通常是READ_COMMITTED。如果你没改配置,Spring 不会主动改,这一点很多人存在误解。
四种隔离级别从松到严排列如下:
- READ_UNCOMMITTED:能读到别的事务未提交的数据,脏读风险最高,几乎不用。
- READ_COMMITTED:只能读到已提交的数据,解决脏读,但存在幻读问题。
- REPEATABLE_READ:同一事务内多次读取结果一致,解决脏读和不可重复读,但依然可能幻读,MySQL InnoDB 通过间隙锁基本解决了幻读。
- SERIALIZABLE:事务串行执行,性能代价最大,基本不在高并发业务里用。
你可能会问:那我代码里该怎么选?我的经验是:绝大多数业务用数据库默认值就够了,不要轻易往上调。事务隔离级别的调整意味着更多的锁竞争和更差的并发性能。如果是因为某个特定场景需要防止并发问题,优先考虑用行锁、乐观锁、唯一索引等方式去解决,而不是一刀切提升隔离级别。
4.2 事务 + 行锁:注意锁的释放时机
事务和锁是紧密相关的。MySQL InnoDB 的行锁在事务提交或回滚时才释放,所以一个事务如果持有某些行的锁,它执行的时间越长,其他事务等锁的时间就越长。在 @Transactional 方法里做耗时操作,比如调远程接口、睡几秒、循环大量计算,都会长时间占用行锁,进一步拖慢所有涉及同一行数据的请求。
我记得有个项目,用户在订单详情页点了"确认收货",后端在事务里发了一条短信验证码、调用了物流查询接口、还往消息队列里塞了一条消息,请求平均耗时 3 秒。用户如果快速连着点两次,第二个请求就会卡在行锁上,直到第一个事务超时。后来把短信、物流查询、消息推送全部挪到事务外面,事务里只剩两个数据库 UPDATE,接口耗时就降到了 50 毫秒以内。
这是一个很值得记住的优化原则:事务越短越好,事务里不要写任何和数据库无关的 IO 操作。
4.3 自增主键回填和批量插入,事务方法的隐藏边界问题
看一个我实际处理过的场景。用 MyBatis Plus 做批量插入时,如果是在事务方法里一批一批地 insert,每批之间做了大量非数据库校验逻辑,整批操作都会在事务提交时才真正写盘。有些人会在同一个事务里先 select count 再判断再 insert,本意是防止并发重复插入,但 REPEATABLE_READ 级别下同一个事务多次 select,结果可能一致(MVCC 快照读),根本读不到其他事务新插入的数据。这时候你的"查重"逻辑其实是自欺欺人。
正确的思路是:用数据库唯一索引做最终防线,让数据库在违反唯一约束时抛异常,再由事务回滚。而不是自己先用 select 去判断再插入。这一点我在高并发下单场景里吃过亏,写出来给各位避雷。
5. 实操配置:从注解参数到事务管理器
5.1 最常用的几个注解参数
我挑实战中最重要的几个参数说:
@Transactional( propagation = Propagation.REQUIRED, isolation = Isolation.DEFAULT, timeout = 10, rollbackFor = Exception.class, readOnly = false ) public void updateOrder(...) { // ... }timeout:事务超时时间(秒),超过就抛异常并回滚。默认没有超时,等于依赖数据库自己的锁等待超时。高并发交易类接口我建议至少设一个超时,避免死锁长时间占用连接。readOnly = true:标记为只读事务。它并不强制数据库只读,但有两点作用:一是给数据库一个提示,可以对其做只读优化;二是在某些数据库(比如 MySQL)上,会优化掉加锁逻辑,降低锁竞争。注意,如果你在只读事务里执行了 insert/update/delete,Spring 不一定会阻止你——实际上很多数据库照样执行成功,别对它有太强的"拦截"期待。rollbackFor:指定哪些异常触发回滚,这个前面说过,生产规范里直接用Exception.class最省心。noRollbackFor:指定某些异常即使抛出也不回滚。我用得不多,但有一种场景:某个异常发生后,你希望事务继续,并且后续代码还有机会补数据。这种情况我会用 REQUIRES_NEW 的内层事务去补,或者提前把数据状态调整好再抛出可忽略的异常。
5.2 自定义事务管理器,什么时候有必要
Spring Boot 在DataSourceAutoConfiguration和TransactionAutoConfiguration里会自动配置好一个DataSourceTransactionManager,针对单一数据源,你基本不需要手动创建。但一旦涉及多数据源、JTA 分布式事务(现在一般用 Seata 之类替代),或者某些特殊的数据库连接池需要定制,你才需要自己注册事务管理器。
@Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager manager = new DataSourceTransactionManager(); manager.setDataSource(dataSource); manager.setDefaultTimeout(10); manager.setRollbackOnCommitFailure(true); return manager; }setRollbackOnCommitFailure(true)这个参数容易被忽略。提交阶段如果发生异常,比如网络中断导致提交请求没到达数据库,设置这个参数可以确保此时依然尝试回滚,否则可能出现"方法以为提交成功了,实际数据库没收到"的不一致。在交易链路里,我建议开启。
5.3 Spring Boot 4.x 的配置变化,别被旧资料带偏
标题热词里出现了 spring boot 4.x 找 DataSourceAutoConfiguration 的问题。Spring Boot 确实在持续调整自动配置类的包名和结构。在新版本里,事务相关的自动配置类位置和默认行为可能有变化,最稳妥的做法是遇到配置类找不到时,先去对应版本的官方文档确认类名,不要依赖旧文章里的全限定类名硬编码。Spring 对事务的核心理念没有变,变的更多是自动装配的路径和组织方式。
6. 事务方法不被外部调用,怎么保证原子性
6.1 如果事务只保护一个 Service 方法,别忘了入口调用链
有些项目里引入 @Transactional 后发现事务像是没生效,排查到最后发现,事务方法是被 Controller 直接调用没错,但 Controller 内部先调了一个不带事务的方法(比如做参数组装),那个方法里又调了另一个 Service 的事务方法。这时候事务边界其实只在被调用的 Service 方法内部,Controller 里其他非事务操作不会跟它绑定。
这种"事务边界不清晰"常常会带来一种错觉:你以为整个请求都是原子的,实际上只有那一小块是原子的。比如用户注册流程:先插入用户表,再插入账户表,再存一个初始优惠券模板到用户模板表。如果你把三个操作分散在三个 Service 方法的调用链上,只有其中一个是 @Transactional,那么第三个操作失败时,前面两个已经提交了。为了让整个注册流程原子化,你应该在入口方法上加 @Transactional,让所有内部操作都加入同一事务。
6.2 编程式事务:什么时候它比注解更靠谱
注解方式方便,但也有不好用的地方。比如你想在某个循环里每处理一条数据就开启一个新事务,或者想事务内部根据业务结果动态决定提交还是回滚——这种场景用注解写着就拧巴。这时我推荐用TransactionTemplate:
@Service public class BatchImportService { private final TransactionTemplate transactionTemplate; public BatchImportService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); this.transactionTemplate.setTimeout(30); this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } public void importBatch(List<Row> rows) { for (Row row : rows) { try { transactionTemplate.execute(status -> { insert(row); return null; }); } catch (DataIntegrityViolationException e) { log.error("单条导入失败,继续处理下一条: {}", row.getId(), e); } } } }这种"每条记录一个独立事务"的写法在批量导入场景非常实用。某一条出问题,只会回滚这一条,不影响其他记录。用注解做这个事会很别扭,因为你需要写一个单独的方法再通过代理调用,而且异常传播满了你很难在循环里精细控制。TransactionTemplate 还有一个好处:它不依赖代理,所以不存在 this 调用失效的问题。
7. 从表象到本质:事务日志、监控和验证手段
7.1 如何确认事务真的生效了
很多人加了注解心里没底,不知道事务到底开没开。其实验证手段很简单,分两步走:
第一步,在配置文件里开启 Spring 事务的日志输出:
logging.level.org.springframework.transaction.interceptor=DEBUG logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG日志里会出现类似这样的内容:
Getting transaction for [com.example.service.OrderService.createOrder] Completing transaction for [com.example.service.OrderService.createOrder]如果只有方法进出栈的 "Getting" 和 "Completing",说明事务确实被拦截了。如果你发现这些日志根本没出现,那基本可以断定注解没有生效,优先检查是不是 this 调用或者方法可见性问题。
第二步,更粗暴也更有说服力的做法:在事务方法里故意抛一个 RuntimeException,看看之前 insert 的数据是否回滚。测试环境这么干最直接,别在生产上试就行。
7.2 事务结合 MyBatis,最容易出现的执行顺序问题
在 Spring Boot + MyBatis 的项目里,@Transactional 和 MyBatis 的执行时机关联紧密。MyBatis 操作的每个 Mapper 方法都会从当前事务上下文中拿到连接,多个 Mapper 方法在同一个事务里共享同一个连接。这个连接在哪里?Spring 用 ThreadLocal 保存了事务资源,所以你可以在一个事务方法里依次调用多个 mapper 方法,它们拿到的都是同一个连接,SQL 都排在同一个事务里。
这里我踩过一个坑:在事务方法里查询数据,拿到的结果不是最新的。原因是同一个事务里第二次查询,走的是快照读,读不到其他事务在该事务启动后提交的数据。这在报表统计、计数类场景里尤其容易让人困惑。解决方法是:如果确实要实时读最新数据,在事务外先查一次,或者把查询拆到独立事务(REQUIRES_NEW)里执行,或者使用 SELECT ... FOR UPDATE 走当前读。
7.3 线上排查事务超时的真实思路
事务超时或者长时间未提交,在线上怎么快速定位?我的经验是结合数据库侧的状态来查。MySQL 里执行:
SELECT * FROM information_schema.innodb_trx;可以查到当前运行的事务、trx_started(事务开始时间)、trx_state、trx_mysql_thread_id 这些关键信息。如果某个事务的trx_started很早,说明它已经存活了很久,很可能是事务方法里的某个远程调用卡住了,或者代码里出现了未提交但因为异常被吞而一直开着的事务。
再配合SHOW PROCESSLIST看看对应线程当前执行的 SQL 是什么,基本就能锁定是哪个业务方法的问题。我之前排查过一次,就是一个加了 @Transactional 的异步任务,内部调了一个第三方报表接口,对方接口 5 分钟没响应,事务连接一直被占用,连接池被拖垮,进而影响了这个连接池上所有其他正常请求。后来那个异步任务整体改造成了线程池隔离,数据库操作单独用独立事务包裹,第三方调用全部放到事务之外才解决。
7.4 事务方法内到底能不能做远程调用:我的结论
关于"事务方法内部能不能做远程调用"这个问题,网上的说法两极分化。我的实际经验是:不要做,除非你有非常充分的理由,并且做好了超时和降级。远程调用的不确定性(网络超时、对方服务重启、消息堆积)会直接拉长事务时间,继而影响所有竞争同一把锁的请求。上面说的行锁就是这么被拖死的。
如果业务上必须在同一个链路里完成更新和通知,推荐的做法是:先提交事务,再发消息。比如用 Spring 的TransactionSynchronizationManager.registerSynchronization(),在事务提交后通过afterCommit回调去发送 MQ 消息或调用远程接口。这样既保证了数据一致性,又避免了长事务。
@Transactional public void updateOrderStatus(String orderId, String status) { orderMapper.updateStatus(orderId, status); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { mqSender.sendOrderStatusChanged(orderId, status); } }); }这个模式在订单状态变更、支付回调、库存变动等场景都很实用,核心思想是"先保证本地数据对,再通知别人",比把远程调用塞进事务里健壮得多。
8. 最后想聊的:事务编码的三个思维层次
写事务代码这么多年,我最大的体会是:@Transactional 只是入口,真正决定系统稳定性的,是你对事务边界的理解和对并发模型的认识。
第一层,是语法层。知道注解怎么加、参数怎么配、哪些情况会失效,这属于"会写"。
第二层,是规划层。设计一个业务接口时,先想清楚哪些操作必须绑定成一个原子单元,哪些可以独立提交,哪些应该放在事务外面。规划得好,很多线上问题根本不会出现。
第三层,是治理层。事务和连接池、线程池、消息队列、分布式锁配合使用时,你能否判断瓶颈出在哪一环。比如连接池耗尽时,是先查数据库连接数还是先看日志?事务回滚后,下次重试的幂等性怎么保证?这些只有踩过坑才懂。
我个人尤其推荐团队里立几条硬性规范:
- 所有 @Transactional 方法的复杂度控制在"方法体内不超过 5 条数据库操作,且没有远程调用"。
- 开启事务的方法全部设置 rollbackFor = Exception.class。
- 批量导入、消息消费者的单条处理,用 TransactionTemplate 而不是注解控制事务粒度。
- 每次 code review 时重点关注事务方法的调用链上有没有 this 调用、有没有 try-catch 吞异常。
最后再分享一个小技巧:在你觉得事务行为很诡异、怎么调试都不对的时候,先把 Spring 事务日志级别调到 DEBUG,看一眼"Getting transaction"和"Completing transaction"的配对情况。大多数事务问题的答案,其实都藏在这两行日志中间的代码里。