《移动游戏产品的架构·网络、资源与热更新》1.3 mga-lab-net 的网络实验骨架与验收口径

实验骨架的价值不是多写一个脚本,而是把「弱网」从形容词变成可复现的输入与可比的输出。本节用 mga-lab-net 把探针、门禁与报告拆成三层,给出消消与方舟两组夹具、验收阈值和证据要求,并说明骨架在后续章节如何扩展而不返工,让每次实验都能复现。

为什么先搭骨架

前两节已经有两类结论:1.1 把网络拆成弱网、切换、丢包,1.2 把联网形态拆成弱联网与强联网。接下来的问题是:这些结论怎么验证?

最常见的做法是临时写一个脚本,抓一段日志,算一个平均值,然后得出结论「这局还行」。这种脚本有三个问题:输入不可复现、阈值散落在各处、输出无法比较。换一个机型、换一个版本,结论就失去意义。

mga-lab-net 的目标不是做一套完整的网络测试平台,而是先固定三件事:探针怎么输入、门禁怎么判定、报告怎么输出。骨架定下来之后,后面章节的协议、心跳、重连、资源下载都可以按同一形状接入,而不需要每次重写验证逻辑。

骨架的三层:探针、门禁、报告

把实验拆成三层,每层只做一件事:

层输入输出不负责
探针RTT 采样、发送/送达包数、切换次数P50、P95、丢包率、弱网标记判断业务对错
门禁探针结果、阈值通过/失败与失败原因解释失败根因
报告多条探针结果可对比的表格与汇总替代真机验收

三层分开的好处是,任何一层都能单独替换。比如后续接入真实抓包时,只替换探针的输入,门禁与报告不用改;调整阈值时,只改门禁,不动探针。

这也解释了为什么门禁不应该写进探针。探针负责「如实描述发生了什么」,门禁负责「按什么标准判断」,两者混在一起,最后会出现「为了让结果好看而修改采样」的问题。

把探针写成可复用夹具

下面的骨架把消消与方舟的事实表固化成两组夹具,并提供探针、门禁、汇总与报告四个函数。

// file: src/lab/net-lab.ts
// tier: L1
// verify: node --test test/lab/net-lab.test.ts

export interface NetProbe {
  readonly id: string;
  readonly rttMs: readonly number[];
  readonly packetsSent: number;
  readonly packetsDelivered: number;
  readonly switches: number;
}

export type GateMetric = "rttP95" | "lossRatio";

export interface Gate {
  readonly metric: GateMetric;
  readonly max: number;
}

export interface ProbeResult {
  readonly id: string;
  readonly rttP50: number;
  readonly rttP95: number;
  readonly lossRatio: number;
  readonly switches: number;
  readonly weak: boolean;
  readonly passed: boolean;
  readonly failures: readonly string[];
}

export interface SuiteResult {
  readonly total: number;
  readonly passed: number;
  readonly failed: number;
  readonly failures: readonly string[];
}

export const DEFAULT_GATES: readonly Gate[] = [
  { metric: "rttP95", max: 300 },
  { metric: "lossRatio", max: 0.02 },
];

export const CASE_FIXTURES = {
  casual: {
    label: "潮汐消消",
    rttP50Ms: 78,
    rttP95Ms: 310,
    lossRatio: 0.004,
    weakShare: 0.22,
    disconnectRate: 0.021,
    reconnectRate: 0.972,
    supplementShare: 0.08,
    heartbeatCombatSeconds: 25,
    heartbeatLobbySeconds: 25,
    sessionTrafficMB: 3.2,
  },
  midcore: {
    label: "潮汐方舟",
    rttP50Ms: 62,
    rttP95Ms: 240,
    lossRatio: 0.006,
    weakShare: 0.14,
    disconnectRate: 0.014,
    reconnectRate: 0.981,
    supplementShare: 0.31,
    heartbeatCombatSeconds: 5,
    heartbeatLobbySeconds: 20,
    sessionTrafficMB: 18,
  },
} as const;

export function percentile(values: readonly number[], p: number): number {
  if (!Number.isFinite(p) || p <= 0 || p > 100) {
    throw new RangeError(`percentile must be in (0, 100], got ${p}`);
  }
  if (values.length === 0) {
    throw new Error("percentile needs at least one value");
  }
  for (const value of values) {
    if (!Number.isFinite(value) || value < 0) {
      throw new RangeError(`invalid sample value: ${value}`);
    }
  }
  const sorted = [...values].sort((a, b) => a - b);
  const rank = Math.ceil((p / 100) * sorted.length);
  return sorted[Math.min(rank, sorted.length) - 1];
}

export function runProbe(probe: NetProbe, gates: readonly Gate[] = DEFAULT_GATES): ProbeResult {
  if (probe.rttMs.length === 0) {
    throw new Error(`${probe.id}: rttMs must not be empty`);
  }
  if (!Number.isInteger(probe.packetsSent) || probe.packetsSent <= 0) {
    throw new RangeError(`${probe.id}: packetsSent must be a positive integer`);
  }
  if (
    !Number.isInteger(probe.packetsDelivered) ||
    probe.packetsDelivered < 0 ||
    probe.packetsDelivered > probe.packetsSent
  ) {
    throw new RangeError(`${probe.id}: packetsDelivered out of range`);
  }
  if (!Number.isInteger(probe.switches) || probe.switches < 0) {
    throw new RangeError(`${probe.id}: switches must be a non-negative integer`);
  }
  const rttP50 = percentile(probe.rttMs, 50);
  const rttP95 = percentile(probe.rttMs, 95);
  const lossRatio = 1 - probe.packetsDelivered / probe.packetsSent;
  const metrics: Record<GateMetric, number> = {
    rttP95,
    lossRatio,
  };
  const failures: string[] = [];
  for (const gate of gates) {
    const value = metrics[gate.metric];
    if (value > gate.max) {
      failures.push(`${gate.metric}=${value.toFixed(4)}>${gate.max}`);
    }
  }
  return {
    id: probe.id,
    rttP50,
    rttP95,
    lossRatio,
    switches: probe.switches,
    weak: rttP95 > 300 || lossRatio > 0.02,
    passed: failures.length === 0,
    failures,
  };
}

export function evaluateSuite(results: readonly ProbeResult[]): SuiteResult {
  const failures = results
    .filter((result) => !result.passed)
    .map((result) => `${result.id}:${result.failures.join(",")}`);
  return {
    total: results.length,
    passed: results.length - failures.length,
    failed: failures.length,
    failures,
  };
}

export function formatReport(results: readonly ProbeResult[]): string {
  const header = "probe\trttP50\trttP95\tloss\tweak\tswitches\tpassed";
  const rows = results.map((result) =>
    [
      result.id,
      `${result.rttP50}ms`,
      `${result.rttP95}ms`,
      `${(result.lossRatio * 100).toFixed(2)}%`,
      result.weak ? "yes" : "no",
      String(result.switches),
      result.passed ? "yes" : "no",
    ].join("\t"),
  );
  return [header, ...rows].join("\n");
}

CASE_FIXTURES 只保存事实表里的基线值:消消的 RTT P50 78ms、P95 310ms、弱网会话 22%、重连率 97.2%、补状态 8%;方舟的 P50 62ms、P95 240ms、弱网会话 14%、重连率 98.1%、补状态 31%。这些值不是用来「跑出」结果的,而是用来做一致性检查:如果某次实验的夹具与事实表对不上,报告就不可信。

DEFAULT_GATES 只有两条,分别对应 1.1 的弱网定义:P95 不超过 300ms、丢包不超过 2%。切换次数会被统计并打印,但不在默认门禁里,因为切换是否造成问题取决于恢复策略,而不是切换次数本身。

runProbe 的输出包含 weak 与 passed 两个容易混淆的字段:

  • weak 描述这条探针是否满足弱网定义,是事实判断。
  • passed 描述这条探针是否通过门禁,是标准判断。

一条弱网探针完全可能通过门禁:如果门禁只关心 P95,而这条探针的 P95 刚好在阈值内、丢包也正常,它仍然是弱网吗?不会,因为弱网定义本身就是由这两个阈值组成的。真正会分离的是后续加入的恢复类门禁:网络弱,但恢复达标,属于「弱网可用」;网络不弱,但恢复不达标,属于「质量回退」。把 weak 与 passed 分开,才能表达这两种情况。

formatReport 输出一张制表符分隔的表:

probe	rttP50	rttP95	loss	weak	switches	passed
casual-probe	78ms	120ms	0.40%	no	1	yes

这张表可以直接贴进实验记录,也可以作为后续基线对比的输入。它的列是固定的,行数随探针数量增长,适合机器读取,也适合人快速扫一眼。

验收口径:指标、阈值、证据

网络实验最常见的争议不是「结果好不好」,而是「这个结果算不算数」。把验收口径写成三列,可以消掉大部分争议:

指标阈值证据
会话 P95不超过 300ms探针原始采样与 rttP95 字段
丢包率不超过 2%发送/送达包数与 lossRatio 字段
弱网标记与阈值一致reasons 列表,必须能指出触发项
切换次数只统计,不单独设门禁switches 字段与时间轴
夹具一致性与事实表逐项相等CASE_FIXTURES 与本文对照

验收时有两条硬规则:

  1. 没有原始采样,只有汇总值,不算证据。 汇总值可以复算,但无法回答「是不是某一段异常拉高了 P95」。
  2. 阈值变更必须同时改事实表与门禁。 只在代码里改阈值,报告会与文档脱节;两者不一致时,以事实表为准。

这两条规则看起来繁琐,但它们把「实验结果」和「实验结果的可信度」分开了。骨架能保证后者,前者仍然要靠真机与真实网络。

两组夹具的基线对照

把消消与方舟的夹具放在一起,可以看到骨架需要同时容纳两种节奏:

夹具字段消消方舟
RTT P50 / P9578ms / 310ms62ms / 240ms
丢包率(中位会话)0.4%0.6%
弱网会话占比22%14%
断线率(每会话)2.1%1.4%
30 秒内重连成功率97.2%98.1%
重连后需补状态8%31%
战斗心跳 / 大厅心跳25 秒 / 25 秒5 秒 / 20 秒
每会话流量3.2MB18MB

这张表说明夹具不能只存「网络质量」,还要存「恢复能力」与「节奏」。消消的弱网会话更多,但重连后只有 8% 需要补状态;方舟网络更好,却有 31% 需要补状态。如果骨架只存 P50/P95,就会把两种完全不同的恢复成本压成同一个分数。

因此 CASE_FIXTURES 里同时保留了 disconnectRate、reconnectRate、supplementShare、heartbeatCombatSeconds、heartbeatLobbySeconds 与 sessionTrafficMB。这些字段暂时不参与默认门禁,但它们是后续章节接入心跳实验、重连实验与流量实验的挂点。

骨架如何长大

mga-lab-net 是卷二的公共实验工程,后续章节会按同一形状扩展:

  • 第 2 章加入消息信封探针:输入是编码后的消息,输出是版本、序号与兼容性判定。
  • 第 3 章加入心跳与重连探针:输入是心跳间隔与断线时间轴,输出是发现时延与恢复结果。
  • 第 4 章加入资源下载探针:输入是分片大小与网络画像,输出是下载成功率与重试次数。
  • 第 5 章之后加入热更探针:输入是补丁与设备状态,输出是拉取、校验、应用三步的结果。

这些扩展都遵循同一个约束:探针只描述事实,门禁只做判定,报告只做对比。只要三层不混,新增实验就不会污染已有基线。

还有一条边界要提前说清楚:骨架不替代真机验收。它能验证口径是否自洽、夹具是否与事实表一致、门禁是否按预期触发;它不能证明某个机型在某个运营商网络下的真实表现。真机数据应该作为新的夹具输入,而不是直接修改骨架里的常量。

与相邻章节的关系

本节把 1.1 的会话画像与 1.2 的责任模型接进同一套实验骨架。1.1 提供「怎么算」,1.2 提供「算什么」,本节提供「怎么验」。

第 2 章开始的消息信封会直接复用这里的探针与报告格式;第 3 章的心跳与重连会用 heartbeatCombatSeconds、heartbeatLobbySeconds 与 supplementShare 作为输入;资源与热更章节则会沿用同一套「夹具 + 门禁 + 报告」的验收口径。

小结

  • 实验骨架先固定三件事:探针怎么输入、门禁怎么判定、报告怎么输出。
  • 探针只描述事实,门禁只做判定,报告只做对比,三层不要混。
  • 默认门禁只包含 300ms 与 2% 两条,对应 1.1 的弱网定义;切换次数只统计,不单独设门禁。
  • weak 是事实判断,passed 是标准判断,两个字段必须分开。
  • 夹具不只存网络质量,还要存恢复能力与节奏,否则会把不同形态压成同一个分数。
  • 夹具必须与事实表逐项一致;阈值变更要同时改事实表与门禁。
  • 骨架不替代真机验收,真机数据应作为新的夹具输入。

阅读导航:上一节:1.2 弱联网与强联网:两种联网形态的责任边界 · 下一节:2.1 消息信封、版本与兼容规则 。

常见错误与边界

  1. 把骨架做成一次性脚本。 输入不可复现、阈值散落、输出不可比,换一个版本就要重写,实验结论无法累积。
  2. 把门禁写进探针。 探针一旦知道阈值,就容易为了让结果好看而调整采样;两者必须分离。
  3. 只存汇总值,不留原始采样。 汇总值无法回答「哪一段异常拉高了 P95」,排查时会重新抓一遍数据。
  4. 把 weak 与 passed 当成同一个字段。 弱网是事实判断,通过门禁是标准判断;合并后无法表达「弱网但恢复达标」。
  5. 夹具与事实表脱节。 代码里的夹具值改了,文档没改,报告就会与正文对不上;两者不一致时以事实表为准。
  6. 用切换次数单独设门禁。 切换是否造成问题取决于恢复策略,切换次数只适合统计与关联分析。
  7. 把骨架当成真机验收。 骨架验证的是口径与夹具的自洽性,不能证明某个机型在某个运营商网络下的真实表现。
  8. 所有章节共用一份可变夹具。 后续章节应新增夹具或字段,而不是修改已有基线,否则历史报告无法复现。
  9. 报告只打印通过与否。 没有 P50、P95、丢包与切换的明细,失败时无法定位,通过时也无法对比。
  10. 忽略门禁之外的恢复指标。 默认门禁只有两条,但 disconnectRate、reconnectRate、supplementShare 已经进入夹具;后续章节接入恢复实验时,不能假装这些字段不存在。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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