Spring事务的传播机制和隔离级别,是很多Java后端开发从“会用”到“懂原理”之间的一道分水岭。面试问到这两块,基本是在考察候选人到底有没有在复杂业务场景里处理过事务边界,还是只会在Service方法上打一个@Transactional注解就完事。而“事务失效”这个话题更是经典中的经典,项目里线上偶发数据不一致,查到最后往往是这几个坑中的一个。
我见过不少团队,技术栈用得挺新,微服务、分布式都上了,结果单体应用里最基础的事务反而没搞对。代码review时看到@Transactional满天飞,但细问一下传播机制默认是什么、隔离级别默认是什么、和MySQL默认的RR有什么关系,答不上来的人不在少数。这篇文章就把这三块彻底聊透,结合我实际开发和排障中的经验,把原理、场景、坑位一次理清楚。
1. 从一次“下单扣库存”讲透Spring事务的传播机制
1.1 为什么要设计传播机制,它到底在解决什么问题
事务传播机制,通俗点讲,就是当多个事务方法互相调用的时候,Spring该怎么处理事务边界。注意,这里的关键词是“方法互相调用”。
比如我有一个下单的入口placeOrder(),它内部需要调用deductStock()扣库存,还需要调用createOrder()生成订单。这三个方法如果都加了事务注解,那问题就来了:placeOrder自己开了一个事务,接着调用deductStock时,Spring要不要为它再单独开一个事务?还是直接加入当前事务?如果deductStock执行失败抛异常,是要把整个下单流程全部回滚,还是只回滚扣库存这一步,然后继续创建订单?
这些问题就是传播机制要解决的。Spring的Propagation枚举一共定义了7种传播行为,但实际业务里高频用到的就三四种。我在面试候选人的时候,不要求他们背全7种,但至少要知道REQUIRED、REQUIRES_NEW、NESTED这三者的区别和适用场景,因为这三个才是真正影响业务结果的。
1.2 七种传播行为逐一拆解,附真实业务匹配
先看一张完整对照表,然后我挑重点详细说:
| 传播行为 | 含义 | 典型业务场景 |
|---|---|---|
| REQUIRED | 有事务则加入,无事务则新建 | 下单、支付等核心写操作,默认值 |
| SUPPORTS | 有事务则加入,无事务则以非事务方式执行 | 纯查询方法,可读不可写 |
| MANDATORY | 必须在已有事务中执行,否则抛异常 | 内部公共服务,禁止单独调用 |
| REQUIRES_NEW | 挂起当前事务,新建一个独立事务 | 异步操作日志、消息推送、邮件发送 |
| NOT_SUPPORTED | 挂起当前事务,以非事务方式执行 | 某些特殊批量操作,避免长事务 |
| NEVER | 禁止事务,存在事务则抛异常 | 只读校验类操作,理论上不碰库 |
| NESTED | 基于Savepoint的嵌套事务 | 批量导入中的逐条回滚 |
REQUIRED(默认值)
REQUIRED的意义是“能共用一个事务就共用一个”。placeOrder调用createOrder时,如果placeOrder已经有了事务,那createOrder直接加入这个事务,两个方法在同一个事务上下文里,任何一个方法抛出RuntimeException,整个事务一起回滚。
这是绝大多数业务操作应该用的传播级别。比如用户下单,无论中间调用了几个方法——扣库存、生成订单、写支付流水——只要有一个环节失败,整体失败才是正确的逻辑。
REQUIRES_NEW,不被主事务绑架的独立事务
REQUIRES_NEW和REQUIRED最大的区别在于,它一定会把当前事务挂起,然后新建一个完全独立的事务。这个新事务提交、回滚都与外部事务无关。
举个最常见的例子:业务主流程里记录操作日志。主流程下单可能因为库存不足回滚,但日志这个动作不应该跟着回滚——你恰恰需要把这次失败的订单和失败原因记录在案。所以记录日志的方法必须用REQUIRES_NEW,让日志事务独立提交。
再比如发送短信验证码、调用外部推送接口,这类动作本身是外呼操作,如果主事务失败被回滚了,消息已经发出去的事实不可能回滚掉。所以这类方法也应该用REQUIRES_NEW独立提交,避免跟着主事务一起回滚后,用户收到了下单成功短信但订单实际没生成。
NESTED,保存点级别的局部回滚
NESTED理解和REQUIRES_NEW很像,但本质完全不同。它是基于数据库的Savepoint(保存点)实现的嵌套事务:外层事务依然存在,NESTED方法只是在当前事务里设置了一个回滚点,如果内部方法失败,可以只回滚到保存点,外部事务可以选择继续执行,也可以选择整体回滚。
典型场景是批量导入Excel。假设一次导入500条数据,其中第50条有问题。你希望前49条成功入库,第50条单独跳过,后面51到500条继续导入,最后把失败的明细记录返回给用户。这时候NESTED非常合适:每条数据在一个独立的保存点里执行,失败只回滚当前保存点,不影响其他数据的提交。
注意:
NESTED生效依赖JDBC 3.0的Savepoint支持,主流的MySQL InnoDB、Oracle、PostgreSQL都支持。另外Spring里NESTED有个限制——如果外层没有事务,它和REQUIRED表现一样,会直接新建一个事务。
1.3 REQUIRES_NEW用不好,连接池和锁会教做人
这里必须多说一嘴。REQUIRES_NEW虽然好用,但它是高并发场景下的“连接池杀手”。
事务的本质是持有数据库连接。一个REQUIRES_NEW方法意味着在外部事务还握着连接的同时,再向连接池申请一个新的连接。如果这种调用发生在一个高频路径上,比如循环里调了100次REQUIRES_NEW方法,就是同时向连接池要100个连接。连接池默认大小一般就10到20个,很快就打满了,其他线程拿不到连接,系统直接雪崩。
另外还有锁的问题。外部事务已经对某一行数据加了行锁,如果REQUIRES_NEW内部再去操作同一行数据,就会形成“自己等自己”的尴尬局面——外部事务还没提交释放锁,内部独立事务请求的是同一把锁,于是内部事务开始等待,而外部事务必须等内部事务返回才能提交,两边互相等待,直到数据库锁超时。
所以REQUIRES_NEW的正确打开方式是:低频、短小、和主事务操作的数据尽量不产生交集。如果你只是想“局部回滚某一步”,优先考虑NESTED,它不会占用额外连接。
2. 隔离级别的底层逻辑,以及为什么MySQL默认RR让很多人踩坑
2.1 脏读、不可重复读、幻读,先搞清楚对手是谁
在进入Spring的@Transactional(isolation = ...)配置前,得先理解数据库的并发读问题。这一步不牢,后面全飘。
有三个经典异常场景:
脏读:事务A修改了一条数据但还没提交,事务B读到了这条未提交的数据。然后事务A回滚了,事务B刚才读到的数据就成了“不存在的数据”,这就是脏数据。
不可重复读:事务A先查一条数据,得到值v1;此时事务B修改并提交了这条数据,值变为v2;事务A再次查询同一行,读到的是v2。同一个事务里,同一条数据前后两次读结果不一致,这叫不可重复读。
幻读:事务A按某个条件查询,得到3行结果;事务B插入了一条符合该条件的新数据并提交;事务A再按同样条件查询,得到4行。多出来的那一行像幻觉一样,所以叫幻读。
这三个问题,本质上对应了三种并发冲突:写未提交数据的冲突、行更新冲突、范围插入冲突。隔离级别就是针对这三种冲突不同程度地“设防”。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交(READ_UNCOMMITTED) | 可能 | 可能 | 可能 |
| 读已提交(READ_COMMITTED) | 不可能 | 可能 | 可能 |
| 可重复读(REPEATABLE_READ) | 不可能 | 不可能 | 可能(MySQL InnoDB通过间隙锁大幅削弱) |
| 串行化(SERIALIZABLE) | 不可能 | 不可能 | 不可能 |
2.2 Spring的隔离级别配置与数据库默认值
Spring的Isolation枚举对应关系:
| Spring隔离级别 | 说明 |
|---|---|
| DEFAULT | 使用数据库默认隔离级别,日常开发绝大多数场景用这个就对了 |
| READ_UNCOMMITTED | 读未提交 |
| READ_COMMITTED | 读已提交 |
| REPEATABLE_READ | 可重复读 |
| SERIALIZABLE | 串行化,并发能力最低 |
这里有一个容易忽略的知识点:Spring的DEFAULT不是Spring自己决定隔离级别,而是把决定权交回给数据库。如果你不传isolation属性,那事务的隔离级别完全取决于你连的数据库是什么。
- MySQL InnoDB默认隔离级别是
REPEATABLE_READ(可重复读) - Oracle默认隔离级别是
READ_COMMITTED(读已提交) - PostgreSQL 默认也是
READ_COMMITTED
不同数据库默认值不一样,意味着同一套业务代码,换一个数据库可能隔离级别就变了。这在公司做数据库国产化替换或者从Oracle迁移到MySQL的时候特别容易踩雷,最好在项目初期统一在数据库连接层显式指定隔离级别,而不是默默依赖默认值。
2.3 MySQL的RR级别下,幻读真的被解决了吗
不少同学有个误解:MySQL默认是可重复读,那就不会有幻读了。这个说法不完整。
InnoDB在REPEATABLE_READ级别下,确实通过两种机制大幅度削弱了幻读:
- MVCC多版本并发控制:普通查询(快照读)直接读快照,所以同一事务内多次普通查询,看到的是同一版本的数据,天然规避了部分幻读。
- 间隙锁(Gap Lock)与Next-Key Lock:当前读(
SELECT ... FOR UPDATE、UPDATE、DELETE)时,InnoDB不仅锁住命中的记录行,还会锁住记录之间的间隙,阻止其他事务往这个范围内插入新数据,从而防止幻读。
但注意,快照读和当前读是两套逻辑。如果你在同一个事务里先快照读,再走当前读,还是可能看到新插入的数据。最典型的场景:事务A先正常SELECT查出3行;事务B插入第4行并提交;事务A执行SELECT ... FOR UPDATE或UPDATE操作时,会对范围内的记录加锁,此时会触发“半一致性读”,可能把第4行也纳入当前读范围。
所以严格来说,MySQL的RR隔离级别并没有完全消灭幻读,只是在你“只用快照读”的前提下游刃有余。要彻底防幻读,还得靠SERIALIZABLE或者手动加锁。
我在实际开发里给团队定的规矩是:互联网高并发场景,隔离级别默认用数据库默认值,不轻易显式修改;如果遇到特殊的一致性需求,优先通过SELECT ... FOR UPDATE、唯一索引等手段在业务层面解决,而不是直接把整个事务升级为SERIALIZABLE。SERIALIZABLE虽然读写在理论上互不干扰,但并发能力断崖式下降,在线业务压测跑一轮就会发现吞吐量惨不忍睹。
3. @Transactional“神秘失效”背后的六大根因
3.1 同类内部方法调用,代理没生效
这是失效场景里出现频率最高的一个,也是初学阶段最疑惑的。
Spring的@Transactional底层基于AOP代理。Spring容器在启动时,会给被事务注解标记的Bean生成一个代理对象。外部调用这个Bean的方法时,实际先进的是代理对象,代理对象开启事务、执行业务逻辑、提交或回滚事务。但注意,这个代理过程只对外部调用生效。
如果OrderService内部有一个methodA(),它调用了同一个类里的methodB(),methodB上有@Transactional注解——很遗憾,这个注解不会生效。因为在JVM里,methodA内部通过this.methodB()调用,本质上绕过了Spring生成的代理对象,直接进入了目标对象自身的方法,事务切面根本没有机会介入。
@Service public class OrderService { public void outerMethod() { // 直接调用内部事务方法,事务不生效 innerMethod(); } @Transactional public void innerMethod() { // 操作数据库 } }解决办法有三个:
- 把
innerMethod拆到另一个独立的Bean里,让外部Bean调用触发代理; - 注入
ApplicationContext,从容器里重新拿代理对象再调用; - 在方法内使用
AopContext.currentProxy()获取当前代理对象(需要在启动类或配置里开启@EnableAspectJAutoProxy(exposeProxy = true))。
其中方案一最推荐,结构上天然规避了代理失效问题。用AopContext.currentProxy()虽然快速,但会让代码变得难读,而且有些人会误以为它是万能药,一旦忘了开启exposeProxy,运行时直接报错,排查成本反而更高。
3.2 方法被private、static修饰,或者类没被Spring管理
@Transactional只能作用在由Spring容器管理的Bean的public方法上。
private方法肯定失效,因为代理机制本质上要求方法能被外部调用,private方法对代理对象完全不可见;static方法同样失效,静态方法属于类而不是实例,代理机制无从插手。
还有一种情况也常发生:类上忘了加@Service、@Component等注解,或者直接new了一个对象,而不是从Spring容器中获取。手动new出来的对象完全游离在Spring容器之外,代理根本不会生成,@Transactional形同虚设。我在code review里见过有人把@Transactional写在Controller层方法上——严格说,如果Controller被容器管理,且方法声明为public,它其实能生效,但把事务切到Controller层是一种坏味道,会把HTTP请求线程和数据库事务的生命周期绑在一起,稍有不慎就引发长事务。事务应该只放在Service层,这是最基本的边界划分。
3.3 异常被吞掉或抛错了类型,回滚根本没被触发
Spring默认的回滚策略是:只对RuntimeException和Error回滚,受检异常(即CheckedException,比如IOException、SQLException)默认不回滚。这个设计逻辑是Spring框架认为受检异常一般是业务可恢复异常,希望开发者显式声明是否需要回滚。
看下面这个经典坑:
@Transactional public void createOrder() throws IOException { try { // 业务操作 orderDao.insert(order); // 调用文件服务 fileService.upload(file); } catch (IOException e) { log.error("上传失败", e); // 异常被吞掉,没有抛出 } }这里有两个问题叠加:第一,IOException被catch住,连日志打完就结束了,异常根本没有传播到事务代理那里,事务自然认为一切正常,直接提交;第二,就算把异常重新抛出去,抛的是IOException这个受检异常,Spring默认依然不会回滚——除非在@Transactional注解上显式声明rollbackFor = Exception.class。
所以正确写法要记住两条:
- 事务方法内不要随意catch异常,如果catch了,必须根据业务决定是否重新抛出;
- 注解上最好显式写明
@Transactional(rollbackFor = Exception.class),既覆盖了受检异常,也让代码阅读者一眼就能知道这个事务方法到底哪些异常会触发回滚。
3.4 多线程调用,事务和线程绑定你没想到
Spring事务是通过TransactionSynchronizationManager对数据库连接做线程绑定来实现的。更准确地说,事务上下文是存在ThreadLocal里的,每个线程持有自己独立的数据库连接副本。
所以,如果在事务方法里通过new Thread()或者线程池异步执行了一个子线程去做数据库写操作,这个子线程里的@Transactional完全无效——它拿不到主线程绑定的数据库连接,更不可能加入主线程的事务。子线程里的SQL要么自动提交,要么报错,但绝不会跟着主线程一起提交或回滚。
更隐蔽的问题是:子线程抛出的异常,主线程根本感知不到。主线程继续正常提交事务,数据就处于部分成功、部分失败的中间状态。
正确做法是:所有数据库操作尽量保持在同一线程内完成;异步任务要么单独声明事务、单独提交,要么用消息队列把数据库操作彻底和异步任务解耦。之前在一个支付对账项目里,有人为了“提升性能”,把用户余额变更和积分累计放进了异步线程,结果对账时频繁发现余额和积分对不上,查了大半天才定位到是线程池异步把事务边界撕裂了。
3.5 传播机制配置不当,事务被“隔离”了
有的失效不是“没事务”,而是“事务开了,但布局和你预期完全不一样”。
比如外层方法用了REQUIRED开了事务,内部调用了一个标记为NOT_SUPPORTED的方法。NOT_SUPPORTED的语义是挂起当前事务,以非事务方式执行。在这个内部方法里,SQL都是自动提交的,如果这里有一条SQL执行失败,它不会触发任何回滚,而且不会影响外部事务——从效果上看,就像这个内部方法“没有事务保护”。
还有一些团队把REQUIRES_NEW用在所有写操作上,导致外层事务和内部事务完全独立。内部方法先提交,外层方法后回滚,于是数据就出现“部分成功”——内部方法的数据已经落库,外层数据被回滚掉了。这种数据不一致最难排查,因为从日志上看,事务日志都打了“事务提交成功”,很难联想到这里有两套互相独立的事务。
所以,配置传播级别之前,先想清楚:这一步操作到底该跟主流程同生共死,还是必须独立提交?想不清楚就别乱用REQUIRES_NEW和NOT_SUPPORTED,默认的REQUIRED在绝大多数业务场景下就是最稳的选择。
3.6 数据库/表引擎不支持事务,注解成摆设
最后说一个低频但真遇到就很坑的原因:数据库表引擎不支持事务。
比如MySQL的MyISAM引擎就不支持事务。虽然MyISAM在业务系统里已经很少见了,但如果在老系统或者某些日志表上还在用,@Transactional会完全不生效——SQL该执行执行,该失败失败,没有回滚的可能。
判断方法很简单:
SHOW TABLE STATUS LIKE 'your_table_name';看Engine字段,InnoDB才支持事务,MyISAM不支持。
同样,有些分布式环境下的中间件、或者某些NewSQL数据库对事务的支持是阉割版,也要在引入之前就确认清楚事务能力边界。别等到线上出了脏数据,再回头查是不是底层存储不支持事务,这一步排查的代价太高了。
4. 事务失效排查的自检清单与一条实用排查链路
4.1 线上怀疑事务没生效,先按这条链路走
之前帮一个团队排查过这类问题,线上出现订单重复,怀疑是事务问题。我给了他们一套排查链路,按顺序走一遍,基本能定位:
第一步,确认方法签名和权限。@Transactional标在public方法上吗?类加了@Service等注解吗?
第二步,确认方法是不是被内部调用。在idea里搜一下这个方法,看调用方是不是同一个类里的另一个方法,如果是,优先怀疑自调用代理失效。
第三步,确认异常有没有被吞。看方法内有没有catch块,catch后有没有重新抛出。
第四步,确认异常类型。看抛出的是RuntimeException还是受检异常,注解的rollbackFor有没有显式配置。
第五步,确认有没有多线程。看方法有没有把写操作丢给子线程或线程池。
第六步,用日志确认事务边界。可以在方法入口和出口分别打日志,配合开启Spring事务日志来观察。
配置Spring事务日志的方式:
logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction.interceptor.TransactionInterceptor: DEBUG开启后,日志里会明确输出Acquired Connection、Releasing transactional SqlSession、Committing transaction、Rolling back transaction等关键信息。如果开启日志后连Committing transaction和Rolling back transaction都没有出现,那基本不是回滚策略的问题,而是事务压根没开。
4.2 终极自查清单,建议收藏
我把排查经验浓缩成一张表,每次写事务代码之前对照一遍:
| 检查项 | 说明 |
|---|---|
| 类是否被Spring容器管理 | 检查类注解 |
| 方法是否为public | 非public方法代理不入场 |
| 注解是否在Service层 | 尽量不放在Controller |
| 是否内部自调用 | this调用绕开代理 |
| 异常是否被吞 | catch后必须考虑重新抛出 |
| rollbackFor是否显式配置 | 覆盖受检异常 |
| 传播机制是否符合业务预期 | 慎用REQUIRES_NEW |
| 是否多线程写库 | 事务无法跨线程传播 |
| 隔离级别是否显式依赖数据库默认值 | 注意MySQL与Oracle不同 |
| 底层表引擎是否支持事务 | InnoDB才支持 |
4.3 代码里怎么主动感知事务状态
很多时候业务代码需要知道自己当前是否真的在一个事务里,可以这样判断:
TransactionSynchronizationManager.isActualTransactionActive()这个方法返回true,说明当前线程确实绑定了一个活跃事务。在调试事务失效问题时,这个方法非常有用。比如在方法入口打个条件断点或临时日志,直接看返回值,如果方法内明明标了@Transactional,返回值却是false,说明事务代理压根没介入,就可以立刻往“自调用”“类未被管理”这两个方向追。
我个人还习惯在单元测试里验证事务行为:
@SpringBootTest class OrderServiceTest { @Autowired private OrderService orderService; @Test void testRollback() { assertThrows(RuntimeException.class, () -> { orderService.outerMethod(); }); // 数据库断言:数据未被插入 } }用真实的Spring容器拉起一次事务测试,能把这篇文章里提到的多数失效场景直接验出来,比靠人肉review可靠得多。
5. 代码示例:用一组对比演示失效与修复
为了让大家更直观地感受,我结合上面内容做一个完整的正反例。
坏例子:
@Service public class OrderService { @Transactional public void createOrder(Order order) { orderDao.insert(order); stockService.deductStock(order.getProductId(), order.getQuantity()); // 这里抛出一个受检异常 try { notifyService.sendSms(order.getPhone()); } catch (IOException e) { log.error("发送短信失败", e); } } }问题点:
sendSms的IOException被catch吞掉,不重抛,事务不会感知到失败,orderDao.insert和deductStock会一起提交;createOrder是外部入口还好,如果它是被同一个类里另一个方法this.createOrder()调用的,那连事务都不会开。
修复版:
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public boolean createOrder(Order order) throws Exception { orderDao.insert(order); stockService.deductStock(order.getProductId(), order.getQuantity()); try { notifyService.sendSms(order.getPhone()); } catch (IOException e) { // 这里选择抛出,触发整个事务回滚 throw new BusinessException("短信发送失败,订单回滚", e); } return true; } }注意修复版的两个变化:一是rollbackFor显式声明为Exception.class,二是不该吞的异常直接包装成业务异常抛出去,让事务代理能收到异常信号。
如果确实希望“短信发送失败不影响下单”,那就不能继续用@Transactional管这个方法了,应该把发送短信这个动作抽到独立Bean,用REQUIRES_NEW配合try-catch包住,失败只记录日志,不影响主事务提交。
@Service public class OrderService { @Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { orderDao.insert(order); stockService.deductStock(order.getProductId(), order.getQuantity()); try { smsService.sendSmsInNewTx(order.getPhone()); } catch (Exception e) { // 记录日志,不影响主事务 log.error("短信发送失败,但订单已创建成功", e); } } } @Service public class SmsService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void sendSmsInNewTx(String phone) { // 独立事务,不受主事务回滚影响 smsGateway.send(phone); } }这套组合在实际项目中非常常用,也是事务传播机制最经典的应用模式之一。
6. 最后聊一点实战体会
整理完这些,我最想强调的其实是一句话:@Transactional不是万能的安全垫,它只是一个基于代理的声明式事务工具,边界非常清晰。你在业务代码里用它的前提,是你理解它背后的代理机制、线程边界、异常传播路径和数据库隔离级别的配合关系。抛开这些前提去使用,出了问题排查起来往往比不用事务更痛苦。
在实际项目中,我给自己定了几条硬规矩,也分享给大家参考:
第一,所有事务方法必须显式配置rollbackFor来兜底。别依赖Spring默认的运行时异常回滚,因为你不知道哪天同事会在事务方法里抛一个受检异常,然后线上的数据就悄悄错位了。第二,事务方法里绝不允许写网络请求。比如在事务里调外部HTTP接口,连接会被数据库事务长占着,一旦外部接口响应慢,数据库连接池立刻被消耗殆尽。需要调外部接口,先处理完数据库事务,再走应用层的异步或消息队列。第三,事务方法不要写得太“胖”。一个大事务里塞几十张表的写操作,锁范围大、持有时间长,并发一高就死锁。能裁就裁,能拆就拆,必要时引入异步补偿机制。
事务这东西,是Java后端开发的基础功底,也是线上事故的高发区。把传播机制、隔离级别、失效场景这三块吃透,你写出来的事务代码会扎实很多,排查问题时的方向感也会完全不同。