问题:重复下单往往不是手抖
「回声」的确认页原来只有一个「提交订单」按钮。网络慢时用户点一次没反应,再点一次;请求超时后端上弹出「下单失败,请重试」,用户又点一次。三笔请求带着三个不同的请求体到达服务端,生成两张甚至三张订单。
事后看,用户只做了一个动作:在不确定的时候想把事情推进下去。端上有三个问题:
- 每次点击都生成新的请求标识,服务端无法判断这是不是同一笔提交。
- 提交中没有禁用按钮,双击直接穿透。
- 超时被当成失败,界面只提供「重试」,不提供「查询」。
这三个问题对应三个设计物件:幂等键、按钮状态、结果查询。它们必须一起设计,缺一个都会漏。
三个物件:键、按钮、查询
先把判断写成表。后面所有实现都围绕这张表。
| 决策点 | 正确做法 | 常见反例 |
|---|---|---|
| 键何时生成 | 进入确认页时生成一次 | 每次点击生成新键 |
| 重试用哪个键 | 复用同一个键 | 重试换新键 |
| 提交中按钮 | 禁用并显示进度 | 仍可点击或只做防抖 |
| 超时后 | 转「结果未知」,只给查询 | 提示失败并引导重试 |
| 返回重进 | 恢复旧键,进行中降级为未知 | 重新生成键 |
最容易被忽略的是第四行。「结果未知」不是错误码,而是一个一等状态:请求可能已经到达服务端,也可能没有。端上无法凭超时判断,只能拿同一个键去问服务端「这笔提交对应的订单存在吗」。
幂等键的边界
makeSubmitKey(draftId, nonce) 把两个输入拼成键:draftId 标识这一次确认快照(商品、数量、地址、优惠、实付金额),nonce 是每次进入确认页生成的随机段。键的作用域是「一次下单意图」,不是「一个请求」。
由此有三条边界:
- 同一个意图内,键必须复用。 用户改地址、换优惠券之前已经确认过一次,那是一次新的意图,应当换新键;而在同一次确认里失败重试,必须复用旧键。
- 键不能只存在内存里。 返回、杀进程、重新进入确认页时,要从草稿里读回旧键。读不到时,如果上一次结果未知,正确动作是查询而不是生成新键重提。
- 服务端要有唯一约束。 端上做的是减少重复请求,真正兜底的是服务端对
(userId, key)的唯一索引:同一个键重复到达,返回第一次生成的订单,而不是再建一张。
这也是幂等键与链路追踪 id 的区别:追踪 id 每个请求都不同,幂等键在同一笔业务里必须相同。
按钮状态与查询出口
提交控制器内部有五个状态,按钮只有三种出口:
| 状态 | 含义 | 按钮 | 点击后 |
|---|---|---|---|
idle | 还没提交 | 提交订单,可点 | 发起提交 |
submitting | 请求在途 | 提交中…,禁用 | 无 |
failed | 服务端明确拒绝 | 重新提交,可点 | 复用同一键重试 |
unknown | 超时、断网、回包丢失 | 查询提交结果,可点 | 查询,不重提 |
succeeded | 已有订单号 | 已提交,禁用 | 无 |
三条规则把这张表变成行为:
- 双击防护有两层。
begin()在submitting时直接返回already_submitting;minIntervalMs再挡住「失败后手速过快」的连点。前者防同一状态重复进入,后者防状态切换瞬间的抖动。 - 返回重进不能假装还在提交。 页面恢复时拿不到原来那次请求的回包,
resumeSubmit把保存的submitting一律降级为unknown,要求先查询。 - 查询有三种结果。 命中订单就直接成功;确认不存在才回到可重试的
failed;查询本身仍不确定就保持unknown。只有第二种允许再次提交,而且仍然用旧键。
提交控制器的实现
下面的模块属于 L2:网络由调用方注入,时间通过 now() 传入,模块内部不注册定时器。测试用假时钟推进超时,不需要真的等待。
// file: src/order/submit.ts
// tier: L2
// verify: node --test test/order/submit.test.ts
export type SubmitPhase = "idle" | "submitting" | "succeeded" | "failed" | "unknown";
export interface SubmitSnapshot {
draftId: string;
key: string;
phase: SubmitPhase;
attempts: number;
submittedAt: number | null;
orderId: string | null;
reason: string | null;
}
export type SubmitBlock =
| "started"
| "too_fast"
| "already_submitting"
| "need_query"
| "already_submitted";
export interface BeginResult {
ok: boolean;
key: string;
reason: SubmitBlock;
}
export interface SubmitButton {
label: string;
clickable: boolean;
action: "submit" | "query" | "none";
hint: string | null;
}
export type QueryStatus = "found" | "not_found" | "unknown";
export interface QueryResult {
status: QueryStatus;
orderId: string | null;
}
export interface SubmitOptions {
timeoutMs?: number;
minIntervalMs?: number;
}
const NONCE_MIN = 8;
// 同一次确认页会话只生成一次;重试必须复用,不能每次点击换新键。
export function makeSubmitKey(draftId: string, nonce: string): string {
const id = draftId.trim();
const tail = nonce.trim();
if (id === "") throw new Error("草稿标识不能为空");
if (tail.length < NONCE_MIN) throw new Error(`随机段至少 ${NONCE_MIN} 位`);
return `sub-${id}-${tail}`;
}
export class SubmitController {
draftId: string;
key: string;
phase: SubmitPhase;
attempts: number;
submittedAt: number | null;
orderId: string | null;
reason: string | null;
timeoutMs: number;
minIntervalMs: number;
now: () => number;
lastAttemptAt: number | null;
constructor(draftId: string, key: string, now: () => number, options: SubmitOptions = {}) {
this.draftId = draftId;
this.key = key;
this.phase = "idle";
this.attempts = 0;
this.submittedAt = null;
this.orderId = null;
this.reason = null;
this.timeoutMs = options.timeoutMs ?? 8_000;
this.minIntervalMs = options.minIntervalMs ?? 800;
this.now = now;
this.lastAttemptAt = null;
}
snapshot(): SubmitSnapshot {
return {
draftId: this.draftId,
key: this.key,
phase: this.phase,
attempts: this.attempts,
submittedAt: this.submittedAt,
orderId: this.orderId,
reason: this.reason,
};
}
// 只有 idle 与 failed 可以发起提交;unknown 必须先查询。
begin(): BeginResult {
if (this.phase === "submitting") {
return { ok: false, key: this.key, reason: "already_submitting" };
}
if (this.phase === "unknown") {
return { ok: false, key: this.key, reason: "need_query" };
}
if (this.phase === "succeeded") {
return { ok: false, key: this.key, reason: "already_submitted" };
}
const at = this.now();
if (this.lastAttemptAt !== null && at - this.lastAttemptAt < this.minIntervalMs) {
return { ok: false, key: this.key, reason: "too_fast" };
}
this.attempts += 1;
this.phase = "submitting";
this.submittedAt = at;
this.lastAttemptAt = at;
this.reason = null;
return { ok: true, key: this.key, reason: "started" };
}
succeed(orderId: string): SubmitSnapshot {
const id = orderId.trim();
if (id === "") throw new Error("订单号不能为空");
this.phase = "succeeded";
this.orderId = id;
this.reason = null;
return this.snapshot();
}
// rejected 是服务端明确拒绝,可用同一键重试;timeout 是结果未知,只能查询。
fail(reason: string, kind: "rejected" | "timeout" = "rejected"): SubmitSnapshot {
if (this.phase !== "submitting" && this.phase !== "unknown") {
throw new Error("当前没有进行中的提交");
}
this.reason = reason;
this.phase = kind === "timeout" ? "unknown" : "failed";
return this.snapshot();
}
resolveQuery(result: QueryResult): SubmitSnapshot {
if (this.phase !== "unknown") throw new Error("只有结果未知时才需要查询");
if (result.status === "found") {
if (result.orderId === null || result.orderId.trim() === "") {
throw new Error("命中订单时缺少订单号");
}
this.phase = "succeeded";
this.orderId = result.orderId;
this.reason = null;
} else if (result.status === "not_found") {
this.phase = "failed";
this.reason = "server_not_found";
} else {
this.reason = "query_pending";
}
return this.snapshot();
}
// 由界面调度器按固定频率调用;逻辑本身不注册定时器。
tick(): SubmitSnapshot {
if (
this.phase === "submitting" &&
this.submittedAt !== null &&
this.now() - this.submittedAt >= this.timeoutMs
) {
this.phase = "unknown";
this.reason = "timeout";
}
return this.snapshot();
}
buttonState(): SubmitButton {
switch (this.phase) {
case "idle":
return { label: "提交订单", clickable: true, action: "submit", hint: null };
case "submitting":
return { label: "提交中…", clickable: false, action: "none", hint: "请勿重复点击" };
case "succeeded":
return { label: "已提交", clickable: false, action: "none", hint: this.orderId };
case "failed":
return { label: "重新提交", clickable: true, action: "submit", hint: this.reason };
default:
return {
label: "查询提交结果",
clickable: true,
action: "query",
hint: "请先查询结果,不要重复提交",
};
}
}
}
export function openSubmit(
draftId: string,
nonce: string,
now: () => number,
options: SubmitOptions = {},
): SubmitController {
return new SubmitController(draftId.trim(), makeSubmitKey(draftId, nonce), now, options);
}
// 返回重进时恢复旧键;进行中被中断的提交一律降级为 unknown。
export function resumeSubmit(
draftId: string,
saved: SubmitSnapshot,
now: () => number,
options: SubmitOptions = {},
): SubmitController {
const id = draftId.trim();
if (saved.draftId !== id) throw new Error("草稿标识不一致,不能恢复提交会话");
if (!saved.key.startsWith(`sub-${id}-`)) throw new Error("幂等键与草稿不匹配");
const controller = new SubmitController(id, saved.key, now, options);
controller.attempts = saved.attempts;
controller.submittedAt = saved.submittedAt;
controller.orderId = saved.orderId;
controller.lastAttemptAt = saved.submittedAt;
if (saved.phase === "submitting") {
controller.phase = "unknown";
controller.reason = "resumed_in_flight";
} else {
controller.phase = saved.phase;
controller.reason = saved.reason;
}
return controller;
}
三个设计点值得单独看。
begin() 是唯一的提交入口,它先看状态、再看最小间隔,最后才增加 attempts。这样双击与连点都不会产生额外请求,计数也保持真实。
fail() 区分 rejected 与 timeout。前者是服务端明确说「不行」,状态落到 failed,可以复用键重试;后者只是没拿到答案,状态落到 unknown,只能查询。把两者混成一个 failed,是重复下单最常见的起点。
resumeSubmit() 不信任保存的 submitting。请求发出后页面被销毁,回包不会有人接收,唯一安全的假设是「结果未知」。
测试与真实输出
21 条用例分成四组:键的生成与复用(4 条)、按钮与双击防护(4 条)、失败、超时与查询分支(9 条)、返回重进(4 条)。全部使用假时钟与内存状态,不访问网络。
本机运行结果如下:
$ node --test --test-concurrency=1 test/order/submit.test.ts
✔ 幂等键由草稿标识与随机段组成 (0.554292ms)
✔ 草稿为空或随机段过短时拒绝生成 (0.223417ms)
✔ 同一草稿与随机段总是生成同一个键 (0.060334ms)
✔ 不同随机段生成不同的键 (0.068208ms)
✔ 新会话处于 idle,按钮可提交 (0.454ms)
✔ 发起提交后进入 submitting 并禁用按钮 (0.109375ms)
✔ 双击提交被拒绝且不增加尝试次数 (0.063916ms)
✔ 连续点击受最小间隔限制 (0.089958ms)
✔ 提交成功后按钮不可点且不能再次提交 (0.081667ms)
✔ 服务端明确拒绝后可用同一个键重试 (0.111166ms)
✔ 提交超时后转为结果未知并切换为查询按钮 (0.098334ms)
✔ 结果未知时禁止直接重新提交 (0.05775ms)
✔ 查询命中订单后转为成功 (0.072458ms)
✔ 查询确认不存在后回到可重试的失败态 (0.082458ms)
✔ 查询仍不确定时保持未知态 (0.047583ms)
✔ 不在未知态时查询会报错 (0.072583ms)
✔ 网络超时可直接从提交中转为未知 (0.054917ms)
✔ 恢复进行中的会话时降级为未知并要求查询 (0.084334ms)
✔ 恢复已成功的会话时保留订单号 (0.04825ms)
✔ 恢复时草稿或幂等键不匹配会被拒绝 (0.090083ms)
✔ 成功回调缺少订单号时拒绝 (0.050125ms)
ℹ tests 21
ℹ suites 0
ℹ pass 21
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 118.4825
一次「超时重试」造成的双订单复盘
某交易客户端的提交 id 是在点击事件里生成的。一次弱网环境里,第一笔请求在客户端 8 秒超时,但服务端在第 11 秒处理完成;端上弹出「下单失败」,用户点了重试,第二笔请求带着新的提交 id 到达,服务端又建了一张订单。
复盘时把责任归给「用户重复点击」并不准确,端上其实给了三次暗示:
- 超时文案写的是「失败」,而事实是「未知」。
- 重试按钮生成新键,服务端没有任何依据把两笔请求认成同一笔。
- 提交中按钮没有禁用,第一笔请求在途时用户仍可再次点击。
修复对应三点:超时后状态改为 unknown,按钮变成「查询提交结果」;查询命中则直接进入订单,确认不存在才允许复用旧键重试;提交中禁用按钮并加最小间隔。回归时重点验证三个场景:双击只发一笔、超时后先查询、返回重进恢复旧键。修复后,同类重复下单不再从这条路径产生。
这个复盘的结论可以压缩成一句话:端上只能对「明确拒绝」做重试,对「没有回答」只能查询。
小结
提交防重不是给按钮加一个 loading,而是把一次下单意图固定在一个幂等键上,再用状态机约束按钮能做什么。
键在进入确认页时生成,重试复用,返回重进恢复;按钮只在 idle 与 failed 可提交,unknown 只能查询;超时永远不等于失败。这三条同时成立,双击、连点、超时重试才不会变成多张订单。
下一节讨论订单提交之后的事:服务端返回的状态如何映射到端上,遇到未知状态又该怎么处理。
阅读导航:上一节:3.3 选择与限购:结算页的约束表达 · 下一节:4.2 订单状态机与端上映射 。
常见错误与边界
- 每次点击生成新的提交 id,服务端无法去重。
- 用链路追踪 id 当幂等键,同一笔重试被当成两笔。
- 超时后直接把状态标成失败,并引导用户重试。
unknown状态仍允许提交,查询入口只是文案摆设。- 提交中按钮只做视觉置灰,事件仍然可以触发。
- 返回重进时重新生成键,丢掉上一次提交的线索。
- 恢复会话时把保存的
submitting当成仍在提交,永远等不到回包。 - 幂等键只存在内存,进程被杀后无法恢复,用户只能重新提交。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。