ChthollyHub
  • Hub
  • Chtholly
  • Search
  • Write
  • Login

Chtholly Hub

|

Rekyrice
@Rekyrice

动漫 · 追番 · 随笔

7文章50标签1活跃
珂朵莉 - default
Observation

珂朵莉观察

今天仓库里还算安静。没有关系,安静的时候也适合读一点东西。

社区入口

写点什么把今天的故事放进仓库搜索仓库找文章、番剧和灵感珂朵莉房间看看她最近在想什么

热门标签

动画电影Agent制作考察Java动画演出动画评论性能排查牛尾宪辅独立游戏生活随笔1000xRESIST3DCGasyncioCITY THE ANIMATIONCosplay

活跃用户

Rekyrice7 篇最近文章

最新文章

  • 即使无法生活,也可以停留——《末日后酒店》与那些比世界活得更久的东西
  • 裕太终于把那句话说完了——《古立特宇宙》
  • 欢迎来到 Chtholly Hub
  • 2026 冬季追番清单
  • 为什么叫 Chtholly

© 2026 Rekyrice · Chtholly Hub

Powered by Next.js & Spring Boot

公安备案图标桂公网安备45020502000330号·桂ICP备2026017801号-1

HubChthollySearchWrite
Spring 事务事件的提交时机实验

Spring 事务事件的提交时机实验

2026年07月07日磁盘又满了
约 9 分钟磁盘又满了
Spring事务TransactionalEventListenerJava

上次碰到这个问题,是一封“订单创建成功”的消息提前发了出去。

代码看起来很顺:保存订单,发布 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。这里的同阶段先后来自实验写下的优先级;若业务里有多个事务监听器,不能把未声明顺序时的一次运行结果当作约定。

提交这条线

提交场景的四条观察如下:

顺序阶段独立连接看到目标行
1ordinary0
2before_commit0
3after_commit1
4after_completion1

普通监听器是同步事件监听器,publishEvent 调用期间就执行。插入语句已经发给数据库,但外部连接还看不见。BEFORE_COMMIT 位于事务完成前,同样读不到未提交行。这个阶段适合在同一事务成败范围内做最终校验;若它抛出异常,提交可以被阻止。

AFTER_COMMIT 执行时,另一条连接第一次查到行数 1。对“只有主数据确定落库后才能发生”的动作,这比普通监听器符合直觉。紧接着的 AFTER_COMPLETION 也看到 1,不过它只说明事务已经结束,并不区分结果是提交还是回滚。

提交和回滚的真实时间线

这里仍有一个边角不能省略:AFTER_COMMIT 不是新的事务。原事务已经提交,资源可能还处于完成回调过程中。如果监听器还要写另一张业务表,应明确新开事务,或更稳妥地把可靠副作用交给 outbox 和异步消费者。仅凭注解名字,不能获得重试、幂等和消息必达。

这里还要区分“监听器已经被调用”和“别人已经能读到数据”。普通监听器最早执行,但它只是跟着发布动作同步进入方法;提交前监听器名字里虽然带有 commit,外部连接看到的仍是旧状态。只有事务管理器完成提交后,独立连接才在第三条记录里读到 1。测试若复用事务内部同一连接,很容易把“读到自己刚写的行”误当作已经提交。

这也是我坚持新开原始连接探测的原因。若用当前线程上的 JdbcTemplate 查询,普通监听器和提交前监听器都可能得到 1,表格看上去会非常整齐,结论却正好相反。可见性问题最好让第二个观察者回答,不能让写入者同时充当证人。

回滚这条线

第二个场景插入主键 2,发布事件后立即把事务标记为 rollback-only。记录顺序是:

顺序阶段独立连接看到目标行
1ordinary0
2after_rollback0
3after_completion0

普通监听器仍然执行。这正是最初那类 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 时间线。好处是结果不靠注解名称联想:提交时第三个节点外部可见,回滚时那一行从未离开事务。以后再争论“这个监听器是不是已经提交”,可以先看另一条连接究竟读到了什么。

Spring事务TransactionalEventListenerJava
阅读轨迹
约 9 分钟2,426 字2026年07月07日
阅读进度0%
本文目录
  1. 实验装置
  2. 提交这条线
  3. 回滚这条线
  4. 异常算在哪一边
  5. 我现在怎样选
文章线索
磁盘又满了
Spring事务TransactionalEventListenerJava
珂朵莉陪读
珂朵莉 - serious

看起来很认真呢……我会安静地在旁边陪着。

解释这篇文章
磁盘又满了

作者

磁盘又满了

主要写 Java 和 Python 的排错记录,偶尔也记一点跑模型时踩过的坑。

查看作者所有文章
珂朵莉 - curious

文章问答

问珂朵莉关于这篇文章

她会先读这篇文章,再安静地回答你的问题。可以继续追问。

评论

加载中…

登录后参与讨论