Spring 事务
Spring 事务不是一种新的数据库事务,而是 Spring 对 JDBC、MyBatis、JPA、Hibernate 等持久化技术的统一事务抽象。真正提交、回滚、加锁、隔离级别仍然由数据库完成,Spring 负责在合适的业务方法边界上开启事务、提交事务、回滚事务、绑定连接和处理传播行为。
一句话理解:
Spring 事务 = 事务抽象接口 + 事务管理器 + AOP 代理或编程 API + 数据库事务能力。
Spring 中最常见的两种事务使用方式:
| 类型 | 代表写法 | 核心特点 |
|---|---|---|
| 声明式事务 | @Transactional | 通过注解声明事务边界,底层用 AOP 代理自动开启、提交、回滚 |
| 编程式事务 | TransactionTemplate / PlatformTransactionManager | 在代码中手动控制事务边界,更灵活但侵入业务代码 |
大多数业务系统优先使用声明式事务;遇到“一个方法中只有一小段需要事务”“需要精确控制提交后再做某件事”“想捕获异常但仍然回滚”等场景,再考虑编程式事务。
事务不是“加注解就一定生效”。传播行为、异常类型、代理调用方式、数据库引擎都会影响最终结果。
如果你已经理解本页概念,但还不知道怎么把事务放到订单、库存、失败日志、批处理和 MQ 场景里,继续做:Spring 事务商业场景训练营。
学习目标
| 目标 | 你需要掌握什么 |
|---|---|
| 知道是什么 | Spring 事务是对数据库事务的统一管理,不是替代数据库事务 |
| 知道为什么 | 事务代码散落在业务中会重复、易错、难维护,所以需要统一抽象 |
| 知道怎么工作 | 声明式事务靠 AOP 代理,编程式事务靠模板或事务管理器 |
| 知道不这样会怎样 | 事务边界过大、异常吞掉、自调用、跨线程都会导致数据不一致 |
| 会写 Demo | 能写 @Transactional、TransactionTemplate、手动事务管理代码 |
| 会项目落地 | 能判断订单、库存、日志、批处理、采集入库该怎么设计事务 |
| 会面试 | 能讲清声明式事务、编程式事务、传播行为、回滚规则和失效原因 |
为什么需要 Spring 事务
没有 Spring 事务时,JDBC 代码通常要这样写:
Connection connection = dataSource.getConnection();
try {
connection.setAutoCommit(false);
orderDao.insert(connection, order);
stockDao.deduct(connection, skuId, count);
connection.commit();
} catch (Exception e) {
connection.rollback();
throw e;
} finally {
connection.close();
}这段代码有几个问题:
- 业务代码被
commit、rollback、Connection管理污染。 - 每个业务方法都要重复写 try/catch/finally。
- 多个 DAO 必须手动传同一个连接,否则不在一个事务里。
- 异常处理稍微写错,就可能该回滚时提交。
- 后面从 JDBC 换成 JPA、MyBatis 时,事务管理方式又要适配。
Spring 事务把这些重复逻辑抽象掉:
flowchart TD
A["业务方法只写业务逻辑"] --> B["Spring 识别事务边界"]
B --> C["事务管理器获取连接"]
C --> D["绑定连接到当前线程"]
D --> E["DAO 层复用同一连接"]
E --> F{"业务是否成功"}
F -- "成功" --> G["提交事务"]
F -- "失败" --> H["回滚事务"]
G --> I["释放资源"]
H --> ISpring 事务核心组件
| 组件 | 作用 | 初学者理解 |
|---|---|---|
PlatformTransactionManager | 事务管理器顶层接口 | 真正负责开启、提交、回滚事务的入口 |
TransactionDefinition | 事务定义 | 传播行为、隔离级别、超时时间、只读等配置 |
TransactionStatus | 事务状态 | 当前事务是否新建、是否只回滚、是否有保存点 |
TransactionTemplate | 编程式事务模板 | 用回调方式把一段代码包进事务 |
@Transactional | 声明式事务注解 | 告诉 Spring 这个方法需要事务 |
TransactionInterceptor | 事务拦截器 | AOP 调用链中真正处理事务的增强逻辑 |
TransactionSynchronizationManager | 线程资源绑定器 | 用 ThreadLocal 保存当前线程的连接和事务同步信息 |
不同技术栈用不同事务管理器:
| 技术 | 常见事务管理器 |
|---|---|
| JDBC / MyBatis | DataSourceTransactionManager |
| JPA | JpaTransactionManager |
| Hibernate | HibernateTransactionManager |
| JTA / XA 分布式事务 | JtaTransactionManager |
MyBatis 常见项目里,Spring Boot 通常会根据 DataSource 自动配置 DataSourceTransactionManager。所以你写 @Transactional 时,底层多数情况下是在控制同一个数据库连接的 autoCommit、commit、rollback。
事务执行流程
flowchart TD
A["外部调用 Service 方法"] --> B["进入 Spring 事务代理"]
B --> C["获取数据库连接"]
C --> D["关闭自动提交,开启事务"]
D --> E["执行业务 SQL"]
E --> F{"是否抛出需回滚异常"}
F -- "是" --> G["回滚事务"]
F -- "否" --> H["提交事务"]
G --> I["释放连接"]
H --> I更贴近源码的流程可以这样理解:
flowchart TD
A["调用代理对象方法"] --> B["进入 TransactionInterceptor"]
B --> C["读取 @Transactional 配置"]
C --> D["选择 PlatformTransactionManager"]
D --> E["按传播行为获取或创建事务"]
E --> F["绑定 Connection 到当前线程"]
F --> G["调用目标业务方法"]
G --> H{"目标方法结果"}
H -- "正常返回" --> I["commit"]
H -- "抛出需回滚异常" --> J["rollback"]
H -- "抛出但不需回滚异常" --> I
I --> K["解绑资源"]
J --> K声明式事务源码级执行链路
零基础不要一上来背源码类名,但要知道每个类在链路里负责什么。@Transactional 真正执行时,可以拆成三段:代理进入、事务准备、方法结束处理。
flowchart TD
A["外部调用代理对象"] --> B["TransactionInterceptor.invoke"]
B --> C["读取 TransactionAttribute"]
C --> D["选择 PlatformTransactionManager"]
D --> E["getTransaction"]
E --> F["创建或加入事务"]
F --> G["调用目标方法"]
G --> H{"方法结果"}
H -- "正常返回" --> I["commitTransactionAfterReturning"]
H -- "异常抛出" --> J["completeTransactionAfterThrowing"]
I --> K["提交或发现 rollback-only 后回滚"]
J --> L["按回滚规则提交或回滚"]
K --> M["cleanupTransactionInfo"]
L --> M关键对象按顺序理解:
| 对象 | 在链路里做什么 | 初学者理解 |
|---|---|---|
TransactionInterceptor | AOP 拦截器,包住目标方法 | 事务增强的入口 |
TransactionAttributeSource | 解析 @Transactional 元信息 | 找到传播、隔离、回滚规则 |
PlatformTransactionManager | 事务管理器接口 | 决定怎么开启、提交、回滚 |
DataSourceTransactionManager | JDBC/MyBatis 常用实现 | 管理数据库连接 |
TransactionStatus | 当前事务状态 | 记录是否新事务、是否只回滚、是否有保存点 |
TransactionSynchronizationManager | 线程资源绑定器 | 把连接和同步回调绑到当前线程 |
TransactionAspectSupport | 事务切面支持类 | 提供模板流程和当前事务状态 |
为什么要这样拆?因为 Spring 既要支持 JDBC,也要支持 JPA、Hibernate、JTA。TransactionInterceptor 不直接写死 JDBC 提交逻辑,而是通过 PlatformTransactionManager 抽象出去。这样 MyBatis 项目用 DataSourceTransactionManager,JPA 项目用 JpaTransactionManager,分布式 XA 场景可以用 JtaTransactionManager。
如果没有这层抽象,就会出现两个问题:
- 每换一种持久化技术,事务代码都要重写。
- Spring AOP 只能管一种资源,无法统一处理传播行为、回滚规则和同步回调。
DataSourceTransactionManager 开启事务全过程
MyBatis/JDBC 项目最常见的是 DataSourceTransactionManager。它开启事务不是“创建一个事务对象”这么简单,而是围绕数据库连接做一系列动作。
flowchart TD
A["getTransaction"] --> B{"当前线程是否已有 ConnectionHolder"}
B -- "有且事务活跃" --> C["根据传播行为加入或挂起"]
B -- "没有" --> D["从 DataSource 获取 Connection"]
D --> E["设置 autoCommit=false"]
E --> F["按需设置隔离级别、只读、超时"]
F --> G["创建 ConnectionHolder"]
G --> H["绑定到 TransactionSynchronizationManager"]
H --> I["Repository 后续复用同一连接"]这里最重要的是两个动作:
| 动作 | 为什么重要 |
|---|---|
autoCommit=false | 否则每条 SQL 执行后都会自动提交,无法多条 SQL 一起回滚 |
| 绑定到当前线程 | 否则 Mapper/DAO 每次可能拿到不同连接,不在同一个事务里 |
可以把事务连接想成“当前线程临时借来的同一支笔”。Service 里多个 Mapper 写数据库时,都从当前线程上下文拿这支笔写同一本账。最后事务管理器决定这本账是盖章生效,还是整页作废。
如果 DAO 绕过 Spring 自己调用 dataSource.getConnection() 并手动提交,就可能拿到另一条连接,脱离当前事务。这也是为什么 Spring 项目里不要随便在业务代码中手动管理连接。
Repository 为什么能拿到同一个连接
很多人知道 @Transactional 能让多个 Mapper 同事务,但不知道 Mapper 为什么能拿到同一个连接。
核心是:Spring 把连接绑定到当前线程,MyBatis/Spring JDBC 获取连接时会优先从当前线程拿。
flowchart TD
A["事务代理开启事务"] --> B["ConnectionHolder 绑定到当前线程"]
C["Mapper A 执行 insert"] --> D["DataSourceUtils 获取连接"]
D --> E["发现当前线程已有连接"]
E --> F["复用事务连接"]
G["Mapper B 执行 update"] --> H["再次从当前线程取连接"]
H --> F
F --> I["两条 SQL 等待统一提交或回滚"]简化代码理解:
// 不是源码,只是帮助理解
Connection connection = TransactionSynchronizationManager.getResource(dataSource);
if (connection == null) {
connection = dataSource.getConnection();
}如果没有线程绑定,同一个业务方法里可能出现:
orderMapper.insert()用连接 A,执行后提交。stockMapper.deduct()用连接 B,执行失败回滚。- 最终订单已经插入,库存没扣,数据不一致。
Spring 事务通过“同线程同连接”保证一个本地事务边界内的 DAO 操作能统一提交或回滚。
commit 不一定真的提交
很多人以为方法正常返回就一定提交。实际上 commit(status) 内部还要检查当前事务状态。如果事务已经被标记为 rollback-only,就算外层方法正常返回,也不能提交。
flowchart TD
A["目标方法正常返回"] --> B["事务拦截器准备 commit"]
B --> C{"status 是否 rollback-only"}
C -- "否" --> D["执行 beforeCommit 回调"]
D --> E["数据库 commit"]
E --> F["执行 afterCommit / afterCompletion"]
C -- "是" --> G["执行 rollback"]
G --> H["可能抛 UnexpectedRollbackException"]rollback-only 常见来源:
| 来源 | 过程 |
|---|---|
内层 REQUIRED 抛运行时异常 | 加入同一个事务,异常让整个事务被标记只能回滚 |
代码调用 setRollbackOnly() | 主动告诉 Spring 不允许提交 |
| 事务管理器发现提交前异常 | 比如连接异常、数据库异常 |
这解释了为什么“外层 catch 住异常继续执行,最后却报 UnexpectedRollbackException”。因为异常虽然被业务代码 catch 了,但事务状态已经被内层标记成只能回滚。
rollback 做了什么
回滚也不是只调用一句 connection.rollback()。Spring 会根据当前事务状态做不同处理:
flowchart TD
A["需要回滚"] --> B{"当前是否新事务"}
B -- "是" --> C["调用数据库 rollback"]
B -- "否" --> D{"是否有保存点"}
D -- "有" --> E["回滚到保存点"]
D -- "无" --> F["标记外层事务 rollback-only"]
C --> G["执行 afterCompletion 回调"]
E --> G
F --> H["等待外层最终处理"]这就是 REQUIRED、REQUIRES_NEW、NESTED 的底层差异之一:
| 场景 | 回滚行为 |
|---|---|
| 当前方法新建了物理事务 | 可以直接 rollback 数据库连接 |
| 当前方法加入外层事务 | 通常不能直接回滚外层,只能标记 rollback-only |
当前方法是 NESTED | 如果支持保存点,可以回滚到保存点 |
当前方法是 REQUIRES_NEW | 内层独立连接和独立事务,可以独立 rollback |
所以传播行为不是背枚举,它决定“当前方法有没有资格提交或回滚物理事务”。
声明式事务是什么
声明式事务就是用注解或 XML 声明“哪些方法需要事务”,业务代码不直接写提交和回滚逻辑。现在项目里最常见的是 @Transactional。
@Service
public class OrderService {
@Transactional
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
stockRepository.deduct(command.getProductId(), command.getCount());
}
}这段代码表面上只写了两行业务逻辑,但真实运行时,调用链是:
flowchart TD
A["Controller 调用 orderService.createOrder"] --> B["实际进入代理对象"]
B --> C["事务拦截器开启事务"]
C --> D["调用 OrderService 原始方法"]
D --> E["插入订单"]
E --> F["扣减库存"]
F --> G{"是否异常"}
G -- "无异常" --> H["提交订单和库存"]
G -- "有回滚异常" --> I["订单和库存一起回滚"]声明式事务的重点不是“注解写法简单”,而是“事务边界从业务代码中抽离出去”。业务方法只关心业务动作,事务的开启、提交、回滚由 Spring 统一处理。
声明式事务怎么生效
@Transactional 生效通常依赖三件事:
- 当前类必须是 Spring 容器管理的 Bean。
- 调用必须经过 Spring 创建的代理对象。
- 容器中要有合适的
PlatformTransactionManager。
底层流程:
flowchart TD
A["Spring 扫描 Bean"] --> B["发现事务相关基础设施"]
B --> C["Bean 初始化后判断是否有 @Transactional"]
C --> D{"是否需要事务增强"}
D -- "否" --> E["普通 Bean"]
D -- "是" --> F["创建代理对象"]
F --> G["容器暴露代理对象"]
G --> H["外部调用代理方法"]
H --> I["TransactionInterceptor 管理事务"]为什么自调用会失效?因为事务增强发生在“调用代理对象方法”这一刻。如果你在同一个类里用 this.inner() 调用,调用没有离开原始对象,没有经过代理,事务拦截器没有机会执行。
关联学习:Spring AOP、Bean生命周期。
声明式事务常用配置
@Transactional 不只是一个开关,它有很多关键属性。
| 属性 | 作用 | 常见写法 |
|---|---|---|
propagation | 事务传播行为 | Propagation.REQUIRED |
isolation | 数据库隔离级别 | Isolation.READ_COMMITTED |
rollbackFor | 指定哪些异常回滚 | rollbackFor = Exception.class |
noRollbackFor | 指定哪些异常不回滚 | noRollbackFor = BizWarnException.class |
timeout | 超时时间,单位秒 | timeout = 5 |
readOnly | 只读事务提示 | readOnly = true |
transactionManager | 指定事务管理器 | transactionManager = "orderTxManager" |
完整示例:
@Transactional(
propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
rollbackFor = Exception.class,
timeout = 5
)
public void payOrder(PayCommand command) throws Exception {
paymentRepository.insert(command);
orderRepository.markPaid(command.getOrderId());
}注解写在类上还是方法上
@Service
@Transactional(readOnly = true)
public class OrderQueryService {
public OrderDetail queryDetail(Long orderId) {
return orderRepository.selectDetail(orderId);
}
@Transactional(readOnly = false, rollbackFor = Exception.class)
public void updateAddress(UpdateAddressCommand command) {
orderRepository.updateAddress(command);
}
}规则:
| 位置 | 作用 |
|---|---|
| 类上 | 类中 public 方法默认使用该事务配置 |
| 方法上 | 覆盖类上的事务配置 |
| 接口上 | 有时能生效,但不建议只写接口 |
| 实现类方法上 | 最推荐,语义最清楚 |
商业项目里常见做法:查询服务类可以类上标 readOnly = true,写方法单独覆盖;写服务类按具体业务方法标注事务。
编程式事务是什么
编程式事务就是在代码中明确写出“这一段逻辑放进事务里”。它不像 @Transactional 那样依赖方法代理,而是你主动调用 Spring 的事务 API。
常见方式有两种:
| 方式 | 特点 |
|---|---|
TransactionTemplate | 推荐使用,模板封装了提交、回滚和资源释放 |
PlatformTransactionManager | 更底层,灵活但代码更啰嗦 |
TransactionTemplate 示例
@Service
public class OrderImportService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
private final ImportLogRepository importLogRepository;
public OrderImportService(TransactionTemplate transactionTemplate,
OrderRepository orderRepository,
ImportLogRepository importLogRepository) {
this.transactionTemplate = transactionTemplate;
this.orderRepository = orderRepository;
this.importLogRepository = importLogRepository;
}
public void importOneRow(ImportRow row) {
transactionTemplate.executeWithoutResult(status -> {
orderRepository.insert(row.toOrder());
importLogRepository.markSuccess(row.getRowNo());
});
}
}这段代码中,只有 executeWithoutResult 回调里的内容在事务里。回调正常结束就提交,抛出运行时异常就回滚。
如果需要返回结果:
public Long createOrder(CreateOrderCommand command) {
return transactionTemplate.execute(status -> {
Long orderId = orderRepository.insert(command);
stockRepository.deduct(command.getSkuId(), command.getCount());
return orderId;
});
}TransactionTemplate 内部执行流程
TransactionTemplate 看起来只是一个回调,内部仍然走 Spring 统一事务抽象。它没有绕过 PlatformTransactionManager,只是把“获取事务、try/catch、提交、回滚、清理资源”写成模板,避免每个业务方法重复写。
flowchart TD
A["调用 transactionTemplate.execute"] --> B["根据模板配置创建 TransactionDefinition"]
B --> C["transactionManager.getTransaction"]
C --> D{"当前线程是否已有事务"}
D -- "按传播行为加入或新建" --> E["执行业务回调 callback"]
E --> F{"回调是否异常或 setRollbackOnly"}
F -- "否" --> G["transactionManager.commit"]
F -- "是" --> H["transactionManager.rollback"]
G --> I["解绑连接并释放资源"]
H --> I把它拆成伪代码更容易理解:
public <T> T execute(TransactionCallback<T> action) {
TransactionStatus status = transactionManager.getTransaction(this);
try {
T result = action.doInTransaction(status);
transactionManager.commit(status);
return result;
} catch (RuntimeException | Error ex) {
transactionManager.rollback(status);
throw ex;
} catch (Throwable ex) {
transactionManager.rollback(status);
throw new UndeclaredThrowableException(ex);
}
}真正源码比这更严谨,会处理回调异常、提交异常、回滚异常、事务同步回调、已有事务参与等细节,但主线就是这几步。
TransactionTemplate 的关键点:
| 点 | 解释 |
|---|---|
| 事务定义从哪里来 | 来自 TransactionTemplate 自身配置,比如传播行为、隔离级别、超时、只读 |
| 事务谁来开 | 仍然是注入的 PlatformTransactionManager |
| 回调里 Mapper 为什么同事务 | 连接仍然绑定到当前线程,Mapper 从线程上下文复用连接 |
| 正常返回一定提交吗 | 不一定,如果回调里调用了 status.setRollbackOnly(),提交阶段会变成回滚 |
| catch 后返回失败能不能回滚 | 可以,前提是显式调用 status.setRollbackOnly() |
编程式事务如何手动回滚
有时业务要求“捕获异常后返回结果,但数据库仍要回滚”。声明式事务里 catch 后不抛出容易导致提交,这时编程式事务更直观。
public ImportResult importOneRow(ImportRow row) {
return transactionTemplate.execute(status -> {
try {
orderRepository.insert(row.toOrder());
detailRepository.insertBatch(row.toDetails());
return ImportResult.success(row.getRowNo());
} catch (Exception e) {
status.setRollbackOnly();
return ImportResult.fail(row.getRowNo(), e.getMessage());
}
});
}这里即使方法最终返回 ImportResult.fail,事务也会回滚,因为调用了 status.setRollbackOnly()。
注意:不要在事务里吞掉异常又忘记 setRollbackOnly()。这会让调用方看到失败结果,但数据库已经提交,最难排查。
更完整的导入场景通常不是“失败就结束”,而是需要记录每一行成功失败,并且失败行不污染业务表:
public List<ImportRowResult> importRows(List<ImportRow> rows) {
List<ImportRowResult> results = new ArrayList<>();
for (ImportRow row : rows) {
ImportRowResult result = transactionTemplate.execute(status -> {
try {
assetRepository.insert(row.toAsset());
assetDetailRepository.insertBatch(row.toDetails());
return ImportRowResult.success(row.getRowNo());
} catch (Exception ex) {
status.setRollbackOnly();
return ImportRowResult.fail(row.getRowNo(), ex.getMessage());
}
});
results.add(result);
}
return results;
}为什么不用一个大事务包住全部行?
- 第 9999 行失败会导致前 9998 行全部回滚,用户无法得到部分成功结果。
- 大事务长时间占用连接、行锁和 undo log,影响在线业务。
- 回滚成本很高,失败时数据库压力反而更大。
- 导入任务重跑时无法精确从失败行继续。
这种“每行或每批一个事务”的设计,在资产导入、医疗采集数据清洗、库存初始化、历史订单迁移里很常见。
编程式事务常见误用
| 误用 | 后果 | 正确做法 |
|---|---|---|
| 在事务回调里调用慢 HTTP 接口 | 连接和锁长时间占用,吞吐下降 | 先完成本地短事务,再用消息或状态机调用外部系统 |
捕获异常后不 setRollbackOnly | 方法返回失败但数据库提交 | 失败分支显式 setRollbackOnly |
| 循环 10 万次只开一个事务 | undo、锁、连接压力大,回滚慢 | 按 200、500、1000 条分批提交 |
| 嵌套多个模板但不理解传播行为 | 内外事务提交回滚结果和预期不一致 | 明确设置传播行为并写测试验证 |
| 把模板散落在 Controller | 事务边界混乱,难测试 | 事务放在 Service 或基础组件中 |
PlatformTransactionManager 示例
更底层的写法如下:
@Service
public class ManualTransactionService {
private final PlatformTransactionManager transactionManager;
private final OrderRepository orderRepository;
public ManualTransactionService(PlatformTransactionManager transactionManager,
OrderRepository orderRepository) {
this.transactionManager = transactionManager;
this.orderRepository = orderRepository;
}
public void createOrder(CreateOrderCommand command) {
DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
definition.setTimeout(5);
TransactionStatus status = transactionManager.getTransaction(definition);
try {
orderRepository.insert(command);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}
}这种写法能完全控制事务定义和提交回滚,但业务代码被事务细节污染。除非你在写框架、基础组件,或者遇到非常特殊的事务边界,否则更推荐 TransactionTemplate。
声明式事务和编程式事务对比
| 对比点 | 声明式事务 @Transactional | 编程式事务 TransactionTemplate |
|---|---|---|
| 侵入性 | 低,业务代码干净 | 高,代码中显式出现事务 API |
| 实现方式 | AOP 代理拦截方法 | 主动调用模板或事务管理器 |
| 事务边界 | 通常是整个 public 方法 | 可以精确到方法内部某一段 |
| 自调用问题 | 有,绕过代理会失效 | 没有代理自调用问题 |
| 异常控制 | 依赖异常抛出和 rollback 规则 | 可以返回失败结果并手动 setRollbackOnly |
| 可读性 | 适合常规业务 | 适合复杂流程里的局部事务 |
| 常见场景 | 下单、支付、更新状态、普通 CRUD | 批处理每批提交、导入逐行事务、事务提交后再执行非事务逻辑 |
选择建议:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 一个 Service 方法就是一个完整业务动作 | 声明式事务 | 简洁,边界清楚 |
| 方法中只有一小段需要事务 | 编程式事务 | 避免把远程调用、文件处理也包进事务 |
| 需要捕获异常并返回失败结果,同时回滚 | 编程式事务 | status.setRollbackOnly() 更明确 |
| 批量导入每 500 条提交一次 | 编程式事务 | 循环中精确控制每批事务 |
| 框架组件封装事务能力 | 编程式事务 | 不依赖调用方是否经过代理 |
| 多个普通写库操作要么都成功要么都失败 | 声明式事务 | 标准业务写法 |
商业场景一:订单创建用声明式事务
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
stockRepository.deduct(command.getProductId(), command.getCount());
couponRepository.use(command.getCouponId());
}
}这里的事务边界覆盖“创建订单、扣库存、使用优惠券”。这三个动作属于同一个本地数据库内的一致性要求:任何一步失败,都不应该留下半成品订单。
为什么适合声明式事务?
- 事务边界正好等于一个业务方法。
- 业务动作短,不需要在事务里调用慢接口。
- 失败时直接抛异常回滚,语义清楚。
商业场景二:批量导入用编程式事务
错误做法:
@Transactional(rollbackFor = Exception.class)
public void importAll(List<ImportRow> rows) {
for (ImportRow row : rows) {
orderRepository.insert(row.toOrder());
}
}如果 rows 有 10 万条,这就是一个巨大事务。它会长时间占用连接、锁、undo log,失败时回滚成本也很高。
更适合用编程式事务按批提交:
public void importAll(List<ImportRow> rows) {
List<List<ImportRow>> partitions = partition(rows, 500);
for (List<ImportRow> batch : partitions) {
transactionTemplate.executeWithoutResult(status -> {
for (ImportRow row : batch) {
orderRepository.insert(row.toOrder());
}
});
}
}这样每 500 条一个事务。某一批失败时,只影响这一批,连接、锁和 undo 压力都更可控。
商业场景三:事务提交后再发消息
常见错误:在事务还没提交时就发 MQ。
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
mqClient.send("order-created", command.getOrderId());
throw new RuntimeException("后续失败");
}问题:MQ 已经发出去了,但数据库事务回滚了。消费者收到订单创建消息,却查不到订单。
更稳的思路:
- 同一个本地事务中写订单和本地消息表。
- 事务提交后由后台任务或消息中继发送 MQ。
- 发送成功后标记消息状态。
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
outboxRepository.insert(new OutboxMessage(
"order-created",
command.getOrderId().toString()
));
}这不是 Spring 本地事务能单独解决的分布式一致性问题。Spring 本地事务只能保证同一个数据源内的操作一致;数据库和 MQ 的一致性要用本地消息表、事务消息、Seata、TCC、Saga 等方案。
TransactionSynchronization 提交后回调
有时业务希望“数据库提交成功后再做一件事”,例如清理本地缓存、发送轻量通知、记录非关键日志。Spring 提供了 TransactionSynchronization,可以注册事务同步回调。
示例:
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
cache.evict("order:" + command.getOrderId());
}
}
);
}执行过程:
flowchart TD
A["进入事务方法"] --> B["写数据库"]
B --> C["注册 TransactionSynchronization"]
C --> D{"事务最终结果"}
D -- "提交成功" --> E["执行 afterCommit"]
D -- "回滚" --> F["不执行 afterCommit"]
E --> G["执行 afterCompletion COMMITTED"]
F --> H["执行 afterCompletion ROLLED_BACK"]常见回调:
| 回调 | 时机 | 适合做什么 |
|---|---|---|
beforeCommit | 数据库提交前 | 做最后校验,不适合慢操作 |
afterCommit | 数据库提交成功后 | 清缓存、轻量通知、发布应用事件 |
afterCompletion | 提交或回滚完成后 | 释放资源、记录最终状态 |
但是要非常注意:afterCommit 不是可靠消息方案。
为什么?
- 数据库已经提交成功。
afterCommit开始执行。- 应用进程突然宕机,或者 MQ 发送失败。
- 数据库里订单已经存在,但消息没有发出去。
flowchart TD
A["数据库 commit 成功"] --> B["准备执行 afterCommit"]
B --> C{"进程或网络是否正常"}
C -- "正常" --> D["发送消息成功"]
C -- "异常" --> E["消息丢失风险"]
E --> F["数据库和下游不一致"]所以生产里要分清:
| 需求 | 推荐方式 |
|---|---|
| 清本地缓存、刷新非关键状态 | afterCommit 可以考虑 |
| 订单创建后必须可靠通知库存、搜索、风控 | 本地消息表、事务消息、CDC、补偿任务 |
| 写库后发 MQ,不能丢 | 不要只靠 afterCommit |
这就是为什么很多商业项目会使用 Outbox 本地消息表:把“订单数据”和“待发送消息”放进同一个本地事务,事务提交后由独立发送器可靠投递,失败可以重试和告警。
本地消息表和 afterCommit 的区别
二者都常出现在“事务提交后做事”的讨论里,但可靠性完全不同。
| 对比 | afterCommit | 本地消息表 |
|---|---|---|
| 是否持久化待办事项 | 否,回调只在内存中 | 是,消息记录写入数据库 |
| 应用宕机是否可恢复 | 宕机后回调丢失 | 重启后可继续扫描发送 |
| 是否适合核心链路 MQ | 不适合单独使用 | 适合最终一致主链路 |
| 实现成本 | 低 | 中等,需要状态、重试、补偿 |
本地消息表示例:
create table outbox_message (
id bigint primary key auto_increment,
topic varchar(128) not null,
biz_key varchar(128) not null,
payload text not null,
status tinyint not null,
retry_count int not null default 0,
next_retry_time datetime not null,
created_at datetime not null,
updated_at datetime not null,
unique key uk_topic_biz_key (topic, biz_key),
key idx_status_next_retry (status, next_retry_time)
) engine = InnoDB default charset = utf8mb4;@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
outboxRepository.insert(
"order-created",
command.getOrderNo(),
Jsons.toJson(command)
);
}发送器流程:
flowchart TD
A["扫描待发送 outbox"] --> B["发送 MQ"]
B --> C{"发送是否成功"}
C -- "成功" --> D["标记 SENT"]
C -- "失败" --> E["retry_count+1"]
E --> F["计算下次重试时间"]
F --> G{"是否超过阈值"}
G -- "否" --> A
G -- "是" --> H["标记 DEAD 并告警"]这套方案不是为了“更复杂”,而是为了把失败变成可见、可重试、可补偿的状态。核心系统最怕的是:主库已经成功,但下游消息丢了,系统表面没有任何失败痕迹。
默认回滚规则
Spring 默认对运行时异常和 Error 回滚,对检查异常不回滚。
| 异常类型 | 默认是否回滚 |
|---|---|
RuntimeException | 回滚 |
Error | 回滚 |
Exception | 不一定,检查异常默认不回滚 |
如果希望检查异常也回滚:
@Transactional(rollbackFor = Exception.class)
public void importData() throws Exception {
// ...
}为什么默认检查异常不回滚?这是 Spring 沿用 EJB 时代的约定:运行时异常通常代表程序错误或不可恢复错误,检查异常可能是业务可预期情况。但在现代业务系统里,很多项目会统一使用 rollbackFor = Exception.class,避免因为抛出检查异常导致数据被提交。
rollbackFor 和 noRollbackFor
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = BizWarningException.class
)
public void importData(ImportCommand command) throws Exception {
// ...
}含义:
| 配置 | 作用 |
|---|---|
rollbackFor = Exception.class | 遇到 Exception 及其子类都回滚 |
noRollbackFor = BizWarningException.class | 遇到指定异常不回滚 |
不要滥用 noRollbackFor。如果一个异常代表业务已经失败,数据库通常也应该回滚;只有“业务提醒但数据可以保留”这类明确场景才考虑不回滚。
异常被 catch 后为什么不回滚
事务代理判断是否回滚,主要看目标方法最终有没有向外抛出需要回滚的异常。
flowchart TD
A["进入事务方法"] --> B["执行业务代码"]
B --> C{"异常是否抛到代理层"}
C -- "抛出 RuntimeException" --> D["事务代理回滚"]
C -- "catch 后不再抛出" --> E["事务代理认为正常返回"]
E --> F["提交事务"]
C -- "手动 setRollbackOnly" --> G["提交阶段发现只回滚"]
G --> H["回滚事务"]所以只写日志不抛异常是危险的:
@Transactional(rollbackFor = Exception.class)
public void createOrder() {
try {
orderRepository.insert();
stockRepository.deduct();
} catch (Exception e) {
log.error("创建订单失败", e);
// 这里不抛出,也不 setRollbackOnly,事务会提交
}
}正确选择:
- 能失败就直接抛出异常,让声明式事务回滚。
- 必须返回失败结果时,用编程式事务或
setRollbackOnly()。
传播行为
| 传播行为 | 说明 | 常见场景 |
|---|---|---|
REQUIRED | 默认,有事务加入,没有则新建 | 大多数业务方法 |
REQUIRES_NEW | 新建事务,挂起外部事务 | 记录独立日志、审计 |
SUPPORTS | 有事务就加入,没有也运行 | 查询方法 |
MANDATORY | 必须存在事务,否则报错 | 必须由上层事务控制 |
NESTED | 嵌套事务,依赖保存点 | 局部回滚 |
NOT_SUPPORTED | 挂起事务,以非事务运行 | 不需要事务的慢操作 |
NEVER | 必须非事务运行 | 特殊限制场景 |
事务传播解决的是:
一个事务方法调用另一个事务方法时,内层方法应该加入外层事务、新开事务、挂起事务,还是必须没有事务?
逻辑事务和物理事务
理解传播行为之前,必须区分两个概念:
| 概念 | 含义 | 例子 |
|---|---|---|
| 逻辑事务 | Spring 在每个 @Transactional 方法入口创建的一层事务语义 | outer() 有一层,inner() 也有一层 |
| 物理事务 | 数据库连接上真正执行的事务 | connection.setAutoCommit(false) 后到 commit/rollback |
很多面试问题卡在这里:两个方法都标了 @Transactional,不代表一定有两个数据库事务。默认 REQUIRED 下,内层逻辑事务会加入外层物理事务。
flowchart TD
A["outer 逻辑事务"] --> B["开启一个数据库物理事务"]
B --> C["inner REQUIRED 逻辑事务"]
C --> D["加入同一个物理事务"]
D --> E{"任意逻辑层标记 rollback-only"}
E -- "是" --> F["物理事务最终回滚"]
E -- "否" --> G["物理事务最终提交"]为什么这个区分重要?
REQUIRED的内层方法没有资格单独提交数据库事务,因为它没有独立物理事务。- 内层异常即使被外层 catch,也可能已经把共同物理事务标记为 rollback-only。
REQUIRES_NEW才会挂起外层物理事务,重新开启一个新的物理事务。NESTED通常不是新物理事务,而是在同一个物理事务里创建保存点。
一句话:传播行为不是“注解嵌套规则”,而是决定当前逻辑事务和底层物理事务如何绑定。
REQUIRED 执行原理
REQUIRED 是默认传播行为。它的规则是:
- 外层已经有事务,就加入外层事务。
- 外层没有事务,就新建一个事务。
flowchart TD
A["outer 调用 inner"] --> B{"当前线程是否已有事务"}
B -- "有" --> C["inner 加入当前事务"]
B -- "没有" --> D["inner 新建事务"]
C --> E["outer 和 inner 一起提交或回滚"]
D --> F["inner 自己提交或回滚"]示例:
@Transactional
public void createOrder() {
orderRepository.insert();
stockService.deduct();
}
@Transactional
public void deduct() {
stockRepository.deduct();
}如果 createOrder 已经开启事务,deduct 默认 REQUIRED 会加入同一个事务。订单和库存要么都提交,要么都回滚。
REQUIRED 的常见坑
REQUIRED 最大的坑是:内层和外层共用同一个物理事务,所以内层失败不是“内层自己失败”,而是把整个事务状态变坏。
@Service
public class ImportService {
@Transactional(rollbackFor = Exception.class)
public void importBatch(List<Row> rows) {
for (Row row : rows) {
try {
rowService.importOne(row);
} catch (Exception ex) {
log.warn("单行失败,继续导入 row={}", row.getNo(), ex);
}
}
}
}
@Service
public class RowService {
@Transactional(rollbackFor = Exception.class)
public void importOne(Row row) {
assetRepository.insert(row.toAsset());
if (row.invalid()) {
throw new IllegalArgumentException("数据不合法");
}
}
}看起来外层 catch 了异常并继续导入,实际上 importOne 默认 REQUIRED 加入外层事务,异常发生后同一个事务可能被标记为 rollback-only。最后外层提交时,整批导入都可能回滚。
如果业务目标是“每一行互不影响”,不要用一个外层大事务包住全部行,可以改成:
- 去掉外层大事务。
- 每行使用
TransactionTemplate。 - 或者让
importOne使用REQUIRES_NEW,并确认调用经过代理。 - 更推荐按批提交,而不是每行都新事务,避免连接频繁获取和提交开销过大。
REQUIRES_NEW 示例
@Transactional
public void createOrder() {
orderRepository.insert();
auditService.saveAuditLog();
throw new RuntimeException("模拟失败");
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAuditLog() {
auditRepository.insert();
}如果 saveAuditLog 确实通过代理调用,它会使用新事务。外层订单事务回滚时,审计日志事务可以独立提交。
REQUIRES_NEW 的本质是:如果当前线程已有事务,先把外层事务挂起,再开启一个全新的事务;内层事务结束后,再恢复外层事务。
flowchart TD
A["外层事务开始"] --> B["调用 REQUIRES_NEW 方法"]
B --> C["挂起外层事务资源"]
C --> D["开启内层新事务"]
D --> E{"内层是否成功"}
E -- "成功" --> F["提交内层事务"]
E -- "失败" --> G["回滚内层事务"]
F --> H["恢复外层事务"]
G --> H
H --> I{"外层最终结果"}
I -- "成功" --> J["提交外层事务"]
I -- "失败" --> K["回滚外层事务"]典型场景:
| 场景 | 为什么用 REQUIRES_NEW |
|---|---|
| 审计日志 | 主业务失败时仍希望保留失败原因 |
| 失败补偿记录 | 主事务回滚后还要知道补偿什么 |
| 导入批次进度 | 每批状态独立提交,便于断点续跑 |
注意:REQUIRES_NEW 会额外占用数据库连接。如果外层事务已经占着一个连接,内层新事务还要再拿一个连接。高并发下滥用 REQUIRES_NEW 可能导致连接池耗尽。
REQUIRES_NEW 的连接池风险
假设 Tomcat 业务线程数是 200,数据库连接池最大连接数是 100。每个请求进入外层事务后占用 1 个连接,又在里面调用 REQUIRES_NEW 需要再拿 1 个连接。
flowchart TD
A["请求进入外层事务"] --> B["占用连接 1"]
B --> C["调用 REQUIRES_NEW"]
C --> D["外层连接挂起但不释放"]
D --> E["内层事务再申请连接 2"]
E --> F{"连接池是否足够"}
F -- "足够" --> G["内层提交后释放连接 2"]
F -- "不足" --> H["等待连接直到超时"]所以 REQUIRES_NEW 适合少量、短小、必要的独立提交逻辑,不适合在高并发核心链路里频繁套用。否则现象会是:接口突然变慢、连接池活跃数打满、线程堆栈卡在获取连接。
排查时重点看:
| 观察项 | 说明 |
|---|---|
HikariCP active 是否接近 maximumPoolSize | 判断连接池是否打满 |
线程堆栈是否卡在 getConnection | 判断是否等连接 |
代码里是否外层事务套 REQUIRES_NEW 循环 | 判断是否放大连接消耗 |
| 慢接口是否伴随数据库锁等待 | 外层事务挂起时锁仍可能未释放 |
NESTED 和 REQUIRES_NEW 区别
NESTED 是嵌套事务,通常依赖数据库保存点 Savepoint。它不是完全独立的新事务。
| 对比 | REQUIRES_NEW | NESTED |
|---|---|---|
| 是否新事务 | 是,独立物理事务 | 不是完全独立,依赖外层事务保存点 |
| 外层回滚影响内层吗 | 内层已提交后通常不受外层回滚影响 | 外层回滚会让内层一起回滚 |
| 内层回滚影响外层吗 | 默认不影响,除非异常继续抛出 | 可以回滚到保存点,外层可继续 |
| 资源占用 | 可能再拿一个连接 | 通常同一个连接 |
| 常见用途 | 独立日志、审计、补偿记录 | 同一大事务中的局部尝试 |
flowchart TD
A["外层事务开始"] --> B["创建保存点"]
B --> C["执行 NESTED 内层逻辑"]
C --> D{"内层是否失败"}
D -- "失败" --> E["回滚到保存点"]
D -- "成功" --> F["继续外层逻辑"]
E --> F
F --> G{"外层是否成功"}
G -- "成功" --> H["整体提交"]
G -- "失败" --> I["整体回滚"]很多项目很少用 NESTED,因为它依赖底层事务管理器和数据库保存点支持。普通业务优先用 REQUIRED,独立提交用 REQUIRES_NEW。
NESTED 的保存点 Demo
NESTED 更像“在一张试卷上先打一个书签,后面某道题写错了就擦回书签位置,但整张试卷还没交”。它适合局部尝试失败后外层继续。
@Service
public class AssetImportService {
@Transactional(rollbackFor = Exception.class)
public void importAsset(AssetRow row) {
assetRepository.insert(row.toAsset());
try {
detailService.tryImportDetails(row);
} catch (Exception ex) {
log.warn("明细导入失败,只回滚明细,主资产继续保留");
}
importLogRepository.markProcessed(row.getRowNo());
}
}
@Service
public class DetailService {
@Transactional(
propagation = Propagation.NESTED,
rollbackFor = Exception.class
)
public void tryImportDetails(AssetRow row) {
detailRepository.insertBatch(row.toDetails());
if (row.detailInvalid()) {
throw new IllegalArgumentException("明细异常");
}
}
}执行结果要分情况:
| 情况 | 结果 |
|---|---|
| 明细失败,外层 catch 后继续 | 回滚到保存点,主资产和处理日志可继续提交 |
| 明细成功,但外层后续失败 | 整个物理事务回滚,明细也会回滚 |
| 数据库或事务管理器不支持保存点 | NESTED 可能退化或直接报错,必须测试验证 |
不要把 NESTED 理解成 REQUIRES_NEW。REQUIRES_NEW 是另开一个物理事务,内层提交后通常可独立保留;NESTED 仍依附外层物理事务,外层最终回滚时内层也保不住。
UnexpectedRollbackException 是什么
一个常见坑:内层方法加入外层事务后,内层把事务标记成 rollback-only,外层 catch 住异常后继续执行,最后外层提交时会抛 UnexpectedRollbackException。
@Transactional
public void outer() {
try {
innerService.inner();
} catch (Exception e) {
log.warn("inner failed, continue");
}
otherRepository.insert();
}
@Transactional
public void inner() {
detailRepository.insert();
throw new RuntimeException("inner error");
}因为 inner 是 REQUIRED,它加入了外层事务。内层异常让同一个事务被标记为“只能回滚”。外层虽然 catch 了异常,但事务状态已经变成 rollback-only,最后想提交时 Spring 发现不能提交,只能回滚并抛异常。
flowchart TD
A["outer 开启事务"] --> B["inner 加入同一事务"]
B --> C["inner 抛 RuntimeException"]
C --> D["事务标记 rollback-only"]
D --> E["outer catch 异常后继续"]
E --> F["outer 尝试提交"]
F --> G["发现 rollback-only"]
G --> H["回滚并抛 UnexpectedRollbackException"]解决思路:
| 目标 | 做法 |
|---|---|
| 内层失败要影响外层 | 不要 catch,直接让异常抛出 |
| 内层失败不影响外层 | 内层用 REQUIRES_NEW,并在外层处理异常 |
| 想局部回滚但外层继续 | 考虑 NESTED 保存点,确认数据库和事务管理器支持 |
| 只是记录失败日志 | 日志方法用 REQUIRES_NEW 独立提交 |
事务上下文为什么和线程有关
Spring 声明式事务底层会把数据库连接、事务状态等信息绑定到当前线程。可以简单理解为:同一个线程里的 DAO 操作能拿到同一个事务连接,所以能处在同一个事务里。
flowchart TD
A["进入事务代理"] --> B["获取 Connection"]
B --> C["关闭 autoCommit"]
C --> D["绑定 Connection 到当前线程"]
D --> E["Repository 执行 SQL"]
E --> F["从当前线程取到同一个 Connection"]
F --> G["提交或回滚"]
G --> H["解绑并释放 Connection"]为什么跨线程容易失效:新线程有自己的 ThreadLocal,上一个线程绑定的事务连接不会自动传过去。
@Transactional
public void importData() {
recordRepository.insertMainRecord();
CompletableFuture.runAsync(() -> {
// 这里是另一个线程,默认不在外层事务中
recordRepository.insertDetailRecord();
});
throw new RuntimeException("外层失败");
}上面代码里,异步线程的 SQL 不会天然跟随外层事务回滚。真实项目中不要把需要同一事务保证的数据拆到异步线程里执行;如果必须异步,要重新设计一致性方案,例如本地消息表、可靠消息、补偿任务或分布式事务。
事务失效常见原因
| 原因 | 说明 | 解决 |
|---|---|---|
| 同类内部调用 | this.method() 绕过代理 | 拆到另一个 Bean 或通过代理调用 |
| 方法不是 public | Spring 代理默认不增强非 public 方法 | 事务方法使用 public |
| 异常被捕获 | 代理感知不到异常 | 继续抛出或手动标记回滚 |
| 检查异常未配置 | 默认不回滚 | 加 rollbackFor |
| 数据库不支持事务 | MyISAM 等引擎不支持 | 使用 InnoDB |
| 没有被 Spring 管理 | 自己 new 对象 | 交给 Spring 容器 |
| 跨线程执行 | 事务上下文绑定当前线程 | 不要把同一事务工作拆到异步线程 |
事务不回滚排查流程
生产排查不要一上来就怀疑 Spring “坏了”。事务问题大多来自调用路径、异常处理、事务管理器和数据库能力不匹配。
flowchart TD
A["发现事务没有按预期回滚"] --> B{"方法是否经过 Spring 代理"}
B -- "否" --> C["检查 self-invocation、new 对象、private/final 方法"]
B -- "是" --> D{"方法是否 public 且注解位置正确"}
D -- "否" --> E["调整到 Spring Bean 的 public Service 方法"]
D -- "是" --> F{"异常是否抛到代理层"}
F -- "否" --> G["catch 后重新抛出或 setRollbackOnly"]
F -- "是" --> H{"异常类型是否匹配回滚规则"}
H -- "否" --> I["配置 rollbackFor 或调整异常类型"]
H -- "是" --> J{"是否跨线程或异步执行"}
J -- "是" --> K["重新设计一致性,使用消息或补偿"]
J -- "否" --> L{"事务管理器和数据源是否正确"}
L -- "否" --> M["指定 transactionManager,检查多数据源配置"]
L -- "是" --> N{"数据库是否支持事务"}
N -- "否" --> O["检查表引擎、DDL、隐式提交"]
N -- "是" --> P["检查传播行为、rollback-only、提交前异常"]逐项解释:
| 排查点 | 怎么看 | 为什么重要 |
|---|---|---|
| 是否经过代理 | 调用方是不是另一个 Bean,是否 this.method() | 没进代理就没有事务拦截器 |
| 方法可见性 | 是否 public,是否在 Spring Bean 上 | Spring AOP 常规代理增强 public 方法最稳定 |
| 异常是否抛出 | catch 后有没有吞掉 | 代理只看到最终返回或最终异常 |
| 回滚规则 | 是否检查异常,是否配置 rollbackFor | 默认检查异常不回滚 |
| 是否跨线程 | @Async、线程池、CompletableFuture | 事务上下文绑定 ThreadLocal |
| 多数据源 | 是否用了正确 transactionManager | 一个本地事务管理器只能管自己的资源 |
| 数据库能力 | MySQL 表是否 InnoDB,是否 DDL 隐式提交 | Spring 不能让不支持事务的操作回滚 |
| 传播行为 | 是否内层 REQUIRED 标记 rollback-only | 外层 catch 也可能最终回滚并抛异常 |
排查时可以临时打开 Spring 事务日志:
logging.level.org.springframework.transaction=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG常见日志含义:
| 日志关键词 | 说明 |
|---|---|
Creating new transaction | 当前方法新建事务 |
Participating in existing transaction | 当前方法加入已有事务 |
Suspending current transaction | 当前事务被挂起,常见于 REQUIRES_NEW |
Initiating transaction rollback | 准备回滚 |
Initiating transaction commit | 准备提交 |
内部调用失效示例
@Service
public class UserService {
public void outer() {
inner();
}
@Transactional
public void inner() {
// 这里可能没有事务,因为是 this.inner()
}
}事务基于代理。外部调用代理对象才会进入事务增强,同一个类内部调用不会经过代理。
异常被吞为什么不回滚
事务代理只能根据方法最终是否抛出异常来判断回滚。如果你 catch 了异常但没有继续抛出,代理会认为方法正常结束,于是提交事务。
@Transactional
public void importRow() {
try {
mainRepository.insert();
detailRepository.insert();
} catch (Exception e) {
log.error("导入失败", e);
// 没有继续抛出,事务代理会认为成功
}
}正确做法之一是继续抛出异常:
@Transactional(rollbackFor = Exception.class)
public void importRow() throws Exception {
try {
mainRepository.insert();
detailRepository.insert();
} catch (Exception e) {
log.error("导入失败", e);
throw e;
}
}如果业务确实要吞掉异常,也可以手动标记回滚,但要谨慎使用:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();事务范围设计
事务范围不是越大越好。
建议:
- 事务放在 Service 层。
- 覆盖一个完整业务动作。
- 事务内不要做慢远程调用。
- 事务内不要等待用户输入。
- 大批量处理要分批提交。
- 长事务会占用连接和锁,影响并发。
错误示例:
@Transactional(rollbackFor = Exception.class)
public void createOrder(CreateOrderCommand command) {
orderRepository.insert(command);
// 慢远程调用,不建议放在数据库事务中
payClient.preAuth(command.getUserId(), command.getAmount());
stockRepository.deduct(command.getSkuId(), command.getCount());
}问题:
- 远程接口慢会导致数据库事务长时间不提交。
- 连接和锁被占用,其他请求可能等待。
- 远程调用成功但本地事务回滚时,还需要额外补偿。
更好的思路:
- 把本地强一致操作放在短事务中。
- 远程调用放在事务外,或者通过可靠消息、状态机、补偿任务串联。
- 对订单、支付、库存这种跨系统一致性,用分布式事务或最终一致性方案,不要指望单个本地事务兜住所有事情。
readOnly 是什么
readOnly = true 表示这是只读事务。它通常不是强制保证“绝对不能写”,更像是给事务管理器、数据库驱动或 ORM 的优化提示。
@Transactional(readOnly = true)
public OrderDetail queryOrder(Long orderId) {
return orderRepository.selectDetail(orderId);
}作用:
| 作用 | 解释 |
|---|---|
| 表达语义 | 让人一眼知道这个方法只查询 |
| 优化机会 | 某些数据库或 ORM 可以减少不必要的脏检查 |
| 防误写 | 部分环境可能对写入做限制,但不能完全依赖 |
注意:不要以为 readOnly = true 一定能阻止所有写入。不同数据库、驱动、事务管理器行为不同。真正不允许写,应该靠权限、代码规范、架构边界和测试保证。
timeout 是什么
timeout 指事务允许执行的最长时间,单位通常是秒。
@Transactional(timeout = 3, rollbackFor = Exception.class)
public void confirmOrder(Long orderId) {
orderRepository.confirm(orderId);
}它解决的是:事务不能无限期占用连接和锁。
常见使用场景:
| 场景 | 建议 |
|---|---|
| 核心短写事务 | 可以设置较短超时,快速失败 |
| 批处理导入 | 不建议一个超大事务,应该分批 |
| 慢查询 | 不应该靠拉长事务超时解决,要优化 SQL |
| 远程调用 | 不应该放进事务内等待 |
隔离级别
| 隔离级别 | 说明 |
|---|---|
READ_UNCOMMITTED | 可能读到未提交数据 |
READ_COMMITTED | 只能读到已提交数据 |
REPEATABLE_READ | 同一事务内多次读取结果一致,MySQL InnoDB 默认 |
SERIALIZABLE | 串行化,隔离最强但性能最低 |
隔离级别越高,并发性能通常越低。业务需要什么一致性,就选择什么隔离级别。
Spring 的 isolation 最终还是映射到底层数据库连接的隔离级别。比如:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void updateInventory(Long skuId, Integer count) {
stockRepository.deduct(skuId, count);
}需要注意:
- Spring 配了隔离级别,不代表数据库一定支持所有级别。
- MySQL InnoDB 默认是
REPEATABLE_READ。 - Oracle 常见默认是
READ_COMMITTED。 - 隔离级别不能替代业务幂等、唯一索引和锁设计。
- 防超卖通常还需要行锁、乐观锁、库存流水、唯一约束等配合。
多数据源事务要注意什么
如果一个项目有多个数据源,例如订单库和日志库:
@Transactional(transactionManager = "orderTransactionManager")
public void createOrder() {
orderRepository.insert();
}transactionManager 用来指定使用哪个事务管理器。
常见误区:
| 误区 | 正确理解 |
|---|---|
一个 @Transactional 能管所有数据源 | 只能管它绑定的事务管理器对应资源 |
| 两个库操作天然原子 | 本地事务不能保证跨库原子性 |
| 多数据源都用 REQUIRED 就行 | 如果不是同一个事务管理器,可能各管各的 |
如果一个业务动作必须同时修改两个数据库,并要求强一致,就已经进入分布式事务范围,需要看 XA/JTA、Seata、TCC、Saga、本地消息表等方案。Spring 本地事务只能保证单个事务资源内的一致性。
MyBatis 中事务为什么能生效
MyBatis 自己执行 SQL,但在 Spring 项目里,它通常通过 SqlSessionTemplate 和 Spring 事务管理集成。
简化流程:
flowchart TD
A["@Transactional 开启事务"] --> B["DataSourceTransactionManager 获取 Connection"]
B --> C["Connection 绑定到当前线程"]
D["Mapper 执行 SQL"] --> E["SqlSessionTemplate 获取 SqlSession"]
E --> F["从当前线程拿到同一个 Connection"]
F --> G["多次 Mapper 操作处于同一事务"]
G --> H["事务代理统一 commit 或 rollback"]所以不要在 Spring 管理的 MyBatis 项目里手动随便 openSession()、commit()、close()。这样很可能绕过 Spring 事务管理。
常见问题
| 现象 | 可能原因 | 排查 |
|---|---|---|
| 抛异常但没回滚 | 异常被 catch 或不是回滚异常 | 看异常类型和 catch 逻辑 |
| 注解没生效 | 内部调用或对象不是 Bean | 看调用链是否经过代理 |
| 数据库仍提交 | 表引擎不支持事务 | 检查 MySQL 引擎 |
| 接口很慢且锁等待 | 事务范围太大 | 查慢 SQL 和锁等待 |
| REQUIRES_NEW 不生效 | 同类内部调用 | 拆到另一个 Service |
练习
- 写一个
@Transactional方法,内部两次插入,第二次抛异常,观察是否回滚。 - 把运行时异常换成检查异常,观察默认回滚差异。
- 写一个同类内部调用事务方法的例子,解释为什么失效。
- 使用
rollbackFor = Exception.class改造导入方法。 - 思考为什么事务内不建议调用慢第三方接口。
- 使用
TransactionTemplate写一个批量导入,每 500 条提交一次。 - 写一个外层
REQUIRED、内层REQUIRES_NEW的审计日志 Demo,观察外层回滚后内层是否保留。 - 模拟
UnexpectedRollbackException,理解 rollback-only 状态。
面试标准回答
Spring 事务是什么?
Spring 事务是 Spring 对数据库事务的统一抽象。它不替代数据库事务,真正的提交、回滚、锁和隔离级别仍然由数据库完成。Spring 负责通过 PlatformTransactionManager 统一管理事务,通过声明式事务或编程式事务在业务边界上开启、提交、回滚事务,并把数据库连接绑定到当前线程,让同一线程内的 DAO 操作使用同一个连接。
声明式事务是什么?
声明式事务最常见写法是 @Transactional。它基于 Spring AOP 代理实现:Spring 在 Bean 初始化后为事务方法创建代理对象,外部调用进入代理后,由 TransactionInterceptor 根据注解配置获取或创建事务,执行业务方法,正常返回则提交,抛出符合回滚规则的异常则回滚。它的优点是业务代码干净,适合大多数 Service 层业务方法。
编程式事务是什么?
编程式事务是在代码中主动使用 TransactionTemplate 或 PlatformTransactionManager 控制事务边界。它不依赖 AOP 代理,所以没有自调用失效问题,并且可以把事务精确控制在方法内部某一段代码上。典型场景是批量导入分批提交、捕获异常后返回失败结果但仍需要回滚、框架组件封装事务能力。缺点是业务代码会出现事务 API,侵入性比声明式事务强。
声明式事务和编程式事务怎么选?
普通业务写操作优先使用声明式事务,比如下单、扣库存、更新订单状态,因为事务边界通常就是一个 Service 方法。复杂流程中只有一小段需要事务、批处理要分批提交、或者 catch 异常后不能向外抛但又要回滚时,更适合编程式事务。简单说:常规业务用声明式,复杂边界用编程式。
@Transactional 为什么会失效?
因为 @Transactional 依赖代理调用。常见失效原因包括:同类内部 this.method() 调用绕过代理;方法不是 public;对象不是 Spring Bean 而是自己 new 的;异常被 catch 后没有继续抛出;抛出检查异常但没有配置 rollbackFor;数据库表引擎不支持事务;事务代码放到新线程执行导致 ThreadLocal 事务上下文丢失。排查时先看调用有没有经过代理,再看异常有没有抛到代理层,最后看数据库和事务管理器配置。
REQUIRED 和 REQUIRES_NEW 区别?
REQUIRED 是默认传播行为,有外层事务就加入外层事务,没有事务就新建事务。外层和内层属于同一个事务,要么一起提交,要么一起回滚。REQUIRES_NEW 会挂起外层事务,开启一个新的独立事务,内层事务可以单独提交或回滚,常用于审计日志、失败补偿记录等需要独立保存的场景。但它可能额外占用数据库连接,不能滥用。
为什么 catch 异常后事务没有回滚?
事务代理主要根据目标方法最终是否向外抛出需要回滚的异常来判断回滚。如果异常在方法内部被 catch 住,且没有重新抛出,也没有手动 setRollbackOnly(),代理会认为方法正常返回,于是提交事务。解决方式是继续抛出异常,或者使用 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(),复杂场景更推荐使用 TransactionTemplate。
小结
Spring 事务的本质是事务抽象加数据库事务能力。声明式事务通过 AOP 代理让业务代码保持干净,编程式事务通过模板或事务管理器让事务边界更精确。学习重点不是背 @Transactional,而是理解事务边界、回滚规则、传播行为、线程绑定、代理失效、长事务风险和本地事务的能力边界。生产中很多事务问题不是注解写错,而是调用方式、异常处理、事务范围和跨系统一致性设计不清。
