数据已经提交,调用为什么还能失败

订单事务提交后发送通知,通知代码抛出异常。调用方得到失败响应,却在下一次查询中发现订单已经存在。这两项结果可以同时成立:异常来自提交后的回调,而订单提交已经结束。把“方法抛异常”直接等同于“数据库回滚”,会让重试重复创建订单。

事务事件提供的是阶段绑定。它能把监听器安排在提交前、提交后或回滚后,却不替外部系统持久保存通知,也不自动重试。要判断一次业务是否成功,至少应分开观察数据库终态、回调是否调用、异步任务是否完成和接收方是否确认。本文实验只涉及前三项,没有外部消息接收方。

实验固定 Spring Framework 6.2.11、JDK 21.0.11、PostgreSQL 18.0、pgJDBC 42.7.7 和 HikariCP 5.0.1。源码固定提交 4c134254642d88e058aa004bdaf44168e1be7bb2;完整程序是 examples/spring-framework-lab/src/main/java/blog/spring/Chapter23.java。数据库结果由单独建立的连接读取,避免把当前事务自己的可见性当成已经提交。

发布、提交、两类回调及资源清理的先后关系

发布时登记,完成时筛选

普通事件监听器通常在事件广播时进入监听方法。事务监听器多了一层适配:TransactionalEventListenerFactory 把带注解的方法转换成 TransactionalApplicationListenerMethodAdapter;适配器先尝试把同步回调登记到当前事务,再决定是否立即执行。

登记有两个条件:当前同步机制处于活动状态,同时存在实际事务。只启用同步、却没有实际事务的范围,不满足这个判断。这解释了为什么单独使用 SUPPORTS 不一定能收到事务事件。事务是否实际存在,需要看调用时状态,不能从传播名称推定。

实验使用程序式 TransactionTemplate,没有依赖启用注解事务的配置,因此显式声明专用工厂。仅仅把 @TransactionalEventListener 放到一个 Bean 上,并不足以证明容器已经安装了正确的事件适配基础设施。

1
2
3
4
@Bean
static TransactionalEventListenerFactory transactionalEventListenerFactory() {
return new TransactionalEventListenerFactory();
}

本段是完整程序中的配置节选。程序同时注册普通配置类和监听器 Bean;入口中创建真实 JDBC 事务管理器,而不是用模拟事务对象代替连接提交。

TransactionalApplicationListenerSynchronization.register() 将事件与监听器保存在回调中。发布动作完成时,监听器可能尚未运行。程序先插入编号 1,再发布事件,在事务体内部断言提交监听次数仍为零;独立连接也查询不到编号 1。提交完成后,监听次数成为一次,独立连接查询到一行。这两项观测共同支持“监听在事务完成后发生”。固定版本的登记与阶段实现

AFTER_COMMIT 与 afterCommit 不是同一个调用点

两个名称很接近,但它们走的异常路径不同。底层 TransactionSynchronization.afterCommit() 是同步接口的提交后方法;注解中的 TransactionPhase.AFTER_COMMIT 则由同步对象的 afterCompletion(STATUS_COMMITTED) 分支触发。

AbstractPlatformTransactionManager.processCommit() 先处理提交前回调,接着执行 doCommit()。物理提交成功后触发底层 afterCommit;无论这个回调是否抛出异常,finally 中还会触发 afterCompletion。最外层 finally 再清理资源。因此,底层 afterCommit 抛出运行时异常,可以传回调用者,但不会使先前的物理提交变成回滚。提交控制流

afterCompletion 的调用器逐个捕获异常并交给日志系统。事务事件的 AFTER_COMMIT 方法在这里抛出异常时,异常不会按底层 afterCommit 的路径传回调用者。实验分别覆盖两条路线,不能把其中一条的结果推广到另一条。同步调用器

输入 监听或回调结果 独立连接终态
编号 1,正常提交 AFTER_COMMIT 被调用 一行
编号 2,标记 rollback-only AFTER_ROLLBACK 被调用,提交监听不增加 零行
编号 4,事务事件监听器抛异常 方法确实进入,异常未逃出事务模板 一行
编号 5,底层 afterCommit 抛异常 调用者收到 IllegalStateException 一行

编号 4 的方法先设置调用标记并输出线程名,再抛异常;断言同时检查标记和数据库行数,排除“监听器根本没执行”的假阳性。实验环境没有安装 SLF4J 日志实现,不能拿终端未出现错误堆栈证明没有异常。日志调用路径来自固定版本源码,实际监听调用则由程序标记证明。

提交前回调又有另一种结果。它运行在物理提交之前,异常仍可能阻止提交。因此,异常定位必须包含阶段。只记录异常类型,不记录抛出阶段,会把已提交、已回滚和完成状态未知混为一谈。

无事务与回滚是不同输入

无事务发布时,默认事务监听器不执行,因为不存在可供等待的事务完成阶段。fallbackExecution=true 允许此时直接处理事件。它没有创建新事务,也没有模拟一次数据库提交。相同监听方法可能在有事务时延迟运行,在无事务时立即运行,调用方必须接受这两种时间语义。

程序在任何事务开始前发布一次事件,断言默认提交监听计数为零,fallback 监听计数为一。随后在真实事务中发布另一个事件并设置 rollback-only,验证回滚监听计数增加,提交监听计数保持不变。无事务的“未登记”和有事务的“登记后筛选到回滚分支”由此被分开。

AFTER_COMPLETION 用于完成后统一处理,既覆盖提交,也覆盖回滚;具体状态仍由底层同步回调表示。不能把完成理解成成功。网络故障等情况还可能出现 UNKNOWN 状态,本实验没有制造提交结果不确定的故障,不能据此宣称所有完成状态都只有提交和回滚。官方事务事件说明

上游 TransactionalEventListenerTests 的 noTransaction()、noTransactionWithFallbackExecution()、afterCommit() 与 afterRollback() 检查了事件收集器的阶段结果。本文阅读这些测试来确认设计分支,但没有运行 Spring 上游测试套件;本文的 PostgreSQL 实验另外验证物理数据终态。上游测试

异步监听的完成需要另一个证据

提交后监听加上 @Async,会使业务处理切换到执行器线程。登记事务阶段仍发生在发布线程;真正执行监听方法时,线程绑定的 JDBC 事务不会自动跟随。把事务资源的 ThreadLocal 复制到线程池也不是正确补救,因为连接与事务生命周期没有因此延长。

实验使用单线程执行器和三个 CountDownLatch。监听器启动后记录线程名与实际事务状态,通知主线程已经进入,再等待放行。事务模板返回后,主线程检查数据库编号 3 已存在,而监听完成闩锁仍未归零。这直接证明“事务调用已返回”不能证明异步任务已经完成。

随后主线程放行,等待完成闩锁,断言工作线程没有继承事务。等待都设置五秒上限,避免失败时无限挂起。这个实验使用同步原语建立可重复顺序,不依赖随意睡眠和日志打印先后来猜测竞争关系。

此处只证明任务进入与本地完成。执行器拒绝任务、进程在提交后退出、监听方法重试造成重复,这些问题不会被阶段绑定自动解决。需要可靠通知时,应在原事务中保存待发送记录,并为投递与消费分别设计重试和幂等;相关内容属于 outbox 专题,不能把一个提交后注解视作该协议的实现。

资源仍绑定,不代表还有下一次提交

提交后的回调运行时,原事务资源可能尚未解绑。程序在底层 afterCommit 中检查 hasResource(pool),结果为 true;整个模板返回后再检查,结果为 false。两个时点夹住了资源清理过程。

此时沿用原资源写入具有误导性:连接仍可取得,数据库操作也可能执行,但原事务的提交动作已经过去。不能从 SQL 没有报错推导第二次写入会被可靠提交。Spring 的同步接口文档明确要求,这一阶段需要事务性写入时使用独立的新事务。afterCommit 契约

实验创建第二个 TransactionTemplate,传播设为 REQUIRES_NEW。原事务写编号 6;afterCommit 回调通过新模板写编号 7。程序分别查询两个事务使用的 pg_backend_pid(),断言不同,最后由独立连接确认两行都存在。新事务挂起旧资源、借出另一连接并独立提交,不能把这条路径缩写成“回调里再调一次 JDBC”。

连接池必须容纳这个额外连接。如果线程仍持有旧连接,又等待新连接,而池已经用尽,回调可能在业务数据已提交之后因借连接失败而报错。即使新事务失败,编号 6 也不能靠回调异常撤回;如需补偿,应明确补偿的是已经提交的业务状态。

运行与判读

在已启动系列本地 PostgreSQL 实验库的环境中,从实验工程目录运行:

1
2
export JAVA_HOME="$HOME/.sdkman/candidates/java/21.0.11-amzn"
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter23

默认连接 127.0.0.1:55432/spring_lab,用户 spring_lab。程序只创建并清空自己的 spring_ch23_event 表,禁止对业务库执行。可通过 spring.lab.jdbc.url 和 spring.lab.jdbc.user JVM 属性调整本地实验连接;密码为空是本机实验配置,不是部署建议。

原始日志保存在 examples/spring-framework-lab/evidence/23/run.txt,进程结果在同目录 exit-code.txt。成功运行包含 13 条 CHECK PASS;任何条件不满足都会抛 AssertionError。端口、数据库版本、线程名、后端连接编号和独立查询结果共同构成证据,退出码不能替代这些分支观测。

练习:把编号 7 的 REQUIRES_NEW 改成 REQUIRED,先预测资源查找会取得哪条连接,再增加独立查询核验最终结果。不要预设所有驱动和池的清理行为相同。第二个练习是让新事务写入违反唯一约束,分别观察原事务数据、新事务数据和调用方异常。判断标准是三栏结果,而不是一条“事务失败”的日志。