Spring事务原理与失效场景剖析:从JDBC到声明式事务的完整调用链
做后端开发这些年Spring事务相关的坑我踩过不少也帮团队排查过不少。最典型的一次是一个订单接口明明加了Transactional结果库存扣减抛异常后订单记录照样落库。代码反复看了好几遍都没发现问题最后把代理调用链和事务同步器的行为完整捋了一遍才定位到根因。那次之后我就萌生了一个想法把Spring事务的核心原理彻底梳理清楚写成一篇既能当自己复盘笔记、又能直接给团队做培训的硬核文章。Spring事务本质上是对底层数据库事务能力的封装和增强开发者不需要再手写setAutoCommit(false)、commit()、rollback()那套重复代码。但它远不止简化写法这么简单背后涉及PlatformTransactionManager抽象、动态代理、事务同步器、传播行为、隔离级别、异常回滚策略等一系列复杂机制。这篇文章适合所有用Spring Boot做业务开发的同事也适合准备面试、想从头把Spring事务搞明白的朋友。我会从最底层的JDBC事务说起一直拆到Spring声明式事务的完整调用链最后再分享几个实战中非常容易踩的坑。1. 先把事务的本质讲透从JDBC到Spring的封装逻辑1.1 数据库事务到底在做什么要理解Spring事务先得搞清楚数据库事务本身是怎么回事。一次事务就是一组数据库操作的逻辑单元这组操作要么全部成功要么全部失败。经典的ACID四个特性——原子性、一致性、隔离性、持久性——是数据库层面承诺的但其中隔离性是可以通过事务隔离级别调节的原子性和持久性则依赖于数据库的日志机制。很多人写业务代码的时候把事务当成了只要方法上标注解就能回滚的开关。这个理解太简单了。事务真正的作用是保证一组跨表、跨行操作的中间状态不被外部看到并且任一步出错时能让数据回滚到操作前的状态。举个例子转账操作需要先扣减A账户余额再增加B账户余额这两个更新必须在一个事务里完成否则第一步成功、第二步失败时钱就凭空消失了。1.2 JDBC原生事务的体验与痛点在没有Spring的日子里Java开发者操作事务是这么写的Connection conn null; try { conn dataSource.getConnection(); conn.setAutoCommit(false); // 执行多个SQL操作 PreparedStatement ps1 conn.prepareStatement(UPDATE account SET balance balance - ? WHERE id ?); ps1.setBigDecimal(1, money); ps1.setLong(2, fromAccountId); ps1.executeUpdate(); PreparedStatement ps2 conn.prepareStatement(UPDATE account SET balance balance ? WHERE id ?); ps2.setBigDecimal(1, money); ps2.setLong(2, toAccountId); ps2.executeUpdate(); conn.commit(); } catch (Exception e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.close(); } }这段代码看着不复杂但一旦放到真实业务里问题立刻暴露每个业务方法都要写一遍try-catch-finally代码重复率极高如果业务里还需要操作多个数据源事务管理就会变得更加混乱再叠加连接池、线程绑定等诉求手写这套东西基本不可维护。Spring要解决的核心痛点就是把这个重复的样板代码抽离出来让开发者用声明式的方式就能获得事务能力。2. Spring事务的抽象内核PlatformTransactionManager与事务同步器2.1 三大核心接口的设计思想Spring事务抽象的核心是三个接口PlatformTransactionManager、TransactionDefinition、TransactionStatus。理解这三个接口基本就理解了Spring事务的设计骨架。PlatformTransactionManager是事务管理器它定义了三个方法public interface PlatformTransactionManager extends TransactionManager { TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }getTransaction负责根据配置获取一个事务或者复用已有事务commit负责提交rollback负责回滚。实现类有很多最常用的是管理单数据源的DataSourceTransactionManager此外还有管理JPA的JpaTransactionManager、管理分布式事务的JtaTransactionManager。每种实现对应不同的底层事务模型但上层代码感知不到这种差异这正是抽象的魅力。TransactionDefinition定义了事务的元信息隔离级别、传播行为、超时时间、是否只读、事务名称。TransactionStatus则代表一次具体事务的运行状态它持有当前事务的资源、是否是新事务、是否已完成等信息是事务管理器操作具体事务的把手。2.2 TransactionSynchronizationManager事务资源的线程绑定事务要真正作用于数据库连接核心在于数据库连接和当前线程的绑定。Spring通过TransactionSynchronizationManager这个内部组件把数据库连接以及其他事务资源保存到ThreadLocal里确保同一个线程在事务期间拿到的都是同一个连接。这个设计的直接效果是Transactional方法内部调用的DAO、Mapper操作不用显式传递Connection就能共享同一个数据库连接进而共享同一个数据库事务。TransactionSynchronizationManager还维护了一个事务同步回调集合允许业务代码在事务提交前、提交后、回滚后等时机注册回调逻辑这是实现某些高级功能比如事务提交后再发消息的基础。2.3 DataSourceTransactionManager的工作流程以DataSourceTransactionManager为例getTransaction方法执行时会做这些事从TransactionSynchronizationManager中获取当前线程绑定的数据库连接资源。如果当前没有绑定连接就从数据源中拿一个新的连接设置autoCommitfalse把连接绑定到当前线程并标记newTransactiontrue。如果当前已经绑定了连接则根据传播行为决定是加入现有事务还是挂起旧事务、开启新事务。事务执行完毕后commit方法会调用Connection.commit()rollback方法会调用Connection.rollback()最后把连接从ThreadLocal中清理掉并归还连接池。这个过程中Spring还做了异常边界处理和资源释放的兜底避免事务异常导致连接泄漏。3. 声明式事务运行机制Transactional背后的代理链条3.1 动态代理与事务拦截器的协作Transactional之所以能够生效核心依赖Spring AOP。Spring容器在启动时会为标注了Transactional的Bean生成代理对象。调用方拿到的其实不是原始对象而是代理对象代理对象在调用真实方法之前会先经过一系列拦截器链路。这里面最重要的就是TransactionInterceptor。TransactionInterceptor继承了TransactionAspectSupport它的核心逻辑在invoke方法里。它遵循一个大致的处理模板根据方法上的事务配置调用getTransaction获得事务状态然后执行被代理的业务方法业务方法正常返回时提交事务抛出异常时根据规则决定是否回滚。public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor { Override public Object invoke(MethodInvocation invocation) throws Throwable { Class? targetClass AopUtils.getTargetClass(invocation.getThis()); return invokeWithinTransaction(invocation.getMethod(), targetClass, new CoroutinesInvocationCallback() { // 执行业务方法 }); } }3.2 完整调用链拆解假设我们有一个OrderService.createOrder()方法标注了Transactional。一次完整的调用过程是这样的外部调用orderService.createOrder(dto)实际上调用的是Spring生成的代理对象的方法。代理对象经过TransactionInterceptor拦截器解析Transactional注解的配置信息。调用TransactionManager.getTransaction(definition)此刻才会真正决定是开启新事务还是加入已有事务。执行业务方法这个过程中所有的数据库操作都通过当前线程绑定的Connection生效。如果方法正常返回拦截器调用transactionManager.commit(status)提交事务。如果方法抛出异常拦截器判断该异常是否需要回滚需要则调用rollback不需要则照样提交。这个调用链设计的关键点在于事务是围绕方法调用边界展开的而不是围绕单个SQL展开的。这也意味着同一个方法里多个DAO操作天然处于同一个事务中。3.3 EnableTransactionManagement做了什么在Spring Boot项目中事务默认是开启的因为TransactionAutoConfiguration会自动配置好事务管理器。但在传统Spring项目中需要手动使用EnableTransactionManagement注解来激活事务管理功能。EnableTransactionManagement的核心作用是向容器中注册一个BeanFactoryTransactionAttributeSourceAdvisor这个Advisor会扫描所有Bean的公共方法上是否有Transactional注解如果有就为这个Bean生成事务代理。它还会引入TransactionInterceptor到Spring容器中负责实际的事务拦截逻辑。4. 传播行为原理精讲七种传播级别背后的机制4.1 REQUIRED与REQUIRES_NEW的本质区别REQUIRED是Spring默认的传播行为也是绝大多数业务场景的最佳选择。它的语义是如果当前线程已经存在事务就加入这个事务如果不存在就新建一个事务。REQUIRES_NEW则不同它永远开启一个新事务。如果当前已经存在事务当前事务会被挂起suspend新事务执行完后再恢复旧事务。这两者的本质区别体现在数据库连接的使用方式上。REQUIRED传播时所有方法共享同一个Connection属于同一个数据库事务任何一个操作导致回滚整个事务都会回滚。REQUIRES_NEW传播时内层方法使用的是另一个独立的Connection对应独立的数据库事务内层事务提交或回滚不会影响外层事务的最终状态。Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder() { orderDao.insert(); userService.updateBalance(); // 加入同一个事务 logService.saveLog(); // REQUIRES_NEW独立事务 } }注意当使用REQUIRES_NEW时如果内层事务提交了即使外层事务后续回滚内层已经提交的数据也不会被回滚。这在操作日志必须保存成功但业务失败时日志不清理的场景下是有用的但也可能造成数据不一致使用时要想清楚。4.2 七种传播方式速览传播级别当前无事务时当前有事务时适用场景REQUIRED新建事务加入当前事务绝大多数业务方法SUPPORTS不创建事务加入当前事务查询方法有事务则参与MANDATORY抛异常加入当前事务强制要求调用方开启事务REQUIRES_NEW新建事务挂起旧事务新建事务独立日志记录、异步操作NOT_SUPPORTED不创建事务挂起旧事务非事务方式运行特殊查询避免锁竞争NEVER不创建事务抛异常明确不允许事务参与NESTED新建事务创建嵌套事务保存点局部回滚场景NESTED是一个容易被混淆的传播级别。它和REQUIRES_NEW不同NESTED是在当前事务内部建立一个保存点Savepoint内层代码回滚时只回滚到保存点位置不影响外层事务的其他操作。这依赖于底层数据库对Savepoint的支持例如MySQL的InnoDB引擎是支持的但某些数据库可能不支持。4.3 传播行为选择的实战建议我个人在实战中的经验是默认全用REQUIRED除非有强烈的隔离诉求。一个比较典型的场景是主业务审计日志。主业务失败时审计日志可能也一起回滚掉了但如果你想保留审计记录可以考虑REQUIRES_NEW。然而更稳妥的做法是不要用同一个事务来处理强一致性和最终一致性混杂的需求把日志发送放到事务提交后的TransactionSynchronizationManager.registerSynchronization回调里或者直接用消息队列异步处理。5. 隔离级别、回滚规则与经典失效场景5.1 隔离级别如何映射到数据库标准Transactional的isolation属性用于设置事务的隔离级别。TransactionDefinition定义了五个常量ISOLATION_DEFAULT、ISOLATION_READ_UNCOMMITTED、ISOLATION_READ_COMMITTED、ISOLATION_REPEATABLE_READ、ISOLATION_SERIALIZABLE。每个隔离级别解决的问题不同读未提交可能读到其他事务未提交的数据存在脏读。读已提交不会脏读但存在不可重复读即同一事务内两次读取结果可能不同。可重复读同一事务内多次读取结果一致但存在幻读风险。串行化所有事务串行执行性能最低但最安全。MySQL默认的隔离级别是REPEATABLE_READ而很多互联网公司实际把业务数据库的隔离级别调成了READ_COMMITTED以便获得更高的并发性能同时减少间隙锁导致的死锁问题。Spring的ISOLATION_DEFAULT会在事务开启时使用数据库默认的隔离级别。5.2 rollbackFor回滚规则的一个大坑Transactional注解默认的回滚规则是只有运行时异常RuntimeException和错误Error才触发回滚受检异常Exception的子类不会触发回滚。这是Spring的设计选择因为受检异常通常表示业务预期内的异常比如余额不足、商品下架等这些情况不应该导致整个事务回滚。很多人在代码里抛出Exception发现事务没有回滚就是这个原因。解决办法是在注解上显式声明回滚规则Transactional(rollbackFor Exception.class) public void createOrder() throws Exception { // 业务逻辑 }或者按需指定回滚和noRollbackForTransactional(rollbackFor BusinessException.class, noRollbackFor NotEnoughStockException.class) public void createOrder() { // 业务逻辑 }5.3 自调用事务失效问题这是所有Spring事务问题中最高频的一个。场景如下Service public class OrderService { Transactional public void outerMethod() { // 业务代码 this.innerMethod(); // 自调用 } Transactional(propagation Propagation.REQUIRES_NEW) public void innerMethod() { // 内部逻辑 } }这段代码的问题在于this.innerMethod()调用的是当前对象的原始方法没有经过Spring代理对象因此innerMethod()上的事务注解完全不起作用。这本质上是Spring AOP的代理机制决定的——只有通过代理对象调用方法时拦截器链才会生效。解决办法有几种把innerMethod()移到另一个独立的Bean中注入后再调用或者在当前类中注入ApplicationContext通过容器获取代理对象来调用还可以使用AopContext.currentProxy()但这需要设置EnableAspectJAutoProxy(exposeProxy true)。6. 提交与回滚的完整链路结合源码级别的执行顺序6.1 正常提交的执行顺序当一个Transactional方法正常返回时TransactionInterceptor会调用commitTransactionAfterReturning。这里有一个容易忽略的细节Spring在真正提交数据库事务之前会先触发TransactionSynchronization的beforeCommit和beforeCompletion回调提交之后再触发afterCommit和afterCompletion回调。这个顺序在业务上非常有用。比如你想在事务提交成功后再发一条消息就应该注册afterCommit回调。如果在业务方法里直接发消息事务还没提交消息消费者可能读取不到最新数据。TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { // 事务提交成功后再发送消息 messageSender.send(event); } });6.2 异常回滚的执行流程当事务方法抛出标记为回滚的异常时Spring会执行如下流程completeTransactionAfterThrowing捕获异常。判断异常是否匹配回滚规则不匹配则正常提交。匹配则调用rollback此时会触发beforeCompletion回调。执行Connection.rollback()真正回滚数据库。清理TransactionSynchronizationManager中绑定的资源触发afterCompletion回调最后把数据库连接归还连接池。这里还有一个只读事务的细节。Transactional(readOnly true)并不会强制数据库执行只读操作它的主要作用是给底层事务管理器一个优化提示。例如Hibernate会在只读模式下跳过一级缓存的脏检查从而减少刷新操作在MySQL层面JDBC驱动的readOnly提示也可能影响连接的设置但并不是严格禁止写入。6.3 事务失败标记对提交的影响TransactionStatus内部有一个rollbackOnly标记。当内层方法标记了整个事务为rollbackOnly例如在REQUIRED传播下的内层方法抛出了需要回滚的异常即使外层方法把这个异常捕获了事务最终依然会回滚。这也是一个典型的隐蔽问题外层方法用try-catch捕获了内层异常后以为自己处理了但事务已经被标记为rollbackOnly等到外层方法执行完Spring提交时发现rollbackOnly标记直接回滚。这种静默回滚在日志不明显时非常难以排查尤其是配合REQUIRED传播的时候。理解这个机制后排查事务回滚问题时就能更快定位到具体是哪个内层调用标记了回滚。7. 实战经验事务问题的排查思路与工具技巧7.1 一眼识别事务没生效的排查清单面对一个事务失效问题我通常按这个顺序排查方法是否是public的Spring事务代理默认只拦截public方法private和protected方法上标注Transactional不会生效。调用是否经过了代理对象自调用、同类内部调用都会绕过代理。事务管理器是否配置正确数据源和事务管理器是否指向同一个DataSource方法是否被final修饰Transactional依赖CGLIB动态代理final方法无法被重写增强。数据库表引擎是否支持事务MyISAM引擎就不支持事务即使代码层面完全正确也会失效。7.2 利用日志与断点验证事务状态排查事务问题时建议开启Spring事务日志在application.yml中配置logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG开启日志后你会看到类似Getting transaction for OrderService.createOrder和Completing transaction for OrderService.createOrder的输出。通过这些日志可以快速判断一个方法是否真正进入了事务拦截器以及最终是提交还是回滚。7.3 关于分布式事务的个人提醒很多团队在做微服务拆分后开始纠结订单与库存的分布式事务该怎么实现。我的个人体会是Spring事务解决的是单体应用内、单数据源的事务问题它无法直接跨越多个微服务或者多个数据库实现全局事务。虽然存在JtaTransactionManager、Seata、TCC等方案但它们都带来了复杂的协调成本和性能损耗。所以在实际规划时优先考虑能否通过业务设计避免跨服务事务例如将库存操作与订单写入放在同一个服务、同一个数据库内实在无法避免时再考虑可靠消息最终一致性的方案。不要把Spring事务当作分布式场景下的银弹这一点想清楚了能帮你少走很多弯路。最后再说一个技巧如果你在排查一个诡异的事务回滚问题不要只盯着Transactional注解。先确认数据库连接是否真的被绑定到了当前线程再看异常类型是否匹配rollback规则最后检查是否有人把rollbackOnly标记设置上了。按照连接是否复用、事务是否标记、异常是否匹配这三个维度去排查大多数事务问题都能在半小时内定位。这里面的很多坑都是不深入源码就无法理解的希望这篇把原理讲透的文章能帮你少踩几个坑。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →