换个 Dispatcher,阻塞还在吗 区分挂起与占用线程,事务中途挂起,连接归谁 用同步 JDBC 检查连接所有权。换到 Spring 后,最容易误读的是普通方法拦截器的返回:它看见了 `proceed()` 的返回值,并不等于挂起调用者已经获得业务结果,更不等于事务已经提交。仓库另有 [深入 Spring E06](/2026/10/02/深入-Spring-E06-Kotlin挂起调用事务与上下文边界/) 使用另一套版本和独立实验;本篇只复验 Kotlin 累计工程的边界。

拦截器比调用者先返回

SpringCoroutineTransactionTest 用两个 CompletableDeferred 充当闸门。被代理的 suspend read() 先报告进入,再等待放行。普通 MethodInterceptor 调用 invocation.proceed() 后立即把计数加一;测试在放行之前检查计数已经是 1,而异步调用 caller.isCompleted 仍为 false,放行后 caller.await() 才得到 done。JUnit XML 原始输出:

1
SPRING_PROXY adviceReturned=1 callerCompletedBeforeRelease=false final=done

这只证明这个普通 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
2
3
4
5
6
7
8
9
10
11
val cancelled = async {
operator.executeAndAwait {
insert("cancelled")
entered.complete(Unit)
awaitCancellation()
}
}
entered.await()
check(count("cancelled") == 0)
cancelled.cancelAndJoin()
check(count("cancelled") == 0)

上述片段略去了隔离 PostgreSQL 的创建、连接池销毁和 insert 的定义;完整可执行测试是累计工程中的 SpringCoroutineTransactionTest。先用信号确认事务体确实已插入,再发起取消,避免“取消在事务开始前”也满足行数为 0 的伪阳性。读连接使用 PostgreSQL 的另一条 JDBC session,不把当前事务内的自读冒充外部可见性。

1
SPRING_R2DBC uncommitted=0 committed=1 failed=0 cancelled=0 requestId=synthetic-e06 acquired=0 pending=0

异常传到调用方、数据库没有提交、租约归还需要分开检查。即使 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
./gradlew --no-daemon --offline clean test springTest loopbackTest packageTest

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 在换线程后不可读。

参考资料