《MMO 服务器开发》9.3 世界时钟与 Tick 预算

世界时钟把不稳定的墙钟时间折算成固定步长的逻辑时间,让「第 N 帧发生了什么」完全确定、可重放。本节从零实现一个逻辑帧时钟:固定步长与累加器、单帧步数上限、追帧 / 丢弃 / 降频三种超支策略,以及时间缩放;并用确定性测试演示「死亡螺旋」是怎么形成的、丢弃策略又是怎么把它掐断的。

9.3 世界时钟与 Tick 预算

9.2 的时间轮解决了「什么时候触发回调」,但还有一个更根本的问题没回答:「现在」是什么时候? 如果每一帧都直接读 time.Now() 算逻辑,那么同一段逻辑在两台机器、两次运行里会得到不同结果——因为墙钟会抖动、会被 NTP 校时、会被 GC 暂停打断。对需要「服务器回滚与和解」(第 13 章)的 MMO 来说,这是致命的:回滚的前提是「第 N 帧的世界状态是确定的」,而依赖墙钟的逐帧 time.Now() 直接破坏了这个前提。

世界时钟就是把这个问题挡在逻辑之外:逻辑只认「第几帧」,不认「真实时刻」。 每一帧固定推进 step 的逻辑时间,真实墙钟走得快一点、慢一点,逻辑一概不管,只在帧与帧之间把墙钟的差值折算成「该跑几步」。这一节从零实现这个折算器,并处理它最难的部分——当一帧算不完时该怎么办。

固定步长:把墙钟抖动挡在逻辑外

世界时钟的核心是一个累加器:每帧把真实流逝的墙钟时间累加进 acc,然后用 acc / step 得出「这一帧该执行几步逻辑」,余数留到下一帧。逻辑时间因此永远等于「执行过的步数 × 步长」,与墙钟抖动彻底解耦。

   墙钟:   |---45ms---|--10ms--|----60ms----|--50ms--|
   累加器: 45          55        115          165
   步长 50ms,每帧步数:
           0           1         2            1
   逻辑时间:0          50        150          200   ← 只由「跑了几步」决定

这就是「固定步长」的全部:墙钟负责提供「流逝了多少」,逻辑只负责回答「跑了几步」。 只要逻辑本身是确定性的(不读墙钟、不读随机数、遍历顺序固定),同一个初始状态 + 同一串输入,在任意机器上都会得到同一串帧结果。这是服务器回滚、录像回放、压测复现的地基。

典型的主循环长这样——注意墙钟只出现在 Advance 的入参里,逻辑步 world.Step() 完全不碰时间:

// snippet: 典型逻辑帧主循环(示意,非完整文件)

ticker := time.NewTicker(framePeriod) // 例如 20ms 一帧
for {
	select {
	case <-ticker.C:
		n := clock.Advance(framePeriod) // 墙钟只在这里出现
		for i := 0; i < n; i++ {
			world.Step() // 纯逻辑:不读墙钟、不读随机,遍历顺序固定
		}
		flushOutbound() // 帧末统一发包,保证同一帧的推送顺序一致
	case <-stop:
		return
	}
}

只要 world.Step() 是确定性的,把同一串输入重放一遍就能得到逐帧一致的世界状态——这就是第 13 章「服务器回滚与和解」需要的那个可重放地基。

但固定步长会引入一个新问题:如果某一帧因为 GC 暂停或一次慢查询花了 300ms,acc 就会攒下 6 步的积压。下一帧是「一次补跑 6 步」还是「只跑 1 步、把其余丢掉」?这个选择就是下面要讲的超支策略。

从零实现 WorldClock

下面这段是 L1,go test 可以直接跑。它实现了固定步长折算、单帧步数上限,以及三种超支策略(CatchUp / Drop / SlowDown)和时间缩放。

// file: internal/timing/worldclock.go
// tier: L1
// verify: go test ./internal/timing/

package timing

import "time"

// Policy 决定「墙钟落后于逻辑时间」时怎么处理。
type Policy int

const (
	// CatchUp 追帧:一帧内补齐所有落后步数(上限 maxSteps),逻辑可重放时首选。
	CatchUp Policy = iota
	// Drop 丢弃:一帧最多执行 maxSteps 步,其余积压直接丢掉,逻辑时间会跳跃。
	Drop
	// SlowDown 降频:不补步,但把有效步长向实际帧时间靠拢,让逻辑频率跟随真实帧率。
	SlowDown
)

// WorldClock 是服务器逻辑帧时钟:把不稳定的墙钟时间折算成固定步长的逻辑时间。
// 调用方每帧调用一次 Advance,按返回的步数执行逻辑;逻辑时间只由「执行了多少步」
// 决定,因此与墙钟抖动解耦,可重放、可确定性回放。
type WorldClock struct {
	policy   Policy
	step     time.Duration
	baseStep time.Duration
	maxSteps int
	scale    float64

	acc     time.Duration // 尚未折算成逻辑步的墙钟余量
	ticks   uint64        // 已执行的逻辑步总数
	dropped time.Duration // 因超预算被丢弃的逻辑时间
}

// NewWorldClock 建一个逻辑帧时钟。step 是逻辑步长,policy 决定积压处理策略。
func NewWorldClock(step time.Duration, policy Policy) *WorldClock {
	return &WorldClock{policy: policy, step: step, baseStep: step, maxSteps: 8, scale: 1}
}

// Step 返回当前有效步长(SlowDown 下会动态变化)。
func (c *WorldClock) Step() time.Duration { return c.step }

// Ticks 返回已执行的逻辑步数。
func (c *WorldClock) Ticks() uint64 { return c.ticks }

// LogicTime 返回逻辑时间 = 步数 × 步长。
func (c *WorldClock) LogicTime() time.Duration { return time.Duration(c.ticks) * c.step }

// Dropped 返回累计被丢弃的逻辑时间。
func (c *WorldClock) Dropped() time.Duration { return c.dropped }

// SetMaxSteps 设置单帧逻辑步数上限。
func (c *WorldClock) SetMaxSteps(n int) {
	if n > 0 {
		c.maxSteps = n
	}
}

// SetScale 设置时间缩放:>1 加速,<1 减速。s <= 0 时忽略。
func (c *WorldClock) SetScale(s float64) {
	if s > 0 {
		c.scale = s
	}
}

// Advance 吸收一段真实墙钟时间,返回本帧应执行的逻辑步数。
//
// 超预算处理:当一帧折算出超过 maxSteps 步时,多余部分按策略被丢弃(Drop)
// 或截断(CatchUp),并累加到 dropped。这是防「死亡螺旋」的关键——若每步逻辑
// 都比 step 还慢,无上限追帧会让积压雪球越滚越大。
func (c *WorldClock) Advance(wall time.Duration) int {
	if wall < 0 {
		wall = 0
	}
	scaled := time.Duration(float64(wall) * c.scale)

	if c.policy == SlowDown {
		// 有效步长向实际帧时间靠拢,上限 4× 基准步长,避免一次卡顿把步长拉爆。
		target := c.step
		if scaled > target {
			target = scaled
		}
		if limit := 4 * c.baseStep; target > limit {
			target = limit
		}
		c.step = target
	}

	c.acc += scaled
	steps := int(c.acc / c.step)
	if steps > c.maxSteps {
		over := time.Duration(steps-c.maxSteps) * c.step
		c.dropped += over
		c.acc -= over
		steps = c.maxSteps
	}
	c.acc -= time.Duration(steps) * c.step
	c.ticks += uint64(steps)
	return steps
}

Advance 只做三件事:折算、限步、记账。注意「限步」那一段:当折算出的步数超过 maxSteps 时,它把超出的部分直接从 acc 里扣掉、并累加到 dropped。这一扣很关键——它让累加器在超支后立刻回到有界状态,而不是把积压留给下一帧继续滚。

超支处理:追帧、丢弃与死亡螺旋

三种策略的差别,用「每步逻辑实际耗时比步长还慢」这个最坏情况最能体现。假设 step = 20ms,但每一步逻辑要花 work = 25ms——这在线上并不罕见(一次慢的持久化、一次卡顿的广播)。下面这段是确定性测试,模拟一个真实帧循环:

// snippet: internal/timing/worldclock_test.go(节选)

// simulate 模拟真实帧循环:每帧吸收墙钟时间、执行 n 步逻辑,逻辑本身又要花 work
// 的墙钟时间,因此下一帧的墙钟间隔至少是上帧逻辑耗时。用来暴露「死亡螺旋」。
func simulate(c *WorldClock, frames int, step, work time.Duration) (steps int, dropped time.Duration) {
	wall := step
	for i := 0; i < frames; i++ {
		n := c.Advance(wall)
		steps += n
		wall = time.Duration(n) * work
		if wall < step {
			wall = step
		}
	}
	return steps, c.Dropped()
}

func TestDeathSpiralAndDropBreaksIt(t *testing.T) {
	const (
		step = 20 * time.Millisecond
		work = 25 * time.Millisecond // 每步逻辑比步长还慢
	)

	catch := NewWorldClock(step, CatchUp)
	catch.SetMaxSteps(1000)
	catchSteps, _ := simulate(catch, 100, step, work)
	if catchSteps <= 100 {
		t.Fatalf("catch-up ran %d steps in 100 frames, expected the backlog to snowball", catchSteps)
	}

	drop := NewWorldClock(step, Drop)
	drop.SetMaxSteps(1)
	dropSteps, dropDropped := simulate(drop, 100, step, work)
	if dropSteps != 100 {
		t.Fatalf("drop ran %d steps in 100 frames, want exactly 1/frame", dropSteps)
	}
	if dropDropped == 0 {
		t.Fatal("drop policy never discarded backlog, death spiral not broken")
	}
}

测试断言了两件相反的事。第一,CatchUp 在 100 帧里跑了远超 100 步——因为每帧补跑更多步,逻辑耗时又更长,下一帧积压更多,步数像滚雪球一样涨上去,这就是死亡螺旋:服务器越忙,越要补跑,于是越忙。第二,Drop 在 100 帧里恰好跑 100 步、且丢弃了大量积压——它主动放弃「追上真实时间」,把每帧钉死在 1 步,螺旋被掐断。

所以选型的判据是「逻辑能不能承受时间跳跃」:

策略行为适用场景风险
CatchUp 追帧一帧补齐落后步数(上限 maxSteps)逻辑可重放、帧率偶发抖动上限设太大 → 死亡螺旋
Drop 丢弃一帧最多 maxSteps 步,其余丢弃实时性优先、能容忍逻辑时间跳跃逻辑时间与真实时间漂移
SlowDown 降频不补步,拉长有效步长逻辑频率可跟随真实帧率步长变化影响物理/手感

工程上的默认选择是 CatchUp + 一个保守的 maxSteps(比如 8):允许偶尔的补帧,但绝不允许无限补。maxSteps 就是给死亡螺旋设的那道闸——超过 8 步的积压宁可丢掉,也不要让服务器陷入「越补越慢」的正反馈。

时间缩放

SetScale 让逻辑时间可以相对墙钟加速或减速:scale = 2 表示墙钟走 50ms、逻辑走 100ms(双倍速),scale = 0.5 是慢动作。它不需要改动逻辑本身,只是在折算那一步乘一个系数——scaled := time.Duration(float64(wall) * c.scale)。

游戏里时间缩放的典型用途是「活动期间的限时玩法加速」「剧情演出的慢动作」「测试时快进一段挂机收益」。要注意它和超支策略的交互:scale = 2 时,同样的墙钟抖动会被放大成两倍的积压,因此 maxSteps 要相应放宽;而 SlowDown 策略下 scale 与动态步长会互相影响,两者一般不叠加使用。

本节小结

  • 世界时钟让逻辑只认「第几帧」、不认「真实时刻」,是服务器回滚与录像回放的地基。
  • 折算公式:acc += wall * scale,steps = acc / step,余数留到下一帧。
  • 超支三策略:CatchUp 追帧(默认,配保守 maxSteps)、Drop 丢弃、SlowDown 降频。
  • maxSteps 是防死亡螺旋的闸门:每步逻辑比步长还慢时,无上限追帧会让积压雪球式增长;Drop 或小 maxSteps 才能掐断它。

阅读导航:上一节:9.2 时间轮从零实现 · 下一节:10.1 消息驱动的实体 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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