《MMO 服务器开发》7.3 时钟回拨与容错

时钟回拨是雪花算法唯一的软肋。本节讲清它的三种成因(NTP 步进校时、虚拟机迁移、闰秒回拨)与三种应对(等待追平、换 worker id、抛错拒绝)各自的代价,并给出一个完整可跑、可注入假时钟的 L1 容错生成器与五条测试用例,让回拨从线上偶发变成可参数化的测试场景。

7.3 时钟回拨与容错

7.1 的雪花算法把本地时间当成了唯一性的一部分:ID 的高 41 位就是「生成时的毫秒数」。这带来一个硬约束——时间只能往前走,绝不能往后退。一旦某台机器的时钟往回拨了哪怕一毫秒,它就可能重新生成出「刚才已经发过的那个时间戳」,如果序列也恰好撞上,就是一个重复 ID。

很多人的第一反应是「机器时钟怎么会往后退」。答案是:会,而且比你想的常见。这一节先把回拨的三种典型成因讲清楚,再给出三种应对策略以及它们各自的代价,最后写一个时钟可注入的容错生成器——这样回拨场景就不再是「等线上偶发」,而是一个能在本机 go test 里稳定复现的测试用例。

回拨的三种成因

成因机制典型幅度可预测性
NTP 校时机器时间跑偏后,NTP 客户端把本地时钟直接拨回正确值毫秒到秒级低,随时可能
虚拟机迁移/挂起VM 在别的物理机上恢复,或从挂起态唤醒,CLOCK_MONOTONIC 被重置秒到分钟级低,但幅度大
闰秒闰秒插入时部分系统把时间「回拨 1 秒」而不是「重复 1 秒」1 秒可提前预知

三种成因里,NTP 校时最常见。NTP 默认的行为是「步进(step)」——发现偏差超过阈值(通常 128ms)就直接把时钟设成正确值,而不是慢慢追。如果这台机器之前快了 300ms,一次 NTP 同步就会让时钟倒退 300ms。对雪花算法来说,这就是一次回拨。

虚拟机迁移的幅度更大。一台 VM 被迁移到另一台物理机,或者从快照恢复,它看到的「墙上时间」可能一下子退回到几分钟前。这在云上很常见,也是为什么「雪花算法跑在容器/VM 里」比「跑在物理机上」风险更高。

闰秒是可预知的,一年最多一两次。问题在于不同系统处理闰秒的方式不同:有的「重复最后一秒」(23:59:60 出现两次,时间不倒退),有的直接「把时钟回拨一秒」。后者会让雪花算法看到一次 1 秒的回拨。

一句话总结:时钟回拨不是异常,是常态。任何依赖本地时钟的 ID 方案,都必须对它有明确的态度。

三种应对

面对回拨,业界主流有三种态度,没有绝对的对错,只有取舍:

策略做法优点代价适用
等待追平自旋等到本地时钟追回 lastMsID 仍然单调、实现简单回拨期间阻塞,回拨多久就卡多久小幅回拨(NTP 级)
换 worker id换一个 worker id 继续发,身份变了就不怕时间重叠不阻塞,不停服需要预留备用 worker 池;ID 来源会变大幅回拨、不能等
抛错拒绝直接返回错误,让上层决定最安全,绝不产生重复发号功能在回拨期间不可用宁可停服也不出脏数据

实际生产里很少只用一种,常见的是组合策略:回拨小于阈值(比如 5ms)就用等待追平,超过阈值就尝试换 worker id,连备用 worker 也没有了才抛错。7.1 里 Snowflake.Next 遇到 now < lastMs 直接返回 ErrClockBackwards,其实是最保守的「抛错拒绝」;本节把它升级成一个可配置的容错版本。

需要特别提醒一点:等待追平的前提是「回拨一定会结束」。如果时钟被拨回 1 小时,自旋等待就是死循环。所以等待策略必须带一个容忍上限(maxBackward),超了就升级为报错——代码里的 WithMaxBackward 就是干这个的。

实现:可注入假时钟的容错生成器

下面这段是 L1,go test 可以直接跑。它复用 7.1 的 Clock 接口与位布局,只是在回拨处加了一个可配置的 recover:

// file: internal/idgen/clockback.go
// tier: L1
// verify: go test ./internal/idgen/
package idgen

import (
	"fmt"
	"sync"
	"time"
)

// Strategy 是时钟回拨时的应对策略。
type Strategy int

const (
	// StrategyWait 自旋等待本地时钟追平上次生成时刻,超过容忍上限则报错。
	StrategyWait Strategy = iota
	// StrategyBorrowWorker 从备用 worker 池借一个 worker id,换身份继续生成。
	StrategyBorrowWorker
	// StrategyReject 直接拒绝,返回包装了 ErrClockBackwards 的错误。
	StrategyReject
)

// TolerantSnowflake 在雪花算法之上加了回拨容错。位布局与 Snowflake 完全一致,
// 因此两种实现生成的 ID 可以混在同一个空间里。
type TolerantSnowflake struct {
	mu           sync.Mutex
	clock        Clock
	workerID     int64
	spareWorkers []int64
	strategy     Strategy
	maxBackward  int64 // 允许等待的最大回拨毫秒数
	lastMs       int64
	sequence     int64
	borrowed     int64 // 已借用的 worker 次数,供观测
}

// TolerantOption 是可选配置。
type TolerantOption func(*TolerantSnowflake)

// WithStrategy 设置回拨策略。
func WithStrategy(s Strategy) TolerantOption {
	return func(g *TolerantSnowflake) { g.strategy = s }
}

// WithMaxBackward 设置等待策略能容忍的最大回拨毫秒数。
func WithMaxBackward(ms int64) TolerantOption {
	return func(g *TolerantSnowflake) { g.maxBackward = ms }
}

// WithSpareWorkers 设置可借用的备用 worker id 池(仅 StrategyBorrowWorker 用)。
func WithSpareWorkers(ids ...int64) TolerantOption {
	return func(g *TolerantSnowflake) { g.spareWorkers = append([]int64(nil), ids...) }
}

// NewTolerantSnowflake 构造带回拨容错的生成器。
func NewTolerantSnowflake(workerID int64, clock Clock, opts ...TolerantOption) (*TolerantSnowflake, error) {
	if workerID < 0 || workerID > MaxWorkerID {
		return nil, ErrWorkerRange
	}
	if clock == nil {
		clock = RealClock{}
	}
	g := &TolerantSnowflake{clock: clock, workerID: workerID, strategy: StrategyWait, maxBackward: 5}
	for _, o := range opts {
		o(g)
	}
	return g, nil
}

// WorkerID 返回当前生效的 worker id(借位后可能已改变)。
func (g *TolerantSnowflake) WorkerID() int64 {
	g.mu.Lock()
	defer g.mu.Unlock()
	return g.workerID
}

// Borrowed 返回累计借位次数。
func (g *TolerantSnowflake) Borrowed() int64 {
	g.mu.Lock()
	defer g.mu.Unlock()
	return g.borrowed
}

// Next 生成下一个 ID,并在时钟回拨时按配置的策略恢复。
func (g *TolerantSnowflake) Next() (uint64, error) {
	g.mu.Lock()
	defer g.mu.Unlock()

	now := g.clock.NowMillis()
	if now < g.lastMs {
		fixed, err := g.recover(now)
		if err != nil {
			return 0, err
		}
		now = fixed
	}
	if now == g.lastMs {
		g.sequence = (g.sequence + 1) & MaxSequence
		if g.sequence == 0 {
			for now <= g.lastMs {
				time.Sleep(100 * time.Microsecond)
				now = g.clock.NowMillis()
			}
		}
	} else {
		g.sequence = 0
	}
	g.lastMs = now
	return compose(now, g.workerID, g.sequence), nil
}

func (g *TolerantSnowflake) recover(now int64) (int64, error) {
	switch g.strategy {
	case StrategyReject:
		return 0, fmt.Errorf("%w: last=%d now=%d", ErrClockBackwards, g.lastMs, now)
	case StrategyWait:
		back := g.lastMs - now
		if back > g.maxBackward {
			return 0, fmt.Errorf("%w: 回拨 %dms 超过容忍上限 %dms", ErrClockBackwards, back, g.maxBackward)
		}
		for now < g.lastMs {
			time.Sleep(100 * time.Microsecond)
			now = g.clock.NowMillis()
		}
		return now, nil
	case StrategyBorrowWorker:
		if len(g.spareWorkers) == 0 {
			return 0, fmt.Errorf("%w: 备用 worker 池已空,无法借位", ErrClockBackwards)
		}
		g.workerID, g.spareWorkers = g.spareWorkers[0], g.spareWorkers[1:]
		g.borrowed++
		g.sequence = 0
		return now, nil
	}
	return 0, ErrClockBackwards
}

关键点在 Next 里那一句 if now < g.lastMs:所有回拨处理都收敛到这一个分支,正常路径(时间前进)一行都没变,因此不会拖慢绝大多数调用。

真跑:假时钟复现回拨

回拨场景之所以难测,是因为你没法让真实时钟「倒退 5 秒再跑测试」。解法是把时钟抽象成接口——7.1 的 Clock 就是为此留的。测试里注入一个 fakeClock,Advance(-5000) 就能精确制造任意幅度的回拨。

下面这段是 L1,go test 可以直接跑,能真跑出回拨:

// file: internal/idgen/clockback_test.go
// tier: L1
// verify: go test ./internal/idgen/ -run TestBackward -v
package idgen

import (
	"errors"
	"sync"
	"testing"
)

// fakeClock 是可注入的假时钟:可以任意前后拨动,用来复现回拨场景。
type fakeClock struct {
	mu  sync.Mutex
	now int64
}

func (c *fakeClock) NowMillis() int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *fakeClock) Set(v int64) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = v
}

func (c *fakeClock) Advance(d int64) { c.Set(c.NowMillis() + d) }

// 成因一:NTP 校时——本地时钟被往回拨。拒绝策略直接报错。
func TestBackwardReject(t *testing.T) {
	clk := &fakeClock{now: Epoch + 10_000}
	g, err := NewTolerantSnowflake(9, clk, WithStrategy(StrategyReject))
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	if _, err := g.Next(); err != nil {
		t.Fatalf("first: %v", err)
	}
	clk.Advance(-5000) // NTP 把时钟往回拨 5 秒
	if _, err := g.Next(); !errors.Is(err, ErrClockBackwards) {
		t.Fatalf("回拨后应拒绝生成,got %v", err)
	}
}

// 成因三:闰秒/大幅校时——回退很大且不能等。借位换 worker 继续生成。
func TestBackwardBorrowWorker(t *testing.T) {
	clk := &fakeClock{now: Epoch + 10_000}
	g, err := NewTolerantSnowflake(9, clk,
		WithStrategy(StrategyBorrowWorker), WithSpareWorkers(11, 12))
	if err != nil {
		t.Fatalf("new: %v", err)
	}
	first, _ := g.Next()
	if WorkerIDOf(first) != 9 {
		t.Fatalf("初始 worker 应为 9,got %d", WorkerIDOf(first))
	}
	clk.Advance(-60_000) // 回退一分钟,借位策略不等待
	second, err := g.Next()
	if err != nil {
		t.Fatalf("借位策略应成功:%v", err)
	}
	if got := WorkerIDOf(second); got != 11 {
		t.Fatalf("借位后 worker 应为 11,got %d", got)
	}
	if second == first {
		t.Fatal("借位后生成的 ID 不能与旧 ID 相同")
	}
	if g.Borrowed() != 1 {
		t.Fatalf("借位次数应为 1,got %d", g.Borrowed())
	}
	// 第二次回拨,再借一个
	clk.Advance(-60_000)
	third, err := g.Next()
	if err != nil {
		t.Fatalf("第二次借位应成功:%v", err)
	}
	if WorkerIDOf(third) != 12 {
		t.Fatalf("第二次借位后 worker 应为 12,got %d", WorkerIDOf(third))
	}
}

本机实跑:

--- PASS: TestBackwardReject (0.00s)
--- PASS: TestBackwardBorrowWorker (0.00s)
--- PASS: TestBackwardWaitCatchesUp (0.02s)
--- PASS: TestBackwardWaitOverLimit (0.00s)
--- PASS: TestBackwardBorrowExhausted (0.00s)

五个回拨用例全绿,覆盖了三种策略各自的正常路径与降级路径:拒绝策略报错、借位策略换到 11 再换到 12、等待策略在 20ms 后追上(真时钟追平由 goroutine 模拟)、等待超限报错、备用池耗尽报错。注意 TestBackwardWaitCatchesUp 里没有 Sleep 出真实回拨——它只是用一个 goroutine 把假时钟往前推,把「时间继续走」这件事精确控制住。假时钟的价值就在这里:回拨的幅度、时机、持续时间全都可以是测试参数,而不是等线上偶发。

本节小结

  • 时钟回拨是常态而非异常:NTP 步进校时、虚拟机迁移/挂起、闰秒回拨,三者都会让本地时间倒退,幅度从毫秒到分钟不等。
  • 三种应对各有取舍:等待追平不阻塞但会卡住发号、换 worker id 不阻塞但需要备用池、抛错拒绝最安全但发号不可用。生产上通常是「小幅等待 + 大幅借位 + 兜底报错」的组合。
  • 等待策略必须带容忍上限,否则遇到小时级回拨就是死循环;WithMaxBackward 就是这个上限。
  • 把时钟抽象成 Clock 接口,回拨就从「线上偶发」变成了可参数化的测试场景;本节五个 L1 用例全绿,覆盖三种策略的正常与降级路径。
  • 7.1 的雪花算法、本节的容错生成器、7.2 的号段模式,位布局与接口完全一致,可以混用:热点主键走雪花,严格递增的序号走号段。

阅读导航:上一节:7.2 号段模式 · 下一节:8.1 Redis 使用与失效策略 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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