深入 Play 23:事务与幂等,数据库提交如何关联 HTTP 结果
两个请求都收到 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 | |
写入顺序是先尝试占用业务键,再扣库存。INSERT ... ON CONFLICT DO NOTHING 没有插入时,读取已经保存的 payload:相同内容返回 REPLAY,内容变化返回 CONFLICT。只有本次新插入的订单才能进入扣库存分支。
库存更新带 remaining > 0 条件。受影响行数为零时抛出冲突异常,让事务回滚此前插入的订单;不能在事务内部吞掉异常,再正常返回一个冲突字符串,否则已经执行的写入可能被提交。
下面是完整的 Java 事务方法封装,使用累计工程的表结构。它接收的 payload 是本实验的合成业务内容;实际系统的摘要应覆盖所有决定业务效果的字段。本实验把 sku 固定在创建出的 fixture 内,不能把仅比较 payload 的代码直接推广到任意商品输入。
1 | |
唯一键把并发请求的业务身份放进数据库约束。先在 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 | |
隔离证据目录为 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和页面各有证据。
两个改动练习
- 将幂等 payload 扩展为包含 sku 和数量的规范化内容,再测试同 key、不同 sku 的请求。验收为冲突且库存不变,并说明规范化规则改变后怎样兼容已有记录。
- 在 commit 之前和之后分别延迟返回,保持外层 100 ms 预算。分别记录 HTTP 状态、原任务状态和新连接可见订单,最后用原业务键恢复;不得以超时次数推算回滚次数。
上一篇:JDBC 与连接池 · 下一篇:Evolutions 与模式升级 · 系列起点:最小应用

