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 号段模式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。