深入 Spring 43:订单应用的六个综合验收场景
一个订单结果需要跨过哪些边界
HTTP 返回 201、数据库出现订单、提交回调执行、异步通知结束,是四个不同的观测结果。请求返回异常时,数据库可能已经提交;客户端超时时,服务器也可能仍在处理。验收把这些结果分别记录,才能判断错误发生在绑定、业务、事务、异步任务还是传输阶段。
综合应用保留此前实验使用的 Spring MVC、JDBC 事务、真实 PostgreSQL、ExecutorService 和 JDK HttpClient。固定 Boot 3.5.6、Framework 6.2.11、JDK 21 与 PostgreSQL 18.0,代码位于 final-lab/。它是围绕同一订单模型的可执行验收应用,没有增加消息中间件、分布式锁或新的编排平台。
独立 JUnit 类 Chapter43Test 通过真实 HTTP 请求和额外数据库连接观察应用,执行正常下单、并发幂等、业务回滚、异步失败、HTTP 超时、关闭六个场景,共 21 条具名断言。数据库写入限定在 spring_final.orders,订单键带本轮 UUID 前缀,清理只删除本次创建的键。
装配固定之后,先确定观察者
OrderApp 显式导入 Controller,注册 Store 和 Work,Boot 提供 Servlet 服务器、DataSource 与事务管理器。Store 内部使用 JdbcTemplate 和 TransactionTemplate;Work 管理一个单线程执行器,并在关闭时等待任务终止。应用只使用一个 JDBC 事务管理器,避免在综合结果里混入管理器选择歧义。
事务内写入和事务外观察刻意使用不同入口。业务 JdbcTemplate 从 Hikari 池取得事务绑定连接;测试观察者通过 DriverManagerDataSource 新建连接,在请求返回以后查询行数。观察者不复用业务事务,可以识别未提交写入与已提交结果的区别。
订单表只保存键和金额。唯一主键承担并发冲突仲裁,正数校验属于业务规则,参数到整数的转换属于 MVC。数据库是最终唯一性约束,Java 代码里的“先查询再插入”没有被当成可靠的并发去重方案。
每条断言包含具体对象与结果,例如“eight concurrent responses one created seven existing”。原始日志与 Surefire XML 同时保留。应用打印“处理成功”不能替代客户端状态码;池对象仍存在也不能证明内部连接已关闭。验收对象由故障可能发生的边界决定。
正常下单要同时证明响应、提交和回调
第一条真实请求向 /orders/{id}?amount=12 发起 POST。Controller 将 Store 的创建结果映射成 201;数据库观察者确认只有一行;提交回调计数为一。这三个检查分别覆盖 HTTP 表达、事务提交与提交后动作。
Store 在事务中插入成功后注册 TransactionSynchronization。回调只有在新行产生时注册,已存在订单不会重复注册。这个条件既影响正常请求,也影响后续幂等请求;如果在每次查询到既有订单时都注册回调,数据库只有一行仍可能产生多次通知。
该回调目前只递增本地计数,不代表邮件或消息已经投递。它提供可确定观察的事务阶段结果。真实外部副作用若要求重试和持久保证,需要另外定义持久状态、投递确认与重放语义,不能由内存计数替代。
八个并发请求如何只创建一行
测试建立八个调用线程,用 CountDownLatch 同时放行,所有请求使用相同订单键和金额。SQL 采用 INSERT ... ON CONFLICT (id) DO NOTHING。返回更新行数为一时表示本请求创建成功,为零时表示存在相同键的已提交结果;此时再读取金额,确认请求内容相同。
在本实验的默认 READ COMMITTED 隔离级别下,竞争插入会等待相关冲突事务确定结果,后续查询使用新的语句快照读取既有行。验收结果为一个 201、七个 200,外部连接只看到一行,累计提交回调也只增加一次。PostgreSQL INSERT 与 ON CONFLICT
幂等不能只按键忽略内容。第九个请求使用相同键但不同金额,应用返回 400,避免把两个不同操作当成同一个成功结果。真实订单还可能涉及币种、商品明细、租户和鉴权上下文;这些字段需要进入请求一致性定义,本例只验证金额这一维度。
该实现适用于单个 PostgreSQL 表上的并发仲裁。它没有演示跨库去重、过期幂等键、服务进程崩溃后的通知重放或 HTTP 客户端自动重试。数据库行数与回调次数同时检查,解决的是本次请求链中“数据唯一但副作用重复”的具体风险。
业务异常应在提交之前产生回滚
回滚场景提交一个新键,同时设置 fail=true。Store 先执行插入,再抛出支付失败异常。异常发生在 TransactionTemplate 回调内部,事务回滚,Controller 的异常处理器将结果映射成 500。
测试不满足于看到 500:额外连接查询该键,必须为零;提交回调计数也不能增加。如果只断言返回失败,异常也可能发生在数据库提交以后,留下“失败响应但已创建”的订单。提交后回调异常的反例已经在 41 的测试中实际执行。
这一场景也没有把所有失败都设计成可重试。输入金额非法映射为 400,同一个幂等键内容不同也映射为 400;它们与支付步骤的内部失败不同。客户端是否重试还要看操作身份是否稳定、下游是否已产生副作用以及请求是否能够重放。
异步失败必须有一个消费结果的位置
Work.fail 通过 CompletableFuture.runAsync 在应用执行器中抛异常。第一次调用保留失败 Future,由测试 join 观察到 CompletionException,但应用失败计数仍是零。这准确展示了缺失的观测:异常已经保存在 Future 中,业务侧没有消费结果就不会自动形成失败指标。
最小修复是在 Future 上注册 whenComplete,只在 failure 非空时递增计数。第二次调用仍然失败,测试仍断言 CompletionException,同时确认失败计数变为一。修复没有吞掉异常、伪造成功或撤销已经提交的订单;它只补上失败观测,外部数据库仍能读取原先的订单。
本例直接使用 CompletableFuture,未声称这是一次 @Async void 方法的异常处理实验。后者的返回通道和 AsyncUncaughtExceptionHandler 语义需要单独验证。即使已经记录失败,通知也没有变成可靠投递;进程崩溃会丢失内存 Future 与计数,这是该最小修复的明确范围。JDK 21 CompletableFuture
从任务提交、异常保存到观测回调,存在独立于数据库事务的时间线。Spring 管理执行器生命周期不会使这些异步步骤自动参加原请求事务,也不会把异常跨线程抛回一个已经结束的 HTTP 响应。
HTTP 超时之后继续检查服务与资源
/slow 在服务器线程暂停 600 毫秒。客户端请求设置 80 毫秒超时,并明确断言 HttpTimeoutException。这是实际监听端口上的响应超时,没有使用模拟异常;它也不是 DNS 或 TCP 建连超时实验。JDK 21 HttpRequest.Builder.timeout
超时后立即发送另一条正常订单请求,要求返回 201,再检查连接池活动借出数为零。这说明应用仍能接收工作、已完成订单请求没有占着数据库连接。它没有证明慢 Controller 被客户端超时中断;JDK 客户端停止等待不等于服务端业务自动取消。
如果慢请求已经执行数据库写入,超时也不能直接解释为“没有创建订单”。可靠处理需要查询同一业务键的结果或采用幂等重试。实验把慢路由设计为无数据库副作用,使超时边界与事务结果不会互相掩盖;有副作用的超时重试需要增加独立验收场景。
关闭验收检查具体资源
context.close 后检查上下文不再 active、HikariDataSource 已关闭、执行器 isTerminated 为真。随后使用新的 Socket 连接原服务端口,必须抛 IOException。这四项分别观察容器状态、数据库池、线程任务与监听端口,不能用一条“shutdown completed”日志替代。
Work.close 先停止接收任务并等待五秒,超时则 shutdownNow,再等待五秒;第二次仍未结束就抛异常。这里的等待上限属于清理策略,不能当成任务可在五秒内完成的业务保证。测试没有往共享 PostgreSQL 发送关闭命令,只关闭应用自己持有的池和线程。
数据清理由独立观察者在 finally 中执行,最后再次查询本轮前缀行数为零。真实 HTTP 请求的事务在服务器线程提交,测试方法没有依赖测试事务自动回滚。HTTP 客户端和调用线程池使用 try-with-resources 结束,避免测试成功后遗留自己的线程。
重跑命令与故障定位练习
从系列实验工程根目录执行:
1 | |
成功结果为退出码零、Surefire 一个测试方法通过、日志包含 21 条 CHECK PASS。测试方法覆盖六个完整场景,断言数量与 JUnit 方法数量不同。保存的 manifest 记录命令、JDK、源文件哈希和日志哈希;本地重新运行会使用新的临时端口与订单前缀。
修改练习可以先删掉 whenComplete 的失败计数更新,预期只有“minimal completion handler records async failure”断言失败;异常类型与数据库已提交记录仍成立。恢复这行后应通过。该练习用于确认修复落在异步观测层,没有通过修改事务或响应码掩盖问题。
另一个练习是把注册提交回调的 rows == 1 条件去掉。并发幂等仍可能保持一行,却会触发多次回调,计数断言应失败。它说明验收需要同时保留数据结果和副作用结果。两个修改练习均未包含在保存的基线证据中,不能把预测结果记成已经运行。

