2598 字
约 8 分钟
15
S07-X06 · 事务传播行为与失效场景:七种传播的边界

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. 自测

  1. REQUIRED 调 REQUIRES_NEW,外层回滚后内层的数据还在吗?为什么?
  2. NESTED 依赖数据库的什么能力?换成 JtaTransactionManager 会怎样?
  3. 受检异常抛出后事务提交了,改哪个属性?默认值是什么?
  4. 内层 REQUIRED 抛异常被外层 catch,为什么最终提交会失败?
  5. 循环里调 REQUIRES_NEW 为什么会把连接池拖死?
  6. readOnly = true 在 Hibernate、驱动、数据库三层分别做了什么?
S07-X06 · 事务传播行为与失效场景:七种传播的边界
https://www.clxhxhhr.top/posts/5853/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。