超时不告诉客户端订单有没有落库

审批后的采购申请发起下单;服务器提交了订单,却在 HTTP 响应到达采购员之前断线。客户端只知道“超时”,不知道订单是否存在。如果直接重试无身份标识的创建动作,可能产生两张订单。如果客户端把超时一律视为失败,订单已经存在却又催促人工补建。网络确认和数据库提交不可能靠一个 HTTP 状态合成原子动作,必须让两次请求指向同一业务操作,在数据库中可查到它的结果。

现有工程 purchase_order 的 request_id 是 UNIQUE;JdbcRequestStore.insertOrder 采用 INSERT ... ON CONFLICT(request_id) DO NOTHING RETURNING id 并回查相同申请和租户,ProcurementUseCases.order 发现申请已 ORDERED 时返回已有订单 id。这是已实现的“对同一申请再次下单”局部保护,不是通用的客户端幂等键:如果超时后客户端重新创建了一张相似申请,新的 request_id 可以再生成一张订单。实验用请求头 X-Lab-Tenant 仍是不可信输入;正式身份、租户绑定与对象授权要到 24–26 篇验证。

操作身份要比连接和请求生命周期长

客户端为一次创建动作生成稳定随机键,超时后原样复用;服务端把它与已认证主体/租户、操作类型、规范化参数摘要绑定。规范化内容至少包含目标申请 id、数量、商品、业务版本等实际影响订单的字段;具体算法和字段要冻结,不能仅按 JSON 原始空白或不受信请求头判定同一个意思。相同键相同摘要返回原来的订单标识;相同键不同摘要拒绝,不能覆盖旧结果。不同键针对同一 request_id 仍由现有订单唯一约束挡住,两个约束回答的是不同问题。

下表是拟议的独立迁移,不在当前 001-initial.sql 中,NOT_RUN:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE order_operation (
tenant_id varchar(255) NOT NULL,
operation_name varchar(40) NOT NULL,
idempotency_key varchar(128) NOT NULL,
request_digest char(64) NOT NULL,
state varchar(10) NOT NULL CHECK (state IN ('PROCESSING', 'DONE')),
order_id bigint REFERENCES purchase_order(id),
CHECK ((state = 'PROCESSING' AND order_id IS NULL)
OR (state = 'DONE' AND order_id IS NOT NULL)),
PRIMARY KEY (tenant_id, operation_name, idempotency_key)
);

这个版本在同一事务中先插 PROCESSING 占位,插订单、更新申请状态,再把结果写成 DONE 后提交。事务外只应看到 DONE;若已有已提交的 PROCESSING,说明实现违反本方案的提交约定,应报警而非返回成功。当两个事务并发争同一个键时,唯一约束使一个等待,胜者完成后败者读取完成记录;若胜者回滚,败者可进入业务操作。要先通过键占位争取操作所有权或回查并核对摘要,不能先无条件执行 order() 然后才检查同键不同参数,否则错误请求可能触发副作用。事务开始读该键未命中,再做业务时必须在原子插入上决胜;先读后写不是锁。若希望提交可见的处理中状态,还须独立设计租约、过期接管与恢复,不能靠上述表自动得到。

对于当前同一申请的 order(),一个更直接的演进方式是:在事务中按 (tenant_id, operation_name, idempotency_key) 先取得唯一操作行的所有权,核对摘要与授权,再执行申请状态检查、条件 UPDATE 和 purchase_order 唯一插入,在提交前填入结果。并发 loser 如果拿不到锁,可返回明确的“处理中,请按键查询”或等胜者结束后读取结果,不要直接返回成功。PostgreSQL 的 ON CONFLICT DO NOTHING 可以避免将唯一键冲突暴露成 500,但读结果需考虑当前隔离级别、冲突等待和事务快照,必要时从新事务回查。事务回滚后未提交的占位不可被当成持久证据。

区分三种失败窗口

故障位置 数据库终态 同键重试的动作
下单前失败并回滚 无订单、无完成键 新事务可以重新尝试
订单和完成键同事务提交,HTTP 响应丢失 订单一张、完成键一条 查摘要后返回原订单 id
数据库提交结果未知(连接断开) 客户端不可判定 新连接按键和业务唯一键查询,再选择返回/重试,不盲目重建

同键不同请求要返回冲突而非复用旧结果;存储的摘要不能代替保留可供审计的业务标识,也不能把密钥或敏感请求体写进普通日志。若依赖外部支付/供货 API,其副作用不在 PostgreSQL 事务中;外部侧必须有自己的操作标识或查询/补偿合同,不能拿数据库唯一键宣称端到端恰好一次。保存期和键回收规则必须长于客户端允许重试窗口;删除历史键而未处理订单身份会使旧重试重新创建操作。

当前可跑的路径,与尚欠的断网实验

按照 examples/javaee-enterprise/README.md 在隔离库部署,已有单次下单场景:

1
2
3
cd examples/javaee-enterprise
JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab \
JAVAEE_LAB_PASSWORD="$JAVAEE_LAB_PASSWORD" bash scenarios/09-lab-procurement.sh

可用 PostgreSQL 16 的现有约束查看订单唯一性,而不是猜测接口已实现幂等键:

1
2
psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-c "SELECT request_id, COUNT(*) FROM purchase_order GROUP BY request_id HAVING COUNT(*) > 1;"

空结果只说明已提交订单中没有同 request_id 的重复,不能排除重建不同申请产生的重复业务订单。完整负向实验要先实现迁移与受管服务入口,在本地代理故意丢弃提交后的 HTTP 响应,进程重启后同键同摘要再请求,检查 order_operation 行、订单行、申请状态;再用同键不同摘要预期拒绝,另用两个连接并发同键观察唯一约束和回查。现有仓库没有该迁移、幂等键 API、断线代理和重启测试,均 NOT_RUN;不可把已有 09 脚本认作以上场景。记录表见未知提交结果实验卡。

现有表的有限失败对照可在本地 psql 交互终端完成。先确认场景 09 留下的测试订单与数据库身份,从查询输出中挑一条专用订单,把真实 request_id 填入 \set existing_request_id 123;不要选择其他用户订单。

1
2
3
4
5
6
7
8
9
SELECT id,request_id,tenant_id FROM purchase_order ORDER BY id DESC LIMIT 5;
\set existing_request_id 123
\set VERBOSITY verbose
BEGIN;
INSERT INTO purchase_order(request_id,tenant_id)
SELECT request_id,tenant_id FROM purchase_order
WHERE request_id=:existing_request_id;
ROLLBACK;
SELECT COUNT(*) FROM purchase_order WHERE request_id=:existing_request_id;

在 PG16 的现有约束下,重复插入应出现 SQLSTATE 23505,回滚后计数仍为 1;若 INSERT 0 0,先查样本 id 是否填错。该终端使用 psql -X -h 127.0.0.1 -U javaee_lab -d javaee_lab,口令用本地配置。负向 SQL 仅检查已有 request_id 唯一性,不证明幂等键、请求摘要或断响应场景;若样本不存在,先搭建隔离环境,不能凭预期写“已运行”。

唯一键为什么必须和结果在一个事务里

只在内存 Map 里缓存键,即使同一进程内的两次请求得到相同返回值,也挡不住服务器重启或另一台实例处理重试。只在数据库里记录“这个键已经收到”,随后另开事务创建订单,也会形成新的双写缺口:第一次提交了键却还没创建订单时崩溃,第二次看见键就直接返回“已经下单”,但根本没有订单。反过来先提交订单再写键,一旦中断,重试没有可以识别原操作的键记录。把占位、订单、申请状态迁移和结果行放在同一个数据库事务里,才能把“存在已完成操作”与“存在订单”变成同一提交事实。

表中的 PROCESSING 是事务内部步骤而非对外承诺。第一次请求在这个事务里插入占位,其他连接此时不能把未提交行读成“处理中已持久化”;它们在唯一索引竞争上等待或根据应用设置的等待上限退出。第一次提交之前必须将状态写成 DONE 且填上 order_id;若任何写入失败,整个事务回滚,留下的是可重试的空位。如果团队希望客户端在长时间供货处理时随时查到“处理中”,那是另一种可提交的状态机:必须增加租约、持有者、过期接管、查询及补偿,而不是把当前不可见的占位直接暴露成 HTTP 202 并当作永久正确。

以一个例子看争夺次序。A 与 B 携带相同键和相同参数同时到达,A 先插入占位,B 也试图在 (tenant_id, operation_name, idempotency_key) 上插入。B 在唯一冲突确定前不应下单。若 A 提交,B 应在可观察 A 完成记录的事务快照中读取旧 order_id 并返回;若 A 回滚,B 可重新争取操作所有权并执行新的一次创建。PostgreSQL READ COMMITTED 下要注意唯一索引与当前语句快照的关系:一次 ON CONFLICT DO NOTHING 报告没有插入行,不等于同一语句的 SELECT 一定已经可见胜者提交的记录。用后续语句或新的事务回查,并记录有限等待、超时以及“未找到完成记录时不能宣称成功”的退出分支。

请求摘要不是对整个 JSON 字节串照相

示意请求 {"requestId":42,"quantity":2} 与字段顺序颠倒但含义相同的 JSON 可以属于同一操作;大小写、货币单位、默认数量、商品价格和额度快照又可能影响真正的业务意图。服务端需要先通过正式身份和对象授权,生成稳定、版本化的规范化业务字段表示,再计算摘要。这里的 schema 存 char(64) 暗示十六进制哈希的尺寸,却没有替应用决定具体规范化算法;算法版本、输入字段与业务价格版本也要随协议冻结,否则服务器升级后对同一个合法重试算出不同摘要,就会把旧操作误拒绝。摘要用于检测同键不同请求,不是密码散列,更不能替代授权。

键的命名空间也决定碰撞的业务含义。只用原始请求头作数据库主键,会让两个租户或两个操作类型互相阻塞;使用租户、操作名与键的组合可划分范围,但前提是租户来自可信认证上下文,不能来自当前演示 X-Lab-Tenant。如果某用户不能操作目标申请,不能因为恰好知道别人曾用的键就返回其订单 ID。先检查主体是否有权查看对象,再在相应安全域里查键;错误信息应避免跨租户泄漏订单存在性。当前累计工程还没有这些正式身份设施,所以本章方案只是第 24–26 篇实现后的部署目标,而非可公开使用的接口。

三类重试应分别落在哪个位置

第一类是应用内部的数据库序列化或死锁重试,发生在一次 HTTP 请求内:整个事务回滚后重新读取状态、重新判断,再在有上限的次数内提交,见第 20–21 篇。第二类是客户端没收到响应后的 HTTP 重试,必须使用相同业务键,可能由另一台服务器处理;它首先查持久结果,而不是假设第一次失败。第三类是提交后对供应商系统的投递重试,需要另一个下游可识别的事件 ID 或操作键,数据库里的订单幂等键本身不能阻止远端重复处理。把三者都叫“重试”而不注明边界,代码里极易出现外部调用在 JDBC 事务内无界循环的错误。

未知结果尤其要小心响应的先后。若服务端已经完成数据库提交,但 HTTP 响应写入 socket 失败,客户端看到网络异常;此时业务上订单存在,服务端无法撤销已经提交的事实。重试时不能靠先前的 HTTP 500 或本地异常日志判断“肯定没下单”,必须按操作键在数据库中核对。若数据库连接恰在提交期间断开,连服务器本身都可能不知道提交是否完成;一个新事务查到完整操作行与订单,可以返回旧结果;查不到时也要考虑读的是不是正确的主库、是否过早读取副本或原事务仍在进行。若这些前提未满足,应明确返回结果未知并允许后续查询,而不是立刻创建第二个申请。

订单唯一性与操作唯一性怎样互相兜底

当前 purchase_order(request_id) 保证单个申请至多一张订单。即使两个客户端使用不同幂等键同时下同一张申请,也至少会在订单唯一键及申请状态上发生争夺,后来的调用须回查已存在订单并验证租户与目标一致。拟议的操作唯一键保证同一客户端意图在超时和进程重启后可追溯;它不能保证不同业务键对应不同订单,也不能自然识别“同样商品但其实是采购员第二次真实订货”的情况。不能按商品列表相同就自动合并:企业采购完全可能两天各买一批同款纸,去重必须由明确的操作身份和时间窗口定义。

测试时除“最终只有一张订单”外,还要核对返回的订单标识稳定、同键不同摘要不产生任何新的申请或订单、操作行在进程重启后仍可查询,并且授权不允许另一租户使用相同键窃取结果。并发输家等待的时间应受限;若返回处理中,客户端按键查询的 API 也需要实现,不能仅在文档中声称存在。当前工程没有这些迁移与 API,因而不能通过两个连续调用现有 order() 便给本篇完整合同签字。已有场景只可用来验证当时同一申请的顺序操作及唯一约束,断响应和重启演练仍为 NOT_RUN。

两道练习与答案

练习一: 客户端用同一 idempotency key 第一次提交商品 paper×2,第二次提交 paper×3。第二次请求要不要返回第一次订单?

解: 不应返回“本次下单成功”;相同键不同规范化参数摘要应拒绝为冲突,且不能改写第一次订单。先核对认证主体、租户、操作种类与键,再核对摘要;查重和业务写入的竞态仍靠同事务唯一约束处理。

练习二: purchase_order(request_id) 已 UNIQUE,为什么还讨论幂等键?

解: UNIQUE 只防相同申请 id 的两个订单。重试如果重新创建采购申请并取得新 id,两个订单都可满足该约束。操作键把两次客户端请求关联成同一个意图;同时还要约束保存窗口、身份和参数,不是把所有“长得像”的订单自动合并。

边界与版本资料

此处的“同键返回同结果”只针对拟议的数据库单事务方案,不是现有 HTTP 功能;用户授权与多机恢复需后续实测。官方依据:PostgreSQL 16 UNIQUE Constraints、PostgreSQL 16 INSERT ... ON CONFLICT、PostgreSQL 16 Transaction Isolation、Jakarta Transactions 2.0。规范规定事务机制,不规定上面这套应用层幂等协议。