《MMO 服务器开发》7.1 雪花算法从零实现

从零实现雪花算法:把 64 位切成符号位、41 位时间戳、10 位 worker id 与 12 位序列,讲清 worker id 如何由区服号合成、同毫秒序列耗尽怎么处理、为什么吞吐天花板由位布局而非核数决定,并给出本机实跑的并发吞吐与重复率 0 断言。

7.1 雪花算法从零实现

到了集群时代,aegis 里几乎每一样东西都需要一个全局唯一、单调、还能塞进 uint64 的编号。角色创建要 PlayerID,掉落要 ItemInstID,开一场跨服战场要 InstanceID,公会、队伍、邮件、订单全都要。单服时代这些可以靠一个自增计数器解决;一旦有多台机器同时写,自增就会撞车。

最省事的做法是「起一个发号服务,谁要 ID 就来取」。但发号服务立刻变成新的单点:它挂了整集群都发不出 ID,它的网络往返把每一次创建角色都拖慢一拍。MMO 恰恰不能接受这个——登录高峰每秒要发几十万个 ID,你不可能让每个 ID 都等一次远程调用。

雪花算法(Snowflake)的思路是:让每个节点自己发号,互不通信,却保证全局不重复。它不靠协调,靠的是把「谁发的」「什么时候发的」「这一毫秒的第几个」三样东西拼进同一个整数里。只要任意两个节点在这三样里至少有一项不同,ID 就不可能相同。

这一节我们从零把这个整数拼出来:先逐位拆解 64 位怎么切,再解决「谁发的」这一项在 MMO 里怎么落地,然后写出一份能直接 go test 的实现,最后在本机测出单节点吞吐和重复率。7.2 会讲另一条路线(号段模式),7.3 处理它唯一的软肋——时钟回拨。

本节与站内既有文章的分工

站内已经有两篇讲区服与分区的架构文,它们和本节是「上游 / 下游」的关系,不重复:

本节站内既有文章它讲什么本节讲什么
7.1/game-server-regional-shard-directory-architecture/分区目录如何把玩家路由到正确的区服跨区服全局唯一的 ID 怎么在本地无协调地生成
7.1/zone-sharding-and-server-splitting-for-online-games/什么时候该分区分服、怎么切分服之后 ID 空间怎么保证不撞、怎么从 ID 反推来源

一句话:那两篇回答「玩家该在哪台机器上」,这一节回答「这台机器发出去的编号,怎么保证别的机器不会发出同一个」。

64 位怎么切:逐位拆解

一个 uint64 是 64 个 bit。雪花算法把它切成四段:

 63      62                              22        12         0
+--+---------------------------------------+---------+----------+
|0 |        41 位毫秒时间戳 (ts)            | 10 位   | 12 位    |
|  |                                       | worker  | 序列 seq |
+--+---------------------------------------+---------+----------+
  1                  41                        10         12
  • 符号位 1 位:永远为 0。这不是浪费,是为了让这个 ID 转成有符号 int64 之后恒为正数。很多语言(Java、MySQL 的 BIGINT)默认按有符号处理,最高位一旦是 1 就会被当成负数,排序和索引都会出问题。留一位下来,换来的是跨语言安全。
  • 时间戳 41 位:相对一个自定义纪元的毫秒数。为什么不用 Unix 纪元?因为 41 位只能表示 2^41 ≈ 2.2×10^12 毫秒。从 Unix 纪元(1970)算起只够撑到 2039 年;把纪元挪到 2024-01-01,就够用到 2093 年,整整多出半个世纪。代码里 Epoch = 1704067200000 就是这个起点。
  • worker id 10 位:标识「谁发的」。10 位能表示 1024 个节点,对一个 MMO 集群绰绰有余。它通常再拆成 5 位机房 + 5 位机器,或者像我们下面要讲的,拆成区服号 + 节点序号。
  • 序列 12 位:同一毫秒内的第几个。12 位 = 4096,也就是单个节点每毫秒最多发 4096 个 ID,换算下来理论天花板是 409.6 万/秒。

四个字段一拼,唯一性就变成一道简单的算术:两台机器只要 worker id 不同,ID 必然不同;同一台机器只要毫秒不同或序列不同,ID 也不同。三者全撞上的概率,取决于你能不能把 worker id 分配对——这就是下一节的事。

worker id 怎么分配

整个雪花算法里,唯一需要「全局达成一致」的东西就是 worker id。它是 10 位,最多 1024 个取值,必须保证同一时刻没有两个节点用同一个 worker id。这是全算法唯一的约束,也是最容易出事的地方。

在 MMO 里,worker id 有一个天然的好来源:区服号 ServerID(uint16)。aegis 的跨卷约定里写明 ServerID 要参与雪花算法的 worker id,正是因为它本来就全局唯一、本来就稳定。做法是把 10 位再拆成「高 5 位放区服号低 5 位、低 5 位放节点序号」:

  • 区服号低 5 位 → 支持 32 个区服;
  • 节点序号 → 每个区服内 32 个节点(网关、场景、战斗各算一个)。

这样分配有两个好处。第一,不需要中心化的分配服务:每个节点从配置里读到自己的区服号和节点序号就能算出 worker id,不用联网去「申请」。第二,ID 自带溯源:出了脏数据,从 PlayerID 里就能反解出它是哪个区服的哪个节点发的,排查问题省掉一大半功夫。

代价是区服号只有低 5 位有效,超过 32 个区服就要换更宽的切法(比如把序列让出 2 位)。本机《晨曦大陆》的部署远没到这个量级,先用 5+5 够用。

实现

先是时钟抽象。雪花算法不直接调 time.Now,而是依赖一个 Clock 接口——这是为 7.3 的回拨测试留的接口,假时钟能注入进来复现任何时间场景。

下面这段是 L1,go test 可以直接跑:

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

import "time"

// Clock 抽象「毫秒时间源」。雪花算法与号段模式都不直接依赖 time.Now,
// 而是依赖这个接口——这样回拨、闰秒这类场景才能用假时钟注入(见 7.3)。
type Clock interface {
	NowMillis() int64
}

// RealClock 是生产环境的实现。
type RealClock struct{}

// NowMillis 返回 Unix 毫秒。
func (RealClock) NowMillis() int64 { return time.Now().UnixMilli() }

再是主体。注意三处细节:Next 全程持锁(保证并发安全);同毫秒序列耗尽时自旋等下一毫秒而不是丢号;compose 用纯移位把四段拼起来,没有任何乘法。

下面这段是 L1,go test 可以直接跑:

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

import (
	"errors"
	"sync"
)

// 64 位布局:符号位(1) + 时间戳(41) + worker id(10) + 序列(12)
//
//	| 1 | 41 位毫秒时间戳 | 10 位 worker | 12 位序列 |
//	最高位恒为 0,保证生成的 uint64 转成 int64 后永远为正。
const (
	// Epoch 自定义纪元:2024-01-01T00:00:00Z,比 Unix 纪元更晚,
	// 41 位可覆盖 (1<<41)/1000/86400/365 ≈ 69.7 年。
	Epoch int64 = 1704067200000

	workerBits   uint = 10
	sequenceBits uint = 12

	// MaxWorkerID 10 位 worker 上限 1023。
	MaxWorkerID int64 = -1 ^ (-1 << workerBits)
	// MaxSequence 12 位序列上限 4095。
	MaxSequence int64 = -1 ^ (-1 << sequenceBits)

	workerShift    uint = sequenceBits              // 12
	timestampShift uint = sequenceBits + workerBits // 22
)

// ErrClockBackwards 表示本地时钟早于上一次生成时刻。
var ErrClockBackwards = errors.New("idgen: clock moved backwards")

// ErrWorkerRange worker id 越界。
var ErrWorkerRange = errors.New("idgen: worker id out of range")

// Snowflake 是单节点的雪花 ID 生成器。并发安全。
type Snowflake struct {
	mu       sync.Mutex
	clock    Clock
	workerID int64
	lastMs   int64
	sequence int64
}

// NewSnowflake 用给定 worker id 与时钟构造生成器。
func NewSnowflake(workerID int64, clock Clock) (*Snowflake, error) {
	if workerID < 0 || workerID > MaxWorkerID {
		return nil, ErrWorkerRange
	}
	if clock == nil {
		clock = RealClock{}
	}
	return &Snowflake{clock: clock, workerID: workerID}, nil
}

// WorkerID 由区服号(ServerID)与节点序号合成 10 位 worker id:
// 高 5 位放区服号低 5 位,低 5 位放节点序号。这样跨服全局唯一的同时,
// 出问题时也能从 ID 反推出它出自哪个区服的哪个节点。
func WorkerID(serverID uint16, nodeIndex uint8) (int64, error) {
	if nodeIndex > 31 {
		return 0, ErrWorkerRange
	}
	return (int64(serverID)&0x1F)<<5 | int64(nodeIndex), nil
}

// Next 生成下一个 ID。同一毫秒内序列耗尽时自旋到下一毫秒。
func (s *Snowflake) Next() (uint64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	now := s.clock.NowMillis()
	if now < s.lastMs {
		return 0, ErrClockBackwards
	}
	if now == s.lastMs {
		s.sequence = (s.sequence + 1) & MaxSequence
		if s.sequence == 0 {
			for now <= s.lastMs {
				now = s.clock.NowMillis()
			}
		}
	} else {
		s.sequence = 0
	}
	s.lastMs = now
	return compose(now, s.workerID, s.sequence), nil
}

func compose(ms, workerID, sequence int64) uint64 {
	return uint64(ms-Epoch)<<timestampShift |
		uint64(workerID)<<workerShift |
		uint64(sequence)
}

// TimestampOf 从 ID 反解出毫秒时间戳(加回 Epoch)。
func TimestampOf(id uint64) int64 { return int64(id>>timestampShift) + Epoch }

// WorkerIDOf 从 ID 反解出 worker id。
func WorkerIDOf(id uint64) int64 { return int64(id>>workerShift) & MaxWorkerID }

// SequenceOf 从 ID 反解出同毫秒内的序列号。
func SequenceOf(id uint64) int64 { return int64(id) & MaxSequence }

实测:单节点吞吐与重复率

下面这段是 B 级基准:8 个 goroutine 共用一个生成器,各发 25 万个 ID,最后排序去重,断言重复数为 0。它同时量出并发吞吐。

// file: internal/idgen/snowflake_bench_test.go
// tier: B
// verify: go test ./internal/idgen/ -run TestSnowflakeThroughputUniqueness -v
package idgen

import (
	"sort"
	"sync"
	"testing"
	"time"
)

// TestSnowflakeThroughputUniqueness 是 B 级基准:并发下的吞吐 + 重复率断言。
func TestSnowflakeThroughputUniqueness(t *testing.T) {
	const (
		workers   = 8
		perWorker = 250_000
	)
	s, _ := NewSnowflake(42, RealClock{})
	all := make([][]uint64, workers)
	var wg sync.WaitGroup
	start := time.Now()
	for w := 0; w < workers; w++ {
		wg.Add(1)
		go func(w int) {
			defer wg.Done()
			buf := make([]uint64, 0, perWorker)
			for i := 0; i < perWorker; i++ {
				id, err := s.Next()
				if err != nil {
					t.Errorf("next: %v", err)
					return
				}
				buf = append(buf, id)
			}
			all[w] = buf
		}(w)
	}
	wg.Wait()
	elapsed := time.Since(start)

	total := workers * perWorker
	flat := make([]uint64, 0, total)
	for _, b := range all {
		flat = append(flat, b...)
	}
	sort.Slice(flat, func(i, j int) bool { return flat[i] < flat[j] })
	dup := 0
	for i := 1; i < len(flat); i++ {
		if flat[i] == flat[i-1] {
			dup++
		}
	}
	if dup != 0 {
		t.Fatalf("重复 ID 必须为 0,实际 %d", dup)
	}
	t.Logf("单节点 %d 个 ID / %d 并发,耗时 %s,吞吐 %.0f 万/秒,重复率 0",
		total, workers, elapsed, float64(total)/elapsed.Seconds()/1e4)
}

func BenchmarkSnowflakeNext(b *testing.B) {
	s, _ := NewSnowflake(1, RealClock{})
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		if _, err := s.Next(); err != nil {
			b.Fatal(err)
		}
	}
}

本机实跑(Apple M1 Pro,go test,无 -race):

单节点 2000000 个 ID / 8 并发,耗时 487ms,吞吐 410 万/秒,重复率 0
BenchmarkSnowflakeNext-10    9849972    244.6 ns/op    0 B/op    0 allocs/op

两个数字都值得看一眼。单 goroutine 的 benchmark 是 244.6 ns/op,倒数是 409 万/秒;8 并发也是 410 万/秒——两者几乎一样。原因不是巧合:4096 个序列 / 毫秒 × 1000 毫秒 = 409.6 万/秒,正好是 12 位序列的硬天花板。8 并发并没有更快,因为瓶颈不在 CPU,而在「同一毫秒内只能发 4096 个」这个位布局本身;加线程只会让它们在锁上排队。

这恰恰是雪花算法的性格:吞吐天花板由位布局写死,与机器核数无关。想要更高,只能加节点(每个节点换一个 worker id),不能加线程。基准数字是同机相对量级,绝对数值不可移植。

本节小结

  • 雪花算法把 64 位切成 1 符号 + 41 时间戳 + 10 worker + 12 序列,让每个节点本地发号、互不通信却全局不重复。
  • 符号位留 0 是为了跨语言安全;时间戳用自定义纪元(2024-01-01)把 41 位的寿命从 2039 年延到 2093 年。
  • worker id 是唯一需要全局一致的字段。在 MMO 里用「区服号低 5 位 + 节点序号」合成,既免去中心化分配,又能从 ID 反查来源。
  • 单节点吞吐的硬天花板是 4096 ID/毫秒 ≈ 409.6 万/秒,由位布局决定,与核数无关;要扩容只能加节点。
  • 唯一的软肋是它依赖时钟单调——一旦本地时钟往回走,Next 会直接返回 ErrClockBackwards。怎么处理这个回拨,是 7.3 的主题。

阅读导航:上一节:6.3 资产归属与跨服结算 · 下一节:7.2 号段模式 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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