34 的 HTTP 服务把“请求已受理”和“后台任务已完成”分开了;任务状态还停在内存中,服务重启就没了。要保存状态,最容易写错的地方并不只是 `INSERT` 语句,而是:一个协程执行 JDBC 时如果换了线程、取消了 Job、甚至连接抛异常,**谁负责整个事务的提交、回滚和关闭**?

本篇继续使用 Kotlin 2.2.21、coroutines 1.10.2、Gradle 8.14、JDK 21、Ktor 3.3.1,在本机隔离临时目录启动 PostgreSQL 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1);JDBC 驱动冻结为 org.postgresql:postgresql:42.7.7。驱动上游 tag REL42.7.7 指向注解对象 7d5a13de18cc77bdbc76a88de36fc61c00799022,剥离后源码 commit 为 9217ed16cb2918ab1b6b9258ae97e6ede244d8a0。版本组合在本机实际编译和运行,不能由这些版本号推断其他数据库驱动有同样的取消行为。依据是 JDK 21 Connection、PostgreSQL JDBC 事务指南及 PostgreSQL 16 BEGIN、ROLLBACK(2026-10-10 核对)。

SQL 桥接只需要三件事。第一,Connection 代表一次会话,关闭连接和提交事务是不同动作;use 只保证离开块时调用 close,不保证业务语义成功。第二,autoCommit=false 后,INSERT 的效果要等 commit() 才对其他连接可见;异常路径应显式 rollback(),否则读回结果会受驱动和连接状态影响。第三,事务只覆盖数据库内部变更,不能撤销已经发出的 HTTP 回包、消息或文件写入。本篇所有 SQL 观察都用独立连接读回,避免把同一事务中的自读当成提交证据。

本篇描述的是同名素材归档中的 35 快照。若后续服务把 HTTP registry 接入 JDBC、调整执行器或补取消竞争测试,应以新的工程复跑结果解释最新行为,不把本篇的局部事务观察直接搬到最新服务链路。

一条事务不跨不受控挂起点

累计工程的 TaskJdbcStore.insert 接收已通过 TaskId、makeTaskDraft 校验的草稿。它用受限单线程执行器提供的 CoroutineDispatcher 执行 withContext;连接在这个块内创建、使用、提交或回滚、关闭。JDBC API 依旧是同步阻塞调用,withContext 只是将这段工作调度到指定的线程,不会让 executeUpdate() 自动变成非阻塞 I/O。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
withContext(jdbcDispatcher) {
DriverManager.getConnection(url).use { connection ->
connection.autoCommit = false
try {
connection.prepareStatement(
"INSERT INTO task_snapshot (id, payload, state) VALUES (?, ?, ?)"
).use { statement ->
statement.setString(1, draft.id.value)
statement.setString(2, draft.text)
statement.setString(3, "Queued")
statement.executeUpdate()
}
connection.commit()
} catch (failure: Exception) {
connection.rollback()
throw failure
}
}
}

实际代码还在 INSERT 后、COMMIT 前接受同步故障注入钩子,用来构造可靠的回滚窗口。代码没有在持有连接的事务体内调用挂起 API,也没有让连接随着协程 withContext 返回被另一个不受控 dispatcher 继续使用。读操作重新开独立连接;服务端点尚未使用这个 Store,这是一条单独的持久化边界实验。

提交前后的独立连接看见什么

测试在同一事务内插入合成 task-35,尚未提交就从第二条新连接读取,结果为缺失;提交后再从独立连接读取到 alpha。在另一个 rollback-35 的 INSERT 完成后、COMMIT 之前,注入 IllegalStateException,同步回滚;独立连接仍读不到这条记录。专用工作线程名称以 kotlin-35-jdbc 开头,线程定位和前后读取是不同的断言,不拿“提交函数正常返回”替代数据库观察。

随后真的停止该临时 PostgreSQL 进程并从同一个磁盘数据目录重启:第一条已经提交的行仍能从新连接读到,故障注入的行仍不存在。原始输出 PG transaction_uncommitted_invisible=true committed_visible=true rollback_invisible=true restart_visible=true worker=kotlin-35-jdbc 对应四项逐个断言,verification/35/TaskJdbcStoreTest.txt 保留 JUnit XML;pg-preflight.txt 另有初始化/启动/SQL 查询/停止的退出码。测试没有读系统默认 PostgreSQL 集群,更没有接触 Go 会话的隔离集群。

这个结果说明单个数据库事务的持久化和回滚行为,不说明 34 篇 HTTP 服务已具备持久任务索引。现在 HTTP registry 仍是内存映射;这个 JDBC Store 没有接入路由或业务状态的所有转换。进程重启后读到已提交文本,也不等于自动恢复运行中的 Job,更不等于 exactly-once 执行。

协程取消与 JDBC 驱动取消分开核对

Job.cancel() 请求的是协作取消。正在同步执行的 JDBC 调用是否响应协程取消,还取决于阻塞 API 与驱动;不能因为外围 withContext 最终抛取消就断言服务器 SQL 已停止。本篇另建连接执行 SELECT pg_sleep(10),用独立连接查询 pg_stat_activity,确证该 SQL 已经在 PostgreSQL 的 active 状态,然后直接对那条查询的 java.sql.Statement 调用 cancel()。工作线程取得的 PostgreSQL SQLSTATE 是 57014(查询被取消)。输出 PG driver_cancel_active_query=true sqlstate=57014 coroutine_cancellation_not_implied=true 只为这个驱动/语句/环境背书,没有把它写成“取消协程即等于驱动 cancel”。

还有一个需要警惕的窗口:同步 commit() 可能已返回,但 withContext 切回调用方时其 Job 才收到取消。这时“调用方没有收到成功返回”并不证明数据库未提交。第 35 篇没有对这个竞争窗口做受控注入,标为 NOT_RUN;若业务需要可重试结果,应靠任务 ID、数据库唯一约束及明确状态查询核对提交,不要只从协程返回值推断事务结果。

检查点 本机证据 不可扩大的推断
单线程专用 dispatcher 内事务 工作线程名,连接在同步块内使用/关闭 数据库 I/O 自动不阻塞或自动响应 Job 取消
提交前/后两条连接 提交前读不到,提交后读到 HTTP 接口已经持久化
INSERT 后/COMMIT 前异常 独立连接查不到回滚行 分布式副作用也能被 SQL 回滚
数据库进程重启 提交行仍在,回滚行不在 运行中的协程 Job 自动恢复
Statement.cancel() active 的 pg_sleep(10) 返回 SQLSTATE 57014 所有驱动/所有同步调用都由协程取消自动中断

练习与答案

练习一:如果 INSERT 成功后、commit() 前抛异常,独立连接应看到什么?答案:应看不到该行;本篇故障注入路径已实测回滚不可见。

练习二:如果 commit() 已返回,但协程在切回调用方时被取消,能否重试插入同一个 ID?答案:不能只凭取消异常重试。该竞争窗口在本篇 NOT_RUN;安全做法是用唯一 ID 和状态查询确认提交结果。

练习三:Statement.cancel() 成功得到 SQLSTATE 57014,是否说明 Job.cancel() 会自动取消任何 JDBC 查询?答案:不能。本篇只证明直接调用该驱动的 Statement.cancel() 能中止 active 的 pg_sleep(10)。

bash examples/kotlin-language/verification/35.sh 会在测试过程中自动创建、重启并停止独立临时集群,执行 37 组累计 JUnit,保留 Netty 隔离测试 JVM 与既有 CLI 正反例;要求宿主提供 PostgreSQL 16 的 initdb、pg_ctl、postgres。没有这些二进制时不能把被跳过的数据库路径写成 PASS。累计工程归档含 Wrapper、研究、版本账本和原始输出,解包复跑见同名素材目录。事务与 Kotlin 的跨层路径仍未完成:下一篇只按锁定的编译器版本追踪现有挂起代码生成物,不由一段 JDBC 实验推断编译器内部。