资讯动态

Spring事务实战:从注解到源码,彻底搞懂事务机制与失效场景

发布时间:2026/9/24 20:44:49 来源:尧图企业网站定制
Spring事务Transaction实战笔记从注解到源码把事务机制一次讲透做Java后端这几年Spring事务可能是被问得最多、踩坑最多、也是最容易被“会用但不懂”的一个知识点。很多人天天写Transactional但真要问一句“为什么这个注解能让方法回滚”或者“为什么有时候事务明明没生效”就卡壳了。这篇文章我想把自己在实际项目中积累的对Spring事务的理解完整梳理一遍从数据库事务的基础讲起到注解的正确用法、失效场景、分布式事务方案再到源码层面的代理与三级缓存机制一次性把这条线打通。不管你是刚入门的新手还是写了几年业务代码的老手这篇文章都值得花二十分钟读完。先说明白Spring事务到底解决了什么问题。在没有Spring之前我们操作数据库要手动去拿Connection手动setAutoCommit(false)然后业务逻辑执行完手动commit出异常了手动rollback。如果业务方法里调了好几个DAO每个DAO都自己开连接、自己提交那业务逻辑中途挂了前面的操作已经提交了后面的操作没执行数据就处于半更新状态问题非常大。Spring事务的核心价值就是把“事务边界”从业务代码里剥离出来通过AOP机制在方法执行前开启事务、方法正常返回后提交事务、方法抛出异常后回滚事务让开发者只需要关注业务逻辑本身。这篇文章适合所有正在用Spring或Spring Boot做开发的工程师。我会尽量把每一个关键概念都讲清楚包括传播行为、隔离级别、回滚规则这些容易混淆的配置也会把自调用、异常被吞、非public方法这些常见的失效场景一一拆解最后还会聊聊分布式事务的几种主流方案并带大家看一下Spring事务在源码层面的实现原理。1. 事务整体设计与Spring解决方案拆解1.1 数据库事务的ACID到底在保证什么在深入Spring事务之前我觉得有必要先统一一下对事务本身的认知。事务是一组数据库操作的执行单元这组操作要么全部成功要么全部失败回滚。它的四个核心特性就是我们常说的ACID原子性Atomicity事务中的操作不可分割要么全部执行成功要么全部不执行。这条靠数据库的undo日志机制保证回滚时利用undo log把数据恢复到事务开始前的状态。一致性Consistency事务执行前后数据总量和业务规则保持一致。比如A转账给BA扣了100块B就必须多100块不能出现只有一边变化的情况。隔离性Isolation多个事务并发执行时彼此之间不能互相干扰。这条靠锁机制和MVCC实现。隔离性的强弱直接决定了并发场景下数据的准确程度。持久性Durability事务一旦提交数据变更就是永久的即使系统宕机也不会丢失。这条靠redo日志保证系统重启后可以通过redo log重放恢复已提交的事务数据。这四条里和开发者日常纠结最多的就是隔离性。不同的隔离级别会带来脏读、不可重复读、幻读这些并发问题后面我会专门展开。1.2 Spring事务要解决的三个核心痛点没有Spring事务的年代写业务代码的痛苦我现在都还记得。总结下来主要是三个痛点第一事务代码与业务代码严重耦合。每个方法里都要重复地写connection.setAutoCommit(false)、try-catch、connection.commit()、connection.rollback()这一套模板代码。一个复杂的Service里如果有五六个事务方法光这些样板代码就能占掉一半行数。第二多数据源情况下事务难以统一管理。一个业务方法里可能同时操作了订单库和库存库如果两个库用的是不同的连接必须借助JTAJava Transaction API这种分布式事务中间件才能保证一致性配置极其复杂。第三事务边界模糊容易出错。手动管理事务时经常出现“忘记提交导致连接一直占用”“异常被catch住导致没有回滚”“事务嵌套时提交顺序混乱”等等问题。我当时在项目里遇到过最诡异的一个Bug就是某个方法在finally块里调用了connection.close()结果连接池把这个连接回收了但事务状态并没有重置下一个人从连接池拿到这个连接执行了一堆操作之后莫名其妙地被回滚了。Spring通过AOP统一解决了这些问题。它把事务管理抽象成PlatformTransactionManager这个接口不同的数据源接入不同的实现类事务的开启、提交、回滚完全由框架控制业务代码里不再需要处理任何事务相关的底层API。1.3 编程式事务与声明式事务的抉择Spring事务有两种使用方式这两种方式在实际项目中的适用场景差别很大我分开说。编程式事务就是在代码里通过TransactionTemplate或PlatformTransactionManager手动控制事务。这里写一个典型的示例Service public class OrderService { Autowired private TransactionTemplate transactionTemplate; Autowired private OrderMapper orderMapper; Autowired private AccountMapper accountMapper; public void createOrder(OrderDTO orderDTO) { transactionTemplate.execute(status - { try { // 1. 保存订单 orderMapper.insert(orderDTO); // 2. 扣减账户余额 accountMapper.deductBalance(orderDTO.getUserId(), orderDTO.getAmount()); // 3. 如果余额不足会抛出业务异常事务自动回滚 return Boolean.TRUE; } catch (Exception e) { // 手动标记回滚 status.setRollbackOnly(); throw e; } }); } }编程式事务的优点是非常灵活事务边界精确控制到代码块级别特别适合在一个方法里只有部分逻辑需要事务保护、或者需要根据运行时条件动态决定是否开启事务的场景。缺点是代码侵入性强每个需要事务的地方都得写这么一段模板逻辑维护成本高。声明式事务就是基于AOP的Transactional注解方式。这也是绝大多数项目中的主流用法核心优势是事务逻辑完全和业务逻辑解耦你只需要在方法或类上加上注解框架自动帮你完成事务的开启和提交回滚。我们日常开发中90%以上的事务需求用声明式事务就够了。这里说句实在话除非你的业务确实有极其特殊的事务边界需求否则我强烈建议优先使用Transactional。编程式事务虽然在笔试题里经常出现但实际项目里用得非常少。1.4 Spring事务管理器的核心抽象PlatformTransactionManager是Spring事务的最顶层抽象定义就三个方法public interface PlatformTransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }getTransaction负责根据事务定义开启事务或者加入已有事务commit提交rollback回滚。不同的持久化框架有不同的实现类最常见的几个DataSourceTransactionManager基于java.sql.Connection的单数据源事务管理器配合MyBatis、JDBC使用这也是绝大多数单体应用用的实现。JpaTransactionManager配合Spring Data JPA / Hibernate使用。JtaTransactionManager基于JTA的分布式事务管理器适合多数据源场景。Spring Boot中只要引入了spring-boot-starter-jdbc或spring-boot-starter-web并且在application.yml里配置了数据源DataSourceTransactionManager就会被自动配置。大多数情况下我们并不需要手动配置事务管理器Spring Boot的自动配置已经搞定了。2. Transactional注解的核心参数与配置要点2.1 注解的属性你真的都理解了吗Transactional注解上有几个重要属性每一个都可以说是“配置错了就出大问题”的级别我逐个拆解。value / transactionManager指定使用哪个事务管理器。这个通常在项目里有多个数据源、配置了多个PlatformTransactionManager时才需要显式指定。单数据源项目里不需要管。propagation事务传播行为。这是Spring事务中最灵活、也最容易把人绕晕的一个属性我会在2.2单独展开。isolation事务隔离级别。对应数据库的四种隔离级别默认是Isolation.DEFAULT即使用数据库默认的隔离级别。MySQL默认是可重复读REPEATABLE_READOracle和SQLServer默认是已提交读READ_COMMITTED。timeout事务超时时间单位秒。如果事务执行时间超过该值Spring会自动回滚事务。默认值TransactionDefinition.TIMEOUT_DEFAULT是-1表示不超时。注意这个超时不是“方法执行超过N秒就回滚”而是“事务内的SQL操作累计执行时间超过N秒就回滚”而且它本质上依赖底层数据库的查询超时机制有时候并不完全准确。readOnly是否只读事务。设置为true时Spring会做一些优化并且底层数据库连接也会被设置为只读模式此时执行insert/update/delete会抛出异常。这个属性应该设置给那些只做查询的方法作用是防止误操作而不是提升性能。有个常见的误解认为readOnlytrue能大幅提升查询性能实际上在大多数数据库中性能提升微乎其微它主要是语义上的保护。rollbackFor/rollbackForClassName指定哪些异常需要回滚。默认情况下只有RuntimeException和Error会触发回滚受检异常也就是checked exception不会回滚。这个设计很多新手不理解Spring这样做是因为受检异常通常代表“业务层面的可预期异常”比如余额不足、库存不够Spring认为这些情况业务代码本来就会处理不需要强制回滚。noRollbackFor/noRollbackForClassName指定哪些异常不需要回滚。我这里给一个最常用的完整配置示例Transactional( propagation Propagation.REQUIRED, isolation Isolation.REPEATABLE_READ, timeout 30, rollbackFor Exception.class ) public void createOrder(OrderDTO dto) { // 业务逻辑 }2.2 七种事务传播行为逐个说清楚传播行为解决的核心问题是当前方法被调用时如果调用方已经存在一个事务那么当前方法是加入这个事务还是挂起当前事务、自己新建一个事务七种传播行为对照着业务场景来看会好理解很多。我用一个最经典的“下单扣库存”场景来讲。假设有一个OrderService.createOrder()方法它内部调用了StockService.deductStock()方法来扣减库存。REQUIRED默认值如果当前存在事务则加入这个事务如果当前没有事务则新建一个事务。这是最常用的一种。在“下单扣库存”的场景中下单和扣库存必须作为一个整体要么都成功要么都失败所以扣库存方法必须加入下单方法的事务。REQUIRES_NEW无论当前是否存在事务都新建一个独立的新事务。如果当前已经存在事务当前事务被挂起直到新事务执行完毕后再恢复。这个用到的一个典型场景是操作日志记录。比如下单方法已经在事务里了但你希望订单操作日志的写入不受业务事务回滚的影响——即使订单创建失败了日志也要记录下来。如果把日志写入也放在同一事务里那么订单回滚时日志也会跟着一起回滚这就完全失去了审计的意义。NESTED如果当前存在事务则创建一个嵌套事务。嵌套事务是当前事务的一个“子事务”它有独立的保存点savepoint。如果嵌套事务回滚不会影响外层事务但如果外层事务最终回滚嵌套事务的提交也一起回滚。这个特性在实际开发中用得不多我印象里最典型的场景是“批量处理多条记录每条记录独立校验希望单条失败只回滚这一条”。SUPPORTS如果当前存在事务则加入如果当前没有事务就以非事务方式执行。这个适合那些“有事务就一起没事务也能跑”的辅助方法。NOT_SUPPORTED如果当前存在事务则挂起当前事务以非事务方式执行。适合某些内部包含大量查询、不想在长事务里执行的辅助方法。MANDATORY如果当前没有事务直接抛出异常。要求调用方必须开启事务适合那些脱离事务没有意义的方法。比如“余额扣减”方法如果在一个非事务环境中被调用扣减了一半突然报错没有回滚机制就会产生数据错误所以强制要求事务存在。NEVER如果当前存在事务直接抛出异常。适合那些不允许在事务中执行的操作比如某些外部接口调用事务环境下的连接占用时间长了会影响性能。我来写一个对比表方便大家快速查阅传播行为当前方法不存在事务当前方法存在事务典型场景REQUIRED新建事务加入当前事务默认选择绝大多数业务方法REQUIRES_NEW新建事务挂起当前事务新建独立事务操作日志、异步消息发送NESTED新建事务创建嵌套事务保存点批量处理单条失败仅回滚该条SUPPORTS非事务方式执行加入当前事务查询辅助方法NOT_SUPPORTED非事务方式执行挂起当前事务非事务执行大查询、外部接口调用MANDATORY抛出异常加入当前事务必须依赖外部事务的方法NEVER非事务方式执行抛出异常不允许在事务中执行的逻辑2.3 事务隔离级别的设置与常见并发问题隔离级别解决的是并发事务之间的可见性问题。越低级别的隔离并发性能越高但数据正确性越差。我用表来总结隔离级别脏读不可重复读幻读说明READ_UNCOMMITTED可能可能可能级别最低几乎不用READ_COMMITTED不可能可能可能Oracle/SQLServer默认REPEATABLE_READ不可能不可能可能MySQL InnoDB通过间隙锁解决了MySQL默认SERIALIZABLE不可能不可能不可能完全串行性能最差这几个问题初学者容易混淆我用人话解释一下。脏读事务A修改了一条数据但还没提交事务B这个时候读到了这条修改后的数据。如果事务A后续回滚了事务B就读到了一条“不存在”的数据。就好比你同事跟你说“这个需求甲方已经通过了”其实他还没正式汇报你按通过的状态开始干活了结果第二天同事说“被甲方打回来了”你昨天干的活全部白费。不可重复读事务A在同一个事务里第一次和第二次读取同一条数据结果两次读到的值不一样。原因是事务B在中间修改并提交了这条数据。就好比你点了杯奶茶第一次问店员“要多久”店员说10分钟过了5分钟再问店员说还要20分钟——同一个问题两次答案不一样。幻读事务A执行了一个范围查询比如select * from orders where amount 100第一次查出来5条事务B插入了2条同时满足条件的数据并提交事务A再次执行同样的查询发现变成了7条。这些多出来的记录就像幻影一样。MySQL的InnoDB存储引擎在可重复读级别下通过间隙锁机制解决了幻读问题这点和其他数据库不同面试时经常被问到。实际项目中怎么选隔离级别我的建议是默认不动用数据库的默认配置就行。MySQL默认的REPEATABLE_READ在InnoDB引擎下不会产生幻读已经能满足绝大多数场景。如果你的项目用的Oracle和SQLServer默认的READ_COMMITTED也基本够用。强行调高隔离级别到SERIALIZABLE意味着所有并发事务串行执行性能下降严重不是特别严格的数据一致性场景不需要这么做。2.4 回滚规则的坑为什么checkException不回滚这一节我单独拎出来说因为这个是新人最容易踩的坑而且踩了还不自知。Transactional默认的回滚触发条件只有RuntimeException和Error。如果你在业务方法里抛出了一个自定义的受检异常继承自Exception而非RuntimeExceptionSpring会认为这是业务预期内的异常不会回滚事务。我之前在一个支付项目中就出过这个事。当时有一个退款接口代码大概是Transactional public void refund(RefundDTO dto) throws RefundException { // 1. 更新退款单状态 refundMapper.updateStatus(dto.getRefundId(), RefundStatus.REFUNDING); // 2. 调用支付渠道退款接口 boolean success payChannel.refund(dto); if (!success) { throw new RefundException(支付渠道退款失败); } // 3. 更新退款单状态为已退款 refundMapper.updateStatus(dto.getRefundId(), RefundStatus.REFUNDED); }RefundException是我自定义的受检异常继承自Exception。结果就是支付渠道退款失败抛出RefundException后事务没有回滚第一步那个“退款中”的状态更新被提交了。后续这个退款单一直卡在“退款中”需要人工介入修复。排查了半天才意识到是异常类型的问题。正确的做法是在注解里显式指定回滚异常Transactional(rollbackFor Exception.class)或者把自定义异常改成继承RuntimeException。我个人建议统一用rollbackFor Exception.class这样不管什么异常都能回滚最省心。不过要注意如果你用的是声明式事务rollbackFor只是指定了回滚的异常类型真正的事务回滚还是依赖异常从被AOP代理的方法中传播出去。3. 事务失效的经典场景与底层原理分析3.1 自调用失效this.method()为什么不行这是Spring事务中最经典的一个失效场景凡是面试问到“事务失效的场景”基本第一个说的就是它。来看这段代码Service public class OrderService { Transactional public void createOrder() { System.out.println(createOrder); this.deductStock(); // 自调用 } Transactional public void deductStock() { System.out.println(deductStock); // 扣减库存逻辑 } }从外部调用orderService.createOrder()你可能会觉得createOrder()和deductStock()都有事务注解应该都在事务保护内。但实际上deductStock()的事务完全没有生效。原因在于Spring的声明式事务是基于AOP动态代理实现的。在Spring容器中真正注入到调用方的对象不是OrderService本身而是它的一个代理对象JDK动态代理或CGLIB代理。外部调用者调用的是代理对象的方法代理对象在方法执行前开启事务、方法执行后提交或回滚事务。但内部调用时this指向的是当前的目标对象不是代理对象所以this.deductStock()直接走的是目标对象的方法绕过了代理逻辑事务注解自然就失效了。解决方式有几种。第一种最简单粗暴把内部方法拆到另一个Service类中通过注入的代理对象调用例如注入一个StockService调用stockService.deductStock()。第二种是在当前类里注入自身Service public class OrderService { Autowired private OrderService self; Transactional public void createOrder() { self.deductStock(); } }注意Spring Boot 2.6版本之后默认不允许循环依赖了如果项目里其他地方有循环依赖用Lazy注解可以绕开或者直接用AopContext.currentProxy()Transactional public void createOrder() { OrderService proxy (OrderService) AopContext.currentProxy(); proxy.deductStock(); }使用AopContext.currentProxy()需要先在启动类或配置类上加上EnableAspectJAutoProxy(exposeProxy true)否则会报“Cannot find current proxy”的异常。3.2 非public方法导致的事务不生效Spring的Transactional注解只能应用于public方法上。如果注解加在private、protected或包内可见的方法上事务不会生效而且不会报错。原理在于Spring AOP的代理机制JDK动态代理要求目标方法实现某个接口CGLIB代理虽然可以代理类但它通过继承目标类并重写方法来实现对于private方法无法重写对于protected和包内可见的方法在跨包的情况下也无法重写。Spring官方文档明确说Transactional应该只放在public方法上。我在实际项目中还遇到过一种隐蔽的写法就是在一个类内部有一个private方法加了Transactional然后public方法里调用这个private方法问身边同事这个地方事务为什么不生效对方都会答不上来。实际上private方法上的注解在编译时就被Spring在解析元数据的过程中过滤掉了根本不会生成事务拦截逻辑。这里多说一句Spring 6开始Transactional在非public方法上的表现有了一些变化部分版本下不再只是“不生效”而是直接启动时校验失败但这并不意味着可以随意在非public方法上用事务最好的习惯还是严格保持public。3.3 异常被捕获后事务静悄悄失效这个问题比自调用更隐蔽因为它不报错、不改架构只是行为不对。来看这个例子Transactional public void batchProcess(ListOrder orders) { for (Order order : orders) { try { processOrder(order); } catch (Exception e) { log.error(处理订单{}失败, order.getId(), e); // 这里把异常吃了 } } }你的本意是“批量处理所有订单单条失败不影响其他条”。但问题在于事务边界是整个batchProcess方法当processOrder(order)抛出异常后被catch住异常不会继续往外抛事务就不会回滚。那么前面已经执行成功的订单以及当前这条失败前写了一半的数据最终都会被提交。你得到的不是“部分成功”而是“脏数据提交”。解决方案有两种思路。如果希望单条失败统一回滚整个批次那就别catch让异常抛出去让事务整体回滚。如果希望单条失败不影响其他条那需要把每个订单的处理逻辑单独设置事务并且不让外层的事务接管通常会用到REQUIRES_NEW传播行为把处理单个订单的逻辑放到独立的Service方法中声明为Transactional(propagation Propagation.REQUIRES_NEW)这样即使这一条事务回滚外层事务或非事务环境不受影响。这个实践在批处理、定时任务、消息消费场景中特别重要。我之前负责过一个对账系统每天凌晨跑批对账明细处理失败就catch住继续下一条结果失败的那些记录既有部分字段更新了又有部分关联记录写入了排查起来非常痛苦。后来改成REQUIRES_NEW独立事务方案每条对账记录一个独立事务失败就回滚这一条再通过日志和状态字段标记失败原因整个数据链路一下就清晰了。3.4 传播行为配置错误导致的事务边界混乱传播行为的错误配置造成的问题往往要到压测或生产环境并发量上来之后才会暴露。我遇到过的一个典型例子是某个AuditLogService.saveLog()方法上的事务传播行为被配置成了REQUIRES_NEW但这个方法实际是在主业务方法内部被调用的每次调用都会把当前事务挂起、开启新事务、提交新事务、恢复主事务。由于挂起和恢复涉及底层连接的占用和切换在高并发场景下数据库连接池被打满大量请求阻塞在获取连接上。这种问题不是事务本身失效而是事务边界划分不合理。审计日志类的写入要么让它在同一事务里REQUIRED要么根据业务需要单独成事务。REQUIRES_NEW不是不能用但一定要明确每次调用都会开启一个新事务、独立提交这意味着它会占用额外的数据库连接而且如果新事务执行时间长会延长整体处理时间。3.5 存储引擎不支持事务这个失效场景最诡异因为代码完全正确但事务就是“没有声音”。如果在MySQL中使用了MyISAM存储引擎那么Transactional是没有任何效果的因为MyISAM本身不支持事务InnoDB才支持。排查方法很简单执行以下SQL查看表引擎SHOW TABLE STATUS FROM your_database WHERE Name your_table;或者在创建表时指定CREATE TABLE your_table ( ... ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这个坑在老旧系统迁移时最容易踩到。之前帮一个客户维护一套老系统里面有一批表还是MyISAM结果加了一堆事务注解该回滚的还是没回滚最后才发现是表引擎的问题。3.6 多数据源事务配置错乱如果项目里配置了多个数据源比如主库和读库分离那么Transactional默认使用的DataSourceTransactionManager只会管理主数据源的事务读写库的数据变更不受事务保护。如果业务逻辑里同时操作了主库和从库而且主库事务回滚了从库的写入已经提交两个库的数据就不一致了。解决这种问题需要为不同数据源配置不同的事务管理器并在使用事务的方法上显式指定Transactional(transactionManager secondaryTransactionManager) public void writeToSecondary() { // 操作从库 }要么就引入JTA或Seata这类分布式事务方案来统一管理多数据源。需要注意的是多数据源事务本身是一个分布式事务问题单独靠Transactional是解决不了的。4. 分布式事务的现实困境与主流方案4.1 为什么微服务架构下事务问题更复杂单体应用时代一个业务操作涉及的所有表都在同一个数据库里用Transactional就可以保证一致性。但微服务架构下订单服务操作订单库库存服务操作库存库账户服务操作账户库一个完整的业务链路跨越了多个服务、多个数据库每个服务自己的本地事务都无法保证整体的一致性。典型的例子就是下单流程。用户下单后订单服务创建一条订单记录库存服务扣减库存账户服务扣减余额。如果库存扣减成功了但余额扣减失败订单服务、库存服务、账户服务各自提交了自己的事务没有统一的回滚机制整个系统就处于“订单已创建库存已扣但钱没扣到”的中间状态。分布式事务要解决的核心问题就是跨服务、跨数据库的一致性。注意这里说的“一致性”和数据库ACID里的一致性不一样分布式事务领域通常强调的是“最终一致性”——在一段时间内允许数据不一致但经过补偿机制后最终达到一致状态。4.2 强一致性方案2PC与3PC2PC两阶段提交是最经典的强一致性方案核心思想是引入一个协调者节点把事务提交过程分为准备阶段和提交阶段。准备阶段协调者向所有参与者发送准备请求各参与者执行事务操作但先不提交把undo和redo日志写入磁盘然后向协调者回复“可以提交”或“不能提交”。提交阶段如果所有参与者都回复“可以提交”协调者广播“提交”指令各参与者正式提交事务如果任何一个参与者回复“不能提交”或超时未响应协调者广播“回滚”指令所有参与者回滚自己的本地事务。2PC的优点是实现简单、强一致性缺点也很明显最核心的是同步阻塞问题准备阶段所有参与者都在等待协调者的最终指令期间数据库资源一直被锁定高并发场景下性能非常差。另外还有一个协调者单点问题如果协调者在提交阶段宕机了所有参与者都会一直阻塞等待无法决定是提交还是回滚。3PC三阶段提交在2PC的基础上增加了一个“预提交阶段”把准备阶段拆成了“CanCommit”和“PreCommit”两步目的是尽量降低协调者单点故障对参与者的阻塞时间。但由于实现复杂业界实际应用并不多。2PC/3PC在实际业务中很少直接使用通常由分布式数据库中间件或XA协议来承载。如果你用过Atomikos、Narayana这类JTA实现底层就是基于2PC的。4.3 柔性事务方案TCC、本地消息表与MQ事务消息由于2PC的阻塞问题互联网大厂在实际落地时更多采用柔性事务方案核心思想是“放弃强一致性追求最终一致性”。TCCTry-Confirm-Cancel是一种补偿型事务方案需要业务方自己实现三个方法Try阶段完成资源预留。比如扣减库存时先冻结库存量不实际扣减。Confirm阶段确认执行。真正扣减冻结的库存这一步如果失败会不断重试直到成功。Cancel阶段取消执行。释放Try阶段冻结的库存。TCC的优点是控制粒度细、性能好但实现成本很高每个业务操作都要设计三套逻辑。目前主流的TCC框架有Seata、ByteTCC、tcc-transaction。本地消息表的方案很有意思思路是“事务操作和消息写入放在同一个本地事务中”。以订单创建为例订单服务在本地事务中同时完成两件事创建订单、往本地消息表插入一条“创建订单”的消息。本地事务提交后消息服务会定时扫描消息表把未发送的消息发送给消息队列。库存服务消费消息完成扣库存操作。如果库存服务处理成功反馈成功并删除消息如果处理失败消息一直存在消息服务会一直重试。为了避免无限重试需要配套一个“对账系统”对于重试多次失败的消息触发告警并进入人工处理流程。这个方案的优点是不依赖外部中间件、实现简单缺点是消息表和数据表耦合在一起会多一次数据库写入和定时扫描的操作而且无法做到实时性。MQ事务消息是目前比较推荐的方案以RocketMQ的事务消息为例核心是一个“两阶段”的半消息机制订单服务发送一条“半消息”给MQ此时消息对消费者不可见。订单服务执行本地事务创建订单。本地事务执行成功后向MQ发送“Commit”指令消息才对消费者可见如果本地事务失败发送“Rollback”指令MQ删除半消息。如果MQ迟迟没有收到Commit/Rollback指令会反向回查订单服务的事务执行状态然后决定是提交还是回滚这条消息。这个方案把最终一致性的通用逻辑下沉到了MQ中间件中业务方的实现成本大幅度降低。如果你用了RabbitMQ它没有原生的事务消息支持需要手动设计一个“本地消息表定时补偿”的方案。4.4 Seata目前生产环境最主流的分布式事务框架SeataSimple Extensible Autonomous Transaction Architecture是阿里开源的一套分布式事务解决方案目前已在生产环境有大量验证。它提供了三种模式AT模式是Seata最有特色的对业务代码侵入最小。它的原理是在执行业务SQL之前先解析SQL生成“前镜像”数据修改前的快照执行SQL后再生成“后镜像”数据修改后的快照这些镜像存放在Seata的undo_log表中。如果全局事务需要回滚Seata根据前后镜像自动生成反向SQL来恢复数据。AT模式的好处是业务方完全不需要感知分布式事务只需要在业务方法上加GlobalTransactional注解。但它的代价是性能损耗比较大每个SQL语句都多了一次日志写入和解析操作。TCC模式就是前面说的手动三阶段补偿Seata只是提供了一个框架支持。SAGA模式是长事务解决方案适合业务流程特别长、包含多个服务调用的场景。它的核心思想是把一个大事务拆成多个本地事务每两个本地事务之间有一个补偿操作。如果某个本地事务失败了会反向依次调用之前所有事务的补偿操作来撤销。从实践角度看如果你的团队没有专门的基础设施团队分布式事务的首选方案应该是MQ事务消息加本地消息表这套方案足够简单、可控性强、容易排查问题。如果团队有能力和精力Seata AT模式是体验最佳的但一定要在压测环境充分验证性能。5. Spring事务源码原理代理机制与三级缓存5.1 事务拦截器的工作链路前面说了很多事务的使用和配置现在进入源码层面看看Spring到底是怎么把Transactional变成一个代理逻辑的。Spring事务的核心类是TransactionInterceptor它实现了MethodInterceptor接口。当一个被Transactional标注的方法被外部调用时真正执行的顺序是这样的调用者拿到的是Spring容器中的代理对象。代理对象调用TransactionInterceptor的invoke()方法。invoke()方法内部先根据Transactional注解创建TransactionInfo其中包含了事务管理器、事务属性等信息。调用被代理的目标方法。目标方法正常返回后调用事务管理器的commit()提交事务。目标方法抛出异常时根据rollbackFor配置判断是否需要回滚如果需要则调用rollback()。简化后的invoke方法核心逻辑大概是public Object invoke(MethodInvocation invocation) throws Throwable { TransactionInfo txInfo createTransactionIfNecessary( (PlatformTransactionManager) transactionManager, txAttr, joinpointIdentification ); Object retVal; try { // 调用目标方法 retVal invocation.proceed(); } catch (Throwable ex) { // 根据异常类型决定是否回滚 completeTransactionAfterThrowing(txInfo, ex); throw ex; } finally { cleanupTransactionInfo(txInfo); } // 方法正常返回提交事务 commitTransactionAfterReturning(txInfo); return retVal; }从这段伪代码可以非常清楚地看到为什么异常被catch住后事务不会回滚——因为异常根本没有传播到TransactionInterceptor的catch块中。整个事务链路是依赖于“异常不被吞掉”这个前提的。5.2 Spring三级缓存与循环依赖下的代理创建时机热词里提到了“Spring三级缓存原理”这个和事务失效有非常微妙的关系。Spring解决循环依赖靠的是三级缓存第一级缓存singletonObjects存放完整的、已经初始化完成的单例Bean。第二级缓存earlySingletonObjects存放被提前暴露出来的、尚未完全初始化完成的Bean。第三级缓存singletonFactories存放Bean的工厂对象用于生成早期引用。循环依赖的解决流程大致是A依赖BB依赖ASpring创建A时发现A还没初始化完就把A的ObjectFactory放入三级缓存然后去创建B。B创建时依赖A从三级缓存中获取A的早期引用完成B的初始化B回到A的创建流程中A完成后B和A都被放入了单例缓存。这里的问题在于如果A的最终完成版本应该是一个代理对象比如A的方法上加了Transactional那么B持有的A的早期引用必须是代理否则事务失效。Spring对这个问题的处理方式是在三级缓存的ObjectFactory中通过AbstractAutoProxyCreator的getEarlyBeanReference()方法提前创建代理对象。也就是说如果Bean需要被代理有Transactional等AOP注解那么它的早期引用就已经是代理对象了。如果循环依赖产生了早期引用且此时代理还没创建后续正式初始化时会发现已经提前暴露了引用那么Bean后续可能不会被再次代理。Spring通过wrapEarlyBeanReference标志来尽量保证一致性但实际开发中最好还是避免循环依赖因为它会导致很多隐蔽的代理问题。5.3 手写一个简化版事务代理来理解原理为了更直观地理解Spring事务的底层机制我建议你可以动手写一个简化版的事务代理工具。核心思路是用Java动态代理在方法执行前开启事务正常返回提交异常抛出则回滚。public class TransactionProxyFactory { public static Object createProxy(Object target, DataSource dataSource) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) - { Connection connection dataSource.getConnection(); boolean originalAutoCommit connection.getAutoCommit(); try { connection.setAutoCommit(false); Object result method.invoke(target, args); connection.commit(); return result; } catch (Exception e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(originalAutoCommit); connection.close(); } } ); } }这个例子当然远不如Spring的实现完整但它展示了一个最核心的思想事务管理的本质就是在目标方法的外面包一层“代理壳”通过方法执行结果的异常与否来决定是提交还是回滚。Spring事务的巨大价值在于把这层“壳”做成了通用的、可配置的、与业务代码解耦的框架你只用加一个注解就能获得它。6. 常见问题排查与调试技巧6.1 如何确认当前方法是否真的在事务中排查Spring事务相关问题第一步永远是确认“事务到底有没有开启”。我分享几个实用的确认方法。最直接的方法是在关键位置打印当前连接的事务状态。在MyBatis的Mapper接口方法中注入SqlSessionFactory通过SqlSession拿到底层连接检查连接的getAutoCommit()值Autowired private SqlSessionFactory sqlSessionFactory; public boolean isInTransaction() { try (SqlSession session sqlSessionFactory.openSession()) { Connection connection session.getConnection(); return !connection.getAutoCommit(); } catch (Exception e) { log.error(检查事务状态失败, e); return false; } }还有一个小技巧在application.yml中开启MyBatis的SQL日志观察日志中是否出现Setting autocommit to false on JDBC Connection这条记录。Spring事务管理器在开启事务时会调用connection.setAutoCommit(false)如果日志里有这行说明事务已经开启logging: level: com.example.mapper: debug如果你用的是Spring Boot 3.x和MyBatis可以配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl6.2 SQLServer事务日志查看与诊断热词里提到了“sqlserver事务日志查看”这里补充说明一下。在SQLServer中如果遇到事务无法提交、日志暴涨、回滚卡死这类问题可以靠几条SQL快速定位。查看当前活跃事务和会话信息SELECT s.session_id, s.status, s.login_name, t.transaction_id, t.name AS transaction_name, t.transaction_begin_time, DB_NAME(t.database_id) AS database_name FROM sys.dm_tran_active_transactions t INNER JOIN sys.dm_tran_session_transactions st ON t.transaction_id st.transaction_id INNER JOIN sys.dm_exec_sessions s ON st.session_id s.session_id WHERE t.database_id DB_ID(your_database);查看事务对应的SQL语句和阻塞情况SELECT t.transaction_begin_time, t.name, s.status, es.text AS sql_text FROM sys.dm_tran_active_transactions t LEFT JOIN sys.dm_tran_session_transactions st ON t.transaction_id st.transaction_id LEFT JOIN sys.dm_exec_sessions s ON st.session_id s.session_id OUTER APPLY sys.dm_exec_sql_text(s.sql_handle) es WHERE t.database_id DB_ID(your_database);如果发现事务长时间不结束大概率是代码里的事务没有正确提交或回滚比如占用连接但事务没关闭、循环体内开启事务但某个提前return绕过了提交逻辑、又或者是锁等待导致事务悬挂。这类问题在生产环境非常致命会造成连接池耗尽。6.3 通过SqlSession显式提交事务的场景大多数时候我们依赖Spring声明式事务就够了但有些渗透在框架底层的数据操作比如用SqlSessionTemplate直接操作时也需要手动控制事务边界。Autowired private SqlSessionTemplate sqlSessionTemplate; public void manualTx() { sqlSessionTemplate.getSqlSessionFactory().openSession(ExecutorType.BATCH, false); // 业务逻辑 sqlSessionTemplate.getSqlSession().commit(); // 显式提交 // sqlSessionTemplate.getSqlSession().rollback(); // 显式回滚 }不过在实际项目中我更推荐直接在Service方法上使用Transactional来管理事务而非在SqlSession层面手动控制因为前者会统一纳入Spring的事务管理体系中连接资源、提交回滚时机都由框架协调不容易出现连接泄漏的问题。6.4 高频面试题速查表最后整理一份Spring事务相关的高频面试题及答案方便大家面试前快速过一遍面试题核心要点Spring事务的传播行为有哪些REQUIRED、REQUIRES_NEW、NESTED、SUPPORTS、NOT_SUPPORTED、MANDATORY、NEVER事务失效的常见场景自调用、非public方法、异常被捕获、rollbackFor未设置、数据库引擎不支持、多数据源未指定事务管理器为什么注解默认不处理受检异常受检异常代表业务预期内的异常需要业务方自行决定是否回滚Spring事务底层原理基于AOP动态代理通过TransactionInterceptor拦截方法方法执行前开启事务异常抛出时决定回滚正常返回时提交循环依赖与事务失效的关系循环依赖时提前暴露的Bean可能是早期引用AOP代理可能无法正确创建因此事务可能失效ReadOnly为什么不能提升性能它主要是语义上的保护实际性能提升非常有限MySQL默认隔离级别REPEATABLE_READInnoDB下靠间隙锁解决了幻读问题分布式事务有哪些方案2PC、3PC、TCC、本地消息表、MQ事务消息、SAGASeata框架实现回到最初的问题为什么Transactional能帮我们管理事务因为Spring把事务的开启、提交、回滚封装成了AOP拦截逻辑你只需要在方法上声明注解剩下的交给代理对象。理解了这一点事务失效的每一类场景都有了解释自调用绕过了代理异常被吞没传到拦截器非public方法无法被代理数据库本身不支持事务。这些并不是玄学而是代理机制、异常传播机制和底层存储引擎的必然结果。我个人在实际项目中的体会是Spring事务本身不难难的是“判断当前方法是否真的处在一个预期的事务边界内”。很多线上事故的根源不是开发不知道事务的用法而是复杂调用链下某个旁路方法的事务边界和主流程的事务边界不一致。所以我一直建议团队里定两条规则第一事务注解统一加rollbackFor Exception.class第二涉及事务的Service方法之间用对象引用调用不要用this直接调私有方法。这两条规则简单但真的能挡掉大部分麻烦。最后再分享一个排障小技巧当你怀疑事务没生效时不要盯着代码猜先开SQL日志看Setting autocommit to false on JDBC Connection这行日志有没有出现。有说明事务已经开启问题出在回滚条件或传播行为上没有说明事务压根没启动直接从代理失效的方向排查。定位问题的思路对了一个复杂的事故往往能在十分钟内找出根因。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价