问题:倒计时到底在数什么
待支付页有一个倒计时。用户看到「剩余 14:32」,默认理解为「我还有 14 分 32 秒可以付款」。端上如果只是打开页面时记一个 15 分钟,再用 setInterval 每秒减一,这个数字很快就会变成谎言。
三种情况会让本地递减失效:
- 用户切到后台,浏览器或系统暂停定时器,回来时少减了几分钟。
- 用户手动改系统时间,倒计时突然变长或归零。
- 用户杀掉进程再进来,本地只记得「还剩多久」,却不知道已经过了多久。
订单是否还能支付,只有一个权威答案:服务端记录的到期时刻。端上的倒计时是展示,不是规则。把展示当成规则,就会出现「页面还能付,服务端说过期」或者反过来的冲突。
三个时间与一个偏移
服务端返回待支付订单时,至少给两个时间:订单的 expiresAt,以及响应当前的服务端时间 serverNow。端上收到响应时再记一个本地时间 receivedAt。三者合起来才能算出可信的剩余时间:
offset = serverNow - receivedAt
serverEstimate = clientNow + offset
remaining = expiresAt - serverEstimate
offset 吸收了本地时钟与服务端的固定偏差。用户把本地时间调快一小时,offset 仍然是负一小时,serverEstimate 不受影响。服务端没有返回 serverNow 时,只能退回本地时钟,此时必须把 clock 标成 local,提醒上层这个数字的置信度较低。
由此得到第一条规则:倒计时以 expiresAt 为锚点,每次重新计算,而不是记住一个本地剩余时长再递减。
切后台、改时间与单调性
切后台不需要特殊处理,只要恢复时用当前时间重算。麻烦的是系统时间被改。端上无法区分「用户改了时间」和「时区切换」,但可以守住一条更简单的约束:同一张订单的剩余时间不允许增加。
- 本地时间前跳:剩余时间直接归零,展示已超时,等查询或服务端推送确认。
- 本地时间回拨:算出来的剩余时间会变大,用上一次的剩余时间做上界,同时标记
backwards,要求刷新服务端时间。 - 时间异常时不做取消决策:先刷新,再决定。
这条单调性约束不能替代服务端裁决,但能避免最糟的展示:用户把时钟往回拨一小时,页面突然多出一小时支付时间,然后付款失败。
超时前后的取消
取消请求应该由用户触发,还是由倒计时归零自动触发?两种都有,但端上要区分「展示超时」与「订单已取消」。服务端才是取消的执行者,端上能做的是:
- 剩余时间大于保护窗口(默认 5 秒):正常发起取消。
- 剩余时间进入保护窗口:仍可取消,但标记
raceRisk,因为支付回调可能同时到达。 - 已经超时:可以发起取消,同样标记
raceRisk,并准备按服务端结果刷新。 - 时钟异常:只刷新服务端时间,不发起取消。
保护窗口的意义不是禁止取消,而是提醒调用方:这次取消的结果不能靠本地判断,必须看服务端返回。
取消与支付回调的竞争,最终只能由服务端事实裁决。端上常见两种错误:按到达顺序取最后一个事件,或者看到取消成功就把订单改成已取消。正确做法是给每个终态事件带上服务端序号,取序号最大的那个;序号相同则支付优先。到达顺序只影响界面刷新时机,不影响事实。
倒计时模块的实现
下面的模块属于 L2:时间由调用方通过 TickContext 注入,模块不注册定时器。测试用假时钟模拟切后台、时间回拨和前跳。
// file: src/order/expiry.ts
// tier: L2
// verify: node --test test/order/expiry.test.ts
export interface ExpiryWindow {
orderId: string;
expiresAt: number;
serverNow: number | null;
receivedAt: number;
}
export interface TickContext {
now: number;
lastNow: number | null;
lastRemainingMs: number | null;
}
export interface CountdownView {
orderId: string;
remainingMs: number;
expired: boolean;
label: string;
clock: "synced" | "local";
anomaly: "none" | "backwards";
needsRefresh: boolean;
}
function assertWindow(window: ExpiryWindow): void {
if (typeof window.orderId !== "string" || window.orderId.trim() === "") {
throw new Error("订单号不能为空");
}
if (!Number.isFinite(window.expiresAt)) throw new Error("到期时间必须是有限数字");
if (!Number.isFinite(window.receivedAt)) throw new Error("接收时间必须是有限数字");
if (window.serverNow !== null && !Number.isFinite(window.serverNow)) {
throw new Error("服务端时间必须是有限数字");
}
}
export function serverOffset(window: ExpiryWindow): number {
if (window.serverNow === null) return 0;
return window.serverNow - window.receivedAt;
}
export function formatRemaining(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) throw new Error("剩余时间非法");
const totalSeconds = Math.ceil(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
// 倒计时只认服务端到期时刻;本地时间回拨时不允许剩余时间变多。
export function countdownAt(
window: ExpiryWindow,
context: TickContext,
): CountdownView {
assertWindow(window);
if (!Number.isFinite(context.now)) throw new Error("本地时间必须是有限数字");
if (context.lastNow !== null && !Number.isFinite(context.lastNow)) {
throw new Error("上次本地时间必须是有限数字");
}
const backwards = context.lastNow !== null && context.now < context.lastNow;
const serverEstimate = context.now + serverOffset(window);
let remaining = window.expiresAt - serverEstimate;
if (backwards && context.lastRemainingMs !== null) {
remaining = Math.min(remaining, context.lastRemainingMs);
}
remaining = Math.max(0, remaining);
const expired = remaining <= 0;
return {
orderId: window.orderId.trim(),
remainingMs: remaining,
expired,
label: expired ? "支付已超时" : `支付剩余 ${formatRemaining(remaining)}`,
clock: window.serverNow === null ? "local" : "synced",
anomaly: backwards ? "backwards" : "none",
needsRefresh: backwards,
};
}
export type CancelAction = "cancel" | "refresh_only";
export interface CancelPlan {
action: CancelAction;
raceRisk: boolean;
reason: "normal" | "near_expiry" | "expired" | "clock_anomaly";
}
// 临近到期时取消与支付回调可能同时发生,发起取消后必须按服务端结果刷新。
export function planCancel(
view: CountdownView,
options: { graceMs?: number } = {},
): CancelPlan {
const grace = options.graceMs ?? 5_000;
if (!Number.isFinite(grace) || grace < 0) throw new Error("保护窗口非法");
if (view.needsRefresh) {
return { action: "refresh_only", raceRisk: false, reason: "clock_anomaly" };
}
if (view.expired) {
return { action: "cancel", raceRisk: true, reason: "expired" };
}
if (view.remainingMs <= grace) {
return { action: "cancel", raceRisk: true, reason: "near_expiry" };
}
return { action: "cancel", raceRisk: false, reason: "normal" };
}
export type RaceEvent =
| { kind: "cancel_ok"; serverSeq: number; receivedAt: number }
| { kind: "cancel_rejected"; serverSeq: number; receivedAt: number; reason: string }
| { kind: "payment_paid"; serverSeq: number; receivedAt: number };
export interface RaceOutcome {
state: "cancelled" | "paid" | "unknown";
winner: "cancel" | "payment" | null;
serverSeq: number | null;
notes: string[];
}
function assertEvent(event: RaceEvent): void {
if (!Number.isFinite(event.serverSeq)) throw new Error("服务端序号非法");
if (!Number.isFinite(event.receivedAt)) throw new Error("到达时间非法");
}
// 竞争结果按服务端序号裁决,不按端上到达顺序;同序号支付优先。
export function resolveExpiryRace(events: RaceEvent[]): RaceOutcome {
const notes: string[] = [];
const terminals: Array<{
state: "cancelled" | "paid";
winner: "cancel" | "payment";
seq: number;
}> = [];
for (const event of events) {
assertEvent(event);
if (event.kind === "cancel_ok") {
terminals.push({ state: "cancelled", winner: "cancel", seq: event.serverSeq });
} else if (event.kind === "payment_paid") {
terminals.push({ state: "paid", winner: "payment", seq: event.serverSeq });
} else {
notes.push(`cancel_rejected@${event.serverSeq}`);
}
}
if (terminals.length === 0) {
return { state: "unknown", winner: null, serverSeq: null, notes };
}
terminals.sort((a, b) => {
if (a.seq !== b.seq) return a.seq - b.seq;
if (a.winner === b.winner) return 0;
return a.winner === "payment" ? 1 : -1;
});
for (const terminal of terminals) {
notes.push(`${terminal.winner}@${terminal.seq}`);
}
const last = terminals[terminals.length - 1];
return { state: last.state, winner: last.winner, serverSeq: last.seq, notes };
}
countdownAt() 每次都从 expiresAt 重算,不做增量递减。lastNow 与 lastRemainingMs 只用于一个目的:本地时间回拨时,把剩余时间钳在上一次的值,不让它变多。
planCancel() 把取消决策分成四种 reason。near_expiry 与 expired 都允许取消,但 raceRisk 为真,调用方必须在取消后查询或刷新,而不是直接把界面改成已取消。
resolveExpiryRace() 只看 serverSeq。测试里专门把「先到的事件序号更小」和「后到的事件序号更小」都跑了一遍,证明到达顺序不参与裁决。
测试与真实输出
24 条用例分成四组:倒计时计算与格式化(7 条)、切后台与时钟异常(4 条)、取消计划(4 条)、竞争裁决(6 条),另有 3 条输入校验与视图字段断言。全部使用假时钟,不等待真实时间。
本机运行结果如下:
$ node --test --test-concurrency=1 test/order/expiry.test.ts
✔ 倒计时以服务端到期时刻为准 (0.682833ms)
✔ 服务端时间可以校正偏快的本地时钟 (0.077625ms)
✔ 没有服务端时间时明确标记本地时钟 (0.064375ms)
✔ 恰好到达到期时刻判为已超时 (0.061834ms)
✔ 不足一秒的剩余时间向上取整 (0.0715ms)
✔ 超过一小时的剩余时间按分钟累计 (0.052041ms)
✔ 非法剩余时间拒绝格式化 (0.216916ms)
✔ 切后台五分钟后剩余时间同步减少 (0.065625ms)
✔ 系统时间回拨时剩余时间不增加 (0.071083ms)
✔ 时钟异常时只刷新服务端时间而不发起取消 (0.49675ms)
✔ 系统时间前跳越过到期时刻判为超时 (0.068ms)
✔ 正常剩余时间下取消没有竞争风险 (0.060709ms)
✔ 临近到期时取消标记竞争风险 (0.064417ms)
✔ 已过期仍可发起取消但必须标记竞争风险 (0.055291ms)
✔ 非法保护窗口被拒绝 (0.067791ms)
✔ 支付回调序号更晚时结果以已支付为准 (0.122583ms)
✔ 取消成功的序号更晚时结果为已取消 (0.045917ms)
✔ 同一服务端序号时支付优先 (0.054416ms)
✔ 只有取消被拒绝时结果仍然未知 (0.04525ms)
✔ 重复支付回调不会改变已支付结果 (0.054333ms)
✔ 没有任何终态事件时结果未知 (0.044042ms)
✔ 非法服务端序号被拒绝 (0.058875ms)
✔ 非法到期时间或服务端时间被拒绝 (0.068833ms)
✔ 视图保留订单号并可用于后续刷新判断 (0.03225ms)
ℹ tests 24
ℹ suites 0
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 119.159292
一次「本地时长」造成的提前超时复盘
某交易客户端在打开待支付页时读取配置里的 paymentTimeoutMinutes,把它换算成毫秒,再用每秒一次的定时器递减。这个方案在正常前台使用中没有问题,直到两类反馈同时出现。
第一类:用户切到后台聊了几分钟,回来发现倒计时还剩很多,但点支付提示「订单已过期」。原因是定时器被系统暂停,本地少减了时间。
第二类:用户把系统时间往前调,页面立刻显示超时并自动隐藏了支付按钮;调回时间后按钮又出现。用户以为订单状态在来回跳。
复盘发现,端上把「配置里的时长」当成了事实,而服务端用的是「下单时刻 + 时长」得到的 expiresAt。两者在页面打开时就可能相差几秒,切后台后差距继续扩大。修复做了三件事:接口返回 expiresAt 与 serverNow;端上计算固定偏移并在每次恢复时重算;剩余时间只允许单调不增,时间异常时先刷新。
回归时覆盖四个场景:后台五分钟、系统时间前跳、系统时间回拨、临期取消撞上支付成功。修复后,倒计时与订单实际可支付状态不再分叉。
小结
待支付倒计时的锚点是服务端 expiresAt,本地时长只能作为兜底展示。服务端时间用于校正本地偏差,本地时间回拨时剩余时间不允许增加,异常时先刷新再决策。
超时前后的取消要区分「正常」「临期」「已过期」「时钟异常」四种情况;临期与过期的取消必须准备面对支付回调,结果按服务端序号裁决,同序号支付优先。
下一节进入支付链路:如何拉起支付、如何轮询、回跳丢失后又如何把结果找回来。
阅读导航:上一节:4.2 订单状态机与端上映射 · 下一节:5.1 支付编排:拉起、轮询与回跳 。
常见错误与边界
- 用配置时长在本地递减,切后台后与服务端到期时间分叉。
- 只信本地时钟,不吸收服务端
serverNow的偏移。 - 系统时间回拨后剩余时间变多,等于给订单悄悄续期。
- 倒计时归零就本地判定订单取消,不等待服务端确认。
- 取消请求成功后立刻把订单改成已取消,忽略随后到达的支付成功。
- 竞争结果按事件到达顺序取最后一个,而不是服务端序号。
- 临期取消不标记竞争风险,取消失败后界面停在中间态。
- 时间异常时仍发起取消或支付,把错误时钟带进资金操作。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。