1.3 时钟、延迟与抖动
1.2 节的消息里到处是时间:tick、serverTimeMs、holdMs。可这些数字只有在「两端对同一时刻有共同理解」时才有意义——而现实是客户端和服务端的时钟根本不同源,两台机器的 Date.now() 差几秒都属正常。这一节就把这个前提补上。
本节写出一个可复用的时钟估计模块:用「发→收→回→到」四元组估计服务端时钟相对本地的偏移,用最小 RTT 样本作权威值、用滑动平均平稳化,同时算出 RTT 与抖动。全部是 L1 纯逻辑,用假时钟断言偏移收敛。这个模块是 4.2 和解、5.2 超时、9.3 延迟补偿共同的地基,本节就要把它写成能直接
import的形态。
1.3.1 两端时钟为什么对不齐
第一个坑是把 Date.now() 当时间轴。它返回的是墙上时钟(wall clock),可以被用户改、被系统 NTP 校正、被时区影响。用它测「一个包走了多久」,只要期间系统对了一次时,结果就能是负数。
正确做法是用单调时钟(monotonic clock):只保证「一直往前走、不受系统时间调整影响」,不保证起点和别的机器一致。浏览器与 Node 里是 performance.now(),Cocos 里是 game.totalTime。用它测时间间隔是准的。
于是问题拆成两半:
| 需求 | 用什么时钟 | 说明 |
|---|---|---|
| 测间隔(一个包走了多久、一帧多久) | 单调时钟 | performance.now() / game.totalTime,不受调时影响 |
| 对齐两端的时间轴(这个快照对应我本地哪一刻) | 单调时钟 + 估计的偏移 | 偏移靠网络采样算出来,1.3.2 节就是干这个 |
我们永远不去对齐墙上时钟,只估计「服务端时钟 = 本地单调时钟 + offset」里的那个 offset。 一旦有了 offset,任何服务端时间戳都能换算到本地时间轴上。
1.3.2 NTP 式偏移估计(L1)
估计方法借自 NTP。一次往返拿到四个时间戳:
t0:客户端发出请求(本地单调 ms)t1:服务端收到请求(服务端时钟 ms)t2:服务端发出回复(服务端时钟 ms)t3:客户端收到回复(本地单调 ms)
于是:
- RTT =
(t3 - t0) - (t2 - t1),把服务端处理时间(t2 - t1)扣掉,剩下的才是纯网络往返。 - 偏移估计 =
((t1 - t0) + (t2 - t3)) / 2。若上下行对称,这个值恰好等于真实 offset;不对称时误差是「上下行差的一半」。
单次采样会被抖动污染,所以要多次采样:取 RTT 最小的那次作权威偏移(路径最对称、最可信),再用滑动平均给一个平稳的展示值。
// assets/scripts/net/session/clock-sync.ts
/** 一次往返采样的四个时间戳 */
export interface ClockSample {
t0: number; // 客户端发出(本地单调 ms)
t1: number; // 服务端收到(服务端时钟 ms)
t2: number; // 服务端回复(服务端时钟 ms)
t3: number; // 客户端收到回复(本地单调 ms)
}
const SMOOTH_ALPHA = 0.1; // 滑动平均系数,越小越平稳、越大越灵敏
export class ClockSync {
private readonly localNow: () => number;
private bestRtt = Number.POSITIVE_INFINITY;
private bestOffset = 0;
private smoothOffset = 0;
private haveSmooth = false;
private lastRtt = 0;
private jitter = 0;
private sampleCount = 0;
constructor(localNow: () => number) {
this.localNow = localNow;
}
/** 喂入一次四元组,更新偏移、RTT 与抖动估计 */
addSample(s: ClockSample): void {
const rtt = (s.t3 - s.t0) - (s.t2 - s.t1);
const offset = ((s.t1 - s.t0) + (s.t2 - s.t3)) / 2;
this.sampleCount += 1;
// 最小 RTT 的样本路径最对称,作权威偏移
if (rtt < this.bestRtt) {
this.bestRtt = rtt;
this.bestOffset = offset;
}
// 滑动平均给一个平稳的展示值
if (!this.haveSmooth) {
this.smoothOffset = offset;
this.haveSmooth = true;
} else {
this.smoothOffset += SMOOTH_ALPHA * (offset - this.smoothOffset);
}
// 抖动:相邻 RTT 绝对差的滑动平均
this.jitter += SMOOTH_ALPHA * (Math.abs(rtt - this.lastRtt) - this.jitter);
this.lastRtt = rtt;
}
/** 本地单调时间 → 估计的服务端时间 */
serverTime(localMs: number): number {
return localMs + this.bestOffset;
}
/** 服务端时间 → 估计的本地单调时间(和解时会用到) */
localTime(serverMs: number): number {
return serverMs - this.bestOffset;
}
get offsetMs(): number {
return this.bestOffset;
}
get smoothedOffsetMs(): number {
return this.smoothOffset;
}
get rttMs(): number {
return this.bestRtt === Number.POSITIVE_INFINITY ? 0 : this.bestRtt;
}
get jitterMs(): number {
return this.jitter;
}
get count(): number {
return this.sampleCount;
}
}
serverTime() 与 localTime() 是一对互逆换算。4.2 节收到快照时,会用 localTime(snapshot.serverTimeMs) 把它落到本地时间轴上,判断「这个权威状态对应我预测的第几帧」。
1.3.3 用假时钟断言偏移收敛
测试不需要真网络,只要按公式构造四元组。构造一个上下行对称、服务端恒快 trueOffset 的场景:
// assets/scripts/net/session/clock-sync.test.ts
import { describe, expect, it } from 'vitest';
import { ClockSync, type ClockSample } from './clock-sync';
function makeSample(
local: number,
downMs: number,
upMs: number,
procMs: number,
trueOffset: number,
): ClockSample {
const t0 = local;
const t1 = t0 + downMs + trueOffset; // 服务端钟 = 本地钟 + trueOffset
const t2 = t1 + procMs;
const t3 = t0 + downMs + procMs + upMs;
return { t0, t1, t2, t3 };
}
describe('时钟同步', () => {
it('对称路径下偏移收敛到真实值', () => {
const sync = new ClockSync(() => 0);
const trueOffset = 1000;
const rtts = [120, 40, 80, 40, 60];
let local = 0;
for (const rtt of rtts) {
const half = rtt / 2;
sync.addSample(makeSample(local, half, half, 2, trueOffset));
local += 200;
}
expect(sync.offsetMs).toBeCloseTo(trueOffset, 6);
});
it('RTT 取所有样本中的最小值', () => {
const sync = new ClockSync(() => 0);
sync.addSample(makeSample(0, 60, 60, 0, 500)); // rtt 120
sync.addSample(makeSample(0, 20, 20, 0, 500)); // rtt 40 ← 最小
sync.addSample(makeSample(0, 40, 40, 0, 500)); // rtt 80
expect(sync.rttMs).toBeCloseTo(40, 6);
});
it('抖动随 RTT 波动累积', () => {
const sync = new ClockSync(() => 0);
sync.addSample(makeSample(0, 10, 10, 0, 0)); // rtt 20
sync.addSample(makeSample(0, 60, 60, 0, 0)); // rtt 120,波动大
expect(sync.jitterMs).toBeGreaterThan(0);
});
});
第一个断言是核心:无论 RTT 怎么变,只要上下行对称,最小 RTT 样本的偏移就是精确的 trueOffset。第二个断言验证「最小 RTT 胜出」这条规则真的生效——如果实现里误用最后一次采样,这里会返回 80 而不是 40。注意测试全程没有网络:ClockSync 不碰 WebSocket,只吃四元组,这正是把会话层写成纯逻辑的回报。
1.3.4 RTT 与抖动
**RTT(往返时延)**是上下行之和。它有几种用法:判定超时(5.2 节)、决定重传间隔、触发和解。
**抖动(jitter)**是 RTT 的波动程度。ClockSync 用「相邻 RTT 绝对差的滑动平均」来算:一次 RTT 从 40 跳到 120,抖动会明显抬升。抖动大意味着网络不稳定,表现层需要更深的插值缓冲(9.1 节),否则画面会一顿一顿。
| 指标 | 计算方式 | 典型量级(参考,需在你的网络条件下复测) |
|---|---|---|
| RTT | (t3 - t0) - (t2 - t1),取多样本最小值 | 同城 20 |
| 单向延迟 | 对称假设下 ≈ RTT / 2 | 上表的一半 |
| 抖动 | 相邻 RTT 绝对差的滑动平均 | 稳定网络 < 10 ms,弱网可达数十毫秒 |
数值只是量级参考。你的网络条件下的真实值必须实测——同一个游戏在 WiFi、4G、地铁里能差出一个数量级。
1.3.5 延迟预算怎么切
联机里说「延迟」往往指三件不同的事,混用就会得出错误结论:
| 概念 | 定义 | 用来做什么 |
|---|---|---|
| 单向延迟 | 本地发出到对端收到 | 预测回滚的时间窗、表现层「拖后播放」的毫秒数 |
| RTT | 往返一圈 | 超时判定、重传间隔、和解触发频率 |
| 处理延迟 | 服务端收到到发出 (t2 - t1) | 从往返里扣除,才能得到纯网络延迟 |
把处理延迟从往返里扣掉是本节的关键一步:(t3 - t0) 是「网络往返 + 服务端处理」,如果直接拿它当 RTT,会高估网络延迟,让超时阈值设得过松、插值缓冲开得过深。所以 1.3.2 节的公式先做减法 (t3 - t0) - (t2 - t1),剩下的才是网络往返。
1.3.6 这个模块后面被谁消费
ClockSync 是跨章复用的模块,本节建立的数据结构后面会被三处消费,先交代清楚,免得你读到那里才发现伏笔:
| 消费点 | 用到的字段 | 用途 |
|---|---|---|
| 4.2 服务器和解与重演 | localTime() / offsetMs | 把服务端快照的 serverTimeMs 落到本地时间轴,判断该回滚到第几帧 |
| 5.2 超时、重传与幂等 | rttMs / jitterMs | 估算合理超时阈值(如 rtt + 4 × jitter + 固定余量) |
| 9.3 延迟补偿与体验权衡 | 单向延迟 / jitterMs | 决定表现层「拖后多少毫秒」播放、插值缓冲开多深 |
所以本节不是「讲个概念就走」:ClockSync 的接口(addSample / serverTime / localTime / rttMs / jitterMs)从这一节起就冻结,后面章节直接 import 使用,不再改签名。
1.3.7 Cocos 侧:用 game.totalTime 当本地钟(L2)
ClockSync 需要一个单调时钟来源。在 Cocos 里用 game.totalTime(引擎自启动以来经过的毫秒数),它基于引擎内部单调计时,不受系统调时影响。用一个组件把它包起来注入:
import { _decorator, Component, game } from 'cc';
const { ccclass } = _decorator;
@ccclass('LocalClock')
export class LocalClock extends Component {
/** 单调毫秒,作为 ClockSync 的本地时间来源 */
now(): number {
return game.totalTime;
}
}
game、game.totalTime、_decorator.ccclass、Component 均已对照 @cocos/creator-types@3.8.8 的 cc.d.ts 确认。注意 game.totalTime 单位是毫秒,而 game.deltaTime 单位是秒——这两个极易混淆,混用会让偏移估计差 1000 倍。
1.3.8 客户端该做什么
本节交付了联机时间轴的地基模块。落地清单:
- 本地一律用单调时钟(
performance.now()/game.totalTime),绝不用Date.now()测间隔 - 落地
ClockSync:addSample收四元组,取最小 RTT 样本作偏移、滑动平均平稳化 - 跑通三个断言:偏移收敛、RTT 取最小、抖动随波动上升
- 记住延迟预算的三分法:单向延迟 / RTT / 处理延迟,往返里要先扣处理延迟
- 冻结
ClockSync接口,后面 4.2 / 5.2 / 9.3 直接复用
到这里第 1 章收尾:我们建立了权威边界(1.1)、协议与编解码(1.2)、时间轴(1.3)三块地基。有了它们,下一章才能回答一个更本质的问题:两个客户端凭什么能算出完全一样的世界? 答案不是「发快照」,而是「发输入 + 确定性地重放」。第 2 章从确定性模拟与定点数讲起——那是让「同样的输入在两端产出同样的结果」成为物理事实的第一块砖。
阅读导航:上一节:1.2 协议选型与序列化 · 下一节:2.1 确定性模拟与定点数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。