7.2 机器人压测
上一节造出了弱网,这一节造规模。提到「压测」,多数人第一反应是压服务端——站内 game/server/ 里那批文章和书稿《游戏服务端编程实践》讲的就是服务端怎么扛并发。本节要压的却是客户端自己:当同一台机器上同时跑着成百上千个玩家逻辑时,我们这套模拟、消息处理与快照消费还扛不扛得住。
为什么客户端也需要压测?因为联机把「一个玩家」变成了「N 个玩家」,而客户端的逻辑层——确定性模拟、快照应用、预测和解——是要每个玩家各跑一份的。机器人压测就是把这 N 份同时放进一个进程里,看它先在哪里喘不过气。
7.2.1 压的是谁:为什么是「客户端压自己」
先厘清边界。真实压测里,机器人是客户端:它连服务端、收快照、跑逻辑,和真人客户端走同一套代码路径。我们压的不是服务端吞吐(那是服务端的事),而是:
- 逻辑层的单帧成本:N 个玩家各跑一份模拟,一帧要花多少毫秒;
- 消息处理的吞吐:N 份快照在客户端解析、应用的开销;
- 内存与 GC:N 份状态常驻会不会把堆推爆。
这三项都是客户端指标。它们和服务端指标(QPS、连接数、CPU 占用)是两条独立的线,不要把两者混在一张表里。
7.2.2 机器人客户端的构造:复用 L1 逻辑层
机器人之所以能「成百上千个跑在一个 Node 进程里」,靠的是卷一就定下的分层:逻辑层不依赖渲染、不依赖引擎。机器人和真实玩家共用同一份 Bot.step 逻辑,区别只在输入来源——真人来自键盘,机器人来自脚本。
这个「共用同一份」是硬要求。如果机器人另写一套简化逻辑,压出来的数字就没有意义:它测的不是你线上真正会跑的那份代码。
7.2.3 脚本化行为:走、跳、收集
机器人行为要确定且有代表性。「确定」是为了可复现——同配置跑两次结果一致;「有代表性」是为了让负载接近真人——不能所有机器人都在原地不动。下面用 id 与帧号生成相位错开的走跳收集行为:
// net/bot.ts —— 纯 TS,无任何 cc 依赖
export interface BotState {
x: number;
y: number;
vy: number;
grounded: boolean;
collected: number;
}
export interface BotInput {
jump: boolean;
moveRight: boolean;
collect: boolean;
}
export interface BotConfig {
speed: number;
gravity: number;
jumpVelocity: number;
}
/** 一个无渲染的机器人客户端:只跑逻辑,不碰节点与引擎 */
export class Bot {
readonly id: number;
state: BotState;
private readonly cfg: BotConfig;
private tick = 0;
constructor(id: number, cfg: BotConfig) {
this.id = id;
this.cfg = cfg;
this.state = { x: 0, y: 0, vy: 0, grounded: true, collected: 0 };
}
/** 脚本化行为:每帧产出一个输入,由 id 与帧号决定,完全确定 */
think(): BotInput {
this.tick += 1;
const phase = (this.tick + this.id * 7) % 90;
return {
jump: this.state.grounded && phase % 30 === 0,
moveRight: true,
collect: phase % 15 === 0,
};
}
/** 本地应用输入,与真实客户端共用同一套逻辑 */
step(input: BotInput, dt: number): void {
if (input.jump && this.state.grounded) {
this.state.vy = this.cfg.jumpVelocity;
this.state.grounded = false;
}
this.state.vy += this.cfg.gravity * dt;
this.state.y += this.state.vy * dt;
if (this.state.y <= 0) {
this.state.y = 0;
this.state.vy = 0;
this.state.grounded = true;
}
if (input.moveRight) this.state.x += this.cfg.speed * dt;
if (input.collect) this.state.collected += 1;
}
}
think 用 (tick + id * 7) % 90 给每个机器人一个错开的相位,于是同一帧里有人跳、有人收集,负载分布接近真实。这是「脚本化行为」的最小形态;真实项目里可以把它换成录制的真人输入回放,更贴近线上。
7.2.4 L1:机器人驱动循环
驱动循环负责「一帧里把 N 个机器人各推进一步」,并把这一帧的耗时量出来:
// net/bot-driver.ts
import type { Transport } from './transport';
import type { Bot } from './bot';
export interface BotFrameReport {
bots: number;
stepMs: number; // 本帧驱动全部机器人的耗时
handledMessages: number; // 累计处理的消息数
}
export class BotDriver {
private readonly bots: Bot[];
private readonly dt: number;
private readonly clock: () => number;
private handledMessages = 0;
constructor(bots: Bot[], transport: Transport, dt: number, clock: () => number) {
this.bots = bots;
this.dt = dt;
this.clock = clock;
// 消息处理也计入客户端开销,不能只算 step
transport.onMessage(() => { this.handledMessages += 1; });
}
/** 单帧:每个机器人思考一次并前进一次 */
tick(): BotFrameReport {
const t0 = this.clock();
for (const bot of this.bots) {
bot.step(bot.think(), this.dt);
}
return { bots: this.bots.length, stepMs: this.clock() - t0, handledMessages: this.handledMessages };
}
}
clock 同样注入。测试里传 performance.now() 量真实耗时,也可以传假时钟让循环在零成本下跑完逻辑——两种用法共用同一个驱动类。
7.2.5 假服务端:只回最小快照
机器人需要一个「对面」才像联机。这里给出的是一个测试替身——它不做任何服务端逻辑,只在收到消息时回一个最小快照,用来触发客户端侧的消息处理路径。它不是服务端实现;真正的服务端裁决见站内服务端专题。
// net/fake-server.ts —— 测试替身,只回最小快照,不是服务端实现
import type { Transport } from './transport';
export class FakeSnapshotServer implements Transport {
private listener: ((p: Uint8Array) => void) | null = null;
private snapshots = 0;
send(payload: Uint8Array): void {
const ack = new Uint8Array(12); // 1 字节类型 + 4 字节回显 + 1 字节序号 + 保留
ack[0] = 0x01; // 0x01 = 快照
ack.set(payload.subarray(0, 4), 4);
ack[8] = this.snapshots++ & 0xff;
this.listener?.(ack);
}
onMessage(cb: (p: Uint8Array) => void): void { this.listener = cb; }
close(): void { this.listener = null; }
get snapshotCount(): number { return this.snapshots; }
}
它实现的是同一个 Transport 接口,所以能直接替换真实连接塞进 BotDriver。这就是第 1 章把传输层抽象成接口的回报——压测不需要真服务端,一个 12 字节的假快照就够触发整条客户端处理链。
7.2.6 vitest:单帧耗时预算
下面把「N 个机器人下单帧耗时在预算内」写成断言。60 Hz 的单帧预算是 16.6 ms,逻辑层应该远远低于它——这里取 8 ms 作为门槛:
import { describe, expect, it } from 'vitest';
import { Bot } from './bot';
import { BotDriver } from './bot-driver';
import { FakeSnapshotServer } from './fake-server';
const cfg = { speed: 120, gravity: -900, jumpVelocity: 320 };
function makeBots(n: number): Bot[] {
return Array.from({ length: n }, (_, i) => new Bot(i, cfg));
}
describe('BotDriver', () => {
it('1000 个机器人单帧逻辑耗时远低于 60Hz 预算', () => {
const driver = new BotDriver(makeBots(1000), new FakeSnapshotServer(), 1 / 60, () => performance.now());
for (let i = 0; i < 20; i++) driver.tick(); // 预热,排除首次 JIT 抖动
let worst = 0;
for (let i = 0; i < 300; i++) worst = Math.max(worst, driver.tick().stepMs);
expect(worst).toBeLessThan(8);
});
it('相同配置的机器人轨迹可复现', () => {
const run = () => makeBots(50).map((b) => {
for (let i = 0; i < 300; i++) b.step(b.think(), 1 / 60);
return b.state;
});
expect(run()).toEqual(run());
});
});
两段断言分工明确:第一段量规模下的耗时,第二段保确定性——只有行为确定,第一次跑出的耗时数字才有可比性。performance.now() 在 Node 里是全局可用的,不需要额外引入。
注意这段断言依赖机器性能,属于「本机基线」而非「通用阈值」。 换机器、换 Node 版本,8 ms 可能要调。它的价值不在于那个具体数字,而在于给出一个可重复的测量口径:同样的机器、同样的规模,改动前后各跑一次,差值就是改动带来的真实开销。
7.2.7 客户端要观测哪些指标
压测不能只看「跑没跑完」,要盯下面这几项客户端指标。注意它们全是客户端的:
| 指标 | 采集方式 | 恶化意味着 |
|---|---|---|
| 单帧逻辑耗时 | tick().stepMs | 逻辑层太重,玩家数一多就掉帧 |
| 消息处理耗时 | 解析 + 应用快照的计时 | 编解码或 diff 应用是瓶颈 |
| 堆内存 | process.memoryUsage().heapUsed | 每份状态太大或泄漏 |
| GC 次数 | --expose-gc 或 perf_hooks | 分配速率过高,会有 GC 尖峰 |
| 峰值 RTT | 假传输层统计 | 消息积压,处理跟不上发送 |
前四项是「客户端算得动吗」,最后一项是「客户端跟得上吗」。两项都要看:算得动但跟不上,同样会表现为卡。
7.2.8 结论怎么写才可信
压测最容易翻车的地方不是测,而是写结论。三条纪律:
- 写清机器配置与规模:CPU 型号、内存、Node 版本、机器人数量、单帧步长。没有这些,「1000 机器人 3ms」是无意义数字。
- 不编造精确数字:本节的 8 ms 是本机门槛,不是普适结论。你的机器上可能是 2 ms,也可能是 20 ms——必须以自己实测为准。
- 给复现命令:结论后面附上跑测的命令与种子,别人能一条命令复现,结论才成立。
反面教材是「实测 1000 机器人仅耗时 1.7ms」——精确到小数点却不说机器,等于没测。
7.2.9 接进 Cocos:压测开关
机器人压测会吃满 CPU,绝不能带进玩家包。用 sys.platform 把它锁在桌面浏览器与编辑器里:
import { _decorator, Component, sys } from 'cc';
const { ccclass } = _decorator;
@ccclass('BotLoadTestGate')
export class BotLoadTestGate extends Component {
onLoad(): void {
const desktop =
sys.platform === sys.Platform.DESKTOP_BROWSER ||
sys.platform === sys.Platform.EDITOR_PAGE;
if (!desktop) {
console.log('[bot] skip load test on', sys.platform);
this.enabled = false;
return;
}
console.log('[bot] load test enabled on', sys.platform);
}
}
sys.platform 的取值是 sys.Platform 里的字符串枚举成员(DESKTOP_BROWSER / EDITOR_PAGE / WECHAT_GAME 等),所以判断用 === 比对成员,而不是猜数字。真机与小游戏上直接 this.enabled = false,把整个组件关掉。
下一节收尾:压测能在本地把负载压出来,可线上那些「偶发卡一下」还是得靠日志抓。我们会讲清从症状到原因的排查表、客户端该落哪些日志、以及怎么把线上抓到的网络序列喂回本节与上节的模拟器里复现。
阅读导航:上一节:7.1 弱网模拟与网络诊断 · 下一节:7.3 线上问题定位 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。