1. 先别急着写注解,搞清楚Spring事务到底在干嘛
很多人在项目里用@Transactional,无非就是方法上加个注解,然后跑起来发现要么没回滚、要么事务没生效、要么死锁,各种莫名其妙的问题一股脑全冒出来。说实话,用了好几年的Spring,真正能把事务讲清楚的人真不多,大部分人对@Transactional的理解停留在“加了注解就有事务”的层面。今天我用一整篇的篇幅,把Spring事务这点事从头到尾捋一遍,包括它的底层原理、传播机制、隔离级别、失效场景,以及我个人在项目里踩坑之后总结出来的排查思路。内容偏实战,建议你找个安静的时间从头看到尾。
先立个基础概念。事务(Transaction)的本质是一组操作要么全部成功、要么全部失败,它要满足ACID四个性质:原子性、一致性、隔离性、持久性。Spring本身并没有实现事务,它做的是把不同的事务管理器(比如JDBC的DataSourceTransactionManager、JPA的JpaTransactionManager)抽象成统一的PlatformTransactionManager接口,然后通过@Transactional注解帮我们在目标方法前后自动完成开启事务、提交、回滚这些操作。
听起来很美好对吧?但问题恰恰出在这层“自动”上。你只是写了一个注解,真正干活的是Spring容器创建的代理对象。方法的调用会被拦截器处理,拦截器决定事务怎么开、什么时候提交、什么时候回滚。如果你不理解这层机制,后面所有的“为什么我加了注解事务却没生效”你都找不到答案。
很多人以为@Transactional是Spring Boot时代才有的东西,实际上它是Spring Framework从2.x版本就开始提供的核心能力。从早期的XML配置<tx:annotation-driven/>到后来的@EnableTransactionManagement,再到Spring Boot的自动装配,底层的原理一直没有变过:基于AOP的动态代理。搞懂了代理,你就搞懂了@Transactional的一大半。
这里先记住一个结论:@Transactional是由代理对象来触发事务逻辑的,而不是你写的那个目标对象本身。这句话我会在后面反复提到,因为几乎所有事务失效的案例都跟它有关。
2. 传播行为与隔离级别:七个传播行为背后的真实应用场景
2.1 七种传播行为逐个拆解
@Transactional注解最容易被忽略的两个属性,一个是propagation(传播行为),一个是isolation(隔离级别)。先说传播行为。Spring定义了7种传播行为,很多新手一看到表格就头大,其实工作中用得到的翻来覆去就那么两三种。
REQUIRED是默认值。它的意思是:如果当前已经存在事务,就直接加入到这个事务里;如果当前没有事务,就新建一个事务。这是使用频率最高的传播行为,适合绝大部分场景。举个例子,你的服务层调了userService.createUser(),里面又调orderService.createOrder(),两个方法都是REQUIRED,那它们就在同一个事务里,任何一个方法抛出异常,整个事务回滚,谁的数据都不会落库。
REQUIRES_NEW是我在项目中第二常用的。它的逻辑是:不管当前有没有事务,都强制开启一个新事务,并且把当前事务挂起。为什么要这么干?最常见的场景是记录日志。假设你的业务方法要更新一批数据,如果失败了要回滚,但你又想把“这次操作失败了”这个日志记录下来,这时候日志写入就不能放在业务事务里,否则业务回滚的时候日志也跟着没了。你需要一个独立的新事务去把日志写进去,REQUIRES_NEW就是干这个的。
NESTED跟REQUIRES_NEW有本质区别。NESTED基于数据库的保存点(Savepoint)机制,它并不是真正独立的事务,而是当前事务里的一个嵌套子事务。子事务回滚了,只回滚到保存点位置,父事务可以继续提交;但是父事务回滚了,整个子事务也会全部回滚。这里要注意,NESTED能否生效取决于事务管理器是否支持保存点,比如JDBC的DataSourceTransactionManager是支持的,但JPA的JpaTransactionManager就不支持。
剩下的SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER工作中见得很少。SUPPORTS是有事务就加入、没事务就以非事务方式运行;NOT_SUPPORTED是如果有事务就先挂起,以非事务方式执行;MANDATORY是必须在一个已有事务中执行,否则直接抛异常;NEVER是必须在非事务环境下执行,否则抛异常。这四种我实际项目里基本没用过,但面试的时候可能会被问到,你能说出它们的区别就行。
2.2 隔离级别到底怎么选
隔离级别解决的是并发事务下数据一致性的问题。SQL标准定义了四个级别,从低到高分别是READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。它们对应的副作用依次是:脏读、不可重复读、幻读。
在Spring里,你通过@Transactional(isolation = Isolation.XXX)来指定,但有一个很重要的点你必须清楚:事务隔离级别本质上是由底层数据库支持的,Spring只是帮你把请求发给数据库,数据库不支持的话,Spring再指定也没用。比如MySQL默认是REPEATABLE_READ,Oracle默认是READ_COMMITTED,各有各的设计考量。
脏读,指的是一个事务读到了另一个事务未提交的数据。这个最危险,因为那个事务后面可能回滚,你读到的话就相当于读到了一份“不存在”的数据。READ_UNCOMMITTED允许脏读,所以生产环境基本没人用。
不可重复读,是在同一个事务里,两次读同一条记录,发现值不一样。原因是另一个事务对这行数据做了修改并提交了。READ_COMMITTED可以避免脏读,但避免不了不可重复读——Oracle默认就是这一档,所以在Oracle下你写代码要特别注意,如果业务要求事务内多次查询结果必须一致,你得手动加锁或者换隔离级别。
幻读,指同一个事务里两次执行相同的范围查询,第二次却多出了一些行。这通常是另一个事务“插入”了新数据导致的。REPEATABLE_READ在MySQL下通过MVCC和间隙锁能一定程度上避免幻读,但不是在所有数据库上都能做到。SERIALIZABLE把并发度降到最低,读读都要排队,性能最差,不到万不得已别用。
我在实际项目中的经验是:绝大概率你不需要自己指定隔离级别,直接用数据库默认值就是最合适的。随意调高隔离级别会显著降低并发能力,你可能没踩到脏读的坑,反而先踩到性能瓶颈的坑。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | MySQL默认 |
|---|---|---|---|---|
| READ_UNCOMMITTED | 可能 | 可能 | 可能 | 否 |
| READ_COMMITTED | 不可能 | 可能 | 可能 | 否 |
| REPEATABLE_READ | 不可能 | 不可能 | 可能(InnoDB基本避免) | 是 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 否 |
2.3 超时设置:一个容易被忽视的坑
@Transactional(timeout = 5)表示事务允许的最长执行时间,默认单位是秒。如果超过了这个时间,事务会被强制回滚。这个属性在长事务、大批量数据处理的场景下很关键,可以用来防止某个慢SQL拖垮整个数据库连接池。
但是这里有个坑:超时时间的计算起点是事务开启的时间,而不是你调用某个方法的时间。如果你的方法在进入事务之前已经执行了一些操作(比如做复杂的参数校验),再去调用一个带@Transactional的方法,那超时计算是从后续事务一开始就计时的,前后容易产生误解。另外,不同的事务管理器对超时的支持程度不一样,DataSourceTransactionManager是支持的,但某些情况下如果你用的是ORM框架,它底层的超时处理不一定能精准控制到你预期的那条SQL上。
3. 事务失效的八大经典场景与排查实录
3.1 自调用:最经典也最容易踩的坑
这是我在代码评审里看到次数最多的问题。一个类里面的方法A调用了方法B,两个都加了@Transactional,结果B的事务压根就没生效。原因就是我开头说的代理机制:外部调用bean.methodA()时,实际调用的是Spring生成的代理对象,代理会开启事务;但methodA内部调用methodB时,是在当前对象(this)上直接调用的,根本没有经过代理,所以methodB上的@Transactional形同虚设。
要解决这个问题,常规手段有三种。第一种,把methodB拆到另一个Service类里,通过注入的方式调用,这样可以保证走代理。第二种,在类内部通过ApplicationContext.getBean(类名.class)拿到代理对象再调用methodB。第三种,在类里注入自己,@Autowired private UserService self;然后self.methodB()。我个人最推荐第一种,职责边界清晰,也便于后续扩展。
注意:在Spring Boot 2.6.x之后,如果你的工程开启了
spring.main.allow-circular-references=false(默认就是false),在类里注入自己这种方式要小心循环依赖的问题。虽然自己注入自己不构成循环依赖,但为了保险起见,优先考虑拆类方案。
3.2 方法修饰符问题:private和protected挂了
@Transactional加在private方法上会失效,加在protected方法上很多情况下也会失效。原因还是代理。Spring的默认代理方式是CGLIB,它生成的是目标类的子类,因此只能对public方法做增强。虽然更高的Spring版本对非public方法在某些条件下也能处理,但我不建议你依赖这种特殊情况。统一把事务方法设计成public,这是最稳妥的习惯。
3.3 异常被catch了还能回滚吗?
这是一个高频问题。你写了事务方法:
@Transactional public void createOrder() { try { orderDao.insert(); throw new RuntimeException("库存不足"); } catch (Exception e) { log.error("订单创建失败", e); } }你以为它会回滚,但实际上订单照样插进去。为什么?因为Spring判定是否回滚,看的不是你有没有抛异常,而是异常有没有从目标方法传播到事务拦截器那一层。你把异常catch在自己的方法里,拦截器根本感知不到异常,它以为方法正常执行完了,自然就提交了。
正确的做法有两种:要么把异常重新抛出去,要么手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()来标记当前事务需要回滚。第二种方式适合你确实需要捕获异常做一些逻辑处理(比如记录日志),但又希望后续事务回滚的场景。我个人更倾向于“把事务控制在正确的方法边界”的做法——异常该抛就抛,让外层统一处理。
3.4 非RuntimeException的默认行为
@Transactional默认只对RuntimeException和Error触发回滚,如果你抛的是一个Exception子类的自定义异常(比如业务异常),默认情况下事务是不会回滚的。这大概是所有失效场景里最反直觉的一个。很多人写代码:
@Transactional public void updateConfig() throws Exception { throw new Exception("配置更新失败"); }然后发现配置照样改了,一脸懵。
解决方案就很直接:在注解里加上rollbackFor,明确指定要回滚的异常类型。我项目里一般会定义一个全局的业务异常基类,然后在@Transactional(rollbackFor = Exception.class)上做统一配置,这样无论是受检异常还是运行时异常,都能正确触发回滚。
@Transactional(rollbackFor = Exception.class) public void updateConfig() throws Exception { // 业务逻辑 }rollbackFor同样可以指定多个异常类型:rollbackFor = {BizException.class, IllegalStateException.class}。如果你有特殊的“某些异常不回滚”的需求,还可以用noRollbackFor来指定。
3.5 代理方式不对:JDK动态代理和CGLIB的区别
Spring的事务默认优先使用JDK动态代理,但它有个限制:只能代理接口。如果你的Service类没有实现任何接口,那Spring就会用CGLIB去生成代理子类。多数情况下框架会自动选择合适的方案,但如果你依赖了接口注入,或者某个类恰好实现了接口、事务方法声明在实现类上,这里就容易出幺蛾子。
一个很典型的场景:UserService接口定义了方法,UserServiceImpl实现了它,@Transactional写在实现类上,外部调用方是通过UserService接口注入的。这种情况下JDK动态代理是正常的,因为调用会走到代理对象。但如果内部有一个方法没有在接口里定义,而是实现类独有,并且这个方法上加了@Transactional,从外部用接口调用时,这个事务就失效了——因为代理对象根本不知道有这个接口方法外的增强方法。
解决方法是简单粗暴的:针对事务方法所在的类,统一用CGLIB代理,或者确保你的事务方法都在接口方法覆盖范围内。Spring Boot下你可以设置spring.aop.proxy-target-class=true来强制使用CGLIB。
3.6 多线程环境下的“伪事务”
这是一个隐蔽的坑。你在一个@Transactional方法里开了个新线程去执行数据操作:
@Transactional public void batchProcess() { new Thread(() -> { dataDao.update(...); // 希望跟主线程在同一个事务里 }).start(); }结果显然是不行的。Spring的事务边界是以“线程”为维度的,事务连接是绑定在当前线程上的,新开的线程拿不到你主线程里的那个数据库连接,也不会加入当前事务。你在新线程里执行的SQL要么走自己的独立事务(很可能还是自动提交的),要么压根没有事务。这个问题一旦出现在生产环境,排查起来非常费劲,因为日志里完全没有报错,看起来一切正常。
我的建议是:事务方法里不要开多线程去操作数据库。如果确实需要并行处理,把并行拆成独立的服务调用,各自管理自己的事务,然后在主流程里做最终一致性对账。
3.7 类没有被Spring容器管理
这就更基础了。@Transactional能起作用的前提是该类必须被Spring容器管理,也就是你得加上@Service、@Component之类的注解,或者通过XML/JavaConfig显式注册。如果你只是new了一个对象出来,那Spring根本不知道这个对象的存在,注解自然完全不生效。这类问题在单体应用里出现得少,但如果你写一些工具类、封装类的时候,容易犯这个毛病。
3.8 数据库引擎不支持事务
最后一个是环境层面的问题。比如MySQL的MyISAM引擎不支持事务,表建出来后加了@Transactional也回滚不了。我这个场景遇到得不多,因为现代项目大多用InnoDB,但如果你接手老系统,发现某些表是MyISAM,而业务代码里又写了完整的事务管理逻辑,那就会出现“事务完全不生效但代码毫无问题”的奇怪现象。排查方法很简单:SHOW TABLE STATUS WHERE Name = 'your_table';查看Engine字段即可。
4. 常见问题与排查技巧实录
4.1 一次真实的回滚失效排查过程
有一年我排查过一个线上问题。场景是这样的:用户批量导入数据,导入过程中有一条数据校验失败,需要整个批次回滚,但实际表现是部分成功部分失败,数据不一致。刚开始代码走查没发现问题——事务注解、异常抛出、传播行为配置看着都对。后来我在关键位置加了日志,发现事务拦截器根本没进入回滚分支。
进一步排查才发现,代码里用了一个封装过的DBUtils工具类去执行SQL,而这个工具类内部自己建立了新的数据库连接,绕过了Spring管理的事务连接。也就是说,你的@Transactional管的是Spring数据源里拿出来的那个连接,但你的工具类用的连接并不是同一个。这个问题本质上属于“事务边界外操作数据库”,但伪装得极其隐蔽,因为日志里看不到任何明显的报错。
那次过后,我在项目规范里加了一条:不允许在事务方法中直接使用自己创建的数据库连接,所有数据访问必须走Spring管理的Bean。那次排查的另一个收获是,我们引入了一个简单的自定义注解,用AOP切面把所有Transactional方法统一记录“是否进入了事务代理”的日志,方便后续排查类似问题。
4.2 排查事务问题的四步方法论
如果你遇到一个问题还没法定位,我建议你按下面的顺序来排查,能省下大量时间。第一步,确认执行事务方法的对象是不是Spring代理对象。最简单的办法是在方法里注入一个AopContext.currentProxy()看看或者加日志打印当前对象类型,或者直接打断点观察方法的进入堆栈里有没有CglibAopProxy相关的调用。第二步,确认你抛的异常是不是被内部catch掉了。去代码里全局搜一下try {和catch(Exception e),重点看事务方法内部有没有吞异常的逻辑。第三步,检查异常类型是否满足回滚条件,看是RuntimeException还是检查异常,确认有没有配rollbackFor。第四步,确认事务管理器和数据源是不是同一个。在Spring Boot里用多数据源的时候,这是最高频的问题——你的事务注解标在A数据源服务上,但实际操作的Mapper连的是B数据源,那事务管的就是A连接,B上的操作自然不受控制。
4.3 一个高频问题速查表
| 症状 | 大概率原因 | 快速验证方式 |
|---|---|---|
| 方法抛异常但没有回滚 | 异常被catch吞掉 | 在catch块里加日志,确认异常是否被捕获 |
| 控制台没有事务日志 | 方法没有被代理 | 查看方法调用堆栈是否经过拦截器 |
| 外抛受检异常不回滚 | 默认只回滚RuntimeException | 检查注解上有没有rollbackFor |
| 事务方法内部调用方法失效 | 自调用绕过了代理 | 拆类或注入代理对象 |
| 多线程操作不同步回滚 | 事务绑定在单个线程上 | 避免在事务方法内开线程 |
| 查询出现脏数据 | 隔离级别太低 | 查看数据库默认隔离级别 |
| 事务貌似生效但偶尔失败 | 多数据源导致事务管理器不匹配 | 检查事务管理器绑定的数据源 |
5. 实操中的配置细节与优化心得
5.1 事务管理器选型:别忽略多数据源场景
Spring Boot集成事务大部分情况下是零配置的,只需要在启动类或配置类上确保没有禁用事务管理自动配置就行。但如果你项目里有多个数据源,情况就变得复杂了。Spring Boot默认会创建一个DataSourceTransactionManager,绑定的是主数据源。如果你的业务代码里有多个数据源,而事务管理器没有指定具体绑定哪个数据源,很可能出现事务管了A却管不了B的割裂状态。
我自己处理过多数据源项目,一般做法是分数据源配置各自的事务管理器,然后在service层用@Transactional(transactionManager = "xxxTransactionManager")来明确指定。这里容易踩的坑:Spring Boot的自动配置只创建了唯一的事务管理器,如果你手动声明了第二个,主事务管理器就不一定是你期望的那个了。需要你把其中一个标记为@Primary,避免注入冲突。
5.2 大事务拆解:事务不是越大越好
@Transactional用起来很爽,但很多团队把事务弄得过大,一个方法里执行几百上千条SQL,导致长事务锁表时间过长,并发量直接被拖垮。我在优化过一个慢接口之后,把事务方法的代码从200行精简到40行,性能提升立竿见影。
优化的思路大致有三条。第一,把不需要事务的纯查询操作移出事务方法,减少锁持有时间。第二,把数据库批量操作用批量接口一次性提交,少让事务管理器做无谓的停留。第三,如果业务允许,异步化非核心链路,用消息队列或者定时任务去最终一致性补偿。
这里必须提醒:事务拆小了之后,原来“一个事务裹住所有操作”的原子性就没了。你要想清楚业务能够接受的中间态是什么,引入确认机制来兜底,不然拆完事务可能带来更头疼的数据不一致问题。
5.3 嵌套事务和保存点:别乱用REQUIRES_NEW
很多团队一遇到“内部方法要回滚但不影响外部事务”的需求,第一反应就是REQUIRES_NEW。但REQUIRES_NEW会把当前事务整个挂起再开一个新事务,这是有代价的——它释放了当前事务持有的数据库锁吗?不会全部释放,而且会额外占用一个数据库连接。在高并发场景下,大量的REQUIRES_NEW会导致数据库连接池被快速耗尽。
如果你的需求是“内部方法失败只回滚内部的部分”,大多数情况下应该考虑NESTED而不是REQUIRES_NEW。NESTED用保存点实现,只在当前事务里打一个标记,内部回滚后外部事务还能继续。代价更低。但是别忘了,NESTED依赖数据库连接是否支持保存点,在JPA环境下不可用,这点在选型时要提前确认。
5.4 回滚策略的最佳实践
关于回滚策略,我总结出一个相对稳妥的模板,代码结构大概是这样的:
@Service public class TradeServiceImpl implements TradeService { @Transactional(rollbackFor = Exception.class, timeout = 10) @Override public void createTrade(TradeDTO dto) { // 1. 参数校验,校验失败直接抛业务异常 // 2. 核心写操作 // 3. 记录操作日志(如有需要则用独立事务) } }几个细节说一下。rollbackFor = Exception.class是一种兜底手段,不要指望靠它来掩盖异常设计不合理的问题。timeout要根据业务耗时设置合理阈值,太大没意义,太小会导致正常业务也被误杀。还有一点,@Transactional建议放在接口实现类上或者实现类的方法上,而不是接口的方法上。因为使用JDK动态代理时,注解在接口上虽然也能被识别,但有些框架的AOP解析对接口注解支持不够完善,放在实现类上最保险。
5.5 一个跟JPA相关的特殊提醒
如果你用的是JPA/Hibernate而不是MyBatis,事务回滚的粒度问题会更复杂一点。JPA的持久化上下文(Persistence Context)会在事务中维护一套实体的状态,当你修改了一个托管实体后,事务提交前Hibernate会做脏检查,自动把变更flush到数据库。这种机制下,事务边界内实体的状态变更非常容易被隐式持久化,哪怕你没有显式调用save方法。很多人用JPA时发现事务回滚了但某些字段还是变了,问题就出在flush时间点上。
我遇到过项目里因为JPA实体在事务中被修改,然后方法抛了异常,事务回滚了,但实体状态仍然被保留到下一次请求,导致缓冲区脏数据。解决方式是尽量在事务方法里避免修改托管实体,或者用DTO交互,不让实体跨事务边界传递。这个细节很多人不关注,但一旦发生数据错乱,排查成本极高。
6. 写在最后的一些实际体会
从我多年的开发和踩坑经历来看,@Transactional确实是一个“入门容易精通难”的注解。它给你提供了极大的便利,但同时也隐藏了非常多的实现细节。年轻人刚进项目时容易犯的毛病是——只要方法里写了数据库操作,就无脑加@Transactional,结果事务粒度失控、数据库连接被长时间占用、并发能力下降。而真正有经验的开发者会把事务当作一个需要精心设计的资源来管理,明确它的边界、明确它的传播方式、明确它是否和数据库的隔离级别匹配。
我再分享一个务实的小建议:如果你所在的项目对数据一致性要求很高,可以考虑把@Transactional的管理规范写到团队的开发文档里,包括哪些场景必须用、哪些场景禁止用、事务方法边界怎么划分、嵌套事务的代价是什么。团队里只要有人在这些细节上踩过一次坑,就值得把这个案例固化下来。
我个人的体会是,理解@Transactional的关键始终是那一条主线——Spring事务是基于代理实现的,事务的开启、提交、回滚全部由代理对象控制。你只要在脑子里面把这根弦绷住,大部分问题你都能自己推出来答案。剩下的就是多动手、多复现、多测边界场景,经验到位之后,事务问题就不再是玄学了。