1. 问题现象回顾
在"日采请购单审批回调"这条链路里发生了两个怪现象:
| 现象 | 表现 |
| ① 查不到上一步的数据 | autoCreateInWarehouseBill 查 purchaseBillQuery.queryBySourceId(billId) 拿不到刚创建的采购单 |
| ② 日志成功但数据不落库 | listener 打出 自动创建入库点检单成功,DB 里查不到入库点检单 |
两个现象根因相同:整条链路的事务边界没有按业务期望切分,写操作复用了一个"已经提交但尚未清理"的事务上下文,导致 entity persist 进 Session 后永远不会再被 commit。
2. 完整调用链与事务边界
先把链路画清楚(→ 表示方法调用,每行右侧标注 @Transactional 状态):
HTTP /workFlow/checkCallback └─ CheckFlowApplicationImpl.checkCallback [@Transactional → TX_outer] └─ flowCallbackApplication.handleAfterCallback [@Transactional(REQUIRES_NEW) → 挂起 TX_outer,开 TX_inner] └─ publishEvent(FlowCallbackEvent) (事件被注册为 TX_inner 的 AFTER_COMMIT 同步回调) ── TX_inner.commit() ── ↓ 灰色地带:commit 完成、cleanup 尚未执行 └─ FlowCallbackEventDispatcher.handleFlowCallbackEvent [@TransactionalEventListener(AFTER_COMMIT)] └─ PreRequestDayFlowCallbackListener.handleCallback [无 @Transactional] ├─ autoCreatePurchaseBill (原: 无 @Tx,内部 saveBills @Tx REQUIRED) ├─ autoApprove (原: 无 @Tx,内部 submitCheck @Tx REQUIRED) └─ autoCreateInWarehouseBill (原: @Tx REQUIRED) ── 回到 TX_outer,TX_outer.commit() ──
记住三个关键事实:
- TX_outer 是 `checkCallback` 自己开的,事件触发时它处于"挂起"状态。
- TX_inner 是 `handleAfterCallback` 用 `REQUIRES_NEW` 开的,事件触发**就发生在它的 commit 流程里**。
- 监听器方法本身没有 `@Transactional`。
TX = Transaction 事务
3. @TransactionalEventListener(AFTER_COMMIT) 到底在什么时机触发?
很多人误以为"AFTER_COMMIT 触发时已经没有任何事务了"。实际上要看 Spring 的 commit 流程(AbstractPlatformTransactionManager.processCommit):
1. prepareForCommit() 2. triggerBeforeCommit() 3. triggerBeforeCompletion() 4. doCommit() ← 物理 commit 到 DB(TX_inner 落库完成) 5. triggerAfterCommit() ← ★ AFTER_COMMIT 回调在这里执行 ★ 6. triggerAfterCompletion() 7. cleanupAfterCompletion() ← 这一步才真正解绑资源、resume 挂起的 TX_outer
也就是说,AFTER_COMMIT 回调执行时,处于一个**"灰色地带"**:
- TX_inner **物理上已提交**,但 `TransactionSynchronizationManager` 里**还没解绑**(Connection、EntityManager 等资源仍挂在当前线程上)。
- TX_outer 处于挂起状态,但因为 cleanup 还没跑,它的恢复也没发生。
这个"灰色地带"会直接影响后续在监听器里调用的 `@Transactional` 方法的传播行为。
4. @Transactional 传播级别在"灰色地带"里的实际表现
`@Transactional` 默认是 `Propagation.REQUIRED`。它的判断逻辑(简化)是:
"当前线程是否已经有一个事务上下文(看 `TransactionSynchronizationManager` 的资源绑定)?有 → 加入它;没有 → 开一个新的。"
在 AFTER_COMMIT 这个灰色地带里:
| 调用方式 | 实际行为 |
| REQUIRED | 大概率复用 TX_inner 残留的资源(Connection / Hibernate Session),但这个事务已经 commit 过了。新的 DML 操作进了同一个 Session,但这个 Session 永远不会再 commit 第二次 |
| REQUIRES_NEW | 强制挂起当前残留上下文,开一个全新的物理事务,方法返回时独立 commit |
这就是关键陷阱:
AFTER_COMMIT 监听器里用 REQUIRED 的 @Transactional 方法,写入的数据有极大概率"看起来成功了,实际从未 commit"。
具体落到我们的代码: 现象 ① 的成因(查不到采购单)
autoCreatePurchaseBill (无 @Tx)
└─ saveBills (@Tx REQUIRED) → 加入残留 Session
→ entity persist 到一级缓存
→ 方法返回,没有新 commit / flush
autoCreateInWarehouseBill (@Tx REQUIRED) → 加入同一残留 Session
└─ purchaseBillQuery.queryBySourceId(billId)
→ 即便 JPA AUTO flush 把缓存写到 Connection,
前一步的"残留事务"也从未真正 commit,
更糟糕的是:整条链结束后这些变更随挂起事务清理一起被丢弃
结果:第二步查询返回空集合,`autoCreateInWarehouseBill` 直接 `return RetData.error("采购单据不存在")`。
现象 ② 的成因(日志成功但单据没生成)
把前两步改成 `REQUIRES_NEW` 后,采购单确实被 commit 了。第三步 `autoCreateInWarehouseBill` 仍是 `REQUIRED`:
autoCreateInWarehouseBill (@Tx REQUIRED) → 复用残留上下文
└─ operationBillApplication.saveBill (@Tx REQUIRED) → 同一上下文
→ entity 进 Session
→ 方法链返回,没异常
→ return RetData.ok()
listener: createInWarehouseBillResult.isOk() == true
→ log.info("自动创建入库点检单成功") ← 日志被打出来
→ markSuccess(...) ← 业务日志表也写"成功"
真实情况:
这个"挂着的事务"在后续 cleanupAfterCompletion() 阶段被丢弃,
入库点检单的 INSERT 从未真正 commit 到数据库。
→ 日志骗了我们:返回值 OK ≠ 数据已经 commit。
5. 两层修复策略
5.1. 第一层:业务语义层 —— 给"必须独立提交"的步骤标 REQUIRES_NEW
业务上,"采购单创建成功"和"自动审批通过"是不可回滚的事实,不应因为后续入库点检单失败被回滚。这是业务语义边界,应在 application service 上声明: AutoCreatePurchaseBillApplicationImpl.java
@Override @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public RetData<?> autoCreatePurchaseBill(Long billId, UserDto userDto) { ... } @Override @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void autoApprove(Long billId) { ... }
`REQUIRES_NEW` 的语义是:
- 把当前线程上**所有已绑定的事务资源**(包括灰色地带的残留)**强制挂起**;
- 从连接池**重新拿一个 Connection**,开一个**全新的物理事务**;
- 方法返回时**独立 commit**这个新事务;
- 然后恢复之前挂起的资源。
这就把每一步彻底变成"自包含、独立可见"的事务单元:
autoCreatePurchaseBill [REQUIRES_NEW] → 独立 commit → DB 里看得到采购单 autoApprove [REQUIRES_NEW] → 独立 commit → 审批状态落库 autoCreateInWarehouseBill[REQUIRES_NEW] → 独立 commit → 入库点检单落库
第三步查询第一步的数据时,因为前两步已经物理 commit,任何新事务、任何 Session 都能看到,不存在可见性问题。同时,第三步的"成功"日志严格等价于"已 commit"。
5.2. 第二层:架构层 —— 在事件分发器上加 REQUIRES_NEW 安全网
业务语义层只解决了 `PreRequestDayFlowCallbackListener` 一个监听器。工程里 5 个 `FlowCallbackListener` 都跑在同一个灰色地带,**每个监听器内部都加 REQUIRES_NEW 既繁琐又容易破坏既有事务原子性**(domain 层方法可能被正常请求路径以 REQUIRED 复用)。
更根本的修法是在 `FlowCallbackEventDispatcher` 中**统一**用 `TransactionTemplate(REQUIRES_NEW)` 包裹 listener 调用:
public FlowCallbackEventDispatcher(List<FlowCallbackListener> listeners, AutoCreateBillLogDomainService autoCreateBillLogDomainService, PlatformTransactionManager transactionManager) { this.listeners = listeners; this.autoCreateBillLogDomainService = autoCreateBillLogDomainService; this.listenerTxTemplate = new TransactionTemplate(transactionManager); this.listenerTxTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } try { listenerTxTemplate.execute(status -> { listener.handleCallback(billId); return null; }); handleSuccess(callbackLog, billType, billId); } catch (Exception e) { handleFailure(callbackLog, billType, billId, e.getMessage(), e); }
加上之后,链路变成:
Dispatcher.handleFlowCallbackEvent (AFTER_COMMIT 灰色地带)
└─ requiresNewTx.execute(...) ← 挂起灰色地带残留,开 TX_listener
└─ listener.handleCallback(billId)
├─ [@Tx REQUIRES_NEW] 业务必须独立提交的步骤 → 自开 TX_xxx 独立 commit
└─ [@Tx REQUIRED] 跟监听器同生共死的步骤 → 加入 TX_listener,随 TX_listener 一起 commit
── TX_listener.commit() ──
── 残留资源恢复 ──
关键设计点:
- **只包裹 `listener.handleCallback`**:`createLog / markSuccess / markFailed` 不进包裹,它们自己已经是 `REQUIRES_NEW`,并且日志必须在 listener TX 回滚时仍能写入"失败"记录。
- **用 `execute(… return null)`**:项目用 Spring Boot 2.1.4(Spring 5.1.x),没有 5.2+ 才有的 `executeWithoutResult` 方法。
- **异常语义保持**:lambda 里抛 RuntimeException → `TransactionTemplate.execute` rollback `TX_listener` 并抛出 → 外层 `try/catch` 接住 → 走 `handleFailure` → `markFailed` 写日志(独立 REQUIRES_NEW,不受 `TX_listener` 回滚影响)。
- **去掉 `@RequiredArgsConstructor`**:因为需要在构造器里初始化 `TransactionTemplate`,改用显式构造器。
两层修复的协同效果(不是替代关系)
TX_listener { ← 架构层安全网(dispatcher)
[TX_purchase REQUIRES_NEW] 独立 commit ← 业务语义边界:采购单不可回滚
[TX_approve REQUIRES_NEW] 独立 commit ← 业务语义边界:审批不可回滚
autoCreateInWarehouseBill (REQUIRED) join TX_listener ← 失败时只回滚自己
}
- **业务语义层(REQUIRES_NEW on application service)**:保证业务原子性边界。
- **架构层(REQUIRES_NEW on dispatcher)**:保证灰色地带下 REQUIRED 也能正确提交。
- **两者不冲突、不重复**,是不同维度的保险。
6. 为什么不在 listener 上加 @Transactional 解决?
这是个常见的"看起来更优雅"的方案,但对这个场景**行不通**:
- **`@TransactionalEventListener` 的 listener 方法本身加 `@Transactional` 会迷惑事务边界**——它跑在 AFTER_COMMIT 的灰色地带,加 REQUIRED 仍会撞上残留上下文,加 REQUIRES_NEW 又会把"三个独立步骤"重新捆成一个大事务(其中一步失败导致前两步也回滚,与业务期望不符)。
- 业务上希望"采购单创建成功就保留,入库点检单失败只回滚自己",**天然就是三个独立事务边界**。
- listener 是回调编排层,让它做事务编排会污染职责。应该让**应用服务层**自己声明事务边界(这是 DDD 分层里 application service 的天职)。
所以正确的修法就是我们做的:**把三个应用服务方法各自标 `@Transactional(REQUIRES_NEW)`**。
7. 可以提炼的几条通用经验
@TransactionalEventListener(AFTER_COMMIT) 里调用的写操作,必须有明确的事务边界 不要依赖默认 `REQUIRED`。两个方案二选一:
- **集中式**(推荐):在事件分发器统一用 `TransactionTemplate(REQUIRES_NEW)` 包裹 listener 调用。一处改、N 处受益。
- **分布式**:每个 listener 调用的入口方法都自带 `@Transactional(REQUIRES_NEW)`。
监听器里要写库 / 调用其他写操作的 service,**不要依赖默认 REQUIRED**。否则一旦撞上"灰色地带",会出现"无异常但数据丢失",这是最难排查的 bug 类型。
"无 @Transactional 注解的方法"不等于"无事务" AutoCreatePurchaseBillApplicationImpl.autoCreatePurchaseBill 本身没注解,但它内部调用了 saveBills (@Tx REQUIRED)。在不同的上层上下文中,这个 REQUIRED 的实际行为完全不同:
- 上层无事务 → 新开并自己 commit
- 上层有正常 TX → 加入上层
- 上层是"灰色地带残留" → 加入残留,永不 commit ← 就是这个场景
做应用服务编排的时候,事务边界一定要在 application service 这一层就明确标注,不要让它"看运气"。
- RetData.ok() ≠ "数据已经持久化" 任何依赖返回值判断业务结果的代码,都隐含一个假设:返回值是在事务 commit 之后才被消费的。这个假设在 REQUIRED 嵌套时不成立——返回值是方法栈帧弹出时拿到的,而 commit 发生在最外层 TX 结束时。
@TransactionalEventListener 选择 phase 时的取舍
Phase 时机 适用场景 BEFORE_COMMIT TX commit 之前 还需要参与原事务,必须保证一致 AFTER_COMMIT TX commit 之后(但 cleanup 之前) 解耦后置动作,原事务必须已经成功 AFTER_ROLLBACK TX rollback 之后 失败补偿 AFTER_COMPLETION 不管成功失败 资源清理 只要选了 AFTER_*,就一定要意识到回调代码处于"灰色地带",回调里要写库就一律 REQUIRES_NEW。
- fallbackExecution=true 是把双刃剑 @TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true) 意思是"如果发布事件时没有事务上下文,也照常同步触发"。这让代码兼容了"无事务路径"(比如手动重试入口),但也意味着监听器内的事务行为要在"有 TX 上下文"和"无 TX 上下文"两种情况下都正确。REQUIRES_NEW 在两种情况下都是确定行为(新开 TX),是最安全的选择。
- REQUIRES_NEW 的代价要心里有数
- 会从连接池**多拿一个 Connection**。在嵌套调用时如果池子小,可能引发死锁(持有 Conn1 等 Conn2)。
- 父事务的回滚**不会**回滚 `REQUIRES_NEW` 子事务的修改 → 业务上必须接受这种"已提交无法撤销"的语义。
- 不要在 read-only 场景滥用,常规读不需要 REQUIRES_NEW。
"业务语义边界" vs "技术安全网"是两件事 我们最终的修复是两层叠加:
- application service 的 @Transactional(REQUIRES_NEW) —— 业务语义边界,表达"这一步必须独立提交,不可回滚";
- dispatcher 的 TransactionTemplate(REQUIRES_NEW) —— 技术安全网,兜住"REQUIRED 在灰色地带的不确定行为"。
两者不互斥,业务边界不能用安全网替代(语义不同),安全网也不能用业务边界替代(覆盖面不够)。
8. 版本兼容备忘
| API | 引入版本 | 备选 |
|---|---|---|
| TransactionTemplate.executeWithoutResult(Consumer) | Spring 5.2 | 5.1 用 execute(TransactionCallback) + return null |
| @TransactionalEventListener | Spring 4.2 | — |
| @TransactionalEventListener.fallbackExecution | Spring 4.3 | — |
9. 一句话总结
@TransactionalEventListener(AFTER_COMMIT) 监听器里执行的写操作链,必须每一步都用 @Transactional(REQUIRES_NEW) 划清事务边界;否则 REQUIRED 会复用一个"已 commit 但尚未清理"的幽灵事务上下文,导致写入"无异常 / 看起来成功 / 实际丢失"。 修复方式: 1)业务语义边界用方法级 @Transactional(REQUIRES_NEW); 2)技术安全网用事件分发器集中包裹 TransactionTemplate(REQUIRES_NEW)。两层叠加,互不冲突。