《移动游戏产品的架构·网络、资源与热更新》2.2 请求响应、推送与超时语义

同一条长连接上,请求、响应和推送的语义完全不同。本节以方舟战斗中 20Hz 上行输入和 10Hz 快照下发为背景,拆解请求 id、重试次数、超时截止与重复响应的处理,并给出一个可测试的请求跟踪器,把「超时后到达的包」变成明确的状态而不是新的请求。

一场战斗里的三种流量

《潮汐方舟》的战斗连接上同时跑着三类流量。客户端以 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 协议契约测试:把兼容规则写成断言 。

常见错误与边界

  1. 把推送塞进请求表。快照没有 requestId,也不需要回包;塞进去会让表随战斗时长增长,并让超时扫描误伤推送。
  2. 重试时生成新的 requestId。服务端会把每次重试当成新操作,幂等失效,最典型的结果是重复扣费或重复释放技能。
  3. 把迟到响应当成新成功。请求已经超时并重试后,第一次的响应到达时必须按 duplicate 忽略。
  4. 延迟从最后一次重试开始算。这会系统性低估玩家感知的等待时间,让弱网问题在指标上消失。
  5. 超时阈值短于 RTT P95。方舟 RTT P95 是 240ms,阈值贴着这个值会让弱网下大量请求无谓重试。
  6. 超时阈值长于心跳间隔。连接可能已被心跳判定失活,请求却还在等一个不会来的响应。
  7. 重试次数口径混乱。「重试两次」和「最多发两次」差一次,线上往往就是这一次造成重复结算。
  8. 取消只删记录、不改状态。删除后迟到的响应会变成 unknown,调用方无法区分「从未发出」和「已经放弃」。
  9. 边界:跟踪器不负责业务幂等。它只能保证同一个 requestId 不重复结算,服务端仍需要按 id 做业务层去重。
  10. 边界:跟踪器不负责连接重连。断线时哪些请求保留、哪些取消,由连接状态机和业务优先级决定,本节不替它们做选择。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查