《MMO 服务器开发》14.1 服务器时间线与时钟同步

「现在几点」这个问题在服务器上比想象中难:墙钟会被 NTP 校时、被 GC 暂停打断,客户端时钟和服务端更是各走各的。本节把逻辑时间与墙钟彻底分离,用单调钟搭一条只向前走的时间线,用 NTP 四时间戳估出客户端时钟偏移,并用确定性测试演示一次向后跳变是怎么被时间线吸收掉的。

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 延迟补偿裁决 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标