《Cocos 游戏联机实战》7.1 弱网模拟与网络诊断

本地复现线上卡顿的前提,是能造出一模一样的弱网。本节把弱网拆成延迟、抖动、丢包、乱序、带宽与突发中断六种病,给出一个可配置、可脚本化的弱网 Transport 装饰器,用固定随机种子保证可复现,并用 vitest 断言丢包率与延迟分布落在设定区间,最后列出诊断面板该采集的指标。

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心跳往返链路本身
jitterRTT 的滑动标准差插值缓冲不够
丢包率序号缺口重传策略
重传次数重传计数器超时参数
快照间隔相邻快照到达差服务端节奏或带宽
预测回滚次数和解触发器预测误差超阈

前五项是「网络层」的,最后一项是「同步层」的。区分二者很重要:回滚次数飙高但网络指标正常,问题就在预测模型,不在网络。第 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 里跑,我们就能同时驱动成百上千个机器人客户端,用它们来压测自己这套逻辑在规模下的表现。

阅读导航:上一节:目录 · 下一节:7.2 机器人压测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画