《互联网产品的客户端架构·交易、增长与平台化》4.1 提交防重:幂等键与按钮状态

下单提交超时后,端上把「未知」当成「失败」就会诱导用户重复下单。本节用进入确认页生成的幂等键、五个内部状态与三种按钮出口,把双击、重试和返回重进收敛到同一个键上,并给出一个可用假时钟验证的 TypeScript 提交控制器与真实测试运行输出。

问题:重复下单往往不是手抖

「回声」的确认页原来只有一个「提交订单」按钮。网络慢时用户点一次没反应,再点一次;请求超时后端上弹出「下单失败,请重试」,用户又点一次。三笔请求带着三个不同的请求体到达服务端,生成两张甚至三张订单。

事后看,用户只做了一个动作:在不确定的时候想把事情推进下去。端上有三个问题:

  • 每次点击都生成新的请求标识,服务端无法判断这是不是同一笔提交。
  • 提交中没有禁用按钮,双击直接穿透。
  • 超时被当成失败,界面只提供「重试」,不提供「查询」。

这三个问题对应三个设计物件:幂等键、按钮状态、结果查询。它们必须一起设计,缺一个都会漏。

三个物件:键、按钮、查询

先把判断写成表。后面所有实现都围绕这张表。

决策点正确做法常见反例
键何时生成进入确认页时生成一次每次点击生成新键
重试用哪个键复用同一个键重试换新键
提交中按钮禁用并显示进度仍可点击或只做防抖
超时后转「结果未知」,只给查询提示失败并引导重试
返回重进恢复旧键,进行中降级为未知重新生成键

最容易被忽略的是第四行。「结果未知」不是错误码,而是一个一等状态:请求可能已经到达服务端,也可能没有。端上无法凭超时判断,只能拿同一个键去问服务端「这笔提交对应的订单存在吗」。

幂等键的边界

makeSubmitKey(draftId, nonce) 把两个输入拼成键:draftId 标识这一次确认快照(商品、数量、地址、优惠、实付金额),nonce 是每次进入确认页生成的随机段。键的作用域是「一次下单意图」,不是「一个请求」。

由此有三条边界:

  1. 同一个意图内,键必须复用。 用户改地址、换优惠券之前已经确认过一次,那是一次新的意图,应当换新键;而在同一次确认里失败重试,必须复用旧键。
  2. 键不能只存在内存里。 返回、杀进程、重新进入确认页时,要从草稿里读回旧键。读不到时,如果上一次结果未知,正确动作是查询而不是生成新键重提。
  3. 服务端要有唯一约束。 端上做的是减少重复请求,真正兜底的是服务端对 (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 到达,服务端又建了一张订单。

复盘时把责任归给「用户重复点击」并不准确,端上其实给了三次暗示:

  1. 超时文案写的是「失败」,而事实是「未知」。
  2. 重试按钮生成新键,服务端没有任何依据把两笔请求认成同一笔。
  3. 提交中按钮没有禁用,第一笔请求在途时用户仍可再次点击。

修复对应三点:超时后状态改为 unknown,按钮变成「查询提交结果」;查询命中则直接进入订单,确认不存在才允许复用旧键重试;提交中禁用按钮并加最小间隔。回归时重点验证三个场景:双击只发一笔、超时后先查询、返回重进恢复旧键。修复后,同类重复下单不再从这条路径产生。

这个复盘的结论可以压缩成一句话:端上只能对「明确拒绝」做重试,对「没有回答」只能查询。

小结

提交防重不是给按钮加一个 loading,而是把一次下单意图固定在一个幂等键上,再用状态机约束按钮能做什么。

键在进入确认页时生成,重试复用,返回重进恢复;按钮只在 idle 与 failed 可提交,unknown 只能查询;超时永远不等于失败。这三条同时成立,双击、连点、超时重试才不会变成多张订单。

下一节讨论订单提交之后的事:服务端返回的状态如何映射到端上,遇到未知状态又该怎么处理。

阅读导航:上一节:3.3 选择与限购:结算页的约束表达 · 下一节:4.2 订单状态机与端上映射 。

常见错误与边界

  • 每次点击生成新的提交 id,服务端无法去重。
  • 用链路追踪 id 当幂等键,同一笔重试被当成两笔。
  • 超时后直接把状态标成失败,并引导用户重试。
  • unknown 状态仍允许提交,查询入口只是文案摆设。
  • 提交中按钮只做视觉置灰,事件仍然可以触发。
  • 返回重进时重新生成键,丢掉上一次提交的线索。
  • 恢复会话时把保存的 submitting 当成仍在提交,永远等不到回包。
  • 幂等键只存在内存,进程被杀后无法恢复,用户只能重新提交。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查