一段业务写了三条 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 | 从事务中移出 | 外部超时会拖垮连接与锁持有时间 |
一句话判断:先确认代理有没有拦到调用,再确认回滚范围,最后才谈隔离级别和超时。
自测
@Transactional生效需要同时满足哪四个前提?REQUIRES_NEW与NESTED在回滚范围上的差别是什么?- 项目用
JpaTransactionManager时写NESTED会发生什么? - 受检异常抛出后数据没回滚,改哪个属性?
@Async方法上同时有@Transactional,事务上下文去了哪里?- 大事务的四个症状分别在哪观测?