ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring事务传播机制与隔离级别解析:原理、失效场景及排查实战

Spring事务传播机制与隔离级别解析:原理、失效场景及排查实战 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后端开发的基础功底也是线上事故的高发区。把传播机制、隔离级别、失效场景这三块吃透你写出来的事务代码会扎实很多排查问题时的方向感也会完全不同。
返回列表