2645 字
约 8 分钟
13
S06-J05 · 事务传播与失效:七种传播行为的真实边界

一段业务写了三条 insert,第三条抛异常,前两条却稳稳留在库里。翻代码,方法上明明有 @Transactional。这类问题的答案几乎从不在"注解参数写错",而在一个更前面的问题:这次调用经过代理了吗?事务管理器管到了这个连接吗?

事务边界不是注解声明的,是拦截器在运行时画出来的。注解只是给拦截器看的元数据。下面把这条链拆成六段:前提、语义、绕过、回滚规则、跨线程交接、大事务。


1. 起作用的四个前提

前提 检查点 失效时的表现
调用经过代理 注入的是代理 Bean,不是 new 出来的实例 一条 SQL 一条 autocommit,异常抛了也没回滚
方法是 public 代理只能覆写/委托 public 方法 注解在包里躺着,拦截器收不到
调用来自外部 this.method() 不过代理 与上一条同样静默
异常能穿出方法 catch 后不重抛、或异常类型不在回滚列表 begin 了但走 commit
@Service
public class OrderService {

    @Transactional
    public void create(Order o) { repo.insert(o); }

    // 反例:同类内部循环调用,create 的注解全部不生效
    public void batch(List<Order> list) {
        list.forEach(this::create);
    }
}

batch 那次调用入口没有代理,this 是裸对象,所以 create 的 @Transactional 从未被读取。这是所有"注解失效"里最常见的一种,且日志完全正常。


2. 七种传播的真实语义

传播 外层有事务 外层无事务 典型用途 代价
REQUIRED(默认) 加入,共用一个连接 新建 绝大多数写操作 内层异常会污染整个外层事务
REQUIRES_NEW 挂起外层,另开连接 新建 审计日志、失败也要留痕 占用第二个连接,易与连接池冲突
NESTED 建 savepoint 新建(等价 REQUIRED) 局部可回滚的批处理 仅 DataSourceTransactionManager 支持
SUPPORTS 加入 无事务执行 查询类,可有可无 无事务时读到的是未提交前的库状态
NOT_SUPPORTED 挂起外层 无事务执行 长耗时外部调用 挂起期间连接被占着不放
MANDATORY 加入 直接抛 IllegalTransactionStateException 强制上层开启事务的底层方法 调用方漏了注解就炸
NEVER 抛异常 无事务执行 断言"此处不该有事务" 与缓存/批量框架组合时容易误伤

三个容易记错的点:

  • REQUIRES_NEW 挂起的是外层事务,不是外层方法;外层之后的 SQL 仍在外层事务里。
  • NESTED 的 savepoint 回滚只撤销内层语句,外层捕获异常后可以继续提交——这是它与 REQUIRES_NEW 的本质差别(一个是同一连接的部分撤销,一个是两条独立事务)。
  • SUPPORTS 在无事务时不给连接托管,EntityManager 的 flush 时机与有事务时不同,读到的可能是旧值。

3. 自调用的三种绕过与 NESTED 的硬约束

@Service
public class PayService {

    @Autowired private PayService self;          // ① 注入自身代理(Boot 2.6+ 需允许循环依赖或延迟注入)
    @Autowired private ApplicationContext ctx;   // ② 从容器取代理

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void writeAudit(Audit a) { auditRepo.insert(a); }

    public void pay(Order o) {
        self.writeAudit(new Audit(o));                        // ① 过代理
        ctx.getBean(PayService.class).writeAudit(...);        // ② 过代理
        ((PayService) AopContext.currentProxy()).writeAudit(...); // ③ 需 exposeProxy=true
    }
}
绕过方式 前置条件 副作用
注入自身 允许自引用,或用 @Lazy 循环依赖告警;单测里难替换
ApplicationContext.getBean 无 与容器强耦合,AOT/native 下要留意
AopContext.currentProxy() @EnableAspectJAutoProxy(exposeProxy = true) 或 spring.aop.expose-proxy=true 每次调用走 ThreadLocal,嵌套代理时有性能与语义成本
拆到另一个 Bean 无 最干净,且让边界在类图上可见

NESTED 不是"随便就能用"的传播级别:

@Bean
public PlatformTransactionManager transactionManager(DataSource ds) {
    return new DataSourceTransactionManager(ds);   // 支持 savepoint
}

JpaTransactionManager 没有 savepoint 实现,NESTED 直接抛 NestedTransactionNotSupportedException;JtaTransactionManager 则看 JTA 提供方是否支持嵌套。另一种常见误判是把内层写成 REQUIRED 却期待局部回滚——内层把外层标记成 rollback-only,外层提交时抛 UnexpectedRollbackException(按 Framework 6.1.x 核对)。


4. rollbackFor、只读与提交时序

默认只对 RuntimeException 与 Error 回滚,受检异常走 commit。

// 需要受检异常也回滚时必须显式声明
@Transactional(rollbackFor = Exception.class)
public void settle(Long id) throws IOException { ... }

反过来,noRollbackFor = BizWarnException.class 用于"业务告警但数据要留"的场景。

readOnly = true 的实际作用被高估了:

层面 实际行为
Spring 把 TransactionDefinition 标记为只读,传给事务管理器
JDBC 调 Connection.setReadOnly(true),由驱动决定是否理会
Hibernate 置 FlushMode.MANUAL,不自动 flush,脏检查仍在跑
MySQL 是否拒绝写语句取决于服务端只读设置,驱动层的 setReadOnly 只是提示

它是给下层的一个提示,不是强制约束。指望它拦住误写,不如在代码评审和权限上做。


5. 与异步、缓存、消息的交接

@Async
@Transactional
public void syncLater(Long id) { ... }   // @Async 先接管,事务注解在新线程里重新解析

@Async 与 @Transactional 叠在同一方法上,执行发生在线程池的工作线程:事务上下文(ThreadLocal 里的 TransactionSynchronizationManager 资源)不会跟过去,等于在新线程里另开一个事务;若外层已经提交,新线程读到的是已提交数据,若外层未提交,新线程读不到——两种结果都"看起来随机"。

缓存与事务的时序问题同样隐蔽:

@Transactional
@CacheEvict(cacheNames = "order", key = "#id")
public void update(Long id, OrderForm f) { ... }   // 默认在方法返回时清缓存,此时事务可能尚未提交

并发读会在"缓存已清、事务未提交"的窗口里把旧值重新写回缓存。稳妥做法是把失效动作挪到 TransactionSynchronization.afterCommit() 里,或改用"先更新库、提交后删缓存 + 短 TTL 兜底"。

消息同理:rabbitTemplate.convertAndSend 写在事务方法里,消息可能先于提交到达消费者,消费者查库查不到;反过来若提交失败,消息已经发出去了,无法撤回。生产上要么用 TransactionSynchronization 的 afterCommit 发送,要么落一张本地发件箱表:

// 同一事务:业务表 + 发件箱表一起提交,再由独立轮询投递
@Transactional
public void place(Order o) {
    orderRepo.insert(o);
    outboxRepo.insert(Outbox.of("order.created", o.getId()));
}

6. 超时、隔离级别与大事务

设置项 写在哪 生效范围 备注
timeout @Transactional(timeout = 5) 单次事务,秒 由事务管理器换算成语句超时,非所有驱动都遵守
isolation @Transactional(isolation = READ_COMMITTED) 单次事务 连接池复用连接时会重置,注意驱动实现
语句超时 JDBC queryTimeout / MyBatis defaultStatementTimeout 单条语句 与事务超时是两件事
等连接上限 spring.datasource.hikari.connection-timeout 全局 REQUIRES_NEW 会额外占连接
锁等待 数据库侧 innodb_lock_wait_timeout 全局 应用层超时先触发时,DB 侧仍在等

大事务的四个症状,出现任意两个就该拆:

症状 观测点 后果
长持有连接 Hikari activeConnections 贴近上限 其他请求排队,connection-timeout 报错
锁等待堆积 information_schema.innodb_trx 的 trx_wait 增长 热点行排队,RT 抖动
回滚成本高 回滚耗时接近甚至超过正向执行 异常路径变成雪崩路径
binlog 膨胀 单事务 binlog 事件过大 主从延迟、备份与恢复变慢

排错

现象 根因 处理
异常抛了但数据还在 同类自调用 / 方法是 private / final 拆类或走代理;改成 public
内层回滚导致外层 UnexpectedRollbackException 内层 REQUIRED 参与外层,被标记 rollback-only 内层改 REQUIRES_NEW 或在外层处理
NESTED 没起到局部回滚作用 事务管理器不支持 savepoint 换 DataSourceTransactionManager 或改 REQUIRES_NEW
REQUIRES_NEW 之后接口变慢、池打满 每个外层事务额外占一个连接 提池上限,或把审计改成异步批量
受检异常不回滚 默认回滚规则只覆盖运行时异常 显式 rollbackFor = Exception.class
只读事务里仍然写成功 readOnly 只是提示 在 DAO 层做写禁令或用只读账号
异步方法里查不到刚写的数据 @Async 线程没有事务上下文 提交后再触发,或把参数传全
缓存与库短暂不一致 @CacheEvict 早于提交 移到 afterCommit,加短 TTL

边界

场景 该用的手段 理由
单库多表一致性 本地事务 + 正确传播 一个连接能解决的别上分布式
跨库/跨服务 本地消息表、Saga、TCC 本地事务管不到第二个数据源
大批量导入 分批小事务 + 幂等键 大事务的回滚与 binlog 成本不可控
必须严格串行的热点行 数据库行锁 + 短事务 应用层锁跨进程无效
事务内调用第三方 HTTP 从事务中移出 外部超时会拖垮连接与锁持有时间

一句话判断:先确认代理有没有拦到调用,再确认回滚范围,最后才谈隔离级别和超时。


自测

  1. @Transactional 生效需要同时满足哪四个前提?
  2. REQUIRES_NEW 与 NESTED 在回滚范围上的差别是什么?
  3. 项目用 JpaTransactionManager 时写 NESTED 会发生什么?
  4. 受检异常抛出后数据没回滚,改哪个属性?
  5. @Async 方法上同时有 @Transactional,事务上下文去了哪里?
  6. 大事务的四个症状分别在哪观测?
S06-J05 · 事务传播与失效:七种传播行为的真实边界
https://www.clxhxhhr.top/posts/6044/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。