两个请求都收到 HTTP 504,一个对应的订单尚未提交,另一个对应的订单已经提交。外层响应预算耗尽时,数据库操作可能处于不同阶段;重试若直接生成新订单,就会把“没有收到成功响应”误当成“没有发生写入”。

事务负责一组数据库修改的原子性,幂等键负责把多次请求关联到同一次业务操作。响应超时、任务完成和事务提交需要分别观察,单独检查 Stage 或 HTTP 状态不能替代数据库终态查询。

withTransaction 等待同步 block 返回

Play 3.0.6 的 Databases.scala 把连接设为非自动提交,调用 block,取得返回值后 commit;普通异常路径 rollback,外层 withConnection 再关闭连接。

返回值的泛型没有改变这一时序。若 block 返回 CompletionStage<T>,这里取得的是 Stage 对象,源码不会等待它完成。事务边界包住的是同步调用 block 的过程。

sequenceDiagram
    participant Worker as 工作线程
    participant Tx as withTransaction
    participant DB as PostgreSQL
    participant Async as 后续任务
    Worker->>Tx: 调用 block
    Tx->>DB: INSERT
    Tx->>Async: 创建被门闩阻塞的 Stage
    Async-->>Tx: 返回待完成 Stage
    Tx->>DB: COMMIT
    Tx-->>Worker: 返回 Stage 并关闭连接句柄
    Worker->>Async: 释放门闩
    Async->>Async: 使用旧连接失败

app/labdb/TransactionLab.java 的 scope 实验在事务中插入一行,再返回等待门闩的异步任务。外层拿到 Stage 时它尚未完成;独立连接已经能查到一行已提交数据。释放门闩后,异步任务访问捕获的连接,观察到 closed=true。

该次返回串为 null:closed=true,其中 null 是异常没有 SQLSTATE 的实际值,不能据此补造一个 PostgreSQL 错误码。实验的判定依据是连接已关闭,以及独立连接在 Stage 完成前看到了提交。

安全的组织方式是把整个同步事务放到明确的 JDBC 执行器任务内,最后返回不依赖连接的值。事务内部可以计算普通对象,但不能把需要继续访问当前连接的工作留到 block 返回之后。

订单与库存必须共享提交边界

实验使用 lab_orders 和 lab_inventory 两张表。订单主键为 (tenant, idempotency_key);库存以 sku 为主键,剩余数量非空且不得小于零。

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE lab_inventory (
sku text PRIMARY KEY,
remaining integer NOT NULL CHECK (remaining >= 0)
);
CREATE TABLE lab_orders (
tenant text NOT NULL,
idempotency_key text NOT NULL,
payload text NOT NULL,
sku text NOT NULL,
PRIMARY KEY (tenant, idempotency_key)
);

写入顺序是先尝试占用业务键,再扣库存。INSERT ... ON CONFLICT DO NOTHING 没有插入时,读取已经保存的 payload:相同内容返回 REPLAY,内容变化返回 CONFLICT。只有本次新插入的订单才能进入扣库存分支。

库存更新带 remaining > 0 条件。受影响行数为零时抛出冲突异常,让事务回滚此前插入的订单;不能在事务内部吞掉异常,再正常返回一个冲突字符串,否则已经执行的写入可能被提交。

下面是完整的 Java 事务方法封装,使用累计工程的表结构。它接收的 payload 是本实验的合成业务内容;实际系统的摘要应覆盖所有决定业务效果的字段。本实验把 sku 固定在创建出的 fixture 内,不能把仅比较 payload 的代码直接推广到任意商品输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import play.db.Database;

public final class AtomicOrder {
private static final class StockConflict extends RuntimeException {
private static final long serialVersionUID = 1L;
}

public static String purchase(Database database, String tenant,
String key, String payload, String sku) {
try {
return database.withTransaction(connection -> {
int inserted;
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO lab_orders VALUES(?,?,?,?) "
+ "ON CONFLICT(tenant,idempotency_key) DO NOTHING")) {
statement.setQueryTimeout(5);
statement.setString(1, tenant);
statement.setString(2, key);
statement.setString(3, payload);
statement.setString(4, sku);
inserted = statement.executeUpdate();
}
if (inserted == 0) {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT payload FROM lab_orders "
+ "WHERE tenant=? AND idempotency_key=?")) {
statement.setQueryTimeout(5);
statement.setString(1, tenant);
statement.setString(2, key);
try (ResultSet rows = statement.executeQuery()) {
if (!rows.next()) {
throw new SQLException("missing idempotency row");
}
return rows.getString(1).equals(payload)
? "REPLAY" : "CONFLICT";
}
}
}
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE lab_inventory SET remaining=remaining-1 "
+ "WHERE sku=? AND remaining>0")) {
statement.setQueryTimeout(5);
statement.setString(1, sku);
if (statement.executeUpdate() != 1) {
throw new StockConflict();
}
}
return "CREATED";
});
} catch (StockConflict conflict) {
return "CONFLICT";
}
}
}

唯一键把并发请求的业务身份放进数据库约束。先在 Java 中查询“是否存在”,再无约束插入,会在查询与插入之间留下竞态。这里的结果还依赖数据库事务隔离和 SQL 语义;实验使用 PostgreSQL 17.6 的实际连接与并发请求,没有用内存 Map 代替唯一约束。

并发结果与失败后的表状态

两个工作任务先到达门闩,再同时开始调用 purchase。断言同时检查返回值、订单行数和剩余库存,避免出现“响应符合预期,但数据库多写了一行”的遗漏。

场景 观测结果 数据库终态
库存 2,普通成功 CREATED 订单 1、库存 1
扣库存后故意抛异常 IllegalStateException 订单 0、库存恢复 1
同 tenant、同 key、同 payload 并发 CREATED 与 REPLAY 订单 1、库存 0
同 key 改 payload CONFLICT 原订单和库存不变
不同 key 竞争库存 1 CREATED 与 CONFLICT 订单 1、库存 0
不同 tenant 使用相同 key 两次 CREATED tenant 维度分别占键

HTTP 层另用 /db/order/prepare 创建独立 fixture,再并发调用 /db/order?id=...&key=same&payload=p,实际返回 201 和 200;改 payload 返回 409。这些实验 GET 会修改合成数据,仅用于回环验证,生产写接口应采用符合业务语义的方法及权限校验。

tenant 是实验显式传入的身份维度,不构成认证实现。真实入口必须从已验证身份和授权结果取得 tenant,不能允许调用者借修改参数访问其他主体的幂等记录。

504 之前和之后都可能提交

LateCommitLab 让任务停在两个不同位置:一种先写入但尚未 commit,另一种已经 commit、尚未完成任务返回。外层通过 Futures.timeout 设置 100 ms 预算,两个真实 HTTP 请求都收到 504。

受控停留位置 504 时 taskDone 新连接此时可见订单 释放后订单 按同键重试新增行
提交前 false 0 1 0
提交后、任务返回前 false 1 1 0

每次状态查询都使用新的连接,避免从同一事务读到尚未提交的自身写入。释放门闩后还要等待任务完成和资源关闭,再查询最终订单数。这里只统计超时响应、业务提交和清理三个独立终点。

响应预算与数据库提交边界

源码中的 Futures.scala 通过竞争原 Future 和超时 Future 完成包装结果,不会因此回滚 JDBC 事务。取消一个 CompletableFuture 也不能替代对数据库取消或回滚结果的验证。

第一个超时场景在更完整的订单/库存事务中也执行了一次:超时时订单数为 0,释放后订单数为 1、库存为 0,同键重试得到 REPLAY。HTTP 版本则把提交位置直接暴露为有限的测试开关,便于对照相同响应状态下不同业务终态。

恢复协议应保留业务键,允许客户端按键查询结果或安全重试。如果幂等记录与业务写入分开提交,就可能出现“记录成功、业务失败”或“业务成功、记录丢失”的窗口。本实验把占键和扣库存放在同一事务中,但未实现跨数据库或外部支付操作的原子提交。

数据库 statement_timeout 是另一条失败路径

对照实验在事务内执行 SET LOCAL statement_timeout='100ms',修改库存后运行 pg_sleep(2)。PostgreSQL 返回 SQLSTATE 57014,异常离开事务 block,库存回滚为 1。

这次超时发生在数据库执行语句的位置,和外层 HTTP 预算耗尽后任务继续运行有不同的因果链。仍不能把一个 SQLSTATE 推广为所有网络中断下“事务肯定没提交”:提交请求已经发出但响应丢失时,应用需要查询业务键来消除不确定性。

复现与证据

配置 JDK 21 和第 22 篇的专用回环 PostgreSQL 后,从仓库根目录运行。所有等待、SQL 和进程观察都有期限,测试 finally 释放门闩,不把等待留给下一次运行。

1
2
3
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
PLAY_LAB_DB_ENABLED=true PLAY_LAB_JDBC_URL='jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' bash examples/play-lab/sbtw 'testOnly DatabaseLabTest'
python3 examples/play-lab/lab/database_checks.py --container play-db-local --jdbc-url 'jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' --evidence examples/play-lab/evidence/local-tx-run

隔离证据目录为 examples/play-lab/evidence/batch22-25/isolated/。http-04/summary.json 的 transactions、httpIdempotency、httpLateCommit 三组记录对应上述数据库、真实请求和超时恢复观测;逐次状态码及 SQL 查询输出分别在 http-observations.json、sql-observations.json。

历史隔离整批38项JUnit与37次HTTP观察覆盖22–25,不是本篇单独的测试数量。接入累计工程后,数据库启用的46项JUnit及stage通过,真实数据库矩阵仍有37次HTTP观察;evidence/batch22-25/shared-build.json核验了9个新增class与生产jar的字节一致,shared-http/保留两个504场景及独立SQL查询。文中完整类另外以JDK21实际编译,测试、HTTP和页面各有证据。

两个改动练习

  1. 将幂等 payload 扩展为包含 sku 和数量的规范化内容,再测试同 key、不同 sku 的请求。验收为冲突且库存不变,并说明规范化规则改变后怎样兼容已有记录。
  2. 在 commit 之前和之后分别延迟返回,保持外层 100 ms 预算。分别记录 HTTP 状态、原任务状态和新连接可见订单,最后用原业务键恢复;不得以超时次数推算回滚次数。

上一篇:JDBC 与连接池 · 下一篇:Evolutions 与模式升级 · 系列起点:最小应用