☰
Spring事务原理与实战:从@Transactional到失效场景全解析
2026/10/12 8:00:29 网站建设 项目流程

做后端开发的,几乎没有绕开Spring事务的。我记得有一次线上出了个事故:用户A给用户B转账,数据库里A的钱扣了,B的钱没到账。问题代码看起来“完全正常”——两个写库操作中间抛了个异常,但方法没加@Transactional,第一条已经提交,第二条直接中断,数据就永远停在半截状态。那天之后我把Spring事务从源码到配置翻了个遍。这篇就基于这些实操经验,把Spring事务的原理、配置、传播行为、失效场景,以及那个让不少人头疼的Could not open Hibernate session for transaction报错,一次性讲透。

1. 为什么业务代码离不开Spring事务

1.1 事务的本质与Spring介入的位置

事务要解决的核心问题很简单:一组操作要么全部成功,要么全部失败。数据库本身就有事务概念,ACID四个特性——原子性、一致性、隔离性、持久性——教材里都写过。原子性是基础,一组SQL语句在执行过程中如果有一条失败,前面所有已执行的都要回滚,不能留下“扣了款但没到账”这种脏状态。

但问题是,业务代码里的大量操作并不是只靠数据库就能保证原子性的。你在Service层写了三行代码,第一行更新订单表,第二行更新库存表,第三行写日志表。这三行代码如果不在同一个数据库连接里执行,数据库层面根本不知道它们属于同一个“整体”。框架层要做的,是在同一个线程里把一个Connection绑定住,让这三条SQL都走这个连接,然后在合适的时候统一commit或rollback。Spring事务就是干这个事的:管理连接绑定、管理事务边界、决定提交还是回滚。

很多人有个误区,觉得“事务是数据库的事”,Spring事务只是锦上添花。实际上没有Spring层的事务抽象,你就要在每一处业务方法里手动写conn.setAutoCommit(false)、try-catch、conn.commit()、conn.rollback(),然后祈祷每个分支都写对。一旦漏了某个catch分支,事务提交时机就是错的。Spring把这些细节收口了,你只需要关心“哪些操作需要放在同一个事务里”。

1.2 编程式事务和声明式事务,先分清再选择

Spring事务有两种用法。一种是编程式事务,通过TransactionTemplate手动控制事务边界:

@Service public class OrderService { private final TransactionTemplate transactionTemplate; public OrderService(PlatformTransactionManager transactionManager) { this.transactionTemplate = new TransactionTemplate(transactionManager); } public void createOrder(OrderDTO dto) { transactionTemplate.execute(status -> { // 操作1:写订单表 orderMapper.insert(dto.getOrder()); // 操作2:扣库存 stockMapper.deduct(dto.getStock()); // 操作3:写流水 logMapper.insert(dto.getLog()); return null; }); } }

一种是声明式事务,也就是大家天天用的@Transactional:

@Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto.getOrder()); stockMapper.deduct(dto.getStock()); logMapper.insert(dto.getLog()); }

两种方式各有适用场景。@Transactional写起来最简洁,适合边界清晰、异常处理简单的大多数业务场景。但是遇到“部分操作成功也要保留,不能全部回滚”这种复杂需求,@Transactional要么调整传播行为,要么得拆方法,反而不如TransactionTemplate在代码里直接控制来得直观。

我的习惯是这样:常规数据写入用@Transactional,代码可读性好;涉及COMPENSATION、部分提交、需要精细控制回滚点时用TransactionTemplate。还有一种混合场景:方法整体加了@Transactional,但中间有一段业务需要“即便主流程失败也记录下来”,这段就用REQUIRES_NEW传播行为,后面细讲。

2. 事务管理器与事务属性,这是配置的底层逻辑

2.1 事务管理器选型:DataSource还是JPA

Spring事务统一通过PlatformTransactionManager接口管理,不同持久层方案有不同实现。最常见的是DataSourceTransactionManager,它管理的是一个DataSource,也就是JDBC连接级别的事务;如果项目用JPA/Hibernate,则使用JpaTransactionManager,它内部管理EntityManagerFactory对应的Session。

在Spring Boot里,只要引入了spring-boot-starter-jdbc或spring-boot-starter-data-jpa,Boot会自动配置对应的事务管理器。单数据源情况下基本不需要手动声明bean。但如果项目里同时依赖JDBC和JPA,或者配置了多个数据源,坑就来了。

我整理了一个表格,方便对照:

持久层方案对应事务管理器依赖
原生JDBC / MyBatisDataSourceTransactionManagerspring-boot-starter-jdbc
JPA / HibernateJpaTransactionManagerspring-boot-starter-data-jpa
JTA(多资源分布式)JtaTransactionManagerspring-boot-starter-jta-atomikos

最典型的报错就是Could not open Hibernate session for transaction,十有八九是项目里选了JPA/Hibernate做持久层,但事务管理器却用了DataSourceTransactionManager,或者根本没有正确注入EntityManagerFactory,导致事务开始的时候Hibernate拿不到Session。这一块我在第四章详细展开。

只要配置了spring.jpa.hibernate.ddl-auto、spring.datasource.url这些,Spring Boot底层会创建LocalContainerEntityManagerFactoryBean,同时自动注册JpaTransactionManager。正常情况下你感知不到它的存在,一旦多数据源出现,就必须手动指定哪个EntityManagerFactory对应哪个事务管理器,否则Spring注入的事务管理器匹配不上,运行时就会报Session相关的异常。

2.2 七个传播行为,用业务场景逐个讲透

传播行为是Spring事务里面试最常考、实际项目里最容易被滥用的点。它的本质是:当前方法被调用时,事务该如何与调用方已有的事务协同。七种传播行为我不想只贴定义,直接对应到真实场景。

传播行为含义典型场景
REQUIRED有事务就加入,没有就新建默认值,90%的业务写这个
SUPPORTS有事务就加入,没有就以非事务方式执行查询方法,有没有事务都行
MANDATORY必须有事务,否则抛异常强制要求调用方已开启事务
REQUIRES_NEW挂起当前事务,新开一个独立事务记录审计日志、操作日志
NOT_SUPPORTED挂起当前事务,以非事务方式执行某些不需要事务的查询
NEVER必须没有事务,否则抛异常禁止事务环境的方法
NESTED嵌套事务,内层回滚不影响外层已提交部分保存点场景,大流程中隔离某一步失败

重点是REQUIRES_NEW和NESTED,这两者经常被混一起。

REQUIRES_NEW是“另起炉灶”。外层事务回滚,内层事务照样提交。最经典的场景是:主业务方法事务回滚后,希望把失败原因写进日志表,日志写入不能被回滚掉。如果日志写入方法用默认的REQUIRED,它会加入主事务,主事务回滚日志也没了,等于白记。改成REQUIRES_NEW后,日志方法自己开一个新事务,独立提交,主流程失败也能查到失败原因。

NESTED是“保存点”。内层事务回滚时只回滚到嵌套开始的保存点,外层事务可以继续。但NESTED依赖底层数据库的保存点机制,而且外层事务最终是否提交,仍然由外层决定——如果外层回滚,内层即使已经执行到保存点之前,最终也会跟着回滚。所以NESTED适合的是“某个子步骤失败后,后续步骤不执行但主流程不回滚”的场景,比如批量导入,一条出错跳过,其余继续导入。

实际开发中我很少用NESTED,因为语义上容易让维护者困惑。大部分“我要单独提交”的需求,用REQUIRES_NEW就够了。

2.3 隔离级别和回滚规则,别只在面试时背

事务隔离级别解决的是并发下数据一致性问题。数据库层面有脏读、不可重复读、幻读三类问题,Spring提供了五种隔离级别:

隔离级别脏读不可重复读幻读
READ_UNCOMMITTED可能可能可能
READ_COMMITTED可避免可能可能
REPEATABLE_READ可避免可避免可能(InnoDB可避免)
SERIALIZABLE可避免可避免可避免
DEFAULT由数据库默认决定由数据库默认决定由数据库默认决定

MySQL默认 REPEATABLE_READ,PostgreSQL默认 READ_COMMITTED。大多数互联网业务用默认值就行,没必要手动调。只有少数场景会主动升级隔离级别,比如库存扣减、账户余额操作,为了避免并发场景下的超卖,除了用数据库行锁,可能需要配合SELECT ... FOR UPDATE或者乐观锁字段。SERIALIZABLE基本不用,性能和并发能力下降太大,能通过锁或版本号解决的问题,不建议用隔离级别硬扛。

回滚规则是另一个容易踩的坑。@Transactional默认只在抛出RuntimeException或Error时回滚,受检异常也就是Exception下面的非运行时异常,默认不会回滚。原因其实也能理解:Spring认为IOException、SQLException这类受检异常可能是可恢复的业务异常,是否回滚需要开发者显式声明。实际项目里很多团队习惯了“抛异常就要回滚”,结果遇到rollbackFor没配的受检异常,事务照常提交,数据照常写进去。正确姿势是显式指定:

@Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) throws BusinessException { // ... }

还有几个容易被忽略的属性:

  • readOnly = true:只读事务不会修改数据,底层可以做优化,但不是所有数据库都会真的优化。MySQL下主要作用是给数据库一个提示,防止一些框架层面的写操作,不要指望它拦截写入。
  • timeout:设置事务超时秒数,超过自动回滚,防止长事务拖着连接不释放。
  • noRollbackFor:指定某些异常不回滚,用得少,知道有这东西就行。

3. 用对@Transactional:AOP代理与失效场景

3.1 @Transactional是怎么生效的:代理对象

要用好@Transactional,必须理解它的底层机制。Spring在启动时会扫描标注了@Transactional的Bean,通过AOP为这些Bean生成代理对象。外部调用这个Bean的方法时,真正执行的是代理对象,代理对象在方法执行前开启事务,方法正常结束提交事务,方法抛出异常回滚事务。这个流程的关键在于:代理必须被“外部”调用。

具体原理是Spring AOP基于动态代理。目标类实现了接口时,默认用JDK动态代理;没实现接口时用CGLIB代理。Spring Boot 2.x开始默认proxyTargetClass=true,也就是优先CGLIB,即使有接口也生成子类代理。但不管是哪种代理,核心都绕不开“外部调用”四个字。

3.2 让事务悄悄失效的几种写法

我见过最多的失效场景就是自调用。同一个类里,一个方法调另一个标注了@Transactional的方法,事务不生效:

@Service public class UserService { public void handleUser() { // 这里的this是普通对象,不是代理对象 this.modifyUser(); // 事务不生效 } @Transactional public void modifyUser() { userMapper.updateName(); userMapper.updateAge(); } }

this.modifyUser()走的是当前对象,没有经过代理,事务注解被直接忽略。这个坑隐蔽在业务代码里非常难发现,因为编译不报错,运行不报错,就是数据会半截提交。

解决办法有三种:

  1. 拆到另一个Service类里调用,让外部代理介入。
  2. 在当前类注入自身的代理对象:@Autowired或者@Lazy @Autowired自己,然后通过代理对象调用。
  3. 用AopContext.currentProxy(),但需要在启动配置里暴露代理:spring.aop.proxy-target-class=true加上@EnableAspectJAutoProxy(exposeProxy = true)。

我一般推荐第一种,拆Service。一方面解决代理失效,另一方面也符合单一职责,让事务边界更清晰。

还有几种常见的失效写法:

  • 方法或类被private、final修饰。CGLIB代理通过继承生成子类,final方法无法被重写;private方法外部根本不能调。事务注解直接无效。
  • 异常被内部try-catch吞掉。事务判定回滚靠异常向外抛出,你catch了就等于告诉Spring“我处理好了”,事务正常提交。
  • 异常被捕获后重新抛出非RuntimeException,且没配rollbackFor。
  • 多线程环境下,子线程里执行数据库操作,事务不会跟着线程走。这个后面单独说。

3.3 通过日志验证事务是否生效

很多失效场景不报错,纯靠肉眼看代码很难发现问题。我的排查思路是先看日志。Spring在事务提交和回滚时有日志,打开debug级别可以确认:

logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG

正常情况会看到类似日志:Initiating transaction commit或Initiating transaction rollback。如果方法执行期间完全没看到transaction相关日志,说明@Transactional可能根本没生效,优先检查自调用、private、final这些问题。如果看到rollback但你预期是提交,检查是不是异常被触发回滚,或者rollbackFor配置不对。

我还会在关键事务方法里临时加断点,确认当前进入方法的是不是代理对象。IDE调试时看调用栈,如果栈帧里出现CGLIB$$或$Proxy类,说明代理正常;如果是纯原始类直接进入,基本就是自调用。

4. 实战排雷:Could not open Hibernate session for transaction

4.1 报错的本质:Session没有绑定到当前事务

这条报错算是JPA/Hibernate项目的“老朋友”了,完整格式一般是:

org.springframework.transaction.CannotCreateTransactionException: Could not open Hibernate session for transaction; nested exception is org.hibernate.exception.GenericJDBCException: Unable to acquire JDBC Connection

核心意思是:Spring事务管理器要开启事务,需要先从EntityManagerFactory拿一个Hibernate Session(或者打开Session),再用它获取数据库连接。这个动作失败,事务自然无法创建。

报错原因的覆盖面其实很广,但归纳起来就两类:一是拿不到数据库连接,二是连Hibernate的SessionFactory/EntityManagerFactory都没有正确创建。

4.2 从四个方向逐层排查

第一,确认数据源配置。spring.datasource.url、username、password是否正确,数据库服务是否可达,最大连接数是否被占满。连接池满的时候也会报Unable to acquire JDBC Connection。我遇到过线上数据库负载很高,连接池连接被占满,新建连接全部超时的场景,表现就是个别接口偶发这个报错。先看数据源,成本最低。

第二,确认事务管理器类型是否匹配。这是最容易忽略的点。项目里如果同时引入了spring-boot-starter-jdbc和spring-boot-starter-data-jpa,Spring Boot默认会注册两个事务管理器吗?不会,它默认会选一个作为主事务管理器。如果你用了MyBatis但混入了JPA依赖,或者反过来,事务管理器就可能匹配错。

Hibernate场景需要注入的是JpaTransactionManager,它管理的不是普通DataSource连接,而是EntityManager的Session。如果Spring容器里的PlatformTransactionManager被DataSourceTransactionManager顶替了,事务开始后无法从EntityManagerFactory拿到Session,就会报这个错。

第三,检查EntityManagerFactory是否配置正确。多数据源场景最常见。比如配置了两个数据源,但只有一个EntityManagerFactory,或者包扫描路径不对。如果是多数据源,务必要显式为每套数据源注册独立的LocalContainerEntityManagerFactoryBean和JpaTransactionManager,并且用@Primary标记主数据源。

第四,排查懒加载和OSIV的问题。Spring Boot默认spring.jpa.open-in-view=true,这会把EntityManager绑定到整个请求生命周期。一旦关了OSIV,在事务外执行懒加载查询,也会触发类似“Session closed”的报错,但表现形式不一样,通常不是Could not open Hibernate session for transaction,而是LazyInitializationException。两者要区分开。

4.3 快速自查清单

我把排查知识整理成了一张表,遇到报错直接对着查:

报错关键字可能原因处理方式
Unable to acquire JDBC Connection连接池满、数据库不可达、账号密码错误检查连接池配置、数据库状态、连接是否泄漏
No Session found for current thread事务管理器与持久层不匹配指定为JpaTransactionManager并绑定EntityManagerFactory
Could not obtain transaction-synchronized SessionOSIV关闭后事务外访问懒加载把访问放入事务内或用fetch join
No qualifying bean of type PlatformTransactionManager多数据源未指定主事务管理器用@Primary标记主要的事务管理器
Connection is not available请求等待连接超时优化最大连接数、减少长事务、排查慢SQL

除了报错本身,还有一个很实用的排查手段:看启动日志。Spring Boot启动时会打印事务管理器相关的信息,确认当前生效的PlatformTransactionManager是不是你期望的那个。如果JPA项目里启动日志显示DataSourceTransactionManager,那基本就能断定问题出在事务管理器选择上。

5. 事务性能与进阶实践

5.1 事务粒度:别把无关操作塞进同一个事务

事务能保证一致性,但代价是数据库连接被长时间占用,影响并发吞吐。很多人在一个方法里从查询到校验到远程调用到写库再到发消息,全部放进@Transactional,看起来省事,实际上是把事务拖成了“长事务”。

长事务的危害有几点:连接被占着不释放,连接池压力大;事务内锁定的行不释放,其他请求阻塞;MySQL binlog/undo log体积膨胀。最典型的反面例子是把远程RPC调用写进事务方法里。远程接口响应慢,事务就一直开着,整个系统的数据库连接池可能被拖垮。我在实际项目里见过一个“增删改查”方法,里面调了三个外部系统接口,正常一顿操作耗时800毫秒,事务就开800毫秒,高峰期连接池直接被打满。

正确的做法是:远程调用、消息发送、文件上传下载这些IO操作放到事务外。事务里只保留必要的数据库写操作,所有依赖的外部数据先查好,再进入事务执行最终落库。改成两段式:先做前置准备和远程调用,最后开一个短事务把结果写入数据库。

5.2 多线程环境下的“伪事务”

这是一个面试中高频、实际代码里容易忽视的点:@Transactional事务是绑定在当前线程的,确切地说是绑定在当前线程当前的数据库连接上。如果在事务方法里用CompletableFuture或new Thread开了子线程,子线程里操作数据库,用的是自己的连接,事务跟它没有任何关系。子线程的写入要么独立提交,要么在子线程内自己的事务里管理,主线程回滚也不会影响到子线程。

我在项目里遇到过这样的设计:主线程更新订单状态,然后并发发短信、更新用户积分,结果积分更新失败但订单状态没问题,按业务逻辑应该都回滚,实际上根本回不滚。

解决多线程一致性不能靠@Transactional,需要引入事务消息、本地消息表、或者分布式事务方案。如果只是子线程“也要参与同一个事务”,单靠Spring原生事务做不到。一个折中方案是把子线程逻辑改为主线程同步执行,或者用TransactionTemplate在子线程内部单独控制原子性。业务上如果必须严格一致,建议直接用消息队列做最终一致性设计,不要在一个方法里硬撑。

5.3 事务与缓存一致性的先后关系

这个坑更隐蔽。很多项目引入Redis做缓存,业务逻辑是“先更新数据库,再删除缓存”,这个顺序总体是对的。但如果在事务方法里的最后一步删缓存,事务还没提交,其他线程已经读到了旧缓存,等缓存删除后重新加载,可能加载到的还是旧数据(因为事务未提交),或者新数据(事务已提交),存在时间差。

正确的模式是:在事务提交成功后,再执行缓存清理。Spring提供了TransactionSynchronizationManager,可以在事务提交后注册回调:

@Transactional public void updateUser(User user) { userMapper.update(user); // 事务提交完成后执行 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { cacheService.delete("user:" + user.getId()); } }); }

核心思想是:缓存操作不能绑定到事务内的执行时机,要绑定到事务的提交时机。不然缓存和数据库很容易出现短暂不一致,业务上如果容忍性差,会引发重复扣款、余额展示错误这类问题。

5.4 锁与事务配合:乐观锁和悲观锁

数据库并发下的超卖、重复扣款问题,单纯靠事务隔离级别不是最好的解法。事务保证的是“要么都成功要么都失败”,但两个事务并发修改同一行,数据库层面最终结果可能覆盖。两个方案选择:

悲观锁:SELECT ... FOR UPDATE。Spring Data JPA里用@Lock(LockModeType.PESSIMISTIC_WRITE),查询时锁住对应行,事务提交后释放。适合并发冲突频繁、一致性要求极高的场景,缺点就是阻塞。

乐观锁:加@Version版本字段,更新时带上version = version + 1的条件,更新行数为0说明版本冲突,重试或提示用户。适合并发冲突不高的场景,性能好。

搭配事务使用时,注意一点:悲观锁必须在事务内生效,锁生命周期等于事务生命周期。如果你在事务外查询数据再进入事务锁行,可能出现锁等待超时。乐观锁的版本号检查同样要在事务内完成“查版本→更新”的连贯动作,否则可能读到旧版本再次更新失败。

结尾

我个人在实际项目里总结出的经验是:写@Transactional之前先问自己几个问题——这个方法真的需要事务吗?事务内有远程调用吗?有没有会被吞掉的异常?异常是继承自RuntimeException吗?传播行为用默认的行不行?这几个问题想清楚,事务相关的坑能避掉一大半。Spring事务本质上是一个“边界管理工具”,边界画在哪里,决定了它能在多大程度上保护你的数据。理解了边界,配置和传播行为就不再是死记硬背的面试题,而是你手里的工程工具。最后再分享一个习惯:凡是加了@Transactional的方法,代码评审时我都会在方法第一行和最后一行附近扫一遍,看看中间有没有外部调用或者大循环,有就拆;这个习惯帮我拦下了不少潜在线上事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询