方法拦截返回时,挂起业务可能还没有完成

一个 suspend 方法插入数据后等待异步结果,等待期间它没有给调用方返回最终业务值。普通 Java 拦截器却可能已经结束 invocation.proceed()。如果把这个返回时点直接当作业务完成,记录的耗时和成功结果就会提前,事务边界也可能解释错误。

实验先构造真实 Spring ProxyFactory 代理,再执行实际 PostgreSQL 事务。前一个场景说明普通环绕拦截的返回时点,后一个场景验证 Spring 协程扩展如何等待事务回调、传播异常并处理取消。两者使用同一个版本基线,但不能把代理适配与事务协议混为一层。

运行使用 JDK 21.0.10、Boot BOM 3.5.6、Framework 6.2.11、Kotlin 1.9.25、kotlinx-coroutines 1.8.1、Reactor Core 3.7.11。数据库为 PostgreSQL 18.0,R2DBC 驱动 1.0.7.RELEASE,连接池 1.0.2.RELEASE。版本来自实际 Maven 依赖树;不能将滚动文档中的后续版本行为直接套用。

挂起完成、事务终态与连接释放

用两个闸门区分返回与完成

被代理对象实现一个带 suspend 方法的接口。方法进入时发出 entered 信号,然后等待 finish,最后才返回字符串。代理上的普通 MethodInterceptor 在 proceed 返回后增加计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface SuspendedReader {
suspend fun read(): String
}
factory.addAdvice(MethodInterceptor { invocation ->
val result = invocation.proceed()
adviceReturned.incrementAndGet()
result
})
val call = async { proxy.read() }
entered.await()
yield()
check(adviceReturned.get() == 1 && !call.isCompleted)
finish.complete(Unit)
check(call.await() == "read-complete")

实验观察到:普通通知已经返回,但调用方的协程尚未完成。释放闸门后,代理适配后的挂起调用才得到最终字符串。这个结果只描述当前代理和拦截器组合,不意味着所有 Spring advice 都忽略协程;专门适配异步结果的拦截逻辑可以继续观察后续完成。

suspend 在 JVM 上涉及 Continuation 和挂起协议。调用方的逻辑顺序仍然是“等待 read 完成,再使用返回值”,但底层 Java 调用栈的返回不必等于最终完成。对挂起函数计时、记录异常或结束资源作用域时,必须选择对应语义的完成点。

Spring 的事务扩展等待回调结果

本实验没有给一个同步 JDBC 事务方法简单加上 suspend。使用的是 R2dbcTransactionManager、TransactionalOperator 和 Spring 提供的 executeAndAwait。DatabaseClient 查询通过 awaitSingle 等待结果,事务与数据库操作都处在响应式资源模型内。

Framework 6.2.11 的扩展获取当前协程上下文,去掉 Job.Key,再以 mono(context) 执行挂起回调,最终 awaitLast 等待事务执行链的结果。这里的源码细节说明协程回调如何接入 Publisher 生命周期,不能将去掉 Job 解释成“取消失效”。实际取消还会通过等待者与订阅关系传播。固定版本扩展源码

1
2
3
4
5
6
7
8
9
10
11
12
val task = async(ReactorContext(Context.of("requestId", "synthetic-e06"))) {
tx.executeAndAwait {
insert("success")
inserted.complete(Unit)
release.await()
"done"
}
}
inserted.await()
check(!task.isCompleted && count("success") == 0)
release.complete(Unit)
check(task.await() == "done" && count("success") == 1)

count 使用独立 JDBC 连接读取已提交数据,因此它不会意外读到当前 R2DBC 事务自己的未提交写入。闸门让验证落在确定的事务中间窗口,无需假设睡眠十毫秒一定足够。最终业务行可见与事务管理器提交回调共同支持“成功提交”的结论。

换线程后,事务状态来自哪个上下文

事务回调内部使用 withContext(Dispatchers.Default),实际检查线程名称已经变化。切换之后,ReactorContext 中的 requestId 仍为预设值,响应式 TransactionSynchronizationManager 也报告实际事务处于活动状态。

这不是证明线程局部变量会自动跟随协程。当前链路传递的是协程上下文中的 ReactorContext,以及 Spring 响应式事务维护的上下文。执行线程变化和上下文是否存在是两项不同的观察。

一个反例是普通 ThreadLocal。调用方把它设置为 caller,进入 Dispatchers.Default 后读到 null;显式加入 local.asContextElement(“explicit”) 后,挂起恢复期间才能读到 explicit,离开作用域后调用方恢复 caller。实验最后 remove,防止后续同线程代码继承测试值。

asContextElement 的例子只验证这个已声明元素的安装与恢复,不证明任意日志框架或任意第三方 ThreadLocal 都被透明传播。把 traceId 放进某个上下文,也不等于 exporter 已经发出完整链路;业务上下文、观测上下文和传播协议仍需分别验证。Kotlin 协程上下文说明

挂起之后抛出的异常仍然影响事务

失败场景先插入 error,再 delay,随后抛出明确的 IllegalStateException。调用方观察到原始业务异常消息,独立 JDBC 查询确认 error 行不存在,事务管理器记录一次成功回滚。

这里的异常发生在挂起之后,不能仅靠最初 Java 方法是否正常返回判断结果。事务执行链需要继续覆盖挂起回调的后续执行。如果捕获异常并正常返回业务值,事务管理器看到的完成方式可能改变,这正是需要单独验证的修改练习。

异常传播与回滚不是同一个断言。前者证明调用方得到失败,后者证明数据库修改未提交。连接归还又是第三项:实验立即执行下一条查询,并检查 acquired=0、pendingAcquire=0。只打印一个异常堆栈无法证明这些终态。

取消必须同时检查业务行和资源租约

取消场景插入 cancel 行后停在 awaitCancellation。独立连接仍看不到该行,同时池显示唯一连接处于已租用状态。主协程随后 cancelAndJoin,等待子协程结束。

之后下一条查询成功取得同一个容量为 1 的池,业务行仍不存在,已租用连接和等待者均归零。全部事务场景结束时,事务管理器的监听器累计记录一次提交和两次回滚,对应成功、异常、取消三个场景。

这个实验展示当前 Spring、Reactor、协程与 R2DBC 组合的取消路径。它没有验证在事务之外调用的 HTTP 服务或已提交消息能够随协程取消撤销,也没有把 cancelAndJoin 本身当作数据库回滚的证据。结论依赖真实数据库读回与资源状态。

取消是协作性的。业务代码如果吞掉取消异常、长期运行不可取消计算或把副作用交给独立生命周期的任务,资源释放与业务终态就可能不同。生产代码需要检查具体等待点和资源协议,不能只在函数签名里找到 suspend 就推断取消一定及时。

连接容量让泄漏更容易暴露

本实验把 R2DBC 池限制为一条连接。成功、异常和取消场景后都立即发起下一条查询;若前一条事务长期占用连接,下一次 acquire 会在五秒上限内失败,而不会被池内其他空闲连接掩盖。

下一条查询成功仍然需要配合租约与等待者归零检查。池随后在 finally 中 disposeLater 并等待结束,最后确认 disposed;Reactor 全局调度器也在实验进程退出前关闭。共享 PostgreSQL 不由该模块停止。

读取另一事务的数据使用独立 JDBC 连接,且只用于观测,不把阻塞 JDBC 查询放进 WebFlux 事件循环。本篇不是 HTTP 吞吐测试,也没有测量每次上下文切换的成本。它关注可以确定复现的语义边界。

复现与预测

先按实验工程的 db/README.md 准备 PostgreSQL 55432,设置 JAVA_HOME 为 JDK 21,再在实验根目录运行:

1
sh concurrency-lab/run.sh E06

原始日志位于 evidence/E06/local-20261002/run.txt。22 条断言包含代理返回窗口、事务提交和回滚、线程切换后的上下文、ThreadLocal 反例及资源释放,最终输出 CHAPTER E06 PASS,退出码为 0。

修改练习:在 error 场景的 executeAndAwait 回调内部捕获业务异常并返回固定字符串。预测调用方不再收到异常,error 行可提交,监听器的计数需要同步改成两次提交、一次回滚。不要只修改调用方 catch 后宣称事务改变;异常必须在事务回调内部转为正常完成。

另一个练习是删除 asContextElement,保持线程切换与 delay。预测 explicit 值不再由该元素安装;把实验断言改为“没有显式传播值”,而不是假定调度器的线程名称能证明上下文内容。

前置阅读:深入 Spring 38:Boot 自动配置的输入、退让与属性绑定 与 深入 Spring 40:观测事件、资源等待与业务终态。

参考资料