☰
Spring事务陷阱:@Transactional用错竟导致数据不一致?
2026/10/3 12:52:13 网站建设 项目流程

引言

在日常开发里,几乎没有哪个业务离得开"事务"。转账要保证"扣款"和"加款"要么同时成功、要么同时失败;下单要保证"扣库存"和"生成订单"步调一致;批量导入要保证中途出错能整体回滚。如果不用事务,一旦某一步抛异常,数据就会处于"一半成功一半失败"的脏状态,排查起来极其痛苦。

Spring 对事务做了非常优雅的封装,让我们不用再写一堆commit/rollback的样板代码,一个@Transactional注解就能搞定绝大多数场景。但注解用起来容易,用对却没那么简单——很多同学遇到的"事务不生效""明明抛了异常却没回滚",往往就栽在几个经典坑上。

这篇文章我会带你系统过一遍 Spring 事务的核心概念、注解用法、传播行为、回滚规则,以及那些最容易踩的"事务失效"场景,每个知识点都配有可直接运行的代码示例。

一、事务到底是什么

事务(Transaction)是数据库操作的一个"最小执行单元",它由一组操作组成,这组操作要么全部成功,要么全部失败,具有ACID四大特性:

  • 原子性(Atomicity)​:事务内的所有操作是一个整体,要么全做,要么全不做。
  • 一致性(Consistency)​:事务执行前后,数据都处于合法状态(例如转账前后总金额不变)。
  • 隔离性(Isolation)​:多个事务并发执行时互不干扰,彼此看不到中间状态。
  • 持久性(Durability)​:事务一旦提交,对数据的修改就是永久的,即使宕机也不会丢失。

举个最经典的例子——转账:

sql

-- 步骤1:从 A 账户扣款 100 元 UPDATE account SET balance = balance - 100 WHERE id = 1; -- 步骤2:给 B 账户加款 100 元 UPDATE account SET balance = balance + 100 WHERE id = 2;

如果步骤 1 成功、步骤 2 因为某种原因失败了,那么 A 的钱凭空消失、B 却没收到,这是绝对不能接受的。事务的作用,就是保证这两步"同生共死"。

二、Spring 事务的两种管理方式

Spring 提供了两种事务管理方式:

1. 编程式事务:手动写代码控制事务的开启、提交和回滚,比较繁琐,现在基本不用。

java

// 编程式事务(老式写法,不推荐) TransactionStatus status = transactionManager.getTransaction(def); try { // 业务逻辑 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; }

2. 声明式事务:通过@Transactional注解(或 XML 配置)来声明某个方法需要事务,Spring 底层基于 AOP 帮你自动完成"开启事务 → 执行业务 → 提交/回滚"这一整套动作。这也是我们平时用的主流方式,下面重点讲它。

三、声明式事务 @Transactional 快速上手

3.1 准备环境

先引入 SpringBoot + JDBC 相关依赖(以 SpringBoot 2.x/3.x 为例):

xml

<dependencies> <!-- web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- JDBC + 事务 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok(可选,简化代码) --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

配置文件application.yml:

yaml

spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver

建一张account表:

sql

CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, balance DECIMAL(10, 2) NOT NULL ); INSERT INTO account (name, balance) VALUES ('张三', 1000.00), ('李四', 1000.00);

3.2 最简事务示例

下面实现一个转账业务:张三给李四转 100 元。

java

@Service public class AccountService { @Autowired private JdbcTemplate jdbcTemplate; @Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { // 1. 扣款 jdbcTemplate.update("UPDATE account SET balance = balance - ? WHERE id = ?", amount, fromId); // 2. 加款 jdbcTemplate.update("UPDATE account SET balance = balance + ? WHERE id = ?", amount, toId); } }

只要在transfer方法上标注@Transactional,这个方法内的两次update就被纳入同一个事务。任何一步抛出运行时异常(RuntimeException),整个事务都会回滚;只有两步都成功,事务才会提交。

测试:

java

@SpringBootTest class AccountServiceTest { @Autowired private AccountService accountService; @Test void testTransfer() { accountService.transfer(1L, 2L, new BigDecimal("100.00")); // 转账成功:张三 900,李四 1100 } }

3.3 手动模拟异常回滚

为了看清楚回滚效果,我们故意在两步中间抛一个异常:

java

@Transactional public void transferWithError(Long fromId, Long toId, BigDecimal amount) { jdbcTemplate.update("UPDATE account SET balance = balance - ? WHERE id = ?", amount, fromId); // 模拟业务异常 int i = 1 / 0; // 抛出 ArithmeticException(运行时异常) jdbcTemplate.update("UPDATE account SET balance = balance + ? WHERE id = ?", amount, toId); }

执行后你会发现:张三的钱没有被扣,因为扣款那一步也被回滚了。这就是事务的原子性。

四、事务传播行为 propagation

事务传播行为(Propagation)解决的是"当前方法已经有事务时,新开的事务该怎么处理"这个问题。比如 A 方法有事务,A 内部又调用了 B 方法(B 也有事务),那么 B 是加入 A 的事务,还是自己新开一个?

@Transactional提供了 7 种传播行为,其中最常用的是前两种:

传播行为含义
REQUIRED(默认)有事务就加入,没有就新建。最常用
REQUIRES_NEW无论如何都新建一个独立事务,并挂起当前事务
SUPPORTS有事务就加入,没有就以非事务方式执行
NOT_SUPPORTED以非事务方式执行,有事务则挂起
MANDATORY必须在事务中执行,否则抛异常
NEVER必须在非事务中执行,否则抛异常
NESTED如果存在事务,则以嵌套事务方式执行

4.1 REQUIRED(默认)—— 加入已有事务

java

@Service public class OrderService { @Autowired private LogService logService; @Transactional // 默认 REQUIRED public void createOrder() { // 下单核心逻辑 saveOrder(); // 记录日志(LogService.log 也是 REQUIRED,会加入当前事务) logService.log("创建订单"); } } @Service public class LogService { @Transactional public void log(String msg) { // 这里如果抛异常,会把 createOrder 整个事务一起回滚 saveLog(msg); } }

特点:REQUIRED下,被调用方法抛异常会导致整个事务回滚,因为大家共用同一个事务。

4.2 REQUIRES_NEW —— 独立新事务

有些场景我们不想让"日志"这种旁路操作影响主业务。比如主业务成功了,但记日志失败,我们不希望因为日志失败而让下单也回滚。这时就可以用REQUIRES_NEW:

java

@Service public class LogService { @Transactional(propagation = Propagation.REQUIRES_NEW) public void log(String msg) { // 独立事务:这里出错不会影响外层事务 saveLog(msg); } }

特点:REQUIRES_NEW会"挂起"外层事务,自己开一个新事务。即使内层抛异常,只要外层捕获了它,外层事务照样能提交。

java

@Service public class OrderService { @Autowired private LogService logService; @Transactional public void createOrder() { saveOrder(); try { logService.log("创建订单"); // 独立事务,失败也不影响 } catch (Exception e) { // 忽略日志失败 } } }

常见坑:REQUIRES_NEW必须通过另一个 Bean的方法调用才能生效(跨方法、跨 Bean)。如果在同一个类里直接this.log(),AOP 代理不生效,传播行为也失效。这一点和下一节"事务失效"密切相关。

五、回滚规则 rollbackFor

@Transactional默认只在抛出 RuntimeException 和 Error时回滚,抛出受检异常(Checked Exception)时不会回滚。这是很多人踩坑的地方。

5.1 默认回滚规则演示

java

@Transactional public void transfer() throws Exception { jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); // 抛出一个受检异常(Checked Exception) throw new Exception("业务失败"); // 默认【不会】回滚! }

上面代码里,Exception是受检异常,默认情况下 Spring不会回滚事务,扣款那步会被提交。这显然不符合预期。

5.2 用 rollbackFor 显式指定

java

@Transactional(rollbackFor = Exception.class) // 任何 Exception 都回滚 public void transfer() throws Exception { jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); throw new Exception("业务失败"); // 现在会回滚 }

rollbackFor = Exception.class表示"遇到Exception及其子类都回滚",这是实际项目里非常推荐的标准写法。

5.3 noRollbackFor —— 指定某些异常不回滚

反过来,如果某个运行时异常你希望不要回滚,可以用noRollbackFor:

java

@Transactional(rollbackFor = Exception.class, noRollbackFor = BusinessException.class) public void transfer() { jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); throw new BusinessException("业务提示,不触发回滚"); }

最佳实践建议:写业务代码时,养成显式写@Transactional(rollbackFor = Exception.class)的习惯,避免受检异常"漏回滚"。

六、事务失效的常见场景与原因

这一节是重头戏。@Transactional是基于 AOP 代理实现的,一旦绕过了代理或违反了它的生效条件,注解就会"静默失效",这是生产环境最常见的坑。

场景 1:方法非 public

Spring 事务要求被代理的方法必须是public。如果是private/protected/ 包级私有,代理无法拦截,事务不生效。

java

@Transactional private void doSave() { // ❌ 不生效!方法不是 public jdbcTemplate.update("INSERT INTO ..."); }

场景 2:同类内部调用(this 调用)

事务是通过动态代理实现的,只有经过代理对象的调用才会被拦截。如果在同一个类内部用this.xxx()调用另一个带@Transactional的方法,走的是原始对象而不是代理,事务不会生效。

java

@Service public class UserService { public void register() { // ❌ 内部直接调用,绕过代理,事务失效 this.doSave(); } @Transactional public void doSave() { jdbcTemplate.update("INSERT INTO ..."); } }

解决方案:把被调用的方法抽到另一个 Bean,或通过@Autowired注入自身(用代理),或用AopContext.currentProxy()调用。

java

@Service public class UserService { @Autowired private UserService self; // 注入代理对象 public void register() { self.doSave(); // ✅ 走代理,事务生效 } @Transactional public void doSave() { jdbcTemplate.update("INSERT INTO ..."); } }

场景 3:异常被 catch 吞掉

事务回滚的触发点是"异常被抛到代理层"。如果你在方法内部try-catch把异常捕获了、没再抛出去,Spring 感知不到异常,自然不会回滚。

java

@Transactional public void transfer() { try { jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); int i = 1 / 0; // 抛出异常 } catch (Exception e) { // ❌ 异常被吞掉,事务提交了,扣款生效了 } }

解决方案:要么不捕获、让异常往上抛;要么在 catch 里手动回滚:

java

@Transactional public void transfer() { try { jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); int i = 1 / 0; } catch (Exception e) { // ✅ 手动回滚 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw new RuntimeException(e); } }

场景 4:抛的是受检异常且未配置 rollbackFor

如上一节所说,默认只回滚 RuntimeException 和 Error。抛Exception(受检异常)却不配rollbackFor,事务不会回滚。

java

@Transactional // ❌ 没配 rollbackFor public void transfer() throws Exception { jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); throw new Exception("受检异常,默认不回滚"); }

场景 5:数据库存储引擎不支持事务

MySQL 的MyISAM引擎不支持事务。如果表用的是 MyISAM,即使代码完全正确,@Transactional也不会生效(数据照常写入)。

sql

-- 查看表引擎 SHOW TABLE STATUS LIKE 'account'; -- 应使用 InnoDB CREATE TABLE account (...) ENGINE = InnoDB;

场景 6:没有被 Spring 管理

@Transactional依赖 Spring 容器生成的代理 Bean。如果你的类是通过new UserService()手动创建的,而不是@Service注册到容器里的,代理不存在,事务自然失效。

java

// ❌ 手动 new,不在容器中,没有代理,事务失效 UserService service = new UserService(); service.transfer();

场景 7:多线程调用

事务和线程是绑定的(事务信息存在ThreadLocal里)。如果在事务方法里新开一个线程去执行数据库操作,新线程拿不到当前事务,也就不会纳入同一个事务。

java

@Transactional public void transfer() { jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE id = 1"); // ❌ 新线程里的事务与当前事务无关 new Thread(() -> { jdbcTemplate.update("UPDATE account SET balance = balance + 100 WHERE id = 2"); }).start(); }

场景 8:事务管理器配置错误

如果项目里存在多个数据源或多个事务管理器,而没有指定@Transactional使用哪一个,可能出现事务没作用到目标数据源的情况。多数据源场景下需要显式指定:

java

@Transactional(transactionManager = "orderTransactionManager") public void transfer() { // ... }

场景 9:方法上注解被类注解覆盖(注意顺序)

注解属性遵循"就近覆盖"原则。类上有@Transactional(readOnly = true),方法上又写了@Transactional,方法级注解会覆盖类级注解,但要注意不要以为类上的配置一定会合并生效。

java

@Service @Transactional(readOnly = true) // 类级别默认只读 public class UserService { @Transactional(readOnly = false) // 方法级覆盖,可写 public void save() { // 写操作 } }

总结

这篇文章我们系统地梳理了 Spring 事务的核心知识:

  1. 事务的 ACID 特性是理解一切事务行为的基础。
  2. 声明式事务@Transactional借助 AOP 帮我们自动完成事务的开启、提交与回滚,是日常开发的首选。
  3. 传播行为中,REQUIRED(默认,加入已有事务)和REQUIRES_NEW(独立新事务)最常用,二者在"一个方法调用另一个带事务的方法"时行为差异很大,务必分清。
  4. 回滚规则默认只对 RuntimeException 和 Error 生效,受检异常需要显式配置rollbackFor = Exception.class才会回滚。
  5. 事务失效的根源几乎都集中在"绕过了 AOP 代理"或"异常没被正确抛到代理层"这两类,具体包括:方法非 public、同类 this 调用、异常被吞、受检异常未配 rollbackFor、引擎不支持事务(MyISAM)、类未被 Spring 管理、多线程调用、多数据源未指定事务管理器等。

一句话记住:想让事务生效,就必须保证"调用走的是 Spring 代理对象" + "异常能正常抛到代理层" + "用的是支持事务的存储引擎"。

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

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

立即咨询