A 调 B,B 抛异常,A 里已写的数据也没了;或者反过来,A 回滚了 B 的数据还在。这类"回滚范围不对"的 bug 根源就一个:被调方是加入了调用方的事务,还是自己另开一个。这就是传播行为。
注解写错只是表象。真正要判断的是三层:有没有事务、是共享还是新建、回滚时谁跟着谁。
1. 七种传播的语义
| 传播 | 当前有事务 | 当前无事务 | 一句话 |
|---|---|---|---|
REQUIRED(默认) |
加入 | 新建 | 有则加入,无则新建 |
REQUIRES_NEW |
挂起当前,新建 | 新建 | 永远自己开一个 |
NESTED |
建保存点 | 新建 | 有则打保存点,无则新建 |
SUPPORTS |
加入 | 不用事务 | 有就跟着,没有也照跑 |
NOT_SUPPORTED |
挂起当前,不用事务 | 不用事务 | 强制不带事务跑 |
MANDATORY |
加入 | 抛异常 | 必须已有事务 |
NEVER |
抛异常 | 不用事务 | 必须没有事务 |
@Service
public class OrderService {
@Transactional(propagation = Propagation.REQUIRED)
public void createOrder(Order o) {
orderMapper.insert(o);
stockService.deduct(o); // 另一个 Bean,跨代理调用才有传播语义
}
}
关键前提:传播行为只在跨代理调用时生效。同类自调用(this.deduct())等于没加注解,被调方怎么写都没意义。
2. 两两组合的实际表现
| 外层(调用方) | 内层传播 | 实际行为 |
|---|---|---|
REQUIRED |
REQUIRED |
同一事务,内层异常传播出去则整体回滚 |
REQUIRED |
REQUIRES_NEW |
内层独立提交,外层回滚不影响内层;内层异常不影响外层除非往外抛 |
REQUIRED |
NESTED |
内层打保存点,内层回滚只回到保存点,外层可继续 |
REQUIRED |
SUPPORTS |
直接加入,等于 REQUIRED |
REQUIRED |
MANDATORY |
加入,正常执行 |
| 无事务 | MANDATORY |
IllegalTransactionStateException |
REQUIRED |
NEVER |
IllegalTransactionStateException |
NESTED 的隐藏前提:必须用支持保存点的管理器(DataSourceTransactionManager、JpaTransactionManager)。JtaTransactionManager 在部分配置下退化成 REQUIRES_NEW 语义,按具体实现核对。
3. REQUIRED 与 REQUIRES_NEW:多一条连接
差别不在语义描述,在资源占用。REQUIRED 复用调用方绑定的那条连接;REQUIRES_NEW 挂起外层事务,从池子里再借一条新连接。
@Transactional
public void importBatch(List<Row> rows) {
for (Row r : rows) {
// 每一行都开新事务 = 每一行都借一次连接
auditService.record(r); // REQUIRES_NEW
}
}
风险很直接:外层占着一条连接不放,循环里再借 N 次。池子 10 条、并发 5 个请求,就可能"外层占满、内层等连接"。典型表现是请求全部挂住、线程栈停在 getConnection,与慢查询无关。
规避:REQUIRES_NEW 只用在"必须独立成功"的少量场景(审计、失败记录、幂等流水),别放进循环。要循环独立提交,就拆小批量逐批提交,或接受 REQUIRED 的整批回滚。
4. NESTED 与 REQUIRES_NEW:保存点还是独立事务
| 维度 | NESTED |
REQUIRES_NEW |
|---|---|---|
| 事务数量 | 1 个 | 2 个 |
| 回滚机制 | SAVEPOINT + ROLLBACK TO |
独立 ROLLBACK |
| 内层回滚后外层 | 可继续,外层数据保留 | 完全不受影响 |
| 外层回滚后内层 | 一起没了 | 已提交的保留 |
| 数据库要求 | 需支持保存点(MySQL InnoDB、PG 支持) | 无特殊要求 |
@Transactional
public void batch() {
for (Item it : items) {
try {
itemService.save(it); // NESTED:失败只回滚到保存点
} catch (Exception e) {
log.warn("skip {}", it, e); // 吞掉异常,外层继续
}
}
}
这段代码能工作的前提:save 是 NESTED,且异常在调用点被 catch。若 save 是 REQUIRED,catch 后继续走,事务已被标记 rollback-only,外层提交时抛 UnexpectedRollbackException。
5. 失效的六种情形
| 情形 | 机制 | 现象 |
|---|---|---|
| 自调用 | this.method() 不经过代理,拦截器不执行 |
注解形同虚设,无事务 |
| 非 public 方法 | Spring 6 的 AbstractFallbackTransactionAttributeSource 不对非 public 方法生效 |
无事务,也不报错 |
| 异常被 catch 吞掉 | 拦截器看不到异常,按正常返回提交 | 数据已写入,未回滚 |
| 异常类型不在回滚范围 | 默认只回滚 RuntimeException 与 Error |
受检异常抛出后照样提交 |
| 多线程 | 事务绑定在 ThreadLocal,子线程拿不到 |
子线程里的操作自动提交 |
| 引擎不支持事务 | MyISAM 之类 | 回滚"成功",数据没回滚 |
@Transactional(rollbackFor = Exception.class) // 受检异常也要回滚时必须显式写
public void transfer(Long from, Long to, BigDecimal amt) throws IOException {
accountMapper.debit(from, amt);
remoteCall(); // 抛 IOException,默认不回滚
}
第六种最难查:代码、注解、日志全正常,只是建表用了非事务引擎。上线前核对 SHOW TABLE STATUS 的 Engine,比事后排查便宜得多。
6. 常用属性与默认值陷阱
| 属性 | 默认 | 陷阱 |
|---|---|---|
rollbackFor |
仅 RuntimeException / Error |
受检异常不回滚 |
readOnly |
false |
只在部分驱动/ORM 上才是强约束 |
timeout |
-1(用底层默认) |
单位秒;超时抛 TransactionTimedOutException |
isolation |
DEFAULT(跟随数据库) |
MySQL 默认 REPEATABLE READ,不是 READ COMMITTED |
propagation |
REQUIRED |
见第 1、2 节 |
@Transactional(
propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
timeout = 5,
rollbackFor = Exception.class,
readOnly = false
)
public void placeOrder(OrderCmd cmd) { }
timeout 不是"整个方法 5 秒":它约束事务内语句执行,含等锁时间,但不含提交后的剩余逻辑,也不会中断已发出的远程调用。
7. 只读事务到底做了什么
readOnly = true 在多数链路上只是一个提示:Hibernate 会把 FlushMode 设为 MANUAL(不再自动 flush),某些驱动会把连接标记为只读。它不是强制拦截。
| 层面 | 表现 |
|---|---|
| Hibernate / JPA | 关闭自动 flush,脏检查开销降低 |
| JDBC 驱动 | Connection.setReadOnly(true),部分库据此路由到只读节点 |
| 数据库 | MySQL 会拒绝写(Cannot execute statement in a READ ONLY transaction 之类),PG 在只读事务里写会报错 |
所以"只读事务里偷偷写"在开发库上一路畅通,到生产主从结构上才炸。方法体里有 insert/update,就别挂 readOnly = true。
8. 事务与锁的顺序
先锁后事务、还是先事务后锁,结论只有一句:锁的生命周期要短于或等于事务,且在事务内尽早取锁。
@Transactional
public void deduct(String sku, int qty) {
// 事务开启后立刻拿行锁,缩短"拿到锁到提交"的窗口
Stock s = stockMapper.selectForUpdate(sku);
if (s.getQty() < qty) throw new BizException("库存不足");
stockMapper.update(sku, s.getQty() - qty);
// 持锁期间不做远程调用、发消息、生成大文件
}
反过来,先 @Transactional 再拿分布式锁,会出现"事务开着、锁还没拿到"的连接空占;先拿锁再开事务,又可能锁已释放而事务未提交,两个请求同时进临界区。正确位置是锁包住整个事务方法:外层加锁、内层调事务方法。
9. 大事务的代价与拆分
| 代价 | 具体表现 |
|---|---|
| 连接占用 | 一个请求从头占到尾,池子吞吐被拉低 |
| 锁持有 | 行锁/间隙锁时间长,别人等锁超时 |
| undo / MVCC | 回滚段膨胀,旧版本堆积,查询变慢 |
| 回滚成本 | 回滚本身也要时间,越大越慢 |
拆分顺序:先把远程调用、发消息、文件 IO 挪出事务边界(提交后事件或消息表),再把大批量写按批切,最后才考虑"短事务 + 状态机补偿"。
// 错误:事务里发消息,消息可能先于提交被消费
@Transactional
public void create(Order o) {
orderMapper.insert(o);
mq.send("order.created", o.getId());
}
// 正确:提交后再发
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreated(OrderCreatedEvent e) { mq.send("order.created", e.orderId()); }
10. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
| 加了注解却完全不回滚 | 自调用或方法非 public | 拆到另一个 Bean;改 public |
| 抛了异常但数据还在 | 异常被 catch 未重抛 | 重抛,或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 抛受检异常没回滚 | 默认不回滚受检异常 | 加 rollbackFor = Exception.class |
UnexpectedRollbackException |
内层标了 rollback-only,外层仍尝试提交 | 内层不要吞异常;或改 NESTED |
内层 REQUIRES_NEW 写完又消失 |
内层方法也是自调用,没走代理 | 换 Bean 调用 |
TransactionTimedOutException |
timeout 到点 |
缩短事务、拆远程调用 |
| 只读方法报"不能写" | readOnly=true 且真的写了 |
去掉 readOnly 或拆读写 |
11. 边界:什么时候不该用声明式事务
| 场景 | 建议 |
|---|---|
| 单条 insert/update 自动提交即可 | 不加事务,减少开销 |
| 跨库、跨数据源 | JtaTransactionManager 或补偿式最终一致 |
| 长流程(审批、订单流转) | 状态机 + 幂等,别一个事务包到底 |
TransactionTemplate 比注解可靠,提交边界在代码里看得见:
transactionTemplate.executeWithoutResult(status -> {
orderMapper.insert(o);
status.setRollbackOnly(); // 需要时显式回滚
});
一句话:传播行为管"加入还是新建",回滚管"异常有没有穿出代理"。两件事分开判断,多数事务问题一次排查就能定位。
12. 自测
REQUIRED调REQUIRES_NEW,外层回滚后内层的数据还在吗?为什么?NESTED依赖数据库的什么能力?换成JtaTransactionManager会怎样?- 受检异常抛出后事务提交了,改哪个属性?默认值是什么?
- 内层
REQUIRED抛异常被外层 catch,为什么最终提交会失败? - 循环里调
REQUIRES_NEW为什么会把连接池拖死? readOnly = true在 Hibernate、驱动、数据库三层分别做了什么?