Spring的事务管理,说句实话,是我这些年面试Java开发必问的一块,也是日常代码里踩坑最密集的区域之一。很多同事一上来就甩一个@Transactional在方法上,然后该提交提交、该报错报错,看着挺正常,可一旦遇到自调用、异常被吞、传播行为选错这类场景,线上数据就悄悄出问题。这篇文章我就从实际项目经验出发,把Spring事务管理从核心原理到实操细节、再到问题排查一次性讲透,适合刚接触Spring Boot的初学者,也适合那些“注解用了一年但没想过为什么”的开发同学。
1. 先搞明白:Spring事务管理到底在管什么
1.1 从数据库事务说起,Spring在哪个层面做文章
事务不是什么Spring发明的概念,它本质上是数据库层面的能力。一组SQL要么全部成功提交,要么全部失败回滚,保证数据不会停留在中间状态。经典的ACID四个特性——原子性、一致性、隔离性、持久性,都是数据库引擎提供的。
那Spring事务管理到底在管理什么?简单说,Spring做的事情是把“开启事务”、“提交事务”、“回滚事务”这三件事从繁琐的JDBC代码里抽象出来,用一套统一API封装好。你不再需要手动connection.setAutoCommit(false)、connection.commit()、connection.rollback(),而是声明一下“这个方法需要事务”,Spring就在方法执行前开启事务、方法正常结束后提交、方法抛异常后回滚。
我之前带过一个新人,他以为事务是Spring“做”出来的,所以遇到问题总往Spring配置上想。其实绝大多数情况下,事务的底层还是数据库在撑着,Spring只是那个“发号施令”的调度员。理解这一点,对后面排查“事务为什么不回滚”特别重要——如果一个异常在进到Spring的代理逻辑之前就被吞掉了,那Spring根本不知道要回滚,数据自然就留下了一半。
1.2 三个核心接口,事务运转的骨架
Spring事务抽象的核心就三个接口,把这几个看明白了,整个事务框架的脉络就清晰了。
第一个是PlatformTransactionManager,这是事务管理的门面。它定义了三个方法:getTransaction获取或创建事务,commit提交,rollback回滚。Spring下面所有的事务行为,最终都通过这个接口落地。
第二个是TransactionDefinition,它定义了一次事务的“规则参数”,包括传播行为、隔离级别、超时时间、是否只读。
第三个是TransactionStatus,它代表一次事务运行时的状态,可以理解成数据库连接的包装状态,Spring通过它来判断当前事务能不能提交、有没有被标记为rollback-only。
这三个接口的关系,可以类比成一次网购:TransactionDefinition是订单规则(多久发货、能不能退换),TransactionStatus是运单状态(包裹走到哪了),PlatformTransactionManager是快递公司调度中心,负责按规则把运单状态推进到签收(提交)或者退回(回滚)。
1.3 事务管理器该选哪个实现
Spring的事务管理器有很多实现,最常见的是DataSourceTransactionManager,它基于java.sql.Connection实现事务控制,适配一切通过DataSource获取连接的场景,MyBatis、JdbcTemplate用的都是它。如果你用了Spring Data JPA,默认会走JpaTransactionManager,它把EntityManager的事务纳入Spring管理。
还有JtaTransactionManager,这个一般用在真·分布式事务场景,比如应用跨了多个数据库或者多个资源,需要JTA规范来协调,日常业务开发很少用到,而且引入它往往会带来不少复杂度和性能损耗。
Spring Boot的自动配置帮我们把大部分选择做掉了——引入spring-boot-starter-jdbc或mybatis-spring-boot-starter后,会自动装配一个DataSourceTransactionManager。你基本不需要手动声明,除非有多数据源或者特殊定制需求。
2. 声明式事务 @Transactional 的真相与失效场景
2.1 注解背后的AOP代理机制
@Transactional之所以能用“声明”的方式管理事务,靠的是Spring AOP。Spring在启动时会扫描被@Transactional标记的Bean,为其生成代理对象。当外部调用这个Bean的方法时,实际上调用的是代理对象的方法,代理对象在方法执行前开启事务,方法返回后提交,抛出未被捕获的异常时回滚。
这个机制有一个很关键的推论:只有通过代理对象调用方法,事务才会被拦截。如果你在类的内部用this.xxx()调用另一个加了@Transactional的方法,那调用的是原始对象的方法,代理逻辑完全没有介入,事务自然不生效。这是所有事务失效问题里出现频率最高的一种,我后面专门用一节来讲怎么判断和解决。
另外,Spring Boot 2.x之后默认使用CGLIB代理,也就是基于子类生成的代理,不再强制要求目标类实现接口。这比早期JDK动态代理要省心一些,但还是绕不开“调用必须经过代理”这条铁律。
2.2 传播行为:7种选择,但日常就这几种
@Transactional的propagation属性控制事务的传播行为,默认值是Propagation.REQUIRED。这个默认值的含义是:当前有事务就直接加入,没有就新建一个。大多数业务场景用默认值就够了。
REQUIRES_NEW也常用,它的作用是挂起当前事务,新建一个独立事务。比如你在一个业务方法里要记录操作日志,日志入库失败不应该回滚主业务,那就应该把日志方法标记为REQUIRES_NEW。但要注意,被挂起的事务并没有提交,它要等新事务结束、自己恢复后继续执行,所以日志记录这个“独立事务”提交了,而主事务还在跑,这时候外面查日志能查到,但主业务数据还没提交。
NESTED则是嵌套事务,基于数据库的Savepoint实现。它的特点是:内部事务回滚时,可以只回滚到保存点,不影响外部事务之前的操作;但如果外部事务最终回滚,内部事务的修改也会一并回滚。这个语义和REQUIRED不同,REQUIRED是“加入同一个事务”,内部异常一旦触发回滚会标记整个事务为rollback-only,外部事务往往也会跟着挂掉。如果你需要部分回滚,NESTED比REQUIRED更可控。
剩下几个——SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER,日常业务用的频率低。SUPPORTS是有事务就加入,没有就以非事务方式运行;MANDATORY要求当前必须有事务,否则直接异常;NEVER反过来,不允许有事务。这些更多用于框架级代码,业务开发知道存在即可。
2.3 事务失效:那些我以为加了注解就万事大吉的时刻
我梳理一下过去几年在生产环境里真实遇到过的@Transactional失效场景,每一个都是血泪教训。
私有方法加@Transactional不生效。CGLIB代理是通过子类重写方法来增强的,私有方法无法被子类访问,天然不能被代理。同理,static方法也不行,final方法也不行,因为CGLIB无法重写final方法。所以事务方法必须是非private、非static、非final的public方法。
异常被try-catch捕获后不抛出不回滚。这是最常见的问题。Spring事务回滚的触发条件是“事务方法抛出未被捕获的异常”,如果你在方法内部把异常捕获了没再抛出去,代理对象看到的是方法正常返回,自然就提交了。有些同学说“用户余额扣减了但订单没生成,事务没回滚”,查到最后往往是这种问题。
默认只对RuntimeException和Error回滚,检查时异常(checked exception)默认不触发回滚。因为Spring的设计哲学是:受检异常代表业务可预见的失败,不一定需要回滚。如果你希望自定义异常也触发回滚,要显式声明@Transactional(rollbackFor = Exception.class)。
调用方和被调用方在同一个类内部,走了this调用而不是代理调用。这个场景我会在排查章节专门演示。
多线程环境下,新线程里的事务和主线程事务无关。@Transactional基于ThreadLocal传播,子线程拿不到父线程的事务上下文。
这些失效场景,归根结底都能用一句话概括:Spring事务是AOP代理驱动的,任何绕开代理、绕开异常抛出路径的操作,都会让事务形同虚设。
3. 一套能直接落地的实操配置与编码
3.1 Spring Boot下的基础配置
在Spring Boot项目里,事务管理几乎是零配置。引入依赖、配置好数据源,框架就能自动装配事务管理器。我这里给一份规范的示例:
spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver业务代码里,事务方法写在Service层:
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrder(OrderCreateDTO dto) { orderMapper.insert(dto.toOrder()); inventoryMapper.deduct(dto.getSkuId(), dto.getQuantity()); accountMapper.deductBalance(dto.getUserId(), dto.getAmount()); } }rollbackFor = Exception.class是我在所有业务代码里的默认选择。虽然Spring默认只回滚运行时异常,但我个人在业务团队里更倾向于“所有异常都回滚”这个保守策略,宁可多回滚,也不能让数据落成半拉子状态。
如果你没有用Spring Boot,而是在普通Spring项目中,需要手动配置:
@Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager manager = new DataSourceTransactionManager(); manager.setDataSource(dataSource); return manager; }同时还要在配置类上开启@EnableTransactionManagement。Spring Boot里这个开关由自动配置处理,不用你操心。
3.2 编程式事务:TransactionTemplate兜底
声明式事务再方便,也有不可控的时候。比如你要在一个方法里动态决定是否开启事务,或者事务边界根据业务条件动态变化,这时候@Transactional这种“先声明后执行”的方式就不太灵活了。我的习惯是用TransactionTemplate做编程式事务兜底。
@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(TransactionTemplate transactionTemplate) { this.transactionTemplate = transactionTemplate; } public void createOrder(OrderCreateDTO dto) { boolean needInventory = dto.getQuantity() > 0; transactionTemplate.execute(status -> { try { orderMapper.insert(dto.toOrder()); if (needInventory) { inventoryMapper.deduct(dto.getSkuId(), dto.getQuantity()); } accountMapper.deductBalance(dto.getUserId(), dto.getAmount()); return null; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); } }TransactionTemplate的底层是PlatformTransactionManager,它同样遵循“抛出异常即回滚”的原则,只是把事务边界从注解挪到了代码块里。它的优势在于条件控制更灵活,而且同一个方法里可以出现多个独立的事务块——这在声明式事务里几乎做不到。
不过我要提醒一句:能用@Transactional的地方还是优先用注解。因为编程式事务容易在业务代码里堆出大量模板代码,可读性会下降。TransactionTemplate更适合那些“注解说不清边界”的特殊场景。
3.3 多数据源事务:别硬刚分布式
很多项目做到后面会引入多数据源,比如订单库和用户库分开。这时候你如果在两个数据源的操作上分别加@Transactional,它们各自管理各自的事务,合起来并不是一个原子操作。
业务第一步往订单库插入数据成功,第二步往用户库扣减余额失败,订单库的数据已经提交了,不会因为用户库的事务回滚而撤销。这就是跨数据源事务难题。
面对这个问题,我建议按下面这个优先级来权衡:
如果业务允许最终一致,比如订单创建后通过消息队列异步扣减库存,那优先使用本地消息表或消息队列保证最终一致性,不要硬用一个事务去包两个库。如果两个库的操作必须强一致,账务类业务尤其如此,那要上真正的分布式事务方案,比如Seata的AT模式。但分布式事务会带来明显的一致性和性能代价,要谨慎评估。
还有一个务实的小技巧:在一个主数据源上执行事务,另一个数据源的操作放在事务提交后的TransactionSynchronizationManager.registerSynchronization回调里执行。这样主数据源失败时副操作不会发生,主数据源提交后再触发副操作,是一个轻量的补偿思路。它不能做到绝对强一致,但能把不一致窗口缩得很小。
4. 大事务、性能与边界控制
4.1 事务里最怕的三件事
事务不是越大越安全,恰恰相反,事务范围太大往往是性能问题的主要源头。我总结了三类在事务内做会拖垮性能的操作。
第一是远程调用。事务方法里调第三方API、调另一个服务的HTTP接口、调用RPC,都是大忌。事务持有数据库连接,远程调用期间连接一直占着不放。一次远程调用最快几十毫秒,慢则几秒,连接池就这么被耗尽了。我之前接手过一个系统,高峰期经常报连接池超时,排查发现是一个@Transactional方法里同步调了短信平台的接口,短信平台偶尔响应两三秒,数据库连接池就被拖垮了。
第二是消息发送。往MQ里发消息虽然比HTTP快,但同样是在事务里做额外IO操作。更麻烦的是,如果消息发送成功了,但后面SQL执行失败回滚,消费者已经拿到了这个消息,会处理一个实际上并未生效的业务操作。正确姿势是事务提交后再发送消息,可以用TransactionSynchronizationManager.registerSynchronization的afterCommit回调。
第三是批量大循环。比如在事务里for循环几千上万条数据逐条update,每条SQL一次网络往返,整体时间呈线性增长。把大循环拆成批次批量执行,或者干脆把大事务拆成多个小事务,性能会好很多。
4.2 缩小事务边界的两个常用姿势
第一个姿势是把非事务操作移到事务方法外面。比如一个方法既要做参数校验、组装数据,又要做数据库更新,那你应该让只有更新那一段走事务方法,校验和组装放在事务外。实现方式可以是拆Service方法,也可以借助TransactionTemplate精确控制事务范围。
第二个姿势是异步化。允许异步的操作不要放在事务里,比如通知推送、日志上报,可以用@Async方法异步执行,或者投递到消息队列。这样主链路的事务方法只保留必要的数据库操作,事务周期大幅缩短。
还有一个小经验:SQL本身也要优化。事务时间的长短,除了业务操作数量之外,单条SQL的执行效率同样关键。缺少索引导致的慢查询,会让本可以几十毫秒完成的事务拖到几百毫秒,并发一上来就会形成连接积压。给高频查询条件建好索引,往往比调整事务边界收益更快。
4.3 只读事务不是摆设
@Transactional(readOnly = true)看起来只是告诉Spring“这个方法不修改数据”,实际上它在底层做了两件事:一是给数据库一个只读提示,某些数据库或者连接池会据此做优化,比如MySQL的InnoDB在只读事务里可以走只读副本;二是Spring本身也会做一些优化,减少不必要的写入检查。
不过要泼一盆冷水:readOnly = true并不能真正阻止代码里执行insert/update语句,它更多是一个“声明”和“优化提示”。如果你抱着“加了这个属性,误操作就不会落库”的幻想,那是会翻车的。数据安全还是要靠代码审核和数据库权限控制。
我的习惯是,查询列表、查询详情等纯读接口一律加readOnly = true,一方面语义清晰,另一方面在数据库连接层面给DBA一个优化依据。但这属于锦上添花,不是雪中送炭。
5. 事务问题排查手册:从现象到根因
5.1 事务没生效:先看代理
场景:Service里两个方法,createOrder调用了updateStock,两个方法都加了@Transactional,但是updateStock里的异常没有让事务回滚。
这种问题十有八九是自调用。代码大概长这样:
@Service public class OrderService { public void createOrder(OrderCreateDTO dto) { // 一系列订单操作 this.updateStock(dto.getSkuId(), dto.getQuantity()); } @Transactional(rollbackFor = Exception.class) public void updateStock(Long skuId, Integer quantity) { inventoryMapper.deduct(skuId, quantity); throw new RuntimeException("库存不足"); } }createOrder没有加@Transactional,但它内部调用this.updateStock()时,走的是当前对象的直接调用,不是代理对象,所以updateStock上的事务注解根本没有机会被解析。
解决方案有两种。最简单的:把updateStock拆到另一个Service类中,让createOrder通过注入的Bean调用。这样调用链路上会经过代理,事务就能生效。另一种方案:在OrderService里注入自身代理,比如用@Lazy注入OrderService,再通过self.updateStock()调用。两种都行,我更推荐拆类,因为职责更清晰。
5.2 异常没回滚:多半是异常“跑了”
场景:事务方法里明明抛了异常,但数据还是提交了。
排查步骤我一般这么走:
第一步,看异常是在哪一层被处理的。如果是在Controller层或者调用方catch掉了,事务方法本身已经正常返回,Spring认为方法执行成功,自然提交。
第二步,看抛出的异常类型。如果是自定义的受检异常,比如BizException extends Exception,默认是不会触发回滚的。这时候要检查有没有配rollbackFor = Exception.class。
第三步,看事务方法内部有没有“脏”的try-catch把异常吞掉。我见过最暧昧的写法是这样:
@Transactional(rollbackFor = Exception.class) public void createOrder(OrderCreateDTO dto) { try { orderMapper.insert(dto.toOrder()); inventoryMapper.deduct(dto.getSkuId(), dto.getQuantity()); } catch (Exception e) { log.error("创建订单失败", e); // 没有重新抛出 } }这代码日志里红通通一片,但数据库那边该提交还是提交了。所谓“事务没回滚”,其实是异常被拦截了,Spring根本没有感知到。解决方式就是catch之后重新抛出,或者直接不catch,让异常自然向上传播。
5.3 连接池被打满:事务持有连接时间太长
场景:系统某个时间段内频繁出现Connection is not available, request timed out,应用没有挂,但请求大面积超时。
这种问题的排查思路,不是看Spring配置,而是看谁长时间持有连接。常见元凶就是事务方法里做了慢操作,比如同步远程调用、大批量遍历、慢SQL。
定位手段有两个。第一,打开连接池的监控,Druid或者HikariCP都有现成的监控指标,可以看到活跃连接数、等待获取连接的线程数。第二,看线程堆栈,jstack导出线程栈,找那些处于WAITING状态、正在获取数据库连接的线程,再看它们在哪里阻塞。
我之前解决过一次类似问题,最终定位到是一个事务方法里循环调用了远程库存接口,平均每个订单耗时1.2秒。把远程调用移到事务外、改成事务提交后异步处理,系统立刻恢复。这类问题往往不是事务配置错了,而是事务边界划得太大。
还有一个容易被忽略的场景:事务方法里做了Thread.sleep或者等待锁。比如事务里加了ReentrantLock,另一个线程持有锁不释放,当前线程一直阻塞,数据库连接被这个线程占着不放,池子很快就被拖垮。事务方法内尽量不要做任何和数据库无关的耗时操作。
结尾:说点我的私人体会
我在实际项目里见过太多次“加了@Transactional就以为万事大吉”导致的线上事故,归根结底是对Spring事务的AOP代理机制和回滚触发条件理解不够透。如果你能把本文里讲到的几个失效场景牢牢记在心里——自调用不走代理、受检异常默认不回滚、异常被吞不抛、事务里做远程调用——那我相信你在事务这条路上能少走很多弯路。
最后再分享一个小技巧:写事务相关代码的时候,可以在本地起一个测试,故意让事务方法抛异常,然后去数据库确认数据有没有被回滚。用这种“破坏性验证”的方式,一次就能确认你的事务配置到底生没生效。这个方法我每次带新人都会让他们试一遍,比看十篇原理文章都管用。