《Cocos 游戏联机实战》7.3 线上问题定位

线上卡顿与不同步不能靠猜。本节先给出一张从症状到原因的排查表(橡皮筋、远端瞬移、逐渐漂移、周期性卡、特定网络必现),再说明客户端该落哪些日志、怎么采样控制体积,并用纯 TS 写出固定容量的环形日志缓冲与按帧号关联输入和快照的追踪,最后把线上抓到的网络序列喂回 7.1 的模拟器复现。

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 客户端权威边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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