zhaojie.tech/me
Spring 2026-10-07T00:00:00.000Z

事务还没提交,事件已经飞出去了:afterCommit 的正确用法

赵杰 · zhaojie.tech

上线后发现一个诡异的现象:一笔销售单业务回滚了,财务账上却多了一条记账凭证。翻日志,记账动作的触发时间比回滚时间还早 200ms——消息在事务提交之前就发出去了,消费端照单全收。

这不是玄学,是把”发事件”写在了事务里、又默认”事务会成功”的经典错觉。业务回滚了,副作用却已经发生,这类”脏动作”还包括:审计日志记了成功、ES 索引写进了即将作废的数据、推送通知发出去了。解法是把副作用挪到提交之后,Spring 给了两把钥匙:@TransactionalEventListener(AFTER_COMMIT) 和 TransactionSynchronization。

两条真实链路

我项目里有两条链路,各选了一把钥匙,取舍很典型。

链路一:库存变更 → 财务记账,用事件广播。

库存引擎每次出入库都会发布 StockChangedEvent,监听方有多个(财务记账、缓存刷新、库存预警),发布方不该知道有谁在听。落地上是 @TransactionalEventListener(AFTER_COMMIT) 配 @Async:

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onStockChanged(StockChangedEvent event) {
    // 事件里带 tenantId/storeId,异步线程上下文丢失时兜底回填
    if (TenantContext.getTenantId() == null && event.getTenantId() != null) {
        TenantContext.setTenantId(event.getTenantId());
    }
    ...
    int booked = bookService.bookStockChange(event); // AccountingBookService 内部 @Transactional,独立事务
}

库存事务提交成功才触发;回滚则事件根本不派发,“库存没动、账却记了”从机制上消失。@Async 把记账挪出提交线程,提交路径不再等财务落库。

链路二:单据审计,用编程式注册。

审计切面 @BillAuditLog 需要的是”就在这个事务旁边挂个回调”——提交后异步落审计表。这里用更轻的 TransactionSynchronization:

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
    @Override
    public void afterCommit() {
        // 提交后才读 afterStatus + asyncSave,写审计表
    }
});

afterCommit 只在 commit 真正成功后触发,回滚不触发,天然保证审计记录与业务结果一致——不会再出现”业务回滚但审计记成功”。这套切面全系统 100 多个切点,独立线程池加 traceId 透传。ES 双写也是同款模式:索引写入挂在 afterCommit 里,脏数据进不了索引。

选型口径:一对多广播、发布方与消费方解耦 → 事件模型;一对一强绑定、只想挂个回调 → 编程式注册,少一层事件抽象,代价是耦合事务管理器 API。

三个必须知道的坑

坑一:fallbackExecution 默认 false。 @TransactionalEventListener 默认在没有事务上下文时不执行。我有一次把一个入口方法的事务注解去掉重构,挂在上面的监听器集体沉默——没有任何报错,事件就是不见了。如果发布点可能无事务,要么显式 fallbackExecution = true,要么保证发布点一定有事务,二者选一且写进注释。我在推送服务里就显式声明了 fallbackExecution = true,因为它的触发点不保证包在事务里。

坑二:AFTER_COMMIT 阶段回滚不了任何东西。 事务已经提交,此时监听器抛异常,已提交的数据纹丝不动。所以 afterCommit 里的写操作要么自带独立事务,要么尽量隔离失败面:@Async 切走线程、内部服务再开 @Transactional(如上面链路一),或显式 REQUIRES_NEW。顺带的顺序问题:afterCommit 回调本身在提交后同线程同步执行,里面做重活会拖住返回给调用方的响应——这就是为什么两条链路都配了异步。

坑三:异步线程的上下文是空的。 租户、traceId 都在 ThreadLocal 里,@Async 换了线程就全丢。多租户系统里丢租户上下文的症状是”静默查空”——SQL 被租户插件注入了错误的 tenant_id,查不到数据也不报错。解法是线程池配 TaskDecorator 统一快照/恢复上下文;事件对象里冗余携带 tenantId/storeId 做兜底(链路一里那两行 if 就是兜底),双保险。

还有一个容易忽略的细节:@EventListener(不带 Transactional)默认同步、同线程、同一事务,监听器抛异常会反噬发布方事务(标记 rollback-only)。它的语义是”事务内联动”,不是”提交后通知”——两者混用是事故高发区。

一句收尾

凡是”事务成功才应该发生”的副作用,就把它拴在提交点上,而不是事务里;同时记住 afterCommit 只是通知,不是新的安全区——它里面的每一步写操作,仍然需要自己的事务边界和失败兜底。

← 全部文章 赵杰 · me.zhaojie.tech