深入 Play 33:相同约束下的 Web 栈,异步返回与事务终态
一个订单请求收到 504,数据库随后插入订单并扣减库存。把控制器返回类型从 CompletionStage<Result> 换成 Mono<ResponseEntity<?>>,这条因果链仍然存在;把 HTTP 入口换成 Spring MVC,也不会自动回滚已经运行的事务。
Web 栈比较需要把控制器适配、业务排队、数据库事务和 HTTP 响应分开。框架负责什么,业务代码自己提供什么,要在负载结果之前说明。否则,一套应用使用两个 JDBC 连接,另一套使用几十个连接,得到的吞吐差异主要来自资源配置。
三个独立进程,一份业务代码
实验包含 Play 3.0.6、Spring Boot 3.3.6 MVC 和 Spring Boot 3.3.6 WebFlux 三个小工程。源码位于 examples/play-electives/comparison/{play,mvc,webflux}。三份 comparison.Kernel 的源码 SHA-256 相同;控制器和应用启动适配层单独实现。
可下载三个工程及运行证据,用SHA256SUMS校验。共享目录重新构建后核对 6 个 class 与 jar 字节,运行 18 个负载单元、396 个测量请求及 69 条断言;记录在 examples/play-electives/evidence/comparison/integration。第一次核验发现编译目录与已打包 jar 不同,保留差异后重新构建、立即核验并重跑矩阵;不同运行的数值各自保留。
| 条件 | 三个工程的共同设置 |
|---|---|
| JVM | Amazon Corretto 21.0.11,Java 21 |
| 数据库 | 同一台 PostgreSQL 17.6,每个进程独立的 cmp_* schema |
| JDBC | PostgreSQL 驱动 42.7.5,HikariCP 5.1.0,连接池大小 2 |
| 业务执行器 | 2 个固定工作线程,16 个排队位置,满队列立即返回 503 |
| 依赖调用 | JDK HttpClient,同一个 loopback HTTP 下游,服务端等待 20 ms |
| 外层预算 | 入业务队列前开始计时,150 ms 后完成 HTTP 结果为 504 |
| 事务范围 | 幂等检查、库存锁、依赖调用、插入订单、扣减库存、提交 |
HikariCP 5.1.0 是这个独立对照实验的冻结版本,系列累计工程此前的 5.0.1 不因此改变。实际生产包中分别核验了 HikariCP 和 PostgreSQL jar,并确认各包只有一个 HikariCP。Boot 包实际携带 Spring Framework 6.1.15;MVC 的 Tomcat 为 10.1.33,WebFlux 的 Reactor Core 为 3.6.12。完整清单位于实验的 artifacts.json。
主测量端点使用 POST 查询参数传入受控的 key、payload、delay 和 fail。参数解析器、表单与 JSON body 的框架差异没有进入这个矩阵。无效整数、缺失字段的框架默认错误页也没有被伪装成统一业务错误契约。实验只暴露 loopback 地址,并关闭 Play 的 CSRF 过滤器;它不能作为公开订单 API 的安全配置。
异步返回改变了哪一段等待
三个写入端点都先调用同一个 Kernel.create,真正的 JDBC 工作在相同大小的业务执行器上运行。区别发生在框架如何等待这个业务 Stage。
flowchart LR
P["Play Action"] --> S["CompletionStage"]
M["MVC 同步方法"] --> W["Servlet 线程 get 等待"]
F["WebFlux 方法"] --> R["Mono.fromCompletionStage"]
S --> B["共同业务执行器 2 + 16"]
W --> B
R --> B
B --> D["JDBC 池 2"]
D --> T["相同事务与依赖调用"]
Play 将 Stage 的完成结果映射为 Result。WebFlux 将 Stage 转为 Mono,再映射为 ResponseEntity。两者都不需要在控制器中调用 get();业务代码依然使用阻塞 JDBC 和阻塞的 HttpClient.send,所以必须明确分配业务线程。
这次 MVC 适配层选择同步方法,在 Servlet 请求线程中等待 Stage。它刻意表达“同步入口调用同一个业务内核”这一部署方式,不能推导出“MVC 不支持异步”。Spring 6.1.15 的 DeferredResultMethodReturnValueHandler 明确识别 CompletionStage,并将它转为 DeferredResult 后启动 Servlet 异步处理。把 MVC 返回类型换成 Stage 会改变入口线程的占用,需要作为第四个组合重新测量。
读取订单的端点也把 JDBC 查询提交到业务执行器,WebFlux 不在事件循环中直接查询数据库。状态端点只读取执行器计数,不执行 SQL。外部验证数据库终态时使用独立 psql 连接,避免把应用自己的缓存或返回值当成提交证据。
Play 异步文档说明了异步响应和阻塞工作的执行上下文;Spring MVC 异步请求文档展示了 Servlet 异步处理。版本结论另外对照了 Spring 6.1.15 的实际返回值处理器。
幂等性必须落到数据库事务中
每个 schema 初始有一行库存,数量为 1000;订单表以 key 为主键。事务先取得 key 对应的 PostgreSQL 事务级 advisory lock,再查询已存在订单的 payload。相同 key、相同 payload 返回 200 existing;相同 key、不同 payload 返回 409 conflict。
不存在订单时,事务锁住唯一库存行,检查余量,执行受控延迟与 HTTP 依赖调用,插入订单并将库存减一。故障开关在两次写入之后抛出异常,catch 分支执行 rollback,再返回统一的 500 failed。锁随着事务提交或回滚释放。
advisory lock 在这里帮助表达幂等键临界区,订单主键约束仍是数据库的唯一性保护。哈希碰撞可能使不同 key 额外串行,不会允许同一 key 插入两行。实验只有一个商品,所有成功新订单最终都竞争同一库存行,因此业务吞吐受这个热点锁约束。这个设计适合观察事务终态,不适合宣传框架的峰值吞吐。
并发负例用同一个 key 发送两个 payload a 和两个 payload b。哪一个 payload 获胜取决于到达顺序,断言只要求两个 200、两个 409、数据库最终恰好一行。每个负载单元还在工作线程空闲后查询订单数,确认所有 200 或 504 对应的已接纳任务都执行完毕,503 拒绝的任务没有写入。
库存检查同时要求“剩余数量 + 订单行数 = 1000”。只检查 HTTP 200 个数会漏掉晚提交;只检查订单行数则可能漏掉重复扣减。两个条件要在同一业务模型下结合使用。
150 ms 到期以后
外层时钟只竞争 HTTP 结果的完成权。计时任务调用 answer.complete(504),不会取消已经入队的 Runnable,也不会调用 Statement.cancel,更不会结束 PostgreSQL 事务。
sequenceDiagram
participant C as HTTP 客户端
participant A as 控制器与预算时钟
participant B as 业务线程
participant D as PostgreSQL
C->>A: POST orders delay=400
A->>B: 提交工作并设置150ms时钟
B->>D: 开始事务并pg_sleep
A-->>C: 504 budget
C->>D: 独立连接查询
D-->>C: 订单0行
B->>D: 插入订单并提交
C->>D: 等待业务空闲后再查
D-->>C: 订单1行
C->>A: 使用相同幂等键重试
A-->>C: 200 existing
三个应用都运行这个用例。第一次查询发生在 HTTP 504 之后,第二次查询发生在业务执行器没有活动任务和排队任务之后。可观察结果是 0 行变为 1 行,随后重试不增加订单,也不再次扣减库存。
有限高并发负载还会产生一个容易误读的现象:客户端已经收到 504,便开始下一次请求,而旧任务仍占用线程、队列或数据库连接。即使客户端并发数只有 8,应用排队数也可能累积到 16,接着返回 503 capacity。容量断言需要接受这条明确的拒绝路径,同时证明被拒任务没有写入。把 503 改成无限排队只会隐藏积压。
下面的完整 Java 21 小程序只验证 Future 的完成竞争,不声称替代 HTTP 或 JDBC 实验。它使用闸门确定先后顺序,避免用固定睡眠碰运气。
1 | |
有限负载结果的读法
每个应用先预热 8 次,再执行 3 轮低、高并发单元。低并发为 1、每单元 12 次;高并发为 8、每单元 32 次。三个应用合计 18 个测量单元、396 次测量请求。每个单元结束后等待业务完全空闲,防止上一单元的晚提交污染下一单元。
| 应用 | 并发1 p50范围/ms | 并发8 p50范围/ms | 测量200 / 504 / 503 |
|---|---|---|---|
| play | 34.8–35.2 | 154.6–159.0 | 51 / 81 / 0 |
| mvc | 29.8–32.7 | 154.1–156.7 | 50 / 82 / 0 |
| webflux | 31.0–38.4 | 156.4–157.6 | 49 / 83 / 0 |
完整运行通过 69 条断言。测量单元中的503为零;另设每个应用32次、SQL延迟200 ms的同时突发,三个应用均出现容量拒绝,且独立SQL确认被拒绝任务没有订单。这个固定上限负例与正常测量单元分别记录。
客户端耗时包括 HTTP 编解码、连接建立、框架调度和业务等待,150 ms 只是应用内业务结果预算,不能直接充当端到端时延上界。原始记录保留每次响应状态、body 和耗时,表格只压缩显示各轮的范围。
三个应用按固定顺序在同一主机上运行,PostgreSQL 也共享这台实验机器的资源。没有随机化进程顺序,没有跨主机隔离,没有持续数小时压测,没有置信区间。样本既不能证明某个 Web 栈普遍更快,也不能推导线上容量。它能证明在这组具体限制下,三种入口都必须面对相同的队列、事务锁和晚提交问题。
打包、关闭与重跑
Play 使用 sbt 1.10.7 打包 stage,Boot 两个工程使用 Maven package 生成独立可执行 jar。应用通过各自打包产物启动,不用开发服务器替代生产模式。构建日志没有虚构 JUnit 用例数:本实验的业务验收来自真实 HTTP 和独立 SQL 断言。
SIGTERM 在业务已空闲后发送。三个应用的关闭回调停止接纳,等待工作线程结束,关闭预算时钟、JDK 客户端与 Hikari 池,写入清理日志。验证还检查原监听端口无法连接,以及 PostgreSQL 中对应 application_name 的会话数量归零。这只证明空闲退出;进行中请求与 SIGTERM 的竞态不在当前对照结果内。
环境命令使用公开配置,不依赖某个开发者的绝对路径。在已有 PostgreSQL 17.6 fixture 上,设置 JDBC 地址、容器名和 JDK 后运行:
1 | |
工作目录是 examples/play-electives/comparison;三个子工程需先按 README 构建。脚本只创建带随机后缀的 cmp_* schema,不删除其他 schema,不停止数据库 fixture。实验账户 postgres 与密码 lab-only-password 仅用于本地合成数据;源代码中的账户设置需要与自有 fixture 对齐。
原始证据位于 examples/play-electives/evidence/comparison/isolated。构建日志、生产包清单、HTTP 单元、SQL 终态及关闭日志分别保留,失败尝试也不覆盖。页面构建成功不能代替这些运行证据。
| 故障面 | 当前观察范围 | 未运行边界 |
|---|---|---|
| 幂等冲突 | 同 key 两种 payload,并发各两次 | 跨地域多主复制 |
| 回滚 | 写入后主动抛异常,两张表同时回滚 | 数据库进程崩溃与恢复 |
| 超时 | 外层504后真实晚提交,相同key重试 | Statement取消、网络分区 |
| 队列 | 2线程16槽,有限负载下503与无写入 | 长时间持续过载 |
| 关闭 | 空闲SIGTERM,线程、池、客户端关闭 | 事务执行中强制kill |
两个改动练习
将 MVC 的同步返回改为 CompletionStage,保持业务 Kernel 的文件哈希不变。重跑同样的三个轮次,并记录 Servlet 请求线程和业务线程的占用。这个练习检验入口异步适配带来的变化;若同时改连接池大小,就失去单变量对照。
再把依赖调用移到事务外,分别放在提交之前和提交之后。让依赖端返回 500 或超时,定义哪一种情况允许订单存在,补充幂等重试与补偿断言。移动调用位置会改变库存锁持有时间,也会改变“订单成功但通知失败”的业务语义,不能只比较吞吐。
上一节:32 版本迁移。下一节:34 故障诊断。

