为什么先搭骨架
前两节已经有两类结论: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 与本文对照 |
验收时有两条硬规则:
- 没有原始采样,只有汇总值,不算证据。 汇总值可以复算,但无法回答「是不是某一段异常拉高了 P95」。
- 阈值变更必须同时改事实表与门禁。 只在代码里改阈值,报告会与文档脱节;两者不一致时,以事实表为准。
这两条规则看起来繁琐,但它们把「实验结果」和「实验结果的可信度」分开了。骨架能保证后者,前者仍然要靠真机与真实网络。
两组夹具的基线对照
把消消与方舟的夹具放在一起,可以看到骨架需要同时容纳两种节奏:
| 夹具字段 | 消消 | 方舟 |
|---|---|---|
| RTT P50 / P95 | 78ms / 310ms | 62ms / 240ms |
| 丢包率(中位会话) | 0.4% | 0.6% |
| 弱网会话占比 | 22% | 14% |
| 断线率(每会话) | 2.1% | 1.4% |
| 30 秒内重连成功率 | 97.2% | 98.1% |
| 重连后需补状态 | 8% | 31% |
| 战斗心跳 / 大厅心跳 | 25 秒 / 25 秒 | 5 秒 / 20 秒 |
| 每会话流量 | 3.2MB | 18MB |
这张表说明夹具不能只存「网络质量」,还要存「恢复能力」与「节奏」。消消的弱网会话更多,但重连后只有 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 消息信封、版本与兼容规则 。
常见错误与边界
- 把骨架做成一次性脚本。 输入不可复现、阈值散落、输出不可比,换一个版本就要重写,实验结论无法累积。
- 把门禁写进探针。 探针一旦知道阈值,就容易为了让结果好看而调整采样;两者必须分离。
- 只存汇总值,不留原始采样。 汇总值无法回答「哪一段异常拉高了 P95」,排查时会重新抓一遍数据。
- 把
weak与passed当成同一个字段。 弱网是事实判断,通过门禁是标准判断;合并后无法表达「弱网但恢复达标」。 - 夹具与事实表脱节。 代码里的夹具值改了,文档没改,报告就会与正文对不上;两者不一致时以事实表为准。
- 用切换次数单独设门禁。 切换是否造成问题取决于恢复策略,切换次数只适合统计与关联分析。
- 把骨架当成真机验收。 骨架验证的是口径与夹具的自洽性,不能证明某个机型在某个运营商网络下的真实表现。
- 所有章节共用一份可变夹具。 后续章节应新增夹具或字段,而不是修改已有基线,否则历史报告无法复现。
- 报告只打印通过与否。 没有 P50、P95、丢包与切换的明细,失败时无法定位,通过时也无法对比。
- 忽略门禁之外的恢复指标。 默认门禁只有两条,但
disconnectRate、reconnectRate、supplementShare已经进入夹具;后续章节接入恢复实验时,不能假装这些字段不存在。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。