《互联网产品的客户端架构·交易、增长与平台化》5.3 回调、轮询与端上确认的时序竞争

同一笔支付的结果会从回调、服务端推送、轮询和回跳四个方向同时到达,端上收到的顺序与事实发生的顺序无关。本节把事件写成带服务端序号的不可变记录,用去重、序号比较、来源强弱与成功吸收五条规则裁决竞争,并给出可在进程内确定性验证的 TypeScript 实现与真实测试输出。

问题:四个来源,四个时钟

一笔支付的结果可能从四个方向到达端上:

  • 回调:渠道通知服务端,服务端再通过推送或页面刷新告诉端上。
  • 推送:长连接把服务端的状态变更推给当前页面。
  • 轮询:端上按退避节奏主动查询。
  • 回跳:浏览器带着渠道参数回到支付页。

它们的到达顺序与事实发生的顺序没有关系。回调可能比回跳早,也可能晚;轮询可能在回调之前返回旧状态;用户可能在另一个标签页完成支付,当前页只收到一条迟到的推送。

如果端上按「最后到达的赢」处理,就会出现两类错误:旧轮询覆盖新回调,页面从成功退回待支付;或者重复回调被当成两次状态变更,触发两次成功动画与两次发货消息。

把事件写成不可变记录

竞争裁决的第一步不是写比较函数,而是把每个来源都归一化成同一种事件:

字段作用注意
eventId事件唯一标识回调重试必须复用同一个 id
paymentNo支付单号不同支付单号的事件不能互相裁决
sourcecallback / push / poll / return决定可信度与同序号强弱
serverSeq服务端状态序号裁决的主键,不是到达时间
statussucceeded / failed / cancelled / pending / unknown归一化后的状态
receivedAt端上收到时间只用于排查,不参与排序

eventId 与 serverSeq 的区别要分清:eventId 标识「这一次通知」,渠道重试会复用;serverSeq 标识「服务端状态的版本」,每次真实状态变更才会增加。回调与轮询可能携带同一个 serverSeq,这时它们是同一事实的两个副本。

五条裁决规则

把规则按优先级写下来:

  1. 同一 eventId 只处理一次。 内容相同视为重复,内容不同直接报错,因为这意味着上游把两个不同事实塞进了同一个标识。
  2. 回跳不参与终态。 它没有渠道签名,只能提示「该查了」。端上可以拿它触发一次查询,但不能拿它写结果。
  3. 序号大的赢。 serverSeq 小于当前值的事件是旧闻,直接丢弃;大于当前值的事件可以推进状态。
  4. 同序号看强弱。 状态强弱为成功 > 取消 > 失败 > 待支付 > 未知;状态相同时,来源强弱为回调 > 推送 > 轮询 > 回跳。
  5. 成功一旦出现即吸收。 后续任何事件都不能把成功改判成失败,只能记录异常等待对账。

这五条里,第三条与第五条共同保证「乱序到达也收敛」。第四条只在同一序号内使用,避免把「更晚的待支付」误判成比「更早的失败」更强。

五种典型时序

时序端上看到正确结果
回调先于回跳成功事件,然后回跳 success成功;回跳记为不可信
回跳先于回调回跳 success,然后回调成功成功;回跳不产生终态
重复回调同一 eventId 到达两次只处理一次,第二次计入重复
轮询与回调同时轮询失败与回调成功同序号成功;同序号取更强状态
旧轮询迟到成功之后收到更小序号的失败保持成功,旧事件计入过期

这张表可以直接变成测试用例。凡是「端上看到的顺序」与「事实顺序」不一致的场景,都应该能在表里找到一行。

竞争裁决的实现

下面的模块属于 L2:纯事件合并,时间由事件携带,不注册定时器,也不访问网络。调用方把四个来源的事件归一化后喂入即可。

// file: src/pay/race.ts
// tier: L2
// verify: node --test test/pay/race.test.ts

export type RaceSource = "callback" | "push" | "poll" | "return";

export type RaceStatus = "succeeded" | "failed" | "cancelled" | "pending" | "unknown";

export interface RaceEvent {
  eventId: string;
  paymentNo: string;
  source: RaceSource;
  serverSeq: number;
  status: RaceStatus;
  receivedAt: number;
}

export type IgnoreReason =
  | "none"
  | "duplicate"
  | "stale"
  | "weaker"
  | "untrusted"
  | "absorbing";

export interface RaceDecision {
  state: RaceStatus;
  serverSeq: number | null;
  changed: boolean;
  ignored: IgnoreReason;
  appliedEventId: string | null;
}

export interface RaceSnapshot {
  paymentNo: string;
  state: RaceStatus;
  serverSeq: number | null;
  eventId: string | null;
  applied: number;
  duplicates: number;
  stale: number;
  weaker: number;
  untrusted: number;
  absorbed: number;
}

const SOURCE_RANK: Record<RaceSource, number> = {
  return: 0,
  poll: 1,
  push: 2,
  callback: 3,
};

const STATUS_RANK: Record<RaceStatus, number> = {
  unknown: 0,
  pending: 1,
  failed: 2,
  cancelled: 3,
  succeeded: 4,
};

const SOURCES: RaceSource[] = ["callback", "push", "poll", "return"];
const STATUSES: RaceStatus[] = ["succeeded", "failed", "cancelled", "pending", "unknown"];

function assertEvent(event: RaceEvent, paymentNo: string): void {
  if (typeof event.eventId !== "string" || event.eventId.trim() === "") {
    throw new Error("事件标识不能为空");
  }
  if (typeof event.paymentNo !== "string" || event.paymentNo !== paymentNo) {
    throw new Error("支付单号不一致,不能参与竞争");
  }
  if (!SOURCES.includes(event.source)) throw new Error(`未知事件来源:${event.source}`);
  if (!STATUSES.includes(event.status)) throw new Error(`未知支付状态:${event.status}`);
  if (!Number.isInteger(event.serverSeq) || event.serverSeq < 0) {
    throw new Error("服务端序号必须是非负整数");
  }
  if (!Number.isFinite(event.receivedAt)) throw new Error("到达时间必须是有限数字");
}

function fingerprint(event: RaceEvent): string {
  return `${event.source}|${event.serverSeq}|${event.status}`;
}

// 竞争结果由服务端序号裁决;同序号看状态强弱与来源强弱,端上到达顺序不参与。
export class RaceResolver {
  paymentNo: string;
  state: RaceStatus;
  serverSeq: number | null;
  eventId: string | null;
  source: RaceSource | null;
  applied: number;
  duplicates: number;
  stale: number;
  weaker: number;
  untrusted: number;
  absorbed: number;
  seen: Map<string, string>;

  constructor(paymentNo: string) {
    const id = paymentNo.trim();
    if (id === "") throw new Error("支付单号不能为空");
    this.paymentNo = id;
    this.state = "unknown";
    this.serverSeq = null;
    this.eventId = null;
    this.source = null;
    this.applied = 0;
    this.duplicates = 0;
    this.stale = 0;
    this.weaker = 0;
    this.untrusted = 0;
    this.absorbed = 0;
    this.seen = new Map();
  }

  snapshot(): RaceSnapshot {
    return {
      paymentNo: this.paymentNo,
      state: this.state,
      serverSeq: this.serverSeq,
      eventId: this.eventId,
      applied: this.applied,
      duplicates: this.duplicates,
      stale: this.stale,
      weaker: this.weaker,
      untrusted: this.untrusted,
      absorbed: this.absorbed,
    };
  }

  decision(ignored: IgnoreReason, changed: boolean): RaceDecision {
    return {
      state: this.state,
      serverSeq: this.serverSeq,
      changed,
      ignored,
      appliedEventId: changed ? this.eventId : null,
    };
  }

  apply(event: RaceEvent): RaceDecision {
    assertEvent(event, this.paymentNo);
    const mark = fingerprint(event);
    const stored = this.seen.get(event.eventId);
    if (stored !== undefined) {
      if (stored !== mark) throw new Error("同一事件标识携带了不同内容");
      this.duplicates += 1;
      return this.decision("duplicate", false);
    }
    this.seen.set(event.eventId, mark);

    // 回跳只是浏览器参数,不能作为任何终态的证据。
    if (event.source === "return") {
      this.untrusted += 1;
      return this.decision("untrusted", false);
    }

    // 成功一旦出现即吸收:宁可多查一次,也不能把已到账改判成失败。
    if (this.state === "succeeded") {
      if (event.status === "succeeded" && event.serverSeq > (this.serverSeq ?? -1)) {
        this.serverSeq = event.serverSeq;
        this.eventId = event.eventId;
        this.source = event.source;
        this.applied += 1;
        return this.decision("none", true);
      }
      this.absorbed += 1;
      return this.decision("absorbing", false);
    }

    if (event.status === "succeeded") {
      this.accept(event);
      return this.decision("none", true);
    }

    if (this.serverSeq !== null) {
      if (event.serverSeq < this.serverSeq) {
        this.stale += 1;
        return this.decision("stale", false);
      }
      if (event.serverSeq === this.serverSeq) {
        const statusBetter = STATUS_RANK[event.status] > STATUS_RANK[this.state];
        const sourceBetter =
          STATUS_RANK[event.status] === STATUS_RANK[this.state] &&
          SOURCE_RANK[event.source] > SOURCE_RANK[this.source ?? "return"];
        if (!statusBetter && !sourceBetter) {
          this.weaker += 1;
          return this.decision("weaker", false);
        }
      }
    }
    this.accept(event);
    return this.decision("none", true);
  }

  accept(event: RaceEvent): void {
    this.state = event.status;
    this.serverSeq = event.serverSeq;
    this.eventId = event.eventId;
    this.source = event.source;
    this.applied += 1;
  }
}

export function resolveRace(paymentNo: string, events: RaceEvent[]): RaceSnapshot {
  const resolver = new RaceResolver(paymentNo);
  for (const event of events) resolver.apply(event);
  return resolver.snapshot();
}

代码里的四个判断

去重先于裁决。 seen 记录 eventId 到内容指纹的映射。重复事件在进入比较之前就被拦下,因此不会污染 serverSeq 与计数器。内容不一致时抛错而不是静默覆盖,是为了让上游的 bug 尽早暴露。

回跳在去重之后、比较之前被拦下。 它仍会占用 eventId,所以同一个回跳重复到达也只记一次;但它永远不会成为终态。这是「回跳只是口信」在代码里的落点。

成功吸收单独处理。 成功不是一个普通的最高优先级状态,而是一个不可逆分支。代码先判断当前是否已成功,再判断新事件是否成功,最后才进入普通的序号比较。

resolveRace 只负责按序喂入。 它不排序、不猜测,最终状态由规则保证与到达顺序无关。测试用多组乱序输入验证这一点。

测试与真实输出

19 条用例覆盖回调先到、回跳先到、重复回调、同序号强弱、旧轮询迟到、成功吸收、非法事件与乱序收敛。本机运行结果如下:

$ node --test --test-concurrency=1 test/pay/race.test.ts
✔ 回调先到,随后的回跳只被记录为不可信线索 (0.980167ms)
✔ 回跳先到不会产生终态,回调到达后才确认成功 (0.081708ms)
✔ 重复回调按事件标识幂等丢弃 (0.071208ms)
✔ 同一事件标识携带不同内容时直接报错 (0.213042ms)
✔ 不同事件标识但同序号同状态的重试回调不改变结果 (0.077375ms)
✔ 同序号下成功压过失败,与到达顺序无关 (0.079459ms)
✔ 同序号下取消强过失败 (0.071ms)
✔ 低序号的轮询结果不会覆盖已经确认的新状态 (0.056625ms)
✔ 成功一旦出现,更高序号的失败也不能改判 (0.068333ms)
✔ 低序号成功可以修正高序号失败 (0.094666ms)
✔ 高序号的失败可以修正低序号失败并推进序号 (0.059416ms)
✔ 更高序号的待支付可以推进低序号的失败 (0.051709ms)
✔ 同序号下来源更强的回调压过轮询 (0.058208ms)
✔ 已终态后到达的回跳被忽略 (0.042458ms)
✔ 支付单号不一致的事件被拒绝 (0.062375ms)
✔ 非法事件字段被拒绝 (0.163417ms)
✔ 计数器分别记录应用、重复、过期、较弱与不可信 (0.089667ms)
✔ 任意到达顺序都得到相同的终态与序号 (0.10925ms)
✔ resolveRace 对成功与失败混合的事件同样稳定 (0.059792ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 148.485167

一次「旧轮询覆盖成功」的复盘

某交易客户端的支付页同时开着 3 秒轮询与长连接推送。一次渠道回调较慢,服务端先通过推送把订单标成已支付;端上展示成功页后,一个更早发出的轮询请求才返回,携带的是回调之前的 pending。页面按「最后到达」更新,成功页退回「支付确认中」,用户以为没付成功,又扫了一次码。

复盘发现三个问题:

  1. 轮询响应没有携带服务端状态序号,端上无法判断新旧。
  2. 合并逻辑是直接赋值,没有「未知/待支付不能降级终态」的保护。
  3. 成功页与订单页各自维护一份状态,退回时只更新了其中一份。

修复做了三件事:所有状态响应都带 serverSeq;端上用本文的裁决器合并事件,旧序号直接丢弃;成功页与订单页共享同一份合并结果。回归重点验证:旧轮询迟到不改变成功、重复推送只触发一次副作用、回跳不能单独点亮成功。

这个复盘的结论是:端上不应该问「谁最后到」,而应该问「谁更新」。

小结

支付结果的竞争来自回调、推送、轮询与回跳四个来源,到达顺序不可信。把事件归一化成带 eventId、paymentNo、source 与 serverSeq 的记录,是裁决的前提。

规则可以压缩成五条:同 id 去重、回跳不产终态、大序号赢、同序号看强弱、成功吸收。它们共同保证乱序输入收敛到同一个终态,也让重复回调不会触发重复副作用。

下一章进入支付之后的账:端上如何避免重复支付,以及退款与对账如何跟上。

阅读导航:上一节:5.2 支付结果不确定时的状态机 · 下一节:6.1 端上如何避免重复支付 。

常见错误与边界

  • 按事件到达顺序取最后一条,旧轮询覆盖已经确认的成功。
  • 用端上 receivedAt 排序,忽略设备时钟与网络延迟。
  • 重复回调没有事件标识,成功副作用被执行两次。
  • 同一个 eventId 携带不同内容时静默覆盖,掩盖上游 bug。
  • 回跳参数直接写入终态,绕过服务端查询。
  • 同序号冲突时按来源取,而不是先按状态强弱取。
  • 成功之后又接受更高序号的失败,页面从成功退回失败。
  • 不同支付单号的事件混在一起裁决,把另一笔支付的结果套到当前订单。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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