
上次碰到这个问题,是一封“订单创建成功”的消息提前发了出去。
代码看起来很顺:保存订单,发布 Spring 事件,监听器里调用消息服务。后来数据库因为另一个约束回滚,消息却已经被下游收到。复盘时大家都知道要换成 @TransactionalEventListener,但说到 BEFORE_COMMIT、AFTER_COMMIT 具体发生在哪里,答案开始互相打架。
我做了一个只含 Spring Framework、H2 和一张表的小工程。不跑 Spring Boot,不接业务代码,只观察同一个事件在提交与回滚时会叫到哪些监听器,以及另一条 JDBC 连接能否看见刚插入的行。
环境固定为 Java 21、Spring Framework 6.1.6、H2 2.2.224,隔离级别是 READ_COMMITTED。每个场景都在 TransactionTemplate 中插入一行,然后同步发布一个普通对象事件。
监听器一共五个:普通 @EventListener,以及绑定到 BEFORE_COMMIT、AFTER_COMMIT、AFTER_ROLLBACK、AFTER_COMPLETION 的四个事务监听器。每次进入监听器,都用数据源新开一条原始 JDBC 连接,查询目标主键的行数。这样不会把事务内部同一连接“读到自己刚写的数据”,误认成其他连接已经可见。

最小容器还有一个容易漏掉的地方:配置类需要启用事务管理。第一次运行时我没有加 @EnableTransactionManagement,四个事务注解方法被普通事件工厂直接注册,发布瞬间连 after_rollback 都调用了。Spring Boot 通常替应用装好了相关基础设施,手写 AnnotationConfigApplicationContext 时不能默认它也存在。实验把这一处显式写出,避免测试的是错误容器。
关键记录逻辑只有下面这层意思:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void afterCommit(ProbeEvent event) {
record("after_commit", event); // record 内新开 JDBC 连接查询
}
程序不只打印顺序,还带着断言运行。为了让输出可以重复比较,我在五个方法上显式标了 @Order:提交路径应为 ordinary → before_commit → after_commit → after_completion,回滚路径应为 ordinary → after_rollback → after_completion,并且回滚路径不允许出现 after_commit。这里的同阶段先后来自实验写下的优先级;若业务里有多个事务监听器,不能把未声明顺序时的一次运行结果当作约定。
提交场景的四条观察如下:
| 顺序 | 阶段 | 独立连接看到目标行 |
|---|---|---|
| 1 | ordinary | 0 |
| 2 | before_commit | 0 |
| 3 | after_commit | 1 |
| 4 | after_completion | 1 |
普通监听器是同步事件监听器,publishEvent 调用期间就执行。插入语句已经发给数据库,但外部连接还看不见。BEFORE_COMMIT 位于事务完成前,同样读不到未提交行。这个阶段适合在同一事务成败范围内做最终校验;若它抛出异常,提交可以被阻止。
AFTER_COMMIT 执行时,另一条连接第一次查到行数 1。对“只有主数据确定落库后才能发生”的动作,这比普通监听器符合直觉。紧接着的 AFTER_COMPLETION 也看到 1,不过它只说明事务已经结束,并不区分结果是提交还是回滚。

这里仍有一个边角不能省略:AFTER_COMMIT 不是新的事务。原事务已经提交,资源可能还处于完成回调过程中。如果监听器还要写另一张业务表,应明确新开事务,或更稳妥地把可靠副作用交给 outbox 和异步消费者。仅凭注解名字,不能获得重试、幂等和消息必达。
这里还要区分“监听器已经被调用”和“别人已经能读到数据”。普通监听器最早执行,但它只是跟着发布动作同步进入方法;提交前监听器名字里虽然带有 commit,外部连接看到的仍是旧状态。只有事务管理器完成提交后,独立连接才在第三条记录里读到 1。测试若复用事务内部同一连接,很容易把“读到自己刚写的行”误当作已经提交。
这也是我坚持新开原始连接探测的原因。若用当前线程上的 JdbcTemplate 查询,普通监听器和提交前监听器都可能得到 1,表格看上去会非常整齐,结论却正好相反。可见性问题最好让第二个观察者回答,不能让写入者同时充当证人。
第二个场景插入主键 2,发布事件后立即把事务标记为 rollback-only。记录顺序是:
| 顺序 | 阶段 | 独立连接看到目标行 |
|---|---|---|
| 1 | ordinary | 0 |
| 2 | after_rollback | 0 |
| 3 | after_completion | 0 |
普通监听器仍然执行。这正是最初那类 bug 的来源:它只知道事件被发布,不知道包住发布动作的事务稍后会不会失败。BEFORE_COMMIT 没有出现,因为事务没有进入提交前回调;AFTER_COMMIT 也没有出现。AFTER_ROLLBACK 和 AFTER_COMPLETION 都执行,但独立连接始终读不到主键 2。
若需求是释放临时资源、记录补偿线索或统计回滚次数,AFTER_ROLLBACK 的语义最清楚。若提交和回滚都要清理某个只与本地执行有关的上下文,可以用 AFTER_COMPLETION,同时读取完成状态,而不是假定它等于成功。
回滚用例还排除了一个常见误会:事件对象已经创建,不代表业务事实已经成立。普通监听器确实拿到了主键 2,也可以把它打印进日志,可数据库从未向其他连接公开这行。若监听器据此发邮件、扣外部额度或写第三方系统,后续已经没有数据库回滚替它收场。
因此集成测试不能只断言“某个方法被调用”。至少还要有一个 rollback-only 场景,检查提交专属动作没有发生;如果副作用写入本地表,再从独立事务查询最终状态。方法调用次数很容易绿,业务边界是否守住要靠最终状态判断。
阶段选对以后,还要问监听器失败会怎样。
普通同步监听器与 BEFORE_COMMIT 都在事务完成前,异常可以沿调用栈返回,使事务回滚。这适合强一致校验,不适合延迟高、失败率不可控的网络调用。把 HTTP 请求塞进这里,会让数据库事务等待外部系统。
AFTER_COMMIT 的异常无法撤回已经完成的数据库提交。调用者可能仍收到异常,但主数据已经存在。若代码据此告诉用户“创建失败”并让用户重试,反而可能制造重复数据。因此提交后副作用需要独立的状态、重试和幂等设计;监听阶段只决定第一次尝试的时点,不解决可靠交付。
AFTER_ROLLBACK 也不应承担一个绝不能丢的补偿动作。应用进程可以在回调前后退出,监听器本身也可能报错。真正要求可追踪的补偿,应把意图写进可恢复的存储,而不是只相信内存回调。
实验中的回调只做读取和记录,没有在提交后继续修改同一事务里的实体。实际项目若要在提交后阶段写数据库,需要明确开启新的事务,或者把任务交给 outbox 消费者。否则代码表面上还能拿到线程绑定资源,写入却未必按开发者想象的方式再次提交。这条边界应该由专门的集成测试锁住。
需要参与当前事务成败的本地校验,我用普通调用或 BEFORE_COMMIT,并保持逻辑短小;只有提交成功才有意义、但允许单次失败的轻量动作,可以放在 AFTER_COMMIT;需要可靠通知下游的业务事件,则优先 outbox,不把事务监听器当消息队列。回滚专属清理用 AFTER_ROLLBACK,两种结果都执行的收尾才用 AFTER_COMPLETION。
代码评审时我现在会额外问三件事:监听器失败是否要让主事务失败,动作是否允许丢一次,回调里有没有新的持久化写入。三个答案足以排掉大部分误用。刷新一个可重建缓存,可以接受提交后单次失败;发货指令或扣减外部额度则不行,它们需要持久化的投递状态和重试记录。
这个实验没有覆盖嵌套事务、REQUIRES_NEW、响应式事务和不同数据库隔离级别。它只回答最常见的一条本地 JDBC 时间线。好处是结果不靠注解名称联想:提交时第三个节点外部可见,回滚时那一行从未离开事务。以后再争论“这个监听器是不是已经提交”,可以先看另一条连接究竟读到了什么。

文章问答
她会先读这篇文章,再安静地回答你的问题。可以继续追问。
加载中…
登录后参与讨论