"加了 @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. 自测
- 同类方法内
this.b()调用带@Transactional的方法,为什么事务不生效? - 默认回滚条件是哪些异常?受检异常抛出去会怎样?
REQUIRES_NEW与NESTED在连接占用和提交时机上有什么不同?- 事务方法里
catch住异常并只打日志,最终会提交还是回滚? @Async方法为什么拿不到调用方的事务上下文?- 拆分大事务时,缺了哪一件东西会让重试变成重复扣款?