一场战斗里的三种流量
《潮汐方舟》的战斗连接上同时跑着三类流量。客户端以 20Hz 上行输入,每条 48B;服务端以 10Hz 下发快照,每条 350B;此外还有心跳,战斗内每 5 秒一次,大厅每 20 秒一次。玩家按下技能键时,客户端还要发一条「释放技能」的请求,等待服务端确认。
这三类流量在抓包里长得差不多,都是信封包着的字节。如果实现时把它们混在一个回调里,问题会在最不该出现的时候出现:技能请求发出后超过 RTT P95 仍没有响应,客户端重试;服务端其实已经处理了第一次请求,第二次被当成新的释放;玩家看到技能进了冷却却没有伤害,或者伤害结算了两次。
方舟的 RTT P95 是 240ms,弱网会话占 14%,每次会话至少断线一次的比例是 1.4%。这些数字说明「等不到响应」不是异常,而是常态的一部分。请求跟踪器的任务不是消灭超时,而是让超时、重试和迟到响应都有确定的状态和归宿。
请求、响应、推送不是同一种消息
先把三类消息的契约写清楚,代码才有地方落。
| 维度 | 请求 | 响应 | 推送 |
|---|---|---|---|
| 方向 | 客户端到服务端 | 服务端到客户端 | 服务端到客户端 |
| 是否等待回包 | 是 | 否 | 否 |
| 关联键 | 客户端分配的 requestId | 回显 requestId | 推送类型,通常不需要关联 |
| 是否可重试 | 可以,必须复用同一个 requestId | 不重试 | 不重试,按类型决定丢弃或覆盖 |
| 重复到达时 | 服务端按 requestId 幂等 | 客户端忽略,不能二次结算 | 快照用新序号覆盖,通知按业务去重 |
| 超时后的处理 | 标记为重试或超时 | 迟到的响应只记录,不影响状态 | 过期快照直接丢弃 |
这张表里最关键的一行是「重复到达时」。请求的重复由服务端负责幂等,响应的重复由客户端负责忽略。如果客户端把迟到的响应当成一次新的成功,就会出现「请求已经超时并重试,第一次的响应才到」的双重结算。请求跟踪器要能区分「这个 id 还在等」和「这个 id 已经关闭」。
推送则不应该被当成响应。快照没有 requestId,也不需要客户端回包;它的价值在于「最新一帧」,旧帧可以丢。把快照塞进请求表,会让请求表随着战斗时长无限增长,也会让超时扫描把快照误判成待回包请求。
请求跟踪器的状态机
跟踪器只维护一张表:每个 requestId 对应一条记录,记录状态、尝试次数、首次发送时间、超时时间和截止时间。状态只有五个:
| 状态 | 含义 | 进入条件 | 离开条件 |
|---|---|---|---|
| pending | 已发出,等待响应 | start | 收到响应、超时、取消 |
| retrying | 超时后已重发 | expire 且还有重试次数 | 收到响应、再次超时、取消 |
| settled | 已收到响应 | ack | 终态 |
| timed-out | 重试次数用尽 | expire 且达到上限 | 终态 |
| abandoned | 主动放弃 | cancel | 终态 |
attempts 从 1 开始,表示「已经发过几次」,而不是「重试了几次」。这样 maxAttempts 的语义和配置一致:设为几就是最多发几次,包含原始请求和重试。很多线上事故来自这两个口径混用,配置写「重试两次」,代码却把原始请求也算进去。
延迟一律从首次发送时间算,而不是从最后一次重试算。否则一个重试过的请求会显示一次单程往返的延迟,看起来网络很好,实际玩家已经等了更久。跟踪器记录的是玩家感知的时延,不是单次往返的时延。
取消是必要的终态。玩家在请求发出后退出战斗、切换角色或者主动断线时,跟踪器不应该继续为这个 id 重试。cancel 只对打开状态的请求生效,已经结算或已经超时的请求不能被重新打开。
超时阈值从网络事实来,不要拍脑袋
超时不是一个可以和业务无关的常数。方舟战斗内的心跳是 5 秒,大厅是 20 秒;RTT P95 是 240ms,弱网会话占 14%。如果请求超时设得比心跳还长,连接可能已经被判定失活,请求还在等;如果设得接近 RTT P50 的 62ms,弱网下几乎每条请求都会超时重试。
合理的做法是把超时分成两层:连接层用心跳判断「这条连接还活着吗」,请求层用截止时间判断「这个操作还要不要等」。两层的时间尺度不同,不能共用一个定时器。战斗内的请求可以短一些,因为玩家在实时操作;大厅里的购买、领奖可以长一些,因为一次重试的代价远小于误判失败。
消消的节奏完全不同:RTT P95 是 310ms,弱网会话占 22%,但心跳是 25 秒全场景,重连后需要补状态的比例只有 8%。同样的超时策略搬到消消,会让回合提交在弱网下频繁重试;反过来把消消的宽松策略搬到方舟,又会让战斗请求拖过整个技能窗口。
请求跟踪实现
下面的实现把状态机、超时扫描、推送计数和取消都收在一个类里。时间由调用方传入,测试可以精确控制每一毫秒,不需要真实等待。
// file: src/net/request-tracker.ts
// tier: L1
// verify: node --test test/net/request-tracker.test.ts
export type RequestState =
| "pending"
| "retrying"
| "settled"
| "timed-out"
| "abandoned";
export type PushKind = "snapshot" | "notice" | "heartbeat";
export interface TrackerOptions {
readonly defaultTimeoutMs: number;
readonly maxAttempts: number;
}
export interface RequestRecord {
readonly id: string;
state: RequestState;
attempts: number;
readonly startedAtMs: number;
readonly timeoutMs: number;
deadlineMs: number;
latencyMs?: number;
}
export interface AckResult {
readonly outcome: "settled" | "duplicate" | "unknown";
readonly latencyMs?: number;
}
export interface ExpireResult {
readonly id: string;
readonly attempt: number;
readonly state: "retrying" | "timed-out";
readonly deadlineMs: number;
}
export interface TrackerSnapshot {
readonly started: number;
readonly settled: number;
readonly timedOut: number;
readonly retried: number;
readonly abandoned: number;
readonly pushes: number;
readonly inFlight: number;
}
const PUSH_KINDS: readonly PushKind[] = ["snapshot", "notice", "heartbeat"];
function isPushKind(value: string): value is PushKind {
return (PUSH_KINDS as readonly string[]).includes(value);
}
function isOpen(state: RequestState): boolean {
return state === "pending" || state === "retrying";
}
export class RequestTracker {
#records = new Map<string, RequestRecord>();
#timeoutMs: number;
#maxAttempts: number;
#started = 0;
#settled = 0;
#timedOut = 0;
#retried = 0;
#abandoned = 0;
#pushes = 0;
constructor(options: Partial<TrackerOptions> = {}) {
const timeout = options.defaultTimeoutMs ?? 1_000;
const attempts = options.maxAttempts ?? 2;
if (!Number.isInteger(timeout) || timeout <= 0) {
throw new Error(`defaultTimeoutMs must be a positive integer: ${timeout}`);
}
if (!Number.isInteger(attempts) || attempts < 1) {
throw new Error(`maxAttempts must be a positive integer: ${attempts}`);
}
this.#timeoutMs = timeout;
this.#maxAttempts = attempts;
}
start(id: string, nowMs: number, timeoutMs = this.#timeoutMs): void {
if (id.length === 0) {
throw new Error("request id must not be empty");
}
if (this.#records.has(id)) {
throw new Error(`duplicate request id: ${id}`);
}
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
throw new Error(`timeout must be a positive integer: ${timeoutMs}`);
}
this.#records.set(id, {
id,
state: "pending",
attempts: 1,
startedAtMs: nowMs,
timeoutMs,
deadlineMs: nowMs + timeoutMs,
});
this.#started += 1;
}
ack(id: string, nowMs: number): AckResult {
const record = this.#records.get(id);
if (record === undefined) {
return { outcome: "unknown" };
}
if (!isOpen(record.state)) {
return { outcome: "duplicate" };
}
const latencyMs = Math.max(0, nowMs - record.startedAtMs);
record.state = "settled";
record.latencyMs = latencyMs;
this.#settled += 1;
return { outcome: "settled", latencyMs };
}
expire(nowMs: number): readonly ExpireResult[] {
const expired: ExpireResult[] = [];
for (const record of this.#records.values()) {
if (!isOpen(record.state) || record.deadlineMs > nowMs) {
continue;
}
if (record.attempts >= this.#maxAttempts) {
record.state = "timed-out";
this.#timedOut += 1;
expired.push({
id: record.id,
attempt: record.attempts,
state: "timed-out",
deadlineMs: record.deadlineMs,
});
continue;
}
record.attempts += 1;
record.state = "retrying";
record.deadlineMs = nowMs + record.timeoutMs;
this.#retried += 1;
expired.push({
id: record.id,
attempt: record.attempts,
state: "retrying",
deadlineMs: record.deadlineMs,
});
}
return expired;
}
recordPush(kind: string): boolean {
if (!isPushKind(kind)) {
return false;
}
this.#pushes += 1;
return true;
}
cancel(id: string): boolean {
const record = this.#records.get(id);
if (record === undefined || !isOpen(record.state)) {
return false;
}
record.state = "abandoned";
this.#abandoned += 1;
return true;
}
snapshot(): TrackerSnapshot {
let inFlight = 0;
for (const record of this.#records.values()) {
if (isOpen(record.state)) {
inFlight += 1;
}
}
return {
started: this.#started,
settled: this.#settled,
timedOut: this.#timedOut,
retried: this.#retried,
abandoned: this.#abandoned,
pushes: this.#pushes,
inFlight,
};
}
record(id: string): RequestRecord | undefined {
return this.#records.get(id);
}
}
export function describeTracker(snapshot: TrackerSnapshot): string {
return `tracker started=${snapshot.started} settled=${snapshot.settled} retried=${snapshot.retried} timedOut=${snapshot.timedOut} abandoned=${snapshot.abandoned} pushes=${snapshot.pushes} inFlight=${snapshot.inFlight}`;
}
export function describeAck(id: string, result: AckResult): string {
if (result.outcome === "settled") {
return `ack ${id}: settled in ${result.latencyMs ?? 0}ms`;
}
return `ack ${id}: ${result.outcome}`;
}
export function createTracker(options: Partial<TrackerOptions> = {}): RequestTracker {
return new RequestTracker(options);
}
start 拒绝重复 id 和空 id,因为这两种错误在真实项目里都很难排查:重复 id 会让后一个请求覆盖前一个的回调,空 id 会让所有匿名请求挤在同一条记录上。ack 对未知 id 返回 unknown,对已关闭 id 返回 duplicate,而不是抛异常——迟到的响应是网络常态,不应该让调用方用 try/catch 处理。
expire 是唯一推进时间的入口。它只处理打开状态且已经到达截止时间的记录:还有重试次数就进入 retrying 并顺延截止时间,否则进入 timed-out。这个函数返回本次变化的列表,调用方可以用它发重试包、记指标或者弹提示,而不需要再遍历一遍表。
recordPush 只接受预定义的推送类型,未知类型返回 false 且不计数。这不是为了严格,而是为了防止把拼错的类型悄悄算进推送统计,让面板上的数字看起来正常、实际却少了一类消息。
输出与测试
describeTracker 的输出是一行紧凑的计数:
tracker started=2 settled=1 retried=0 timedOut=1 abandoned=0 pushes=1 inFlight=0
这行输出适合直接进日志或指标。排障时先看 inFlight 是否长期不归零,再看 retried 和 timedOut 的比例;如果 timedOut 很高但 retried 很低,通常说明重试次数配置成了 1,或者重试入口根本没有接上。
测试覆盖了九类情况:正常结算与延迟计算、重复响应、未知 id、超时重试后超时、重试期间响应仍按首次时间计延迟、重复 id 与非法配置、推送类型过滤、取消后不可重开、以及计数快照。每条用例都只推进逻辑时间,不依赖真实时钟,因此可以在毫秒级重复运行。
方舟的上下行均值是 240B 和 1.4KB,每会话流量 18MB,每 DAU 日流量 21MB。这些数字决定了请求表的规模上限:战斗内以 20Hz 发输入,如果每条输入都进请求表,34 分钟的会话会积累大量记录。因此输入流不应该走请求跟踪器,它属于「发了就不等回包」的推送式上行;只有需要确认的操作才分配 requestId。
与相邻章节的关系
2.1 定义了信封的固定头和版本规则,本节假设信封已经能正确切出消息边界。2.3 会把请求与响应的字段约束写成契约测试,防止某次热更悄悄改了必需字段。3.1 会讨论连接状态机,决定断线时哪些请求应该取消、哪些应该保留到重连后重发。
重连后是否需要补状态,方舟的比例是 31%,消消是 8%。这个差异会直接影响 3.x 的恢复策略,本节只负责把「请求还在不在等」这件事记清楚。
小结
- 请求、响应、推送是三种契约:只有请求需要回包,只有请求可以重试。
- 请求 id 由客户端分配,重试必须复用同一个 id;服务端按 id 幂等,客户端按 id 去重。
- 状态机只保留 pending、retrying、settled、timed-out、abandoned 五个状态,迟到的响应是 duplicate 而不是新成功。
- 超时要和心跳、RTT P95、弱网占比一起定;连接存活与请求截止是两层判断。
- 延迟从首次发送算,取消要能关闭打开状态的请求,推送不应进入请求表。
阅读导航:上一节:2.1 消息信封、版本与兼容规则 · 下一节:2.3 协议契约测试:把兼容规则写成断言 。
常见错误与边界
- 把推送塞进请求表。快照没有 requestId,也不需要回包;塞进去会让表随战斗时长增长,并让超时扫描误伤推送。
- 重试时生成新的 requestId。服务端会把每次重试当成新操作,幂等失效,最典型的结果是重复扣费或重复释放技能。
- 把迟到响应当成新成功。请求已经超时并重试后,第一次的响应到达时必须按 duplicate 忽略。
- 延迟从最后一次重试开始算。这会系统性低估玩家感知的等待时间,让弱网问题在指标上消失。
- 超时阈值短于 RTT P95。方舟 RTT P95 是 240ms,阈值贴着这个值会让弱网下大量请求无谓重试。
- 超时阈值长于心跳间隔。连接可能已被心跳判定失活,请求却还在等一个不会来的响应。
- 重试次数口径混乱。「重试两次」和「最多发两次」差一次,线上往往就是这一次造成重复结算。
- 取消只删记录、不改状态。删除后迟到的响应会变成 unknown,调用方无法区分「从未发出」和「已经放弃」。
- 边界:跟踪器不负责业务幂等。它只能保证同一个 requestId 不重复结算,服务端仍需要按 id 做业务层去重。
- 边界:跟踪器不负责连接重连。断线时哪些请求保留、哪些取消,由连接状态机和业务优先级决定,本节不替它们做选择。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。