7.3 线上问题定位
前两节在本地造出了弱网与规模,但线上那些「偶尔卡一下」依旧抓不到。原因很朴素:线上没有你的断点。 玩家描述永远是「卡」,而「卡」背后的成因有五六种,光靠复述根本分不出来。
本节要做的,是把线上问题变成可复现的工程问题:先用一张症状到原因的排查表把「卡」拆开,再规定客户端该落哪些日志、怎么控制体积,最后用纯 TS 写出环形日志缓冲与事件关联,把线上抓到的网络序列喂回 7.1 的模拟器,在本地重放出那一刻。
7.3.1 从症状到原因:一张排查表
玩家说的「卡」其实是不同现象。先按现象分诊,再谈原因:
| 症状 | 最可能的原因 | 先查什么 |
|---|---|---|
| 橡皮筋(自己弹回又追上去) | 预测与和解 | 和解回滚记录、预测误差超阈次数 |
| 远端瞬移 | 外推失配 | 远端快照间隔、外推时长 |
| 逐渐漂移(越玩越偏) | 时钟估计漂移 | RTT 估计、时钟偏移曲线 |
| 周期性卡顿 | GC 或快照突发 | 堆增长曲线、快照到达节奏 |
| 特定网络必现 | 分片或 MTU | 包大小、是否触发分片 |
这张表的价值在于排除法:先看现象落在哪一行,就少走一半弯路。「橡皮筋」几乎一定出在预测和解(第 4 章),「远端瞬移」几乎一定出在外推(4.3),而「特定网络必现」多半是包太大被分片——它和逻辑无关,是传输层的事。
7.3.2 客户端要落哪些日志
排查要靠日志,但日志不能乱记。客户端至少要落四类记录,每类都带时间戳与帧号:
| 类别 | 关键字段 | 用途 |
|---|---|---|
| 输入 | 帧号、输入序号、按键 | 重演时的输入源 |
| 快照 | 帧号、快照序号、到达时刻 | 对齐「服务端说了什么」 |
| 和解 | 帧号、回滚帧数、预测误差 | 定位橡皮筋 |
| 网络质量 | 时刻、RTT、丢包、jitter | 定位链路问题 |
四类的公共键是帧号——正是它把「我按了什么」「服务端回了什么」「我回滚了多少」串成一条时间线。少了帧号,日志就只是一堆孤立的数字。
7.3.3 采样与体积控制
全量上报不现实:一局几分钟的输入日志就能到几 MB,几万个玩家同时上报会直接打爆带宽。控制体积有三招:
- 本地环形缓冲:内存里只留最近 N 条,超了覆盖最旧的——平时不上报,出事时才把当前缓冲打包带走。
- 会话采样:只让 1% 的会话开详细日志,其余只留汇总指标。
- 分级:正常时只记汇总(每分钟一行),异常(回滚次数超阈)时才切到逐帧明细。
三招叠加,就能做到「平时几乎零成本,出事时数据齐全」。下面的环形缓冲是这一切的地基。
7.3.4 L1:环形日志缓冲
环形缓冲的核心是固定容量、覆盖最旧,内存占用与游戏时长无关:
// net/net-log.ts —— 纯 TS,无任何 cc 依赖
export type LogKind = 'input' | 'snapshot' | 'reconcile' | 'netstat';
export interface NetLogRecord {
t: number; // 客户端单调时刻 ms
frame: number; // 逻辑帧号
kind: LogKind;
seq: number; // 输入序号或快照序号
data: Record<string, number>; // 各类别的附加字段
}
export class RingLog {
private readonly capacity: number;
private readonly buf: (NetLogRecord | null)[];
private head = 0;
private count = 0;
constructor(capacity: number) {
this.capacity = capacity;
this.buf = new Array(capacity).fill(null);
}
push(rec: NetLogRecord): void {
this.buf[this.head] = rec;
this.head = (this.head + 1) % this.capacity;
if (this.count < this.capacity) this.count += 1;
}
get size(): number {
return this.count;
}
/** 按写入顺序返回全部记录,最旧在前 */
toArray(): NetLogRecord[] {
const out: NetLogRecord[] = [];
const start = (this.head - this.count + this.capacity) % this.capacity;
for (let i = 0; i < this.count; i++) {
const rec = this.buf[(start + i) % this.capacity];
if (rec) out.push(rec);
}
return out;
}
}
两个细节值得说。head 指向下一个写入位置,toArray 从 (head - count + capacity) % capacity 读起——这个取模保证即使发生过环绕,顺序也是对的。buf 里放 null 占位而不是 undefined,是为了让 toArray 的类型收窄更干净。
7.3.5 L1:用帧号与序号把输入和快照串起来
有了缓冲,下一步是关联。排查橡皮筋时最常问的是:「这次回滚,重演了哪些输入?触发它的是哪个快照?」下面把这个问题写成函数:
export interface ReconcileTrace {
frame: number;
inputs: NetLogRecord[];
snapshot: NetLogRecord | null;
}
/** 把一次和解回溯到「当时用过的输入」与「触发它的快照」 */
export function traceReconcile(log: RingLog, frame: number): ReconcileTrace {
const all = log.toArray();
const snapshot = all.find((r) => r.kind === 'snapshot' && r.frame <= frame) ?? null;
const since = snapshot ? snapshot.seq : 0;
const inputs = all.filter(
(r) => r.kind === 'input' && r.frame <= frame && r.seq >= since,
);
return { frame, inputs, snapshot };
}
逻辑是:先找帧号不大于本次和解的最近一个快照(它就是这个和解的基准),再收集帧号不晚于和解、序号不早于该快照的输入——这些正是和解时要重演的输入。有了这条链,橡皮筋的成因就能落到具体帧:是输入重演丢了,还是快照本身迟到。
7.3.6 复现:把线上网络序列喂回模拟器
诊断的终点是复现。日志里的 netstat 记录本身就是一段延迟与丢包的序列,把它统计成 7.1 那套 WeakNetworkConfig,就能在本地把同样的弱网重放出来:
import type { WeakNetConfig } from './weak-net';
export interface NetSeries {
latencyMs: number[];
lossFlags: boolean[];
}
/** 用日志里的网络序列重建弱网配置,用于本地复现那一刻 */
export function replaySeries(series: NetSeries): WeakNetConfig {
const n = Math.max(1, series.latencyMs.length);
const latencyMs = series.latencyMs.reduce((a, b) => a + b, 0) / n;
const m = Math.max(1, series.lossFlags.length);
const lossRate = series.lossFlags.filter(Boolean).length / m;
return { latencyMs, jitterMs: 0, lossRate, duplicateRate: 0, reorderRate: 0, bandwidthKBps: 0, seed: 1 };
}
这是「症状表」里「特定网络必现」一行的落地方式:拿到出问题玩家的 netstat 序列,重建出等价弱网,本地就能稳定重现——不用等他再遇到一次。
要注意 replaySeries 只保留了均值,丢掉了抖动的分布形态。若某类问题只在「延迟忽高忽低」时出现,光靠均值复现不出来——这时应把 netstat 序列按时间窗切段、逐段建多个 WeakNetConfig,用 7.1 的 loadTimeline 串成时间线,才能还原那条起伏的曲线。
7.3.7 vitest:缓冲、关联与复现
import { describe, expect, it } from 'vitest';
import { RingLog, traceReconcile, replaySeries } from './net-log';
describe('RingLog', () => {
it('容量固定,写满后覆盖最旧记录', () => {
const log = new RingLog(4);
for (let i = 0; i < 6; i++) {
log.push({ t: i, frame: i, kind: 'input', seq: i, data: {} });
}
expect(log.size).toBe(4);
expect(log.toArray().map((r) => r.frame)).toEqual([2, 3, 4, 5]);
});
it('把和解回溯到对应的输入与快照', () => {
const log = new RingLog(16);
log.push({ t: 0, frame: 10, kind: 'snapshot', seq: 3, data: {} });
log.push({ t: 1, frame: 12, kind: 'input', seq: 5, data: { right: 1 } });
log.push({ t: 2, frame: 13, kind: 'reconcile', seq: 6, data: { rollback: 1 } });
const trace = traceReconcile(log, 13);
expect(trace.snapshot?.frame).toBe(10);
expect(trace.inputs.map((r) => r.frame)).toEqual([12]);
});
it('从日志序列重建的弱网参数与原序列一致', () => {
const cfg = replaySeries({ latencyMs: [40, 60, 50, 50], lossFlags: [false, true, false, false] });
expect(cfg.latencyMs).toBe(50);
expect(cfg.lossRate).toBe(0.25);
});
});
三段断言分别钉住:环形覆盖(写 6 条容量 4,只剩帧 2–5)、事件关联(和解 13 回溯到快照 10 与输入 12)、复现参数(延迟均值 50、丢包率 0.25)。跑起来:
cd mini2d && npx vitest run net/net-log.test.ts
7.3.8 接进 Cocos:分级采样与上报
日志采样与上报要按平台分流。下面用 sys.isNative 区分原生与 Web 两条路径,并把采样率与体积上限收在组件里:
import { _decorator, Component, sys } from 'cc';
const { ccclass } = _decorator;
@ccclass('NetLogUploader')
export class NetLogUploader extends Component {
private readonly sampleRate = 0.01; // 只采样 1% 的会话
private readonly maxUploadBytes = 64 * 1024; // 单次上报体积上限
onLoad(): void {
if (Math.random() >= this.sampleRate) {
console.log('[netlog] session not sampled');
return;
}
if (sys.isNative) {
console.log('[netlog] native: write ring buffer to file');
} else {
console.log('[netlog] web: batch upload <=', this.maxUploadBytes);
}
}
}
sys.isNative 是布尔属性,原生端为 true。原生端把环形缓冲落文件、不占额外内存;Web 与小游戏端只能批量上报,所以更要守住 maxUploadBytes 上限。
7.3.9 客户端该做什么
本章三节讲完,客户端多了三块能力,也都留了可配置项:
- 弱网模拟器(7.1):
WeakNetworkTransport装饰器,可配置六种病与随机种子,可加载突发中断时间线。 - 机器人压测(7.2):
Bot+BotDriver复用 L1 逻辑层,配假服务端;BotLoadTestGate保证只在桌面端开启。 - 诊断与日志(7.3):
NetDiagOverlay展示指标,RingLog承载固定容量日志,traceReconcile关联事件,replaySeries复现线上。
第 7 章到这里结束。第 8 章接手时,你会从「客户端能观测什么」转向「客户端能信什么」——既然客户端能被玩家改写,那哪些数据必须校验、哪些边界必须守住,就是下一章的主题。
阅读导航:上一节:7.2 机器人压测 · 下一节:8.1 客户端权威边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。