技术栈:Java 17 / Spring Boot 3.x / Spring Framework 6.x / Spring JDBC / H2
适合读者:已经在 Service 层使用
@Transactional,但还不太清楚事务拦截、回滚规则和传播行为的 Java 开发者说明:文中使用 H2 演示核心行为;涉及 Spring Framework 6.2、7.x 的版本差异会单独注明。
前言:明明加了事务,数据为什么还是提交了?
假设创建订单需要连续执行两次数据库操作:
- 写入订单。
- 扣减库存。
第二步失败时,我们希望第一步也撤销。于是很自然地写下:
java
@Transactional
public void createOrder() {
orderMapper.insert(order);
stockMapper.deduct(productId, quantity);
}
大多数时候,这样做没有问题。但当代码经过重构、增加异常处理、拆分异步任务,或者调整事务传播行为之后,有时就会出现令人困惑的现象:
方法上明明有 @Transactional,异常也出现了,数据库里的部分数据却还在。
此时如果只盯着注解看,很容易陷入反复试错。真正需要回答的是三个问题:
- 这次方法调用有没有经过 Spring 事务代理?
- 事务管理器有没有收到需要回滚的信号?
- 当前数据库操作究竟属于哪个事务?
下面先用一个可以复现的例子说明最典型的陷阱,再逐项排查。本文讨论的是常见的基于代理的声明式事务,不是 AspectJ 编织模式,也不是 Reactor 响应式事务。
一、先理解一句话:@Transactional 不等于方法自带事务
在默认的代理模式中,Spring 不是因为方法上写了注解,就自动让 JVM 中任何一次调用都拥有事务。
简化后的调用链是:
text
Controller / 其他 Service
│
▼
Spring 管理的 Service 代理对象
│
├── 事务拦截器:判断传播行为,开始或加入事务
│
▼
目标方法执行
│
├── 正常返回:尝试提交
└── 抛出异常:按照回滚规则处理
关键是调用要经过代理对象。注解描述事务规则,真正执行规则的是 Spring 的事务基础设施。
这也解释了为什么"同一个类内部互相调用"值得特别警惕。
二、用 H2 复现:同一个方法,外部调用回滚,this 调用却留下数据
为了减少 MyBatis、数据库容器和业务代码带来的干扰,这里选择 Spring JDBC + H2 内存数据库。
示例以 Spring Boot 3.x、Java 17 为基础,项目使用常规 Spring Boot Maven 父工程即可。
1. 添加依赖
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring-boot-starter-jdbc 提供 JDBC 及数据源相关能力。示例使用单数据源和 Spring Boot 的常规事务自动配置,不需要手工声明事务管理器。
2. 准备数据表
在 src/main/resources/schema.sql 中写入:
sql
CREATE TABLE tx_demo (
id BIGINT PRIMARY KEY,
note VARCHAR(100) NOT NULL
);
src/main/resources/application.yml:
yaml
spring:
datasource:
url: jdbc:h2:mem:txdemo;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
mode: always
这里不涉及真实业务数据。schema.sql 在应用启动时初始化表结构,测试之间只清理表中的记录。
3. 写一个会抛异常的 Service
java
package com.example.tx;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 演示事务代理调用与同类内部调用的区别。
*/
@Service
public class TransactionDemoService {
private final JdbcTemplate jdbcTemplate;
public TransactionDemoService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void saveAndFail(long id) {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
throw new IllegalStateException("模拟业务异常");
}
public void saveViaThis(long id) {
this.saveAndFail(id);
}
}
注意这里有两个入口:
saveAndFail():外部直接调用,经过 Spring 代理,正常情况下会开启事务。saveViaThis():外部进入后,通过this调用同类方法,不再经过该次内部调用所需要的事务代理拦截。
4. 写两个测试,不靠猜测判断回滚
java
package com.example.tx;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* 验证外部代理调用与内部自调用的事务行为。
*/
@SpringBootTest
class TransactionDemoTests {
@Autowired
private TransactionDemoService service;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void clean() {
jdbcTemplate.update("DELETE FROM tx_demo");
}
@Test
void externalCallShouldRollBack() {
assertThrows(IllegalStateException.class,
() -> service.saveAndFail(1001L));
assertEquals(0L, countRows());
}
@Test
void selfInvocationShouldLeaveInsertedRow() {
assertThrows(IllegalStateException.class,
() -> service.saveViaThis(1002L));
assertEquals(1L, countRows());
}
private long countRows() {
return jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM tx_demo",
Long.class);
}
}
测试类不要额外添加 @Transactional,否则测试本身可能建立外层事务,干扰我们对 Service 行为的观察。
在上述单数据源、正常自动配置的前提下,预期结果是:
| 调用方式 | 是否经过对应方法的事务拦截 | 抛异常后表中记录 |
|---|---|---|
service.saveAndFail(1001L) |
是 | 0 条 |
service.saveViaThis(1002L) |
内部 this.saveAndFail() 没有经过 |
1 条 |
这里展示的是基于代码和 Spring 代理语义推导的预期测试结果,不是声称已经在本文环境执行过测试。复制到工程后请实际运行验证;若已有外层事务、手动调整了事务配置,结果可能不同。
这就是我们排查的第一个重要结论:
自调用不会让被调用方法上的事务注解重新生效;但如果外部方法已经启动了事务,内部数据库操作仍可能参与那个现有事务。
换句话说,不能粗暴地把"this 调用"解释为"肯定没有任何事务"。
5. 推荐修复:拆成两个 Spring Bean
java
@Service
public class OrderEntryService {
private final OrderWriteService orderWriteService;
public OrderEntryService(OrderWriteService orderWriteService) {
this.orderWriteService = orderWriteService;
}
public void submit(long id) {
orderWriteService.saveAndFail(id);
}
}
@Service
public class OrderWriteService {
private final JdbcTemplate jdbcTemplate;
public OrderWriteService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void saveAndFail(long id) {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
throw new IllegalStateException("模拟业务异常");
}
}
此时 OrderEntryService 调用的是容器提供的 OrderWriteService,事务边界清楚,也更容易写测试。相比为了绕过自调用而注入自身代理或使用 AopContext.currentProxy(),拆分职责通常更值得优先考虑。
三、第二个坑:抛的是受检异常,默认规则可能不回滚
看下面的代码:
java
@Transactional
public void importOrders() throws IOException {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
2001L, "import");
throw new IOException("导入文件失败");
}
我们直觉上可能认为"既然抛了异常,就应该回滚"。但 Spring 声明式事务的传统默认回滚规则是:
RuntimeException:回滚。Error:回滚。- 受检异常,例如
IOException:默认不回滚。
因此,在没有其他回滚规则的情况下,IOException 从代理方法抛出去,事务仍可能提交。
更明确的写法是:
java
@Transactional(rollbackFor = IOException.class)
public void importOrders() throws IOException {
// 数据库写入与文件导入流程
}
如果项目业务异常较多,也可以考虑 rollbackFor = Exception.class,但不要不经评估就全局修改历史项目的回滚约定。
一个容易漏掉的版本变化: 从 Spring Framework 6.2 开始,可以通过 @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) 全局调整默认回滚规则。因此排查时还要检查项目是否已经设置了全局规则,而不是只看单个方法上的注解。
四、第三个坑:异常在方法内部被吞掉,事务拦截器看见的是"正常结束"
考虑这样的处理:
java
@Transactional
public void saveWithCatch(long id) {
try {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
throw new IllegalStateException("业务校验失败");
} catch (IllegalStateException ex) {
// 记录错误后直接返回
System.err.println(ex.getMessage());
}
}
IllegalStateException 在事务方法内部就被捕获了,没有传播到事务拦截器。
如果没有其他代码把事务标记为 rollback-only,Spring 看见的是方法正常返回,便会尝试提交。
比较稳妥的处理方式:
java
@Transactional
public void saveWithRollback(long id) {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
if (id <= 0) {
throw new IllegalArgumentException("ID 不合法");
}
}
如果你必须捕获异常来补充上下文,可以记录后重新抛出,或转换成符合回滚规则的业务异常。确实无法抛出时,也可以在确认存在当前 Spring 事务的前提下,使用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();但它会让业务代码依赖 Spring 事务 API,不宜作为首选。
这里还要区分另一种情况:被调用的另一个代理方法已经把共享事务标记为回滚 。即使外层捕获了异常,最终提交仍可能失败,并出现 UnexpectedRollbackException。这不是事务注解突然失效,而是内部事务已经明确要求回滚,外层却还尝试提交。
五、第四个坑:private、final,以及"不全是 public"的方法可见性
网上经常能看到一句话:"@Transactional 必须加在 public 方法上,其他方法一定不生效。"
作为日常编码约定,优先使用 public 方法没有问题;但作为严格的框架结论,这句话已经不够准确。
在常见的 Spring AOP 代理模型中:
| 方法或调用方式 | 需要注意 |
|---|---|
private 方法 |
不能由普通 Spring AOP 代理直接拦截 |
类代理下的 final 方法 |
不能通过重写实现拦截 |
public 方法 |
推荐的事务入口,但仍需经过代理 |
protected / 包可见方法 |
Spring 6.0 起,在类代理的相应条件下也可能被事务拦截 |
| JDK 接口代理 | 事务入口需要满足接口代理的方法约束 |
同类内部 this 调用 |
即使目标方法是 public,也绕过那次代理拦截 |
所以我更推荐一条工程约定,而不是死记某个版本的可见性细节:
把事务边界放在 Spring 管理的 Service 的清晰、可从外部调用的
public方法上;不要依赖私有方法、构造或初始化阶段的方法来建立事务。
还有一点:new OrderService(...) 得到的是自己创建的对象,不是 Spring 容器注入的事务代理。即便类上有 @Service,自行 new 也不会自动取得事务增强。
六、第五个坑:开启了异步线程,以为事务会跟着过去
例如:
java
@Transactional
public void createAndNotify() {
orderMapper.insert(order);
CompletableFuture.runAsync(() -> {
auditMapper.insert(auditRecord);
});
throw new IllegalStateException("订单流程失败");
}
这里存在两条执行链:
text
请求线程:开启事务 → 写订单 → 抛异常 → 订单事务回滚
异步线程: → 写审计记录 → 使用异步线程自己的数据库访问上下文
对于通常由 PlatformTransactionManager 管理的命令式事务,事务上下文是线程绑定的,不会因为创建了新线程就自动传递过去。
所以异步操作如果独立提交,就可能出现"订单回滚了,审计记录还在"的结果;至于异步操作具体执行、报错或提交的时序,还取决于线程调度和数据访问配置。
更合适的做法取决于业务语义:
- 必须与订单原子提交的数据,不要随便放进新线程。
- 可以独立完成的通知、审计,要明确设计独立事务和失败补偿。
- 使用
@Async的方法可以在异步线程中通过自己的代理调用建立自己的事务,但那不是原线程事务的延续。 - 如果需要"本地事务提交成功后再触发后续动作",可以考虑
@TransactionalEventListener(phase = AFTER_COMMIT);若涉及消息可靠投递,还应评估 Outbox 等机制,不能把监听器当成可靠消息队列。
七、第六个坑:REQUIRES_NEW 写了,但没有发生预期中的"新事务"
例如:
java
@Service
public class OrderService {
@Transactional
public void create() {
saveOrder();
this.saveAudit();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAudit() {
// 保存审计记录
}
}
开发者可能期待 saveAudit() 使用一个独立事务:审计提交后,外层事务即使回滚也不影响审计记录。
但这里仍然是同类自调用 。REQUIRES_NEW 的拦截没有发生,审计操作通常仍处于外层 create() 的事务范围内。
要让传播设置生效,最直接的做法还是拆成另一个受 Spring 管理的 Service,通过代理调用。
同时记住两种传播行为的差别:
| 传播行为 | 事务语义 |
|---|---|
REQUIRED(默认) |
有事务就加入,没有就新建 |
REQUIRES_NEW |
建立独立物理事务,外层事务暂时挂起 |
REQUIRES_NEW 也不是万能的"防回滚注解"。外层事务可能仍占用连接,内层又需要获取新连接。如果并发量较高、连接池容量不足,可能造成连接等待甚至资源耗尽。
如果你的实际意图是"订单和库存要么一起成功,要么一起失败",绝大多数情况下,使用一个正常的 REQUIRED 事务更简单。
八、第七个坑:事务管理器管着 A 数据源,SQL 却操作了 B 数据源
到了多数据源系统,问题可能不在异常,也不在代理。
比如:
- 订单数据写入 MySQL 的订单数据源。
- 日志或统计数据写入另一个 PostgreSQL 数据源。
- 方法只使用了某一个
DataSourceTransactionManager。
这时,即便方法里的 @Transactional 生效了,也不能据此认为两个数据库的写入会一起回滚。
本地 JDBC 事务管理器负责的是对应数据源上的事务,而不是自动帮你建立跨数据库的原子提交。
多事务管理器项目可以显式指定:
java
@Transactional(transactionManager = "orderTransactionManager")
public void saveOrder() {
// 使用订单数据源
}
但指定一个管理器仍不会自动包含其他数据源。真正需要跨系统一致性时,应根据业务选择消息最终一致性、Outbox、补偿流程或合适的分布式事务方案。
同样要警惕绕过 Spring 数据源管理的手工连接:
java
Connection connection =
DriverManager.getConnection(url, username, password);
这条连接并不会因为当前方法上存在 @Transactional,就自动加入由另一个 Spring 数据源管理的事务。使用 JdbcTemplate、正确配置的数据源和事务管理器,通常更不容易踩这个坑。
此外,数据库或存储引擎本身也必须支持相应事务。例如使用 MySQL 时,应确认相关表使用支持事务的引擎(通常是 InnoDB),而不是只根据 Java 代码判断结果。
九、遇到事务不生效,我会按这个顺序排查
我不建议第一步就修改 rollbackFor,也不建议一上来就把所有传播行为改成 REQUIRES_NEW。先确认最基本的链路。
第一步:确认事务是否真的激活
在需要观察的代码位置临时打印:
java
boolean active =
TransactionSynchronizationManager
.isActualTransactionActive();
System.out.println("当前线程是否存在实际事务:" + active);
注意,这只能说明当前线程此刻 有没有 Spring 管理的实际事务,并不能单独证明某个特定注解已经按预期生效。若外层已有事务,自调用内部也可能打印 true。
第二步:打开事务日志
application.yml:
yaml
logging:
level:
org.springframework.transaction.interceptor: TRACE
org.springframework.jdbc.datasource: DEBUG
根据实际事务管理器的实现,日志类和输出内容可能不同。重点观察:
- 有没有进入事务拦截器?
- 创建了新事务,还是加入已有事务?
- 是否设置了回滚标记?
- 最终是提交还是回滚?
调试完成后关闭过细日志,避免生产环境长期输出大量事务细节。
第三步:逐项检查
| 检查项 | 关键问题 |
|---|---|
| Bean 与代理 | 是容器注入的 Bean,还是自己 new 出来的? |
| 调用路径 | 是否通过 this 调用另一个带事务注解的方法? |
| 异常 | 异常类型是什么?是否被方法内部捕获? |
| 回滚规则 | 有没有 rollbackFor、noRollbackFor 或全局覆盖? |
| 传播行为 | 是 REQUIRED 还是 REQUIRES_NEW?是否真的经过代理? |
| 线程 | 有无 @Async、线程池、CompletableFuture? |
| 数据源 | SQL 所在数据源是否归当前事务管理器负责? |
| 数据库 | 表和驱动是否支持事务?是否混用了独立连接? |
第四步:写一个真正检查数据库结果的测试
不要只断言抛出异常:
java
assertThrows(IllegalStateException.class,
() -> service.saveAndFail(1001L));
还应该检查最终数据:
java
assertEquals(0L, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM tx_demo", Long.class));
"异常抛出来了"和"数据库真的回滚了"是两回事。
对于事务边界测试,还要特别留意测试框架自身的事务设置。让测试方法自动回滚,有时反而会掩盖业务 Service 没有回滚的问题。
十、总结:别只看有没有注解,要看事务边界在哪里
@Transactional 最容易让人产生的误解,是以为它会给一个 Java 方法永久绑定"执行失败就全部撤销"的能力。
实际上,在常见的 Spring 代理事务模型里,它能否按预期工作,还取决于:
- 入口有没有经过代理。
- 异常或回滚标记有没有被事务管理器感知。
- 数据库操作是否处在同一线程、同一个受管理的事务资源中。
- 传播行为是否真的建立了你以为的事务边界。
如果只能记住一句话,我希望是:
排查事务问题时,先画出"谁调用谁、谁开启事务、谁提交回滚"的链路,再决定修改哪个注解。
这通常比反复加 rollbackFor = Exception.class 更有效,也更不容易把一个局部问题扩大成新的事务一致性问题。