14.1 服务器时间线与时钟同步
第 13 章讨论的服务器回滚与和解,有一个隐含前提:服务端和客户端对「这一刻发生了什么」有一套共同的时间参照。一旦这个前提破了,回滚就会回错帧,和解就会把玩家的输入插到错误的时刻上,延迟补偿更是无从谈起——因为「回退多久」本身就是一个时间问题。
这一节要回答的是整条延迟补偿链最底层的那个问题:「现在」到底是几点? 它难在三个地方。第一,墙钟不是单调的,NTP 会把主机时钟往前或往后掰一下。第二,客户端时钟和服务端时钟从来没对过表,你不能直接拿两边的 time.Now() 相减。第三,即便你都用单调钟,客户端看到的世界也因为网络延迟和插值缓冲而落后于服务端。
本节把这三件事拆开:先用单调钟给服务端搭一条只向前走的时间线,再用 NTP 四时间戳估出客户端时钟相对服务端的偏移,最后处理墙钟跳变——包括为什么「向后跳」必须被吸收而不是传播。
与站内 practice 文章的分工
站内已经有一组讨论时间同步与时钟架构的 practice 文章,它们负责「为什么需要、架构怎么分、工程上怎么落地」;本书这一节负责「从零实现 + 真跑」。读法建议:先看架构文章建立判断,再回来看这里的可运行代码。
| 站内文章(绝对路径) | 负责的「架构方案与取舍」 | 本书 14.1 负责的「从零实现 + 真跑」 |
|---|---|---|
| /why-realtime-game-servers-need-time-synchronization/ | 为什么实时服务端需要一条统一时间线,时间同步会牵动哪些子系统 | 用单调钟实现 Timeline,把逻辑时间与墙钟解耦 |
| /game-server-world-clock-tick-architecture/ | 世界时钟与 Tick 在架构上的位置、和调度/持久化的关系 | 只讲「现在几点」这一层;Tick 折算见第 9 章 9.3 |
| /game-server-cross-region-latency-routing/ | 跨区域路由里延迟如何测量与归因 | 客户端时钟偏移估计的可跑实现与实测偏差 |
一句话区分:站内文章回答「架构上该不该有这条时间线」,本书回答「这条时间线怎么用几十行 Go 写出来、它的误差有多大」。
逻辑时间、墙钟与单调钟
最朴素的做法是每帧读一次 time.Now()。这在单机单次运行里没问题,但一进入 MMO 就崩:time.Now() 读的是墙钟,它会被 NTP 校正、会被用户手动改系统时间、会在虚拟机迁移时跳一下。更糟的是,time.Now() 的两次读数之差不保证单调——一次向后校正就能让「流逝了 50ms」变成「流逝了 −30ms」,回滚判定直接算出负数。
正确的分层是:
- 墙钟(wall clock):给人看的、绝对的时刻。会被校正,不单调。
- 单调钟(monotonic clock):从某个起点开始只增不减的计数器。Go 的
time.Time内部就同时带着墙钟和单调钟两个读数,time.Since(t)优先用单调读数相减。 - 逻辑时间(logical time):由「跑了多少步 × 步长」决定,只认帧号,不认真实时刻(第 9 章 9.3 已实现)。
服务端的权威时间线应该建在单调钟上:时间线只回答「自启动以来过了多久」,永远向前。墙钟只在两个地方用——给玩家显示「下次重置还有 3 小时」,以及给日志打绝对时间戳——这两个场景都不参与玩法裁决。
客户端时钟偏移估计:NTP 四时间戳
服务端有了单调时间线,客户端却还在用自己那台机器的时钟。两者之间有一个恒定偏移 server_clock − client_clock(外加网络延迟带来的抖动)。要估这个偏移,经典办法是 NTP 的四时间戳:
客户端 服务端
t1 ──── 请求 ────────────→ t2 (服务端收到的时刻)
t4 ←─── 响应 ───────────── t3 (服务端发出的时刻)
offset = ((t2 − t1) + (t3 − t4)) / 2 ← 服务端时钟 − 客户端时钟
rtt = (t4 − t1) − (t3 − t2) ← 扣掉服务端处理时间后的往返
关键洞察是:这四条时间戳里,t2/t3 是服务端时钟、t1/t4 是客户端时钟,公式却不需要两边共用同一个纪元。 偏移是「服务端时钟减去客户端时钟」,两边各自读自己的表就行。下行和上行不对称时,偏移会有一个等于「不对称量一半」的系统偏差——下面代码的测试会把这点量出来。
下面这段是 L1,go test 可以直接跑。它实现了四时间戳折算、一个「取窗口内 RTT 最小样本」的偏移估计器,以及前面说的单调时间线。
// file: internal/clock/clock.go
// tier: L1
// verify: go test ./internal/clock/
package clock
import (
"errors"
"sync"
"time"
)
// Source reads a monotonic counter. Production passes a closure over
// time.Since(boot); tests pass a virtual counter so every number below is
// reproducible.
type Source func() time.Duration
// ErrNoSamples is returned by Estimator.Best before any sample is recorded.
var ErrNoSamples = errors.New("clock: no offset samples")
// Offset is one NTP-style four-timestamp measurement. t1,t4 are stamped on the
// client clock, t2,t3 on the server clock. No shared epoch is required, which is
// exactly why the method survives clocks that were never synchronised.
type Offset struct {
RTT time.Duration
Offset time.Duration // server_clock - client_clock at the round-trip midpoint
}
// Measure derives the offset and round-trip time from the four timestamps.
func Measure(t1, t2, t3, t4 time.Duration) Offset {
rtt := (t4 - t1) - (t3 - t2)
if rtt < 0 {
rtt = 0
}
return Offset{RTT: rtt, Offset: ((t2 - t1) + (t3 - t4)) / 2}
}
// Estimator keeps a sliding window of samples and returns the one with the
// lowest RTT. Queueing only ever adds delay, so the fastest sample is the least
// distorted; taking the minimum is a cheap stand-in for a full NTP filter.
type Estimator struct {
window int
samples []Offset
}
// NewEstimator builds an estimator holding at most window samples.
func NewEstimator(window int) *Estimator {
if window < 1 {
window = 1
}
return &Estimator{window: window}
}
// Add records a sample, dropping the oldest once the window is full.
func (e *Estimator) Add(o Offset) {
e.samples = append(e.samples, o)
if len(e.samples) > e.window {
e.samples = e.samples[len(e.samples)-e.window:]
}
}
// Best returns the lowest-RTT sample, or ErrNoSamples when the window is empty.
func (e *Estimator) Best() (Offset, error) {
if len(e.samples) == 0 {
return Offset{}, ErrNoSamples
}
best := e.samples[0]
for _, s := range e.samples[1:] {
if s.RTT < best.RTT {
best = s
}
}
return best, nil
}
// JumpThreshold: a source move larger than this between two reads is treated as
// a host clock correction (NTP step), not as real elapsed time.
const JumpThreshold = 2 * time.Second
// Timeline is the server's authoritative time line. Gameplay never reads a
// wall-clock instant: it reads Elapsed, which only ever moves forward. A
// backward NTP step is absorbed instead of propagated, so the world cannot
// rewind under a running simulation.
type Timeline struct {
mu sync.Mutex
source Source
last time.Duration
elapsed time.Duration
backward int
forward int
}
// NewTimeline starts a timeline at the source's current reading.
func NewTimeline(source Source) *Timeline {
return &Timeline{source: source, last: source()}
}
// Elapsed advances by the source delta and returns total monotonic time since
// construction. Backward steps are clamped to zero (the timeline never rewinds);
// forward steps beyond JumpThreshold are counted as corrections.
func (t *Timeline) Elapsed() time.Duration {
t.mu.Lock()
defer t.mu.Unlock()
now := t.source()
delta := now - t.last
switch {
case delta < 0:
t.backward++
delta = 0
case delta > JumpThreshold:
t.forward++
}
t.last = now
t.elapsed += delta
return t.elapsed
}
// BackwardJumps reports how many times the source stepped backwards.
func (t *Timeline) BackwardJumps() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.backward
}
// ForwardJumps reports how many forward corrections exceeded JumpThreshold.
func (t *Timeline) ForwardJumps() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.forward
}
Measure 里最容易被忽略的是 rtt 的算法:它用 (t4−t1) 得到「客户端眼里这一趟花了多久」,再减去 (t3−t2) 即「服务端自己占用的时间」,剩下的才是纯网络往返。偏移取往返中点的估计值,因此对上下行延迟相等的情况是精确的。
Estimator 之所以取「RTT 最小」而不是「平均」,是因为排队只会增加延迟。一个低 RTT 样本说明那一趟几乎没被排队干扰,它的偏移最接近真值。滑动窗口保证估计器能跟上客户端时钟的缓慢漂移,同时不会因为一次拥塞把偏移带偏。
NTP 跳变与时间线保护
真正让回滚系统出事故的,是墙钟的向后跳。设想一台服务端用 time.Now() 做时间线:运维校时把主机时钟往回拨了 200ms,于是时间线瞬间倒退 200ms——所有实体的历史位置变得「未来化」,回滚窗口算出一个负数,命中判定在错误的时刻取样。一次校时就能制造一批诡异的误判。
Timeline 的对策很简单:把「源在两次读数之间的变化量」当作流逝时间,并且拒绝负的变化量。 Elapsed 里 delta < 0 的分支把这一帧的流逝时间钉成 0,同时把 backward 计数器加一——时间线宁可「停一拍」,也绝不倒退。向前的大跳变(超过 JumpThreshold,默认 2 秒)被接受,但单独计数,方便调度层知道「刚才发生了校时」。
下面这段是 L1 测试节选,用虚拟时钟精确复现一次向后跳变,以及一次被计数的向前跳变:
// snippet: internal/clock/clock_test.go(节选)
func TestTimelineAbsorbsBackwardStep(t *testing.T) {
var now = 100 * time.Millisecond
tl := NewTimeline(func() time.Duration { return now })
now += 50 * time.Millisecond
if got := tl.Elapsed(); got != 50*time.Millisecond {
t.Fatalf("elapsed = %v, want 50ms", got)
}
// Host clock is corrected 30ms backwards: the timeline must not rewind.
now -= 30 * time.Millisecond
if got := tl.Elapsed(); got != 50*time.Millisecond {
t.Fatalf("elapsed after backward step = %v, want 50ms (held)", got)
}
if tl.BackwardJumps() != 1 {
t.Fatalf("backward jumps = %d, want 1", tl.BackwardJumps())
}
now += 20 * time.Millisecond
if got := tl.Elapsed(); got != 70*time.Millisecond {
t.Fatalf("elapsed = %v, want 70ms", got)
}
}
测试里的数字是确定的:时间线走到 50ms 后源被往回拨 30ms,Elapsed 仍然返回 50ms(被吸收),BackwardJumps 变成 1;之后源再前进 20ms,时间线才回到 70ms。注意这里少了 30ms——那正是被吸收掉的、不存在的「负时间」。这就是「宁可停一拍、绝不倒退」的代价:逻辑时间会相对真实时间慢一点点,但永远不会把世界倒带。
本节小结
- 逻辑时间、单调钟、墙钟是三样东西:玩法只认逻辑时间,时间线建在单调钟上,墙钟只用于展示与日志。
- NTP 四时间戳能在两边时钟从未对表的前提下估出偏移:
offset = ((t2−t1)+(t3−t4))/2,rtt = (t4−t1)−(t3−t2)。 - 偏移估计取 RTT 最小样本:排队只会加延迟,最快的样本最接近真值。
- 向后跳变必须被吸收:时间线只向前走,代价是逻辑时间偶尔比真实时间慢一拍,换来的是回滚永不错帧。
下一节把这条时间线接到延迟补偿上:有了「现在几点」和「客户端落后服务端多少」,就能回答「打得到底算不算打中」。
阅读导航:上一节:目录 · 下一节:14.2 延迟补偿裁决 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。