2841 字
约 9 分钟
21
S07-A05 · Spring 事务边界:传播、回滚与失效场景

"加了 @Transactional 还是不回滚",第一反应总是查注解参数。真正该问的是:这次调用压根没进事务。事务是一层 AOP 包装——TransactionInterceptor 在入口 begin、出口 commit/rollback;注解是给切面看的标记,写在没被代理的调用上等于没写。

只谈边界:画在哪、画错什么现象、边界内放什么最贵、怎么验证。语境 Boot 3.3.x / Framework 6.1.x。


1. 边界的位置:代理入口

事务边界只可能落在代理方法调用的入口。三种调用形态的结论完全不同:

调用形态 是否进事务 原因
外部经注入的 bean 调用 是 走代理对象
同类内 this.methodB() 否 绕过代理,this 是原始对象
private/final 方法 否 代理无法覆盖
@Service
public class OrderService {
    public void create(Order o) {
        this.audit(o);                 // 不进事务:this 调用绕过代理
    }
    @Transactional
    public void audit(Order o) { /* 写审计表 */ }
}

三种误判:注解贴在 private/final 方法上、贴接口而调用方持有实现类、抽出"公共事务方法"再用 this 调。共同症状:方法执行了、异常抛了、数据留下了。


2. 传播行为:一张决策表就够

七种传播的机制不必背,需要判断的只有一件事:被调方是否需要独立于调用方的提交与回滚。

传播 语义 用在什么判断上 主要风险
REQUIRED(默认) 有则加入,无则新建 绝大多数业务方法 内层异常把外层一起拖回滚
REQUIRES_NEW 挂起当前,独立新事务 日志、审计、必须留痕 池里同时占两条连接
NESTED 打保存点,随外层回滚 内层局部回滚 依赖 JDBC 保存点
SUPPORTS 有就加入,没有就裸跑 只读查询 同一方法有时在事务里、有时不在
NOT_SUPPORTED 挂起当前,不用事务 边界内不该长耗时的小动作 与主流程不一致
MANDATORY 必须已有事务 强约束的内部方法 直接调用抛异常,需调用方保证
NEVER 必须没有事务 禁止事务的批处理 反向约束

决策差别:REQUIRES_NEW 是两条独立事务(两个连接、两次提交),NESTED 是一条事务里的保存点(一个连接、外层定结果)。内层失败不污染外层选 NESTED;内层失败也要留痕选 REQUIRES_NEW。


3. 回滚规则:默认只认运行时异常

默认回滚条件是 RuntimeException 及 Error。受检异常不回滚——这是设计约定,不是疏漏。

@Transactional(rollbackFor = Exception.class)     // 受检异常也要回滚时必须显式写
public void importCsv(Path p) throws IOException { ... }

@Transactional
public void bad() {
    try {
        repo.save(a);
        riskyCall();                              // 抛异常
    } catch (Exception e) {
        log.warn("ignored", e);                   // 吞掉:事务看不到异常,照常提交
    }
}
写法 结果 说明
抛 RuntimeException 回滚 默认行为
抛 IOException 提交 受检异常默认不回滚
catch 后不再抛 提交 切面拿不到异常
catch 后转抛运行时异常 回滚 需保证状态一致
noRollbackFor 指定 提交 用于"失败也要留痕"的特殊流程

catch 吃掉异常是最常见的失效,因为日志一切正常。补救:setRollbackOnly(),或不在事务方法里 catch。


4. 边界内放远程调用:超时叠加与连接挂起

事务边界内的每一毫秒都在占用一条数据库连接,而远程调用的耗时不受你控制。

边界内的动作 直接代价 失效形态
HTTP/RPC 调用 连接被占整个调用时长 下游抖动 → 池耗尽 → 全站排队
消息发送 与提交不同步 回滚后消息仍在
缓存写入 顺序不一致 提交失败、缓存有值
大文件/批处理 长事务、undo 膨胀 锁等待、主从延迟
加锁 持有时间被拉长 锁超时先于事务结束

判据:边界只包住"必须一起成败"的数据库写。远程调用、消息、缓存挪到提交后或提交前。超时要算叠加:网关 60s、应用 5s、下游 10s,最坏是三者串起来。


5. 连接池:事务时长的真实成本

事务开始即占用连接,到提交或回滚才归还。事务时长 × 并发 = 需要的连接数,这条乘法解释了多数"池不够用"。

# 事务与连接池的两个观察点
management.metrics.enable.spring.transaction=true    # spring.transaction.* 指标
spring.datasource.hikari.leak-detection-threshold=20000
现象 真因 处理
Connection is not available 事务过长或 REQUIRES_NEW 翻倍占连接 缩短边界、降嵌套
池满但库 CPU 不高 连接停在应用侧等远程 远程调用移出边界
提交慢、锁等待高 边界内批量更新 拆批、缩短行数
泄漏告警间歇出现 未正确关闭或嵌套过深 看 leak-detection 栈

6. 只读事务:省的是什么

readOnly = true 做两件事:让 ORM 跳脏检查、省快照;给主从路由一个只读信号。它不保证没人写。

用法 是否合适 理由
只读查询方法 合适 省脏检查,配合读写分离
整类默认只读、写方法覆盖 合适 默认安全,异常显式
只读事务里调远程写接口 不合适 事务语义不覆盖远程
无一致性要求的单条查询 不必加 单语句自带原子性

7. 嵌套与保存点:可用性的现实判断

NESTED 依赖 JDBC 保存点。InnoDB 支持,但有三条约束:JPA 下由 provider 决定,可能退化为加入外层;保存点回滚后外层继续,但已发的 DDL 与消息不回滚;分库分表中间件常不透传。

判据:只有"内层是纯数据库写、且必须局部回滚"时才用 NESTED。跨中间件、跨数据源、含远程调用时,一律改用业务补偿。


8. 声明式还是编程式

维度 @Transactional TransactionTemplate
边界可见性 注解在方法上,范围=整个方法 代码块,范围精确
回滚控制 靠异常与注解参数 显式 setRollbackOnly
组合传播 注解叠加 多次 execute 嵌套
误用风险 自调用、私有方法、异常被吞 忘包裹、异常吞掉

判据:边界等于方法 → 声明式;边界小于方法(分段、分批、条件回滚)→ 编程式。半包半提交时,注解会写出两层嵌套,TransactionTemplate 更清楚。

@RequiredArgsConstructor
public class BatchWriter {
    private final TransactionTemplate tx;
    public void writeAll(List<Row> rows) {
        for (List<Row> chunk : partition(rows, 500)) {
            tx.executeWithoutResult(s -> repo.saveAll(chunk));   // 每批一个事务
        }
    }
}

9. 异步与事务:上下文不会跟过去

@Async 跑在另一线程,事务绑定是 ThreadLocal,异步方法拿不到调用方事务,REQUIRED 会新建一条。后果:主事务回滚而异步已提交、两线程各占一条连接。

修复顺序:能提交后触发的用 afterCommit;必须解耦的用本地消息表;真要异步写库,接受最终一致并写清补偿。


10. 大事务:拆成状态机加补偿

三个信号:行数过万、时长过秒、边界内有远程调用或文件 IO。拆法不是加 REQUIRES_NEW,而是改成可重入的状态推进:

// 状态机推进:每一步独立事务,失败可从断点续跑
enum Step { CREATED, RESERVED, PAID, DONE }
// 每步:@Transactional 更新 status + 写 step_log(唯一键幂等)
// 失败:由调度器按 status 重试;副作用由 step_log 去重

补偿代价要提前写清:哪些步骤可逆、哪些不可逆(短信、扣款)、重试的幂等键是什么。没有幂等键的拆分只是把大事务换成重复扣款。


11. 怎么验证事务真的生效

验证点 手段 期望看到
是否进事务 ...transaction=DEBUG Creating new transaction
是否同一连接 打印 DataSourceUtils.getConnection 同事务内相同
隔离级别 SHOW VARIABLES LIKE 'transaction_isolation' 与注解一致
回滚是否发生 故意抛异常后查库 数据不存在
边界时长 spring.transaction.* 指标 / APM span 与预期量级一致
# 应用侧开关(仅本地/预发)
java -jar app.jar --logging.level.org.springframework.transaction.interceptor=TRACE
# 连接级核对:在事务内执行
-- SELECT @@transaction_isolation, @@autocommit, CONNECTION_ID();

12. 排错:六种"看起来加了事务"

现象 真实原因 处理
方法执行了、异常抛了、数据还在 自调用绕过代理 拆到另一个 bean 或注入自身代理
只有受检异常时数据留下 默认只回滚运行时异常 rollbackFor = Exception.class
内层回滚把外层也带走了 用了 REQUIRED 需要独立则 REQUIRES_NEW 或 NESTED
内层提交了、外层回滚了它还在 REQUIRES_NEW 的预期行为 确认是否真要独立事务
高频接口偶发池耗尽 边界内含远程调用 移出边界,或提交后再做
异步任务读不到刚写的数据 线程切换丢上下文 改为提交后回调

13. 边界:这些场景不需要显式事务

场景 结论 理由
单条 INSERT/UPDATE/DELETE 不需要 单语句自带原子性
纯读且无一致性要求 只读事务或不用 省连接占用
跨服务/跨库一致性 本地事务无效 需要 Saga/TCC/消息

一句话:事务边界是一致性的价格标签。边界越长,买到的一致性越多,付出的连接、锁、延迟也越多;要判断的不是能不能加,是值不值。


14. 自测

  1. 同类方法内 this.b() 调用带 @Transactional 的方法,为什么事务不生效?
  2. 默认回滚条件是哪些异常?受检异常抛出去会怎样?
  3. REQUIRES_NEW 与 NESTED 在连接占用和提交时机上有什么不同?
  4. 事务方法里 catch 住异常并只打日志,最终会提交还是回滚?
  5. @Async 方法为什么拿不到调用方的事务上下文?
  6. 拆分大事务时,缺了哪一件东西会让重试变成重复扣款?
S07-A05 · Spring 事务边界:传播、回滚与失效场景
https://www.clxhxhhr.top/posts/6166/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。