ARTICLE DETAIL

资讯详情

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

Spring事务传播机制详解:从线程绑定到AOP代理,彻底搞懂事务传递

Spring事务传播机制详解:从线程绑定到AOP代理,彻底搞懂事务传递 作为Java后端开发者Spring事务大概是面试八股文里被问得最烂、但实战中出错率又最高的一个话题。尤其方法调用会不会开启事务事务到底怎么传这两个问题几乎每次技术复盘、每次故障排查都会撞上。网上解释Spring事务传播行为的文章很多但大多数只停留在REQUIRED是默认的REQUIRES_NEW会挂起当前事务这种口诀层面一旦牵扯到自调用、代理、内部方法嵌套很多人就乱套了。这篇文章我结合自己排查线上问题的真实经历把Spring事务的传播机制掰开揉碎讲清楚重点回答两个问题事务在方法调用链里到底是怎么传递的什么时候调方法会开新事务什么时候不会先说一个很多人的认知误区事务传播不是把连接像快递一样从一个方法传到另一个方法而是每个逻辑事务绑定到一个线程上下文同一线程内的方法调用共享同一套事务资源传播行为只决定了当前线程里此时已存在事务时新进入的方法该怎么处理。理解了这个本质后面所有传播级别、事务失效的坑都能推出来。1. 事务传播的本质绑定线程上下文而不是传递数据库连接1.1 同一个事务到底是什么意思我们先来看一个最简单的场景serviceA.methodA()调用serviceB.methodB()两个方法都加了Transactional。执行结果是一条SQL要么全成、要么全不成。表面上看这好像是因为两个方法共用了一个连接但准确来说是它们共用了一个事务同步管理器里的同一个事务资源。Spring事务管理最核心的类是TransactionSynchronizationManager。这个类内部维护着一个ThreadLocal里面存着当前线程的事务资源、事务同步回调、事务名称、只读状态等信息。当某个方法需要参与事务时它会先去这个ThreadLocal里取——取到了就用现有的这就是传播取不到就新建这就是开启。线程上下文是这个机制里最容易忽视的关键词。同一个线程里的方法调用天然共享ThreadLocal所以同一条调用链上的方法如果传播行为合适就会处于同一个事务这件事根子上是由ThreadLocal的线程绑定特性决定的不是Spring做了什么魔法。1.2 事务资源是怎么从Service层传递到DAO层的很多同学问Service层方法加了事务Mapper里执行的SQL怎么就知道自己在事务里答案隐藏在一个叫DataSourceUtils的类里。每次MyBatis执行SQL需要从SqlSession拿到Connection时走的不是直接dataSource.getConnection()而是DataSourceUtils.getConnection(dataSource)。这个工具类做了两件事// 伪代码演示核心逻辑 public static Connection getConnection(DataSource dataSource) { // 1. 从当前线程的事务资源里找有没有已经绑定过这个DataSource的Connection ConnectionHolder conHolder TransactionSynchronizationManager.getResource(dataSource); if (conHolder ! null conHolder.hasConnection()) { // 2. 有就直接返回不新建这就是“同一个事务” return conHolder.getConnection(); } // 3. 没有才从连接池拿新连接 Connection con dataSource.getConnection(); // 4. 把新连接绑到线程上 TransactionSynchronizationManager.bindResource(dataSource, conHolder); return con; }也就是说连接能不能复用取决于当前线程的ThreadLocal里有没有已经绑定好的ConnectionHolder。而TransactionSynchronizationManager里的资源Key又是DataSource所以一个线程可以同时绑定多个数据源的连接这也是多数据源事务各自独立的基础。1.3 内层方法想要新事务时发生了什么如果methodA()在事务里methodB()的传播行为是REQUIRES_NEW那Spring真正做的事情是挂起当前事务把当前线程的事务资源、同步回调等信息从ThreadLocal里拿出来存到一个SuspendedResourcesHolder对象里。清空当前线程的事务上下文。创建新事务从DataSource重新拿一个连接注意不是之前那个绑定到当前线程。执行完methodB()后恢复挂起的事务把新事务资源解绑再把suspendedResources里的旧连接重新绑回线程。这段逻辑在AbstractPlatformTransactionManager.handleExistingTransaction()里可以看到非常清晰的实现。所以挂起事务这个词不是比喻它就是在ThreadLocal层面做了一次换绑操作。理解到这一层你就会明白一个非常反直觉的事实REQUIRES_NEW开启的新事务用的数据库连接和外部事务不是同一个哪怕外部事务还没提交内部新事务的操作也会立即提交因为它是独立连接独立提交的。这也解释了为什么REQUIRES_NEW的事务不受外部事务回滚影响——它压根不在同一个事务上下文里。2. 方法调用会不会开启事务关键看调用方是不是AOP代理2.1 为什么自己调自己就失效这是纯技术面试里最经典的一道题Service public class OrderService { // 这个注解看上去“应该有事务” Transactional public void createOrder(OrderDTO dto) { saveOrder(dto); deductStock(dto); } Transactional(propagation Propagation.REQUIRES_NEW) public void deductStock(OrderDTO dto) { // 扣减库存异常时希望这里单独回滚 } }如果外面直接注入OrderService调用createOrder一切都正常但如果在createOrder内部直接调deductStock()你会发现REQUIRES_NEW完全不生效扣减库存和下单还是同一个事务里deductStock抛异常把整个订单创建也回滚了。为什么会这样因为Transactional是靠Spring AOP代理实现的。Spring容器创建Bean时如果检测到有事务注解会生成一个代理对象默认为JDK动态代理或CGLIB代理注入到依赖方。外部调用方持有的其实是代理对象调用createOrder时先进代理的拦截器逻辑TransactionInterceptor在这里开启事务然后再调用真实目标方法。问题在于目标方法内部执行this.deductStock(dto)时this是真实的对象不是代理对象。真实对象上压根没有事务拦截器所以这次调用只是普通的方法调用事务注解完全被无视了。2.2 从字节码层面看透代理对象这件事我曾经用javap -c看过编译后的class文件createOrder内部调用deductStock的字节码是invokevirtual方法接收者就是this。这个this在运行时是CGLIB生成的目标类子类吗不是。Spring注入给外部的是代理但代理内部最终会调用目标Bean的原始实例而这个原始实例内部this只可能是它自己。所以一句话总结同一个类内方法互调事务注解不生效除非你拿到的对象本身是代理。网上流传的自调用事务失效本质不是Spring的坑而是AOP的本质——代理只能拦截从外部进入代理对象的方法调用。2.3 事务不生效的另一种隐晦场景方法不是public还有一类情况是方法上加了Transactional但方法被写成了private或protected。Spring AOP默认只增强public方法AbstractFallbackTransactionAttributeSource里会过滤非publicJDK动态代理更是只能代理接口方法。如果方法不是public那TransactionInterceptor根本不会拦注解纯摆设。这点我在很久以前踩过一次把事务方法写在了一个private的helper方法里还特别得意地觉得这样更封装结果代码Review时被同事揪出来一测果然是提交和回滚都失效。2.4 三类场景排查清单现象根本原因排查思路方法A调方法BB的REQUIRES_NEW不生效B是私有方法或A/B在同一个类里拆到不同Bean或者将B设为public并注入B的代理外部调C方法C上事务注解但完全无事务C所在类没被Spring扫描到或方法不是public检查Bean是否被代理看日志里有没有Creating new transaction异常了但没回滚方法把异常catch了或rollbackFor没配检查TransactionAspectSupport的日志看事务状态是否标记rollback-only3. 传播行为别死记用业务场景去理解3.1 REQUIRED绝大多数业务的默认选择REQUIRED的语义是如果当前线程没有事务就新建一个如果已经有事务就加入这个事务。默认传播级别就是它所以很多人把Transactional简化成开启事务严格说是不准确的——准确说应该是确保当前方法运行在一个事务中必要时才新建。为什么这个默认值设计得很合理因为大多数业务不关心内外层是不是同一事务只想要要么全成要么全不成的原子性。用一个订单创建的经典场景orderService.createOrder()创建订单有事务内部调用inventoryService.deduct()扣库存内部调用accountService.pay()扣款这三个操作天然应该在同一个事务里任何一个失败了订单、库存、账户流水都应该一起回滚。它们之间没有部分提交的诉求所以REQUIRED完全够用。3.2 REQUIRES_NEW用于不想受外部事务牵连的独立动作什么时候需要强制新事务最典型的是操作日志和消息推送。假设你在一个大的业务事务里要记录一条审计日志。如果日志和业务在同一个事务里业务回滚了日志也被回滚了——那排查问题就少了一条线索。你肯定希望日志不管业务成败都落库这时候就应该用REQUIRES_NEW把日志写入独立成事务和主事务解耦。另一个经典场景是MQ消息发送。如果消息发送和业务在同一个事务里业务回滚后消息也跟着没发出去下游就会缺数据。用REQUIRES_NEW把发送消息独立出去业务回滚了消息也已经发出这时候需要自己在业务侧做补偿。这里要小心REQUIRES_NEW事务里的操作不依赖外部事务的最终结果所以要么你接受消息先发出去这个现实要么用事务消息等更可靠的方案。3.3 NESTED想要部分回滚但还依赖外部事务最终提交NESTED是一个容易被忽略但很有用的传播级别。它的语义是如果当前存在事务则创建一个保存点savepoint之后方法里的操作在这个保存点之后执行如果内部方法异常只回滚到保存点不影响外部事务继续往下走如果外部事务最终回滚内部方法做的操作也一起回滚。看到没NESTED和REQUIRES_NEW最大的区别是NESTED不是真独立事务它依然在外部事务里只是多了一个回滚标记点。这特别适合批量处理一批数据其中某几条失败不影响其他条处理的场景。举一个我自己做过的案例一个批量导入接口解析了1000条数据要逐一校验并入库。我期望的效果是单条数据格式错误时这一条回滚其他999条正常保存但是这一整批导入如果用户中途取消或者出现致命异常1000条全部回滚。这个需求用REQUIRES_NEW做不到其他条无法跟随整体回滚用单个大事务也做不到单条失败导致全部失败NESTED恰好满足。Transactional public void batchImport(ListRow rows) { for (Row row : rows) { try { // 注意这个方法里传播级别是 NESTED singleImportService.importOne(row); } catch (DataInvalidException e) { // 单条失败只回滚该条不影响其他条 errorList.add(row.getRowNum()); } } }原理上NESTED在MySQL里依赖SAVEPOINT所以要求底层数据库支持保存点InnoDB支持如果数据库不支持Spring会退回REQUIRED的行为这点要注意。3.4 其他几种传播行为SUPPORTS、MANDATORY、NOT_SUPPORTED、NEVER这四种实际业务中用得很少但面试常考我也简单说说SUPPORTS有事务就加入没有就以非事务方式执行。适合查询方法这种可事务可不事务的场景不过现在很多团队一致给查询加Transactional(readOnly true)其实也可以不加。MANDATORY必须在一个已有事务中执行否则抛异常。适合那些只允许被业务事务调用不允许单独执行的内部方法是一种很清爽的代码约束比if判断靠谱。NOT_SUPPORTED当前有事务就把事务挂起以非事务方式执行。适合发送短信验证码记录临时缓存这种不希望数据库事务拖累的操作因为事务持锁的时间越长并发冲突的概率越大。NEVER如果有事务就直接抛异常。这个用处非常小除非你要强约束某个方法绝对不能出现在事务上下文中。3.5 一张决策表按业务需求挑传播级别业务诉求推荐传播级别理由多个操作要么全成、要么全败REQUIRED默认、最简单独立日志/消息不受主事务影响REQUIRES_NEW独立连接、独立提交批量处理单条失败不拖累整体但整体可回滚NESTED保存点回滚强制要求被事务方法调用MANDATORY调用方无事务直接报错耗时操作不想占事务连接NOT_SUPPORTED挂起当前事务释放连接查询方法可有可无事务SUPPORTS灵活禁止事务上下文NEVER极少使用4. 传播和隔离级别、回滚行为混在一起时最容易翻车4.1 事务默认只回滚RuntimeException和Error很多人以为加了Transactional方法抛任何异常都会回滚这是错的。Spring默认只在抛出运行时异常RuntimeException或Error时回滚受检异常checked exception比如IOException默认不触发回滚。这个设计初衷是受检异常代表业务上可预期的、也许可以恢复的情况比如文件不存在、参数不合法不一定需要回滚整个事务。但在实际业务里很多时候受检异常也需要回滚。比如Transactional public void createOrder(OrderDTO dto) throws IOException { // 做一些业务操作 // 后续调用远程服务抛了个受检异常 if (dto.getAddress() null) { throw new IOException(地址为空); } }如果不下配置这个IOException不会回滚订单就带着地址为空的状态入库了。正确的做法是用rollbackFor显式声明要回滚的异常类。建议所有用事务的团队统一规范事务方法上要么写rollbackFor Exception.class要么明确知道自己依赖默认行为。4.2 隔离级别决定了并发下的可见性和传播一样重要Transactional里还有一个常被忽略的属性isolation。事务隔离级别解决的是多个事务并发读写同一批数据时互相能看到什么的问题。MySQL InnoDB默认隔离级别是REPEATABLE_READ可重复读这个级别下普通的SELECT在事务内部多次查询结果是一致的通过MVCC快照机制实现。如果你在一个事务里先查一次库存、减库存、再查一次库存看到的结果还是事务开始时的快照这就叫可重复读。不过要注意可重复读虽然是默认值但它不解决幻读。InnoDB在RR级别下通过next-key lock解决了大部分幻读场景但如果你用SELECT ... FOR UPDATE或者LOCK IN SHARE MODE会走当前读锁住的是最新的数据。这个就偏数据库原理了面试常问实际排查锁冲突时也关键。Java里Transactional支持的隔离级别有DEFAULT跟随数据库默认READ_UNCOMMITTED读未提交性能最好但脏读、不可重复读、幻读全都有READ_COMMITTED读已提交Oracle默认MySQL也可用REPEATABLE_READ可重复读MySQL默认SERIALIZABLE串行化性能最差一般不推荐我通常的建议是不要轻易把隔离级别调成SERIALIZABLE因为它本质上是以锁换一致性在高并发下等于把数据库串行化了吞吐量会直线下降。宁可把事务尽量缩短、锁粒度尽量控制好也不要全局提升隔离级别。4.3 只读事务readOnlytrue并没有你想象中那么神Transactional(readOnly true)是一个被广泛使用的配置尤其查询方法。很多人以为它能禁止写入提升查询性能。实际上它不是完全没有用但作用没那么神在MySQL/InnoDB下readOnly事务不会做SQL层面的写入拦截。你在一个readOnly事务里执行INSERT数据库照样能插入成功除非你的数据库账号本身没有写权限。真正的作用主要有两点一是给Spring一个优化信号JPA/Hibernate在只读事务下会跳过脏检查dirty checking减少一些性能损耗二是给数据库一个提示某些数据库比如Oracle下会启动只读事务的优化。还有一个隐藏价值是从语义上约束团队看到readOnly true就知道这个方法是只读的减少误改。但如果你性子比较较真也可以不给查询方法加事务直接一条SELECT走非事务连接即可逻辑上没问题。不过对于查询之后再根据结果做业务判断这种场景加readOnly能保证多次查询看到同一个快照避免并发写入导致前后结果不一致这也是一种值得的习惯。4.4 事务超时和回滚的连锁效应Transactional(timeout 5)是设置事务超时秒数超过后Spring会抛出TransactionTimedOutException并尝试回滚。这个属性在压测、排查慢SQL时非常有用但也要注意超时检查是在SQL执行之后的getNextDeadline检查点才判断的如果一个SQL本身跑了很久超时真正生效要等这个SQL返回。回滚的连锁效应是另一个容易踩坑的点。假设外层事务是REQUIRED内层方法也是REQUIRED内层方法抛了异常并且被内层方法自己捕获了没有向上抛——那事务不会回滚这是catch住异常导致事务不生效的经典原因。如果内层方法标记为REQUIRES_NEW它自己回滚了但外层事务不受影响继续提交这也是上面日志场景的典型使用方式。这里要特别小心rollback-only标记外层事务因为内层REQUIRED方法标记了rollback-only外层即使后续没有抛异常提交时也会直接UnexpectedRollbackException。这个异常信息里通常会提示Transaction rolled back because it has been marked as rollback-only。排查思路是看哪里把事务标记成了rollbackOnly通常就是内层某个REQUIRED方法抛了异常并被吞掉了。5. 用这三板斧10分钟快速定位事务问题5.1 打开Spring事务日志排查事务问题时我第一步永远是改日志级别。在application.yml里加上logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource: DEBUG org.springframework.jdbc.core: DEBUG如果你用的是application.propertieslogging.level.org.springframework.transactionDEBUG logging.level.org.springframework.jdbc.datasourceDEBUG logging.level.org.springframework.jdbc.coreDEBUG开启后日志里会明确打印Getting transaction for [com.example.service.OrderService.createOrder] Participating in existing transaction Creating new transaction with name [...] Releasing transactional SqlSession [...]看到Participating in existing transaction就说明方法是加入已有事务看到Creating new transaction说明产生了新事务。这两条日志是判断事务边界最直接的证据比你自己加断点看代码快得多。5.2 断点看AbstractPlatformTransactionManager如果日志不够比如日志被屏蔽、或者你想亲眼看看当前线程上绑定了哪些资源可以直接在AbstractPlatformTransactionManager.processTransaction()里打上断点。这个方法的status参数里保存了当前是否是新事务、是否存在保存点、是否“已标记回滚”等信息。配合Idea的Evaluate表达式输入TransactionSynchronizationManager.getResourceMap()就能看到当前线程绑定了哪些数据源连接非常直观。在实际排查中我最常用的一个动作是在调用链的入口方法打上断点然后在内部方法入口打上断点观察两次断点之间的TransactionSynchronizationManager状态变化。如果第二次断点时isActualNewTransaction()还是true且连接对象和第一次一样说明是同一个事务如果连接对象变了说明发生了事务切换REQUIRES_NEW/挂起或新的DataSource。5.3 一个可以抄作业的验证案例最后给一个完整的最小复现Demo。假设有一个CouponService用户下单时要给用户发一张优惠券发优惠券的逻辑独立于下单事务Service public class OrderService { private final CouponService couponService; public OrderService(CouponService couponService) { this.couponService couponService; } Transactional(rollbackFor Exception.class) public void placeOrder(OrderDTO dto) { // 1. 插入订单 orderMapper.insert(dto); // 2. 发优惠券期望独立提交 couponService.issueCoupon(dto.getUserId()); // 3. 故意抛异常检查订单是否回滚、优惠券是否仍在 throw new RuntimeException(库存不足); } } Service public class CouponService { Transactional(propagation Propagation.REQUIRES_NEW, rollbackFor Exception.class) public void issueCoupon(Long userId) { couponMapper.insert(userId); } }跑一次单测你会发现抛出异常后订单表没有数据但优惠券表多了一条记录。这就验证了REQUIRES_NEW的独立提交行为。如果把CouponService改成OrderService内部直接调用自己的方法优惠券就会跟着订单一起回滚从而验证自调用失效。这种示例比我之前只靠想象记结论要扎实得多强烈建议你在自己的工程里建一个测试用例跑一遍几分钟的实操胜过半小时的背诵。这个内容后续其实还能扩展很多方向比如事务和二级缓存、事务和消息队列的最终一致性、分布式事务下的传播拆解。但那些属于另一个话题了。当前这篇文章把事务传播和方法调用是否开事务这两个根子上的问题讲透日常开发里90%的事务疑问你应该都能自己推出来了。
返回列表