7.1 弱网模拟与网络诊断
前三卷把《方块冲刺》的单机体验做扎实了,前六章又把同步、预测与重连接上了。可一到联调,问题几乎总是同一句话:「我本地好好的,线上就是卡。」 这句话的根因是——你本地根本没有线上那种网络。
站内 game/server/ 那 315 篇与书稿《游戏服务端编程实践》讲的是服务端怎么扛住弱网(限流、超时、幂等、断线保护)。本节只讲客户端这一侧:怎么在本地把线上网络造出来,又怎么把它的表现量化成能看的指标。
一句话概括本节的立场:不能复现的弱网,等于没有弱网。 所以我们要的不是「拔网线」这种粗糙手段,而是一个可配置、可脚本化、可复现的弱网模拟器,把它做成一个 Transport 装饰器套在真实连接外面。
7.1.1 为什么必须用模拟器,而不是「拔网线」
「拔网线」能测出什么?能测出「彻底断了」这一种极端情况——也就是第 5 章讲的断线重连。但线上真正折磨人的不是断,而是半死不活:延迟忽高忽低、偶尔丢一个包、偶尔乱序、偶尔带宽被压到几十 KB/s。这些状态手动拔网线永远造不出来,更没法重复。
模拟器相对手动操作有三个不可替代的点:
- 可复现:同一个随机种子,跑两次得到完全一样的丢包与延迟序列,bug 才能被稳定重现。
- 可回归:把「地铁里进隧道断 2 秒」写进配置,纳入 CI,每次改动都能重跑同一场景。
- 可组合:延迟 + 丢包 + 乱序可以叠加,单独拔网线只能一次造一种。
所以弱网模拟器的第一设计原则不是「像」,而是确定——给定配置与种子,输出必须逐字节一致。
7.1.2 弱网要造出哪六种病
「网络差」是个笼统说法,拆开至少是六种互相独立的病。每种病破坏的环节不同,兜底策略也不同:
| 病 | 参数 | 主要破坏什么 | 该由谁兜 |
|---|---|---|---|
| 延迟 | latencyMs | 操作到反馈的时延 | 本地预测(4.1) |
| 抖动 | jitterMs | 到达节奏不稳定 | 插值缓冲(4.3) |
| 丢包 | lossRate | 快照缺失 | 重传与幂等(5.2) |
| 乱序 | reorderRate | 快照序号跳变 | 序号去重与排序(5.2) |
| 带宽限制 | bandwidthKBps | 发送排队、积压 | 增量同步与压缩(3.3) |
| 突发中断 | 时间线 | 短时全丢 | 重连与降级(5.1 / 5.3) |
把这六种都做进同一个装饰器,是因为它们在真实链路上同时发生。只测丢包不测乱序,就会漏掉「迟到但没丢」的快照——那正是预测和解里最容易被写错的边界。
7.1.3 L1:一个可配置的弱网 Transport 装饰器
下面的实现是纯 TypeScript,零 cc 依赖,直接能在 Node 里跑。它包住任意 Transport,出站与入站都过一遍弱网模型;时钟由外部注入,所以测试可以用假时钟精确推进。
// net/weak-net.ts —— 纯 TS,无任何 cc 依赖
import type { Transport } from './transport';
export interface WeakNetConfig {
latencyMs: number; // 单程基础延迟
jitterMs: number; // 抖动幅度(±)
lossRate: number; // 丢包率 0~1
duplicateRate: number; // 重复率 0~1
reorderRate: number; // 乱序率 0~1(额外等一个基础延迟)
bandwidthKBps: number; // 带宽上限,<=0 表示不限
seed: number; // 固定随机种子
}
export interface OutageWindow {
startMs: number;
durationMs: number;
}
/** mulberry32:32 位状态的小型伪随机数发生器,跨运行完全可复现 */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
interface Pending {
at: number;
seq: number;
payload: Uint8Array;
}
export class WeakNetworkTransport implements Transport {
private readonly inner: Transport;
private readonly cfg: WeakNetConfig;
private readonly rng: () => number;
private readonly now: () => number;
private outbox: Pending[] = [];
private inbox: Pending[] = [];
private listeners: Array<(p: Uint8Array) => void> = [];
private outages: OutageWindow[] = [];
private seq = 0;
private nextFreeAt = 0;
readonly stats = { sent: 0, dropped: 0, delivered: 0, duplicated: 0, reordered: 0 };
constructor(inner: Transport, cfg: WeakNetConfig, now: () => number) {
this.inner = inner;
this.cfg = cfg;
this.rng = mulberry32(cfg.seed);
this.now = now;
inner.onMessage((p) => this.enqueueInbound(p)); // 入站同样过弱网
}
send(payload: Uint8Array): void {
this.stats.sent += 1;
const t = this.now();
if (this.inOutage(t) || this.rng() < this.cfg.lossRate) {
this.stats.dropped += 1;
return;
}
const delay = this.sampleDelay();
this.outbox.push({ at: this.scheduleAt(payload.byteLength) + delay, seq: this.seq++, payload });
if (this.rng() < this.cfg.duplicateRate) {
this.stats.duplicated += 1;
this.outbox.push({ at: t + delay + 1, seq: this.seq++, payload });
}
}
onMessage(cb: (payload: Uint8Array) => void): void {
this.listeners.push(cb);
}
close(): void {
this.inner.close();
}
/** 由外部时钟驱动:把所有到期消息真正投递出去 */
advance(): void {
const t = this.now();
this.flush(this.outbox, t, (p) => { this.inner.send(p.payload); this.stats.delivered += 1; });
this.flush(this.inbox, t, (p) => { for (const cb of this.listeners) cb(p.payload); });
}
/** 脚本化时间线:在指定时刻插入突发中断 */
loadTimeline(windows: OutageWindow[]): void {
this.outages = windows;
}
private flush(queue: Pending[], t: number, sink: (p: Pending) => void): void {
queue.sort((a, b) => a.at - b.at || a.seq - b.seq);
const keep: Pending[] = [];
for (const p of queue) {
if (p.at <= t) sink(p);
else keep.push(p);
}
queue.length = 0;
queue.push(...keep);
}
private sampleDelay(): number {
const base = this.cfg.latencyMs + (this.rng() * 2 - 1) * this.cfg.jitterMs;
let extra = 0;
if (this.rng() < this.cfg.reorderRate) {
extra = this.cfg.latencyMs;
this.stats.reordered += 1;
}
return Math.max(0, base + extra);
}
/** 令牌桶式限速:返回这条消息最早可以离开发送队列的时刻 */
private scheduleAt(bytes: number): number {
const rate = this.cfg.bandwidthKBps;
if (rate <= 0) return this.now();
const at = Math.max(this.now(), this.nextFreeAt);
this.nextFreeAt = at + (bytes / 1024 / rate) * 1000;
return at;
}
private enqueueInbound(payload: Uint8Array): void {
const t = this.now();
if (this.inOutage(t) || this.rng() < this.cfg.lossRate) {
this.stats.dropped += 1;
return;
}
this.inbox.push({ at: t + this.sampleDelay(), seq: this.seq++, payload });
}
private inOutage(t: number): boolean {
return this.outages.some((w) => t >= w.startMs && t < w.startMs + w.durationMs);
}
}
几个刻意的设计。第一,随机数发生器自己实现(mulberry32)而不是用 Math.random()——后者不可播种,测试里没法复现。第二,时钟注入(now 参数)而不是直接读 Date.now()——测试用假时钟一步步推进,避免等真实毫秒。第三,出站与入站都过弱网,因为真实链路上两个方向的丢包与延迟是独立发生的。
7.1.4 固定随机种子:让弱网可复现
seed 是整个模拟器的命脉。同一份配置、同一个种子,两次运行必须产出逐字节一致的丢包与延迟序列。这不是「锦上添花」,而是让 bug 可被稳定重现的前提:线上抓到一次异常,只要拿到当时的种子,本地就能把那一刻重放出来。
反过来说,不要在生产包里用固定种子——玩家之间不该有一模一样的网络。种子只在开发与回归时固定;线上要么随机,要么从服务端下发。
7.1.5 vitest:断言丢包率与延迟分布
模拟器对不对,不能靠肉眼看日志。下面用 vitest 把「配置值」与「实测统计」钉在一起:
import { describe, expect, it } from 'vitest';
import { WeakNetworkTransport, type WeakNetConfig } from './weak-net';
import type { Transport } from './transport';
class RecordingTransport implements Transport {
times: number[] = [];
private readonly now: () => number;
constructor(now: () => number) { this.now = now; }
send(): void { this.times.push(this.now()); }
onMessage(): void {}
close(): void {}
}
function run(cfg: WeakNetConfig, count: number) {
let t = 0;
const now = () => t;
const inner = new RecordingTransport(now);
const net = new WeakNetworkTransport(inner, cfg, now);
for (let i = 0; i < count; i++) {
net.send(new Uint8Array(16));
t += 1;
net.advance();
}
// 继续按 1ms 推进,把在途包收干;一次性跳 1000ms 会把到达时刻算偏
for (let k = 0; k < 200; k++) { t += 1; net.advance(); }
return { net, inner };
}
const base: WeakNetConfig = {
latencyMs: 50, jitterMs: 10, lossRate: 0, duplicateRate: 0,
reorderRate: 0, bandwidthKBps: 0, seed: 7,
};
describe('WeakNetworkTransport', () => {
it('实测丢包率接近配置值', () => {
const { net } = run({ ...base, lossRate: 0.3, seed: 42 }, 10000);
const rate = net.stats.dropped / net.stats.sent;
expect(rate).toBeGreaterThan(0.28);
expect(rate).toBeLessThan(0.32);
});
it('延迟分布落在设定区间内', () => {
const { inner } = run(base, 2000);
const delays = inner.times.map((arriveAt, i) => arriveAt - i); // 第 i 个包在 t=i 发出
const mean = delays.reduce((a, b) => a + b, 0) / delays.length;
expect(mean).toBeGreaterThan(48);
expect(mean).toBeLessThan(52);
expect(Math.min(...delays)).toBeGreaterThanOrEqual(40);
expect(Math.max(...delays)).toBeLessThanOrEqual(60);
});
it('同一 seed 两次运行结果完全一致', () => {
const cfg = { ...base, lossRate: 0.2, duplicateRate: 0.05, reorderRate: 0.1, seed: 1234 };
expect(run(cfg, 500).net.stats).toEqual(run(cfg, 500).net.stats);
});
});
三段断言各管一件事:丢包率验证概率模型没写偏(1 万个包,实测落在 ±2% 内);延迟分布验证均值与极值都在 50 ± 10 区间;可复现验证同种子两次运行的统计完全相等。跑起来:
cd mini2d && npx vitest run net/weak-net.test.ts
如果延迟断言偶发失败,先看 advance 的推进粒度——测试里每毫秒推进一次,投递时刻会被量化到整数毫秒,jitterMs 太小时统计会有偏,把推进步长调细即可。
7.1.6 诊断面板要采集什么
模拟器负责「造病」,诊断面板负责「看病」。联机体验劣化时,玩家描述永远是「卡」,但「卡」至少有六种来源,面板必须把它们分开量化:
| 指标 | 采集点 | 恶化时优先怀疑 |
|---|---|---|
| RTT | 心跳往返 | 链路本身 |
| jitter | RTT 的滑动标准差 | 插值缓冲不够 |
| 丢包率 | 序号缺口 | 重传策略 |
| 重传次数 | 重传计数器 | 超时参数 |
| 快照间隔 | 相邻快照到达差 | 服务端节奏或带宽 |
| 预测回滚次数 | 和解触发器 | 预测误差超阈 |
前五项是「网络层」的,最后一项是「同步层」的。区分二者很重要:回滚次数飙高但网络指标正常,问题就在预测模型,不在网络。第 9.2 节会把这张表做成给玩家看的 UI,本节只先把数据采出来。
7.1.7 接进 Cocos:诊断浮层
诊断数据最终要显示在屏幕上。下面这个 L2 组件每约 0.5 秒刷一次 Label,是能过 tsc --noEmit 的公开 API 用法:
import { _decorator, Component, Label, director } from 'cc';
const { ccclass, property } = _decorator;
export interface NetStats {
rttMs: number;
jitterMs: number;
lossRate: number;
retransmits: number;
reconcileCount: number;
}
@ccclass('NetDiagOverlay')
export class NetDiagOverlay extends Component {
@property(Label)
label: Label | null = null;
private stats: NetStats | null = null;
private lastFrame = 0;
setStats(s: NetStats): void {
this.stats = s;
}
update(): void {
const frame = director.getTotalFrames();
if (frame - this.lastFrame < 30 || !this.label || !this.stats) return;
this.lastFrame = frame;
const s = this.stats;
this.label.string =
`RTT ${s.rttMs.toFixed(0)}ms · jitter ${s.jitterMs.toFixed(0)}ms · ` +
`loss ${(s.lossRate * 100).toFixed(1)}% · rb ${s.reconcileCount}`;
}
}
director.getTotalFrames() 返回引擎启动至今的总帧数,用它做「每 30 帧刷新一次」比用 setTimeout 稳——它天然跟渲染帧对齐,不会在后台标签页里疯跑。Label.string 是可读写的访问器,直接赋值即可刷新文本。
下一节把视角从「造弱网」转到「造玩家」:既然逻辑层能脱离渲染在 Node 里跑,我们就能同时驱动成百上千个机器人客户端,用它们来压测自己这套逻辑在规模下的表现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。