深入 Play 35:可验证的订单与订阅服务
订单创建返回 200,响应中却包含 notification=BUDGET_FAILED。数据库订单已经提交,固定本地下游超过了 120ms 请求预算;这两个结果同时成立。把通知超时转换成“订单失败”,会诱导调用方换一个幂等键重试,产生第二笔合法订单。
结课工程把这种边界放进一个真实服务:PostgreSQL 保存订单和库存,Play 签名 session 提供合成身份,对象策略同时约束 tenant 与 subject;SSE 查询订单状态,CSV 通过文件源下载。每项结论都有对应输入、数据库或资源终态,以及可重跑的有限实验。
工程范围与可复核产物
结课服务已接入第00篇的累计源码包。共享55项JUnit全部执行,112个应用及生成class与stage jar字节一致;生产重放再次验证订单、库存、幂等、导出、订阅与在途停机,见 evidence/batch30-35/shared-capstone-http。实验仅删除自己的UUID schema,保留共享PostgreSQL实例。
本篇固定 Play 3.0.6、JDK 21.0.11、PostgreSQL 17.6。Play 源码锚定完整提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6,四个引用文件已与该提交的官方原始文件逐字节核对。实验通过 stage 启动独立 Prod 模式进程,仅监听 127.0.0.1。
Prod 是运行模式。capstone.conf 仍是专项实验配置,显式开放公开合成登录、就绪检查、撤权和状态接口,首次初始化还会创建自己的 capstone_* schema。基础应用默认未启用该模块,登录开关缺省为 false。这个实验配置不能直接作为生产身份认证或数据库迁移方案。
flowchart LR
H[HTTP Cookie 与请求体] --> A[签名身份与对象权限]
A --> Q[4 个工作线程<br/>16 个等待任务]
Q --> DB[(PostgreSQL<br/>订单与库存)]
DB --> C[事务提交后返回订单]
C --> W[固定本地 WS 下游<br/>120ms 预算]
W --> R[HTTP Result]
DB --> S[SSE 每事件再查权限]
DB --> F[有限 CSV 文件]
S --> T[流终止记录]
F --> T
代码位于累计工程 examples/play-lab。app/capstone/OrderRepository.java 负责同步 JDBC 事务,CapstoneService.java 拥有线程池、数据库池、WS 客户端、临时文件和关闭记录,Controller 负责请求形状与 HTTP 错误契约。已有 labdb.DbAccess 被直接复用;订单算法沿用事务实验的唯一键占位思路,但表与权限键改为结课模块自己的 schema。生命周期顺序沿用后台任务实验的先停止接收、再排空、最后关闭资源;没有调用旧实验中故意失败的 stop hook。
实际验收是 2 项针对性 JUnit 加完整真实网络矩阵。它没有执行共享工程的累计 suite,不能把先前章节测试数相加写成本次通过数。stage-manifest.json 保存 246 个生产阶段文件的哈希,artifacts/ 保存实际应用、assets 和业务模块 jar;源文件集成清单与这些二进制产物分开记录。
身份坐标进入每一条对象查询
Security.AuthenticatedAction 与默认 Authenticator 先读取 session.username。存在用户名时执行 delegate,缺失时返回 401。这个默认 Action 没有订单所有者的知识,也不会从用户名自动推出 tenant。
实验登录把 tenant 与 username 写入框架签名 session。Controller 将它们转换为不可变的 Principal;客户端提交的 X-Username、X-Tenant 不参加计算,JSON 中额外提交 tenant 字段会得到 400。对象访问继续进入数据库事务,先检查 principals.revoked,再用 id、tenant、subject 同时限定订单。
1 | |
这两条 SQL 是 OrderRepository 的查询形状;实际 schema 由每次运行生成,名称经过固定正则校验,身份与订单值使用参数绑定。列表同样带 tenant、subject 条件,按 id 排序,最多返回 100 行。这个上限只限制本次结果集,服务尚未提供翻页游标。
FOR SHARE 使授权检查所在事务持有权限行的共享锁。撤权更新与已开始的事务按数据库锁规则协调,允许当前已授权事务结束,再让后续检查读到 revoked。它没有追溯取消之前已提交的订单,也没有清空 TCP 接收端已经取得的事件。
| 实际请求 | 结果 | 证明的边界 |
|---|---|---|
| 无 Cookie、仅伪造身份头、修改真实 Cookie 签名字节 | 分别 401 | 网络验签与默认认证入口 |
| 有 session,缺 JSON/Form CSRF token,或使用旧 bypass 头 | 分别 403 | 专项配置清除了早期实验的 CSRF bypass |
| t1/alice 与 t1/bob | 列表各自只有自己的订单 | 同一租户内的 subject 隔离 |
| t1/alice 与 t2/alice | 访问对方订单 403 | 相同 subject 不能跨 tenant |
| 对方 detail、cancel、SSE、CSV | 三组用户各四项均 403 | 策略覆盖普通与流式入口 |
只验证 detail 接口会遗漏导出与订阅。流在完成授权前不能分配出口;这里初次 SQL 检查通过后才申请流许可证。拒绝访问因此不会消耗四个有限流槽位,也不会创建导出文件。
唯一键、payload 和库存处在同一个事务
幂等键的数据库范围是 (tenant, subject, idem_key)。相同字符串由不同主体使用时可以产生不同订单,符合该实验的业务契约。请求中的 quantity 范围为 1–3,note 限定字符与 80 字符长度,规范化 payload 为 quantity + ":" + note;幂等键不只是一个“见过没有”的集合。
flowchart TD
I[校验身份与有限输入] --> P[插入订单唯一键占位]
P -->|插入一行| U[条件扣减库存]
U -->|库存足够| C[同事务提交]
U -->|更新零行| X[抛出冲突并回滚占位]
P -->|唯一键冲突| L[下一条 SELECT 读取已有订单]
L --> E{payload 相等}
E -->|是| R[返回已有 id 与当前状态]
E -->|否| N[409 PAYLOAD_CONFLICT]
占位使用 INSERT ... ON CONFLICT(tenant,subject,idem_key) DO NOTHING。插入成功后,执行 UPDATE stock SET remaining=remaining-? WHERE tenant=? AND remaining>=?。只有库存更新影响一行才允许事务返回;不足时抛出 Conflict,此前插入的订单随事务回滚。
PostgreSQL 17 的 Read Committed 说明指出,ON CONFLICT DO NOTHING 可能因当前语句快照不可见的并发结果而放弃插入;下一条语句使用新快照。因此这里在冲突后另发 SELECT,读取已提交的行并比较 payload。实验记录数据库默认隔离级别为 read committed。改变隔离级别后需要重新测试冲突与重试路径,不能照搬这次观察。
Play 的 Scala withTransaction 在同步 block 返回后调用 commit,随后外层 finally 关闭连接;本工程的 Java 业务异常进入 rollback 分支。源码中的 Scala ControlThrowable 是单独提交再抛出的分支,不能把整个 catch 描述为“任意 throwable 都回滚”。
事务 block 返回的是 JSON 订单值,异步通知在 block 外组合。若把尚未完成的 CompletionStage 直接作为 block 的返回值,Play 只会看到 block 已返回,随后提交并关闭连接;它不会自动等待这个 Stage 里的数据库操作。
真实矩阵对相同新键并发发送四次 quantity=2,得到同一个 UUID、一项 CREATED、三项 REPLAY,库存从 20 降为 18。随后四个不同 payload 请求全部 409。另一个全新键同时竞争 quantity=1 与 2,各发送两次,结果是两项 200、两项 409;成功组内部仍为一次创建和一次重放。胜出的 payload 取决于竞争顺序,断言没有预设它必须是哪一个。
库存不足也经过实际事务。t2/bob 连续提交七笔 quantity=3,其中六笔成功,第七笔 409;独立 psql 查询确认失败键 stock-6 没有残留订单。只检查 409 会遗漏“订单插入成功、库存扣减失败、却忘记回滚”的错误实现。
取消只对一次状态迁移恢复库存
取消事务先按对象权限 SELECT ... FOR UPDATE 锁定订单。CREATED 分支把状态更新成 CANCELLED,并在同一事务增加库存;已经 CANCELLED 的分支返回 REPLAY_CANCEL,不再写库存。两个并发取消请求得到一次 CANCELLED、一次 REPLAY_CANCEL。
1 | |
唯一键记录没有在取消时删除,所以旧创建请求再次到达时仍返回原 id 与 CANCELLED 状态。若取消后需要重新购买,业务协议应要求新的幂等键;静默删除旧键会让延迟重试变成新订单。
每份独立数据库快照都断言 remaining = 20 - sum(CREATED.quantity),按 tenant 分别计算。这个守恒关系把创建、取消和失败回滚连接起来,比只统计接口成功次数更容易发现多扣或多还。它仍属于当前单库业务约束,没有包含支付、物流或其他系统。
提交结果与通知预算使用不同字段
服务将同步 JDBC 放入四个专用工作线程,等待队列长度为 16,数据库池上限同为 4。借连接期限为 1000ms,语句设置 5 秒查询超时,JDBC URL 另有连接与 socket 期限。线程数与连接数相同并不能消除事务锁等待,队列长度也不等于请求延迟上限。
事务返回后记录 transaction-returned。请求 notifySlow=true 才调用配置中的固定 http://127.0.0.1:<port>/slow,请求数据不能提供任意 URL,重定向关闭,专有 WS 客户端的连接上限为 2。notifySlow 和实验用 delayMillis 不属于订单 payload;重放请求可能再次尝试通知,订单幂等不等于通知幂等。
sequenceDiagram
participant C as HTTP客户端
participant P as Play工作线程
participant D as PostgreSQL
participant W as 固定慢下游
C->>P: 创建订单,notifySlow=true
P->>D: BEGIN / 占位 / 扣库存
D-->>P: COMMIT 成功
P->>P: transaction-returned
P->>W: HTTP请求,预算120ms
P->>P: notification-terminal = BUDGET_FAILED
P-->>C: 200,订单id + 通知失败字段
Note over W: 本轮约700ms后写响应
最终样本中,下游记录确实进入 /slow,稍后记录 written。WS 预算先失败,Play 返回已提交订单及 BUDGET_FAILED,独立 SQL 查询仍存在该订单。下游 write 返回只能表明本机写调用没有报错,不能证明超时后的 WS 调用方消费了响应。
BUDGET_FAILED 是本教学实现对 WS 异常的合并分类,代码没有把所有异常逐类拆开。它在这次受控慢响应中由有限请求预算触发;面对连接拒绝、TLS 或其他异常时,需要增加分类,不能仅凭这个字段断言网络一定慢。
JDK 21 CompletionStage提供依赖阶段的组合契约。以下完整示例只验证这部分行为:事务失败时不调用通知;通知失败时保留提交标识。它使用合成函数,没有连接数据库,实际事务与网络结论仍由前述独立进程矩阵证明。record 自 Java 16 提供,failedFuture 自 Java 9 提供。
1 | |
当调用方未收到响应时,服务端可能已经提交。现有唯一键使同键查询与重试能够查到既有结果;数据库提交确认丢失时怎样分类错误,尚未在本轮故障注入中执行。工程没有实现 outbox、持久通知重试或跨服务 exactly-once。
SSE 订阅复查权限,文件下载观察源关闭
SSE 使用有限 Source.range(1,20),每 150ms 最多推进一个元素,通过 mapAsync(1, ...) 查询当前订单。每次查询重新执行权限 SQL,再交给 EventSource 编码;整个流额外限制在 5 秒内。四个流槽位由许可证控制,记录表最多保存 128 项,淘汰已关闭项,不无限保留连接历史。
flowchart LR
N[下一个有限 tick] --> G[提交数据库任务]
G --> A{主体仍授权?}
A -->|是| O[读取该主体订单状态]
O --> E[EventSource 编码]
E --> B[有界流与网络缓冲]
A -->|否| X[流失败]
B -->|实际客户端断开| X
X --> T[watchTermination<br/>closeCount=1 / 归还槽位]
真实客户端先收到 CREATED,再通过另一个请求取消同一订单,原 SSE 连接随后收到 CANCELLED。另一个场景在收到首事件后撤权,客户端出现 ConnectionResetError,服务端 ledger 记录 failure=Denied 和 closeCount=1;后续 list、detail、create、cancel、SSE、CSV 均返回 403。
这个实现发布的是“定期查询到的状态”,没有历史事件表,也没有 Last-Event-ID 重放。两个 tick 之间发生的多次变化可能只剩最后状态;检查权限之后已进入缓冲的事件,也可能与撤权提交并发。应用不能根据“每事件查一次”推导出撤权瞬间远端绝不会再看到旧数据。
CSV 首先校验对象权限,写有限临时文件,再通过 sendPath 返回。文件名由服务器生成,导出内容只含 id、status、quantity。普通导出为一个订单行;压力参数 copies 可把同一授权行重复最多 524288 次,用于生成足够大的有限文件,不代表数据库拥有这么多订单。
Scala Results.sendPath 使用 FileIO 文件源,并在物化结果完成时调用 onClose。应用回调删除临时文件、记录 closeCount 并归还槽位;Java 层没有用 readAllBytes 把文件整体装入内存。
本轮取消下载的 Content-Length 为 24641555,客户端实际读取 16384 字节就关闭连接;随后观察 closeCount=1、临时目录为空。文件存在与否单独不能证明资源释放,因此验收同时保存源关闭回调和实际读取长度。这个回调也不证明对端收齐 24641555 字节。
流容量实验同时保持四条真实 SSE 连接,第五条得到 503。关闭前四条连接后,每条记录只关闭一次,许可证恢复为 4。这个结果证明当前有限输入下容量拒绝与释放成立,没有测量长期常量内存或持续负载吞吐。
SIGTERM 先关闭提交入口,再等待已有 SQL
关闭入口需要早于资源释放。CapstoneService 在 PhaseBeforeServiceUnbind 将 stopping 置为 true;提交函数在同一个同步区检查该状态并增加 inFlight,避免停止标志与入队之间留下独立检查窗口。已接收任务继续执行,后续提交得到 RejectedExecutionException,Controller 可映射为 503。
Play Pekko HTTP 后端的关闭注册 将取消监听、等待请求、应用 stop hook 放在不同阶段。Pekko Coordinated Shutdown 文档将任务按阶段组织;相同阶段内的任务不能当作严格串行顺序使用;应用这里只依赖阶段先后,资源关闭留在后续 ApplicationLifecycle hook。
sequenceDiagram
participant T as 验证脚本
participant P as Play服务
participant D as PostgreSQL
T->>P: 创建订单,有限pg_sleep(2)
P->>D: 执行事务内SQL
T->>D: pg_stat_activity确认PgSleep
T->>P: SIGTERM
P->>P: 停止接收新任务,inFlight=1
P->>P: 取消HTTP监听
D-->>P: SQL返回与COMMIT
P-->>T: 原请求200
P->>P: 排空executor,关闭WS与DB池
P->>P: 关闭journal文件,记录终态
脚本没有用“等待两秒”猜测任务已经开始。它通过独立 psql 查询看到 pg_stat_activity 的 PgSleep,再发送 SIGTERM。最终原请求得到 200,独立查询存在一条 sigterm 订单;关闭提交入口时 inFlight=1,内部提交探针确实被拒绝,新建网络连接实际得到 ConnectionRefusedError。
503 与拒绝连接属于不同位置的结果。服务仍能处理已建立连接上的新请求时,应用拒绝分支可以产生 503;本轮观察到监听已取消后的拒绝连接,不能把它改写成网络收到 503。inFlight 只统计该服务接收的 JDBC 工作,不能解释为全部 HTTP、通知或流的数量。
两个进程都以 143 退出。退出记录同时满足 fileClosed/poolClosed/clientClosed/executorTerminated=true、inFlight=0、filesLeft=0、streamPermits=4,没有 cleanupFailure。退出码本身不能替代这些记录。正常排空样本只有一个在途 2000ms SQL,尚未证明满队列下的关闭期限。
配置将 play.server.terminationTimeout 与 pekko.coordinated-shutdown.phases.service-requests-done.timeout 都设为 12 秒。只增大前者会超过阶段期限;历史首轮日志保存了这一警告,最终重跑已消除。资源关闭代码会分别尝试客户端、数据库池和 journal 关闭,并把后续失败附加到原始异常上,不能因一个 close 抛错就跳过其余资源。
重跑入口与证据范围
源码包与累计工程入口见第00篇最小应用。准备 JDK 21 的 JAVA_HOME,并按数据库章节准备 PostgreSQL 17.6 fixture;下列命令在 examples/play-lab 执行,输出目录须不存在。
1 | |
脚本只创建和删除自己的随机 capstone schema,保留数据库容器。HTTP 服务与慢下游使用临时 loopback 端口,由 finally 清理自有进程与线程。运行时 secret 随机注入环境,最终两份日志与 246 个 stage 文件均扫描未发现其字面量;公开 fixture 身份不应被误认为真实认证凭证。
读者证据根为 examples/play-lab/evidence/batch35/isolated/。build3.log 与 TEST-CapstoneTest.xml 是编译和两个单元测试;run-final/http.json 保留逐请求状态、响应头及订单响应,Cookie 与 CSRF token 被省略;sql.json 保留独立数据库查询和输出;observations.json 串联场景断言。两份 *-journal.jsonl 与服务器日志用于交叉核对流终态和关闭顺序。
| 尚未覆盖的故障 | 缺失的实验 | 当前结论不能扩大到 |
|---|---|---|
| COMMIT 已完成但确认丢失 | 数据库连接断开注入 | 任意500都代表未提交 |
| SIGKILL、数据库崩溃、故障转移 | 强制终止及恢复运行 | 有序 stop hook 保证崩溃清理 |
| 多实例同时写同一业务空间 | 第二个并发服务实例 | 本轮线程竞争等同分布式全覆盖 |
| 文件系统满或删除被拒绝 | 权限/容量故障注入 | closeCount1总能删除文件 |
| 通知可靠投递 | outbox、重试与消费去重 | 订单幂等包含通知幂等 |
| 长流恢复与长期负载 | SSE游标、满队列停机、持续压测 | 有限样本证明事件无丢失或恒定内存 |
改动练习一:只在隔离副本删除取消路径的 FOR UPDATE,给状态读取与更新之间增加一个有期限的双请求屏障,再并发取消同一订单。保留数据库快照,断言库存守恒是否失败;恢复行锁后重跑相同输入。这个练习尚未执行,不能预填失败次数。
改动练习二:在同一数据库事务插入通知 outbox 行,另用有界工作器投递;让下游在返回前中断连接,并使用持久通知 id 去重。验收要同时记录订单提交、outbox 状态、下游实际副作用次数和重启后的处理结果,不能把 HTTP 重试次数当作副作用次数。这属于工程扩展,当前证据没有实现或覆盖。
上一篇:34 故障诊断。下一篇:E01 Scala API对照。

