问题:四个来源,四个时钟
一笔支付的结果可能从四个方向到达端上:
- 回调:渠道通知服务端,服务端再通过推送或页面刷新告诉端上。
- 推送:长连接把服务端的状态变更推给当前页面。
- 轮询:端上按退避节奏主动查询。
- 回跳:浏览器带着渠道参数回到支付页。
它们的到达顺序与事实发生的顺序没有关系。回调可能比回跳早,也可能晚;轮询可能在回调之前返回旧状态;用户可能在另一个标签页完成支付,当前页只收到一条迟到的推送。
如果端上按「最后到达的赢」处理,就会出现两类错误:旧轮询覆盖新回调,页面从成功退回待支付;或者重复回调被当成两次状态变更,触发两次成功动画与两次发货消息。
把事件写成不可变记录
竞争裁决的第一步不是写比较函数,而是把每个来源都归一化成同一种事件:
| 字段 | 作用 | 注意 |
|---|---|---|
eventId | 事件唯一标识 | 回调重试必须复用同一个 id |
paymentNo | 支付单号 | 不同支付单号的事件不能互相裁决 |
source | callback / push / poll / return | 决定可信度与同序号强弱 |
serverSeq | 服务端状态序号 | 裁决的主键,不是到达时间 |
status | succeeded / failed / cancelled / pending / unknown | 归一化后的状态 |
receivedAt | 端上收到时间 | 只用于排查,不参与排序 |
eventId 与 serverSeq 的区别要分清:eventId 标识「这一次通知」,渠道重试会复用;serverSeq 标识「服务端状态的版本」,每次真实状态变更才会增加。回调与轮询可能携带同一个 serverSeq,这时它们是同一事实的两个副本。
五条裁决规则
把规则按优先级写下来:
- 同一
eventId只处理一次。 内容相同视为重复,内容不同直接报错,因为这意味着上游把两个不同事实塞进了同一个标识。 - 回跳不参与终态。 它没有渠道签名,只能提示「该查了」。端上可以拿它触发一次查询,但不能拿它写结果。
- 序号大的赢。
serverSeq小于当前值的事件是旧闻,直接丢弃;大于当前值的事件可以推进状态。 - 同序号看强弱。 状态强弱为成功 > 取消 > 失败 > 待支付 > 未知;状态相同时,来源强弱为回调 > 推送 > 轮询 > 回跳。
- 成功一旦出现即吸收。 后续任何事件都不能把成功改判成失败,只能记录异常等待对账。
这五条里,第三条与第五条共同保证「乱序到达也收敛」。第四条只在同一序号内使用,避免把「更晚的待支付」误判成比「更早的失败」更强。
五种典型时序
| 时序 | 端上看到 | 正确结果 |
|---|---|---|
| 回调先于回跳 | 成功事件,然后回跳 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。页面按「最后到达」更新,成功页退回「支付确认中」,用户以为没付成功,又扫了一次码。
复盘发现三个问题:
- 轮询响应没有携带服务端状态序号,端上无法判断新旧。
- 合并逻辑是直接赋值,没有「未知/待支付不能降级终态」的保护。
- 成功页与订单页各自维护一份状态,退回时只更新了其中一份。
修复做了三件事:所有状态响应都带 serverSeq;端上用本文的裁决器合并事件,旧序号直接丢弃;成功页与订单页共享同一份合并结果。回归重点验证:旧轮询迟到不改变成功、重复推送只触发一次副作用、回跳不能单独点亮成功。
这个复盘的结论是:端上不应该问「谁最后到」,而应该问「谁更新」。
小结
支付结果的竞争来自回调、推送、轮询与回跳四个来源,到达顺序不可信。把事件归一化成带 eventId、paymentNo、source 与 serverSeq 的记录,是裁决的前提。
规则可以压缩成五条:同 id 去重、回跳不产终态、大序号赢、同序号看强弱、成功吸收。它们共同保证乱序输入收敛到同一个终态,也让重复回调不会触发重复副作用。
下一章进入支付之后的账:端上如何避免重复支付,以及退款与对账如何跟上。
阅读导航:上一节:5.2 支付结果不确定时的状态机 · 下一节:6.1 端上如何避免重复支付 。
常见错误与边界
- 按事件到达顺序取最后一条,旧轮询覆盖已经确认的成功。
- 用端上
receivedAt排序,忽略设备时钟与网络延迟。 - 重复回调没有事件标识,成功副作用被执行两次。
- 同一个
eventId携带不同内容时静默覆盖,掩盖上游 bug。 - 回跳参数直接写入终态,绕过服务端查询。
- 同序号冲突时按来源取,而不是先按状态强弱取。
- 成功之后又接受更高序号的失败,页面从成功退回失败。
- 不同支付单号的事件混在一起裁决,把另一笔支付的结果套到当前订单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。