Kotlin E06:挂起事务究竟等到哪里
拦截器比调用者先返回
SpringCoroutineTransactionTest 用两个 CompletableDeferred 充当闸门。被代理的 suspend read() 先报告进入,再等待放行。普通 MethodInterceptor 调用 invocation.proceed() 后立即把计数加一;测试在放行之前检查计数已经是 1,而异步调用 caller.isCompleted 仍为 false,放行后 caller.await() 才得到 done。JUnit XML 原始输出:
1 | |
这只证明这个普通 AOP 拦截器看见的方法返回时点。它没有把 Spring 的事务扩展也描述成“提前提交”。suspend 的 JVM 方法有 Continuation 参数并可能在挂起时返回标记;36 的反编译记录可用来核对调用形状,而业务最终成功或失败仍需等待恢复路径。
事务回调才是提交的候选终点
当前隔离实验固定 Spring Framework 6.2.11、Kotlin 2.2.21、coroutines 1.10.2、Reactor 3.7.11、R2DBC PostgreSQL 1.0.7.RELEASE 与 PostgreSQL 16.15。Spring 的 TransactionalOperator.executeAndAwait 在 6.2.11 官方源码中把挂起回调放入 mono(context),以 awaitLast() 等待 execute 的响应式结果;它将当前上下文去掉旧 Job 键交给 Reactor 桥接,而不是以普通代理 proceed() 的返回值宣布成功。版本和代码路径可在 research/E06.md 与测试依赖树中复核。
测试先建立 e06_probe,打开容量仅为 1 的 R2DBC 连接池。成功分支在 executeAndAwait 中插入 success,随后挂在 release.await()。从另一条 JDBC 连接 查询时,行数为 0;此时任务 isCompleted 为 false。放行并等待返回 committed 后,独立连接读到 1 行。这两个读数只约束本次数据库的未提交可见性和最终结果,不能凭第一个读数推断未来一定提交。
反例先插入 failed、delay(1),再抛 IllegalStateException;调用者观察到异常,独立查询仍为 0。另一个分支插入 cancelled 后等待 awaitCancellation();取消前行不可见,cancelAndJoin 返回后行仍不存在,连接池 acquiredSize、pendingAcquireSize 均为 0,最后还等待 disposeLater() 完成。XML 原样记录:
1 | |
上述片段略去了隔离 PostgreSQL 的创建、连接池销毁和 insert 的定义;完整可执行测试是累计工程中的 SpringCoroutineTransactionTest。先用信号确认事务体确实已插入,再发起取消,避免“取消在事务开始前”也满足行数为 0 的伪阳性。读连接使用 PostgreSQL 的另一条 JDBC session,不把当前事务内的自读冒充外部可见性。
1 | |
异常传到调用方、数据库没有提交、租约归还需要分开检查。即使 cancelAndJoin 正常结束,也不能在没有数据库读回与连接指标的情况下声称回滚。这里没有提交后发消息,不能把数据库回滚扩展为外部副作用的撤销。
连接池容量 1 让未归还的租约更显眼,maxAcquireTime 为五秒;pendingAcquireSize == 0 只是观测当下队列没有等待者,不能证明任何将来请求永不阻塞。测试正常与异常都读回数据库,但只在取消场景尾部检查池指标,不能把一次指标采样当作所有场景持续监测。
上下文不能按线程名猜
成功事务在 Dispatchers.Default + ReactorContext(Context.of("requestId", "synthetic-e06")) 下开始。事务体进入独立的 e06-origin 单线程,再切到 e06-context。后者仍能读到 ReactorContext 中的 requestId,却读不到只在原线程设置的普通 ThreadLocal 值;返回原线程后仍可读到那个值,随后显式清理。这使“上下文元素跨 dispatcher”和“未包装 ThreadLocal 不自动跨线程”成为两个独立断言。JUnit 的协程调试模式会给线程名追加 @coroutine#... 后缀,因此只断言它以指定线程名开头;一次要求严格等于的测试失败原始 XML 也保存了。
该实验没有接入 MDC、WebFlux 请求上下文或生产 trace exporter。不要拿“线程碰巧没变”解释传播,也不要把业务 requestId 的存在当成观测链路已经导出。
springTest 与默认 test 分账:Spring/R2DBC 的依赖只放在测试作用域,默认的 Kotlin/JVM 服务仍然使用 39 的显式 JDBC 路径。同步 Connection 不能因为隔壁的 R2DBC 事务通过,就获得可挂起或取消时自动安全的结论。
这里可以做两个有方向的修改实验。若把失败分支里的异常在 executeAndAwait 内部捕获并返回普通值,预期事务看到正常完成,数据库结果可能改变;只在操作符外部 catch,并不改变操作符已经观察到的失败。另一个实验把 ReactorContext 从启动上下文删掉:跨线程读 requestId 的断言应失败,普通 ThreadLocal 的反例不应因此变成自动传播。这两项修改未写进默认源码,也未存实跑结果,属于预测而非已验收结论。
复跑与尚未验证的路径
从仓库 examples/kotlin-language/ 在 JDK 21 和已缓存依赖、可执行本机 PostgreSQL 16 工具链的环境中运行:
1 | |
verification/E06/spring-test.xml 中 Spring 两项测试均成功,regression.txt 记录这次退出码 0;错写 R2DBC group 的首次失败输出也保留在 first-failed.txt。素材目录附完整累计工程及 SHA,可解包后重新运行。正式 Spring @Transactional 标注的所有代理形态、生产 HTTP 中途断开、任意第三方驱动中止与纯 JDBC 跨挂起事务均 NOT_RUN。如需验证 ThreadLocal,请增加明确绑定和换线程后的正反断言,而不是从这个 ReactorContext 样本推导。
练习与答案
练习一:普通拦截器 proceed() 返回,能否说明事务已提交?答案:不能。该实验证明普通 AOP 看见挂起函数返回标记的时点,事务完成点要看 executeAndAwait 观察到的响应式结果。
练习二:把异常在 executeAndAwait 内部 catch 并返回普通值,预期会怎样?答案:事务可能把它当正常完成;这是正文中标明的预测,默认源码没有改,也没有保存实跑结果。
练习三:ReactorContext 里的 requestId 能跨 dispatcher,是否说明普通 ThreadLocal 也会自动传播?答案:不能。测试正是把二者分开:上下文元素可读,未包装的 ThreadLocal 在换线程后不可读。




