Spring的事务管理,很多人一开始都是直接上手用@Transactional,项目跑通了就觉得自己已经掌握了。但一到线上出问题,比如接口偶尔报异常数据却写进去了、A方法调用B方法事务不生效、大事务把连接池拖垮了等等,才开始回头翻源码找答案。这篇文章不会去逐行解读源码,而是从“为什么事务会失效”“传播行为到底该怎么选”“隔离级别在实际项目中怎么落地”这几个真正影响开发的核心问题入手,把这些年踩过的坑、排查过的线上问题,连同最常用的配置方案一起梳理出来。
Spring核心到底是什么?抛开IoC和AOP那些抽象概念不谈,事务管理其实就是AOP思想最典型的落地场景之一。理解了这个底层逻辑,你就能明白为什么自调用事务失效、为什么加@Transactional会失效、为什么有时候异常被吞掉了数据还是提交了。这些问题的根源都指向同一个点:代理机制。
1. 从IoC到AOP,把Spring的骨架先理清楚
1.1 为什么搞懂容器原理是理解事务的前提
很多同学对Spring的理解停留在“它可以自动帮我们创建对象、注入依赖”。这句话本身没错,但它掩盖了非常重要的细节——谁在什么时候创建了谁?这个细节恰恰是理解事务是否生效的关键。
Spring容器启动时,会扫描配置的包路径,把标注了@Component、@Service、@Repository、@Controller的类实例化,放到IoC容器中管理。当你在某个类里用@Autowired引用另一个组件时,Spring会按类型或名称从容器中把对应的Bean取出来注入进去。到这一步,工作的是IoC容器。
但Spring并不满足于只做“对象工厂”。它通过AOP机制,在Bean初始化阶段为符合切点条件的Bean生成代理对象。正是这个代理对象,让Spring可以在你的方法执行前后插入额外的逻辑——比如开启事务、提交事务、回滚事务、记录日志、权限校验。
所以当你注入一个类时,你拿到的常常不是该类的原始对象,而是它的代理。即使类本身没有实现接口,Spring也能通过CGLIB生成子类代理。这就是为什么你的Service方法能被Spring拦截的原因。
提示:理解这一点后你会意识到,凡是绕过了代理对象的调用,Spring统统管不着。这是排查无数事务问题的起点。
1.2 代理对象在事务场景中扮演的关键角色
我们以声明式事务最常见的写法为例:
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insert(dto); stockService.deduct(dto.getSkuId(), dto.getCount()); } }当Spring容器启动时,会扫描到OrderService的createOrder方法上带有@Transactional注解。此时Spring会依据TransactionInterceptor这个增强器,为该Bean生成一个CGLIB代理对象。
在运行阶段,你通过@Autowired获取到的OrderService其实是代理类。代理类在执行createOrder方法时,会先进入TransactionInterceptor的拦截逻辑:
- 根据事务注解的属性决定是否开启事务;
- 开启事务(获取数据库连接、设置自动提交为false);
- 执行业务逻辑;
- 根据方法返回值/异常情况决定提交还是回滚;
- 清理事务资源。
这个流程看起来简单,但它隐含了两个致命前提:你在调用一个代理对象,你没有被事务管理器误判。任何一环出问题,事务都不会按你预期生效。
1.3 AOP增强的顺序性与事务通知的关系
大型业务系统中,一个方法上可能同时存在多个AOP切面,比如日志切面、鉴权切面、缓存切面、事务切面。这些切面的执行顺序会直接影响事务行为。
假设你有一个切面负责在方法执行前开启一个Redis分布式锁,方法执行后释放锁。如果这个切面的@Order值小于事务切面(越小的数值优先级越高,越靠外),它会在事务开启之前执行加锁操作。一旦锁的等待时间超过数据库事务的隔离时间,容易引发锁冲突、死锁等连锁反应。
实际项目中,我建议:
- 事务切面保持在默认优先级(
Ordered.LOWEST_PRECEDENCE); - 自定义切面中,涉及锁、外部调用、远程心跳等操作要仔细评估它相对于事务的先后关系;
- 如果自定义切面需要在事务提交后执行某些动作,可以考虑注册
TransactionSynchronization,在afterCommit回调中处理。
2. 声明式事务与编程式事务,选型背后的实操考量
2.1 声明式事务:注解配置与默认属性陷阱
声明式事务的开发效率最高,也是大多数团队成员最熟悉的方式。只需要在方法或类上标注@Transactional,Spring就会接管方法的事务边界。
但注解方式隐藏了很多默认行为,这正是隐患所在:
第一,默认情况下,@Transactional只对RuntimeException和Error回滚,对受检异常(如IOException、SQLException直接捕获后抛出)不会回滚。在业务代码里如果抛出的是自定义的受检异常,事务是不会自动回滚的。为此,最稳妥的约定是把rollbackFor显式设置为Exception.class。
第二,默认的事务传播行为是REQUIRED,当前存在事务则加入,否则新建。大多数业务场景下这是合理的,但如果你在一个类内部通过this.method()调用事务方法,由于调用发生在原始对象上而非代理对象,REQUIRED也不会生效。这个场景下文会详细展开。
第三,注解可以加在类上,也可以加在方法上。加在类上时,类中所有公共方法都会被事务增强;加在方法上时只对目标方法生效。当二者同时存在时,方法上的注解优先级更高。团队里知识传递不到位时,很容易出现“类上加了注解,某个内部方法内抛异常但外部调用时事务却失效”的认知偏差。
2.2 编程式事务:什么时候需要放弃注解
注解虽然方便,但在以下场景中会显得无能为力:
- 你需要在同一个方法中动态决定提交或回滚的时机,比如先做一轮校验,不符合条件则回滚,符合则继续,再进行二次校验,再次回滚或提交;
- 你需要在一个长任务中分段提交,避免单事务时间过长占用数据库连接;
- 你的事务边界跨越了两个不同的数据源;
- 你需要程序化地控制事务挂起、恢复与嵌套,而不是依赖传播行为的被动处理。
在这些情况下,我习惯使用TransactionTemplate:
@Service public class ReconcileService { private final TransactionTemplate transactionTemplate; public ReconcileService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); this.transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT); } public void doReconcile() { Object result = transactionTemplate.execute(status -> { try { // 业务逻辑 return doCoreWork(); } catch (Exception e) { status.setRollbackOnly(); throw e; } }); } }这里有两个实操细节:
execute回调如果抛出异常,事务管理器会自动标记回滚,不需要在回调里重复调用status.setRollbackOnly()。只有当你捕获了异常、不想让它继续往外抛但又需要回滚时,才需要手动标记。TransactionTemplate是线程安全的,可以在Spring容器中注册为单例组件反复使用。但它本身不具备传播事务的能力——如果外层已有事务,它的传播行为由你在构造时设定的属性决定。
2.3 事务管理器如何与数据源对接
Spring的PlatformTransactionManager有几个经典的实现类:
DataSourceTransactionManager:基于javax.sql.DataSource的普通JDBC事务,用得最多;JpaTransactionManager:配合JPA规范使用;JtaTransactionManager:用于分布式JTA事务。
实际开发中,如果你用的是MyBatis,那么通常配置一个DataSourceTransactionManager即可。Spring Boot的自动配置会在classpath中存在MyBatis或者Spring Data JPA时自动创建对应的事务管理器。手动配置也很简单:
@Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource); manager.setNestedTransactionAllowed(true); return manager; }这里setNestedTransactionAllowed是一个容易被忽略的开关。当你需要使用嵌套事务(PROPAGATION_NESTED)时,如果该开关未开启,即使代码里配置了嵌套传播行为,底层也不是真正的嵌套事务,而是退化为普通的新事务行为。
3. 事务传播行为:七个选项究竟怎么用
3.1 七种传播行为速查与本质区别
Spring在TransactionDefinition接口中定义了七种传播行为,它们决定了事务方法之间如何共享事务边界。我用一张对照表说明它们的行为和典型场景:
| 传播行为 | 含义 | 典型使用场景 |
|---|---|---|
| REQUIRED | 支持当前事务,不存在则创建新事务 | 默认选项,绝大多数DAO操作和Service内部调用 |
| SUPPORTS | 支持当前事务,不存在则非事务执行 | 查询方法,有事务时加入事务,无事务时不需要额外开启 |
| MANDATORY | 必须存在当前事务,否则抛异常 | 不允许在无事务状态下被调用,用于强制约束 |
| REQUIRES_NEW | 无论当前有无事务,都新开一个独立事务 | 日志记录、审计、发消息等不希望随主事务回滚的操作 |
| NOT_SUPPORTED | 当前存在事务则挂起,以非事务方式执行 | 避免长事务中执行某些耗时操作,或连接外部慢接口 |
| NEVER | 当前存在事务则抛异常 | 明确禁止在事务中调用的方法 |
| NESTED | 当前存在事务时,以保存点方式嵌套执行 | 分段业务逻辑,各自独立回滚,不相互污染 |
在实际项目中,真正高频使用的也就前面几种。REQUIRED是默认值,REQUIRES_NEW在日志、消息推送、积分记录等子任务中非常常见,MANDATORY做框架层约束很有价值。
3.2 REQUIRED与REQUIRES_NEW的现场对比
假设有这样一个场景:主方法创建订单,订单创建成功后需要记录操作日志,即使创建订单失败,日志也应当被记录。如果用REQUIRED,日志写入会合并进主事务。订单创建失败导致主事务回滚时,日志也会一并回滚,日志等于没有记录。这是非常常见的需求,解决方案就是把日志记录方法标注为REQUIRES_NEW。
要注意的是,REQUIRES_NEW会挂起当前事务,意味着它的数据库连接会独立获取。如果当前已有一个大事务占用了连接,REQUIRES_NEW会额外消耗一个新的连接。在高并发请求下,这会导致连接池的连接被快速耗尽。你需要在设计时评估日志写入的频率和并发量,必要时用异步线程池配合REQUIRES_NEW来降低连接占用。
3.3 NESTED嵌套事务与PROPAGATION_REQUIRED的区别
很多人分不清NESTED和REQUIRES_NEW。关键区别在于:
REQUIRES_NEW是完全独立的事务,内层事务提交或回滚不会影响外层事务的状态;NESTED则是在当前事务中创建一个保存点(savepoint),内层事务回滚只回滚到保存点位置,外层事务仍然可以继续执行并决定最终是否提交。
举个例子:
@Transactional public void processParent() { processChild1(); // NESTED processChild2(); // NESTED }如果processChild1内部出现异常,只回滚processChild1的处理结果,processParent中后续的processChild2还可以继续跑。如果外层方法最终本身出了异常,则整个事务全部回滚。
NESTED在底层依赖数据库的保存点能力,MySQL的InnoDB引擎支持。但如果连接池或数据库配置不足以支持保存点,Spring会退化为REQUIRED行为。这里建议在核心账务、状态机流转等场景中使用前,先确认数据库版本与驱动兼容性。
4. 隔离级别:怎么理解MySQL的默认RR与Spring配置的对照
4.1 数据库隔离级别与Spring配置的关系
Spring的@Transactional注解中可以通过isolation属性指定隔离级别,它最终会通过事务管理器传递给数据库连接,执行SET TRANSACTION ISOLATION LEVEL指令。但要注意,Spring只是传递者,真正的隔离行为由数据库引擎实现。
MySQL InnoDB默认的隔离级别是REPEATABLE READ(可重复读),而Oracle、PostgreSQL默认是READ COMMITTED(读已提交)。很多团队在开发阶段使用默认配置,上线到PostgreSQL时行为却不一样,原因就在这里。
Spring支持的隔离级别常量对应关系如下:
| Spring隔离级别 | 说明 | 可能引发的问题 |
|---|---|---|
| ISOLATION_DEFAULT | 使用数据库默认隔离级别 | 依赖数据库,跨库行为不一致 |
| ISOLATION_READ_UNCOMMITTED | 读未提交 | 脏读、不可重复读、幻读 |
| ISOLATION_READ_COMMITTED | 读已提交 | 不可重复读、幻读 |
| ISOLATION_REPEATABLE_READ | 可重复读 | 幻读(InnoDB通过间隙锁降低概率) |
| ISOLATION_SERIALIZABLE | 串行化 | 并发性能急剧下降 |
4.2 不可重复读与幻读的实际区别
理论上一说到隔离级别就开始列脏读、不可重复读、幻读。但很多人在实际开发中并没有真正遇到过,因为没有在高并发+长事务的环境中观察过。
不可重复读:事务A先读某一行数据,事务B更新了这行数据并提交,事务A再次读取同一行时发现数值变了。这个问题在账务计算、价格计算中影响很大,通常发生在一条记录的多次读取之间。
幻读:事务A按条件读取出一批行(比如查询状态为“待支付”的订单,得到10条),事务B插入了新的“待支付”订单并提交,事务A再次查询时多出了1条。这在分页、统计、对账等场景中会出现数据量不一致的诡异问题。
InnoDB在REPEATABLE READ下通过MVCC保证普通读的一致性快照,但对于当前读(比如SELECT ... FOR UPDATE)会使用间隙锁来阻止幻读的发生。这也是为什么在开发中,如果存在并发插入同一范围内的记录,容易在REPEATABLE READ下出现锁等待甚至死锁。
4.3 多数据源或分库时隔离级别如何统一
一旦项目走向微服务化,或者一个服务里配了多个数据源,隔离级别的管理就会变得复杂。每个数据源对应的事务管理器是独立的,它们各自的连接池可以配置不同的默认隔离级别。而且业务方法可能横跨两个数据源,此时Spring声明式事务并不能保证跨库的原子性,你需要引入分布式事务方案。
所以如果你在代码里统一把隔离级别写死为ISOLATION_READ_COMMITTED,它对多数据源是全局生效的,反而可能帮你在Oracle切换时统一行为。但代价是事务代码在数据库层面丧失了针对特定引擎的优化空间。我的建议是:在一个服务内尽量使用同一套数据库引擎,隔离级别遵循统一规范,避免在方法级别频繁自定义隔离级别。隔离级别越严格,锁竞争越剧烈,吞吐量越容易掉。
5. 事务失效的典型场景与排查策略
5.1 方法自调用导致事务失效
这是最经典的问题。代码如下:
@Service public class UserService { @Transactional public void updateUser(User user) { updateUserName(user); updateUserEmail(user); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateUserEmail(User user) { userMapper.updateEmail(user); } }当updateUser方法被外部调用时,Spring拦截的是代理对象。但方法内部调用updateUserEmail时,this指向的是原始对象,不是代理对象,因此updateUserEmail上的事务注解根本没有机会被解析。
这也是Spring官方文档中明确指出的一个问题。解决办法有几种:
- 把内部事务方法拆分到另一个Service类中,通过注入对象调用;
- 使用
@Autowired注入自身代理(注意循环依赖风险,在Spring Boot 2.6+默认不允许循环依赖); - 通过
AopContext.currentProxy()获取当前代理,然后调用方法(需要在启动配置中设置exposeProxy=true)。
实际开发中,最干净、最易懂的方案是拆Service。业务边界本来就是不同的领域职责,两个方法合并在一个类里本身可能就说明设计上可以再优化。如果你因为某些原因必须放在同一个类中,可以使用@Lazy注入自身解决:
@Service public class UserService { @Autowired @Lazy private UserService self; @Transactional public void updateUser(User user) { self.updateUserEmail(user); } }5.2 异常被捕获后事务回滚失败
看这段代码:
@Transactional public void createOrder(OrderDTO dto) { try { orderMapper.insert(dto); stockService.deduct(dto.getSkuId(), dto.getCount()); } catch (Exception e) { log.error("下单失败", e); } }异常被捕获后没有向外抛出,TransactionInterceptor自然认为方法正常完成,事务正常提交。即使stockService.deduct中抛出了运行时异常,只要被外层捕获,事务就失去了回滚依据。
这里我给团队的约定是:
- 事务方法中如果不需要处理异常,就让异常直接向外抛出;
- 如果确实需要捕获并记录日志,要么在捕获后重新抛出,要么手动调用
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
手法上更推荐前者。重新抛出时注意不要包装成受检异常,否则默认情况下还是不会回滚。如果你确实需要抛出自定义异常,请确认自己显式配置了rollbackFor = Exception.class。
5.3 非public方法上的事务注解不生效
Spring的声明式事务最早基于JDK动态代理时,只对接口方法的public调用生效。后来CGLIB代理出现后,对非public方法的代理仍然有限制。虽然Spring 4之后对protected方法有了一定支持,但事务切面仍只拦截通过代理调用的外部方法。在一个类内部直接调用本类的private方法,事务同样失效。
如果你遇到某个方法上标了@Transactional却完全没有事务行为,先检查这个方法是不是private或protected,再看是不是类内部自调用。这两点可以排除绝大多数“注解看起来没问题但事务没开启”的案例。
5.4 数据库表引擎与事务不兼容
这个坑在MySQL上很典型:如果你的表是MyISAM引擎,它本身不支持事务。即使Spring正确开启了事务,最终提交时数据库也不会给你真正的回滚能力。我排查过一个线上数据异常,方法加了@Transactional,测试环境正常,生产数据出现脏数据,最后发现是某几张历史表被迁移时改成了MyISAM。
用一条SQL就能确认:
SHOW TABLE STATUS WHERE Name = 'your_table';如果Engine列显示MyISAM,事务特性自然无从谈起。建议把核心业务表全部统一为InnoDB。另外在分库分表中间件环境下,单表事务能保证,跨分片事务需要依赖中间件的分布式事务方案,此时Spring本地事务管理器的能力边界也需要注意。
6. 事务管理的最佳实践与项目落地建议
6.1 事务边界设计的五个原则
事务不是越大越好,也不是越小越好。结合大量项目的实际经验,我总结了下面五条原则:
第一,事务范围宁可小不要大。尽量不要在@Transactional方法中做远程调用、外部RPC、文件上传、邮件发送、消息队列发送等耗时操作。数据库事务持有连接的同时阻塞了业务线程,时间越长,连接池被占用的可能性越大,整体吞吐量掉得越快。
第二,查询方法按需开启事务。如果是一个只读操作且不需要一致性的当前读,可以不开启事务;如果读操作需要保证多次查询结果一致,则可以使用REQUIRED或SUPPORTS配合合适的隔离级别。
第三,跨服务的事务不要硬做。分布式场景下优先考虑最终一致性方案,比如本地消息表、事务消息、TCC等,尽量避免在大事务中同时操作多个微服务的数据库。
第四,事务中尽量不依赖外部时间、随机数、本地时钟等不可控因素,避免产生不可再现的异常回溯问题。
第五,事务代码要可读、可审计。不要在一个方法里夹带多个不相干的数据操作,哪怕它们都“需要同一个事务”。
6.2 分布式事务与本地事务的边界
当业务量增长到分库分表阶段,DataSourceTransactionManager已经无法保证全局事务一致性。常见方案有:
- XA协议:严格一致性,但性能损耗大,且对数据库和中间件有较高要求;
- TCC(Try-Confirm-Cancel):业务侵入性高,适合强一致性要求高的资金类业务;
- 本地消息表/事务消息:适合最终一致性场景,比如积分、通知、日志归档等;
- SAGA:长事务拆分,每一段本地事务+补偿操作。
我个人在项目中落地最顺手的是事务消息配合本地消息表。因为它实现相对简单,不依赖全局锁和长事务,应用最多、排查路径清晰。核心思路是:业务操作和消息记录放在同一个本地事务里,业务提交成功后,消息表的数据会被投递出去。如果投递失败,有定时任务兜底扫描重发。
6.3 项目落地时的配置模板与监控建议
每个项目的事务配置大同小异,我给出一个可复制的Spring Boot配置参考:
spring: datasource: url: jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=utf8 username: demo password: demo hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000@Configuration @EnableTransactionManagement public class TransactionConfig { @Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource); manager.setDefaultTimeout(10); manager.setNestedTransactionAllowed(true); return manager; } }监控方面,建议关注几个指标:
- 当前活跃连接数,以及等待获取连接的线程数;
- 事务方法平均执行时间与最长执行时间;
- 数据库锁等待次数与死锁次数;
- 事务回滚次数与提交次数的比例。
场景如果允许,在日志中打印事务方法入参和耗时。出现问题的时候,根据事务开始与结束的日志时间戳,可以快速定位是哪一步拖慢了整个事务。
6.4 事务代码的单元测试与压测建议
最后说下事务相关代码的测试方法。单元测试中你可以直接操纵事务状态:
@SpringBootTest @Transactional class OrderServiceTest { @Autowired OrderService orderService; }让测试方法自身也运行在事务中,结束时回滚,保证不污染测试数据库。但要注意,这个测试事务和被测方法的事务传播行为互相影响。如果你想验证REQUIRES_NEW行为,最好通过真实的数据库连接或用测试容器进行集成测试。
压测时重点关注两个场景:
- 同一行数据的并发更新:两个事务同时更新同一记录,观察锁等待时间;
- 批量插入场景:观察是否因为间隙锁导致锁范围扩大,形成死锁。
一旦测试环境出现死锁,不要急着在代码层加锁,先看数据库的死锁日志,找到两个事务的加锁顺序,再调整事务内的操作顺序或调整索引设计。
说到最后,Spring的事务本质上是“AOP + 数据库事务”的组合。你可以在短时间内学会注解怎么用,但真正决定项目事务质量的是对代理机制、事务边界、异常处理方式和数据库隔离级别的理解深度。多花一点时间把这些原理吃透,遇到线上问题你才能根据日志与现象快速定位,而不是靠猜、靠试、靠盲改配置。