自增主键先撑不住
统一认证中心要给用户、应用、团队、授权记录这些实体发全局唯一 ID。早期直接用 MySQL 自增主键,问题很快暴露:分库分表之后,自增 ID 在不同分片之间会冲突;业务方希望 ID 自带时间信息,好排序;批量写入时,自增锁还是个热点。
调研了一圈候选:UUID、号段模式(Leaf)、Snowflake。UUID 无序,InnoDB 页分裂严重;号段模式依赖 DB,还得额外部署一套;Snowflake 本地生成、趋势递增、就是个 64 位整型,最对我们的场景。
64 位怎么切,WorkerID 怎么分
经典位分配:1 位符号 + 41 位毫秒时间戳 + 10 位 WorkerID + 12 位序列号,单机每毫秒理论上能出 4096 个 ID。
要花心思的是 WorkerID 怎么分。我们跑在 KubeSphere 上,每个 Pod 用 StatefulSet 的下标派生 WorkerID(0-1023),再配合配置中心给不同服务预留号段范围,避免不同实例撞车。光这样还不放心,启动时把 WorkerID 连同 Pod IP、启动时间写进 Redis,做一次占用校验。
时钟回拨:等还是拒
NTP 同步可能让时间毫秒级倒退,直接生成就是重复 ID,这事没有商量余地。我们的策略分两档:小幅回拨(5ms 内)自旋等待;超过阈值直接拒绝服务并告警,宁可这单失败,不产脏数据。

核心的 NextID 长这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| const (
epoch int64 = 1704067200000 // 2024-01-01 00:00:00 UTC
workerIDBits uint8 = 10
seqBits uint8 = 12
maxWorkerID int64 = -1 ^ (-1 << workerIDBits)
maxSeq int64 = -1 ^ (-1 << seqBits)
)
type Snowflake struct {
mu sync.Mutex
lastStamp int64
workerID int64
seq int64
}
func NewSnowflake(workerID int64) (*Snowflake, error) {
if workerID < 0 || workerID > maxWorkerID {
return nil, fmt.Errorf("workerID %d out of range", workerID)
}
return &Snowflake{workerID: workerID}, nil
}
func (s *Snowflake) NextID() (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UnixMilli()
if now < s.lastStamp {
offset := s.lastStamp - now
if offset > 5 {
return 0, fmt.Errorf("clock moved backwards %dms, refused", offset)
}
time.Sleep(time.Duration(offset) * time.Millisecond)
now = time.Now().UnixMilli()
if now < s.lastStamp {
return 0, errors.New("clock still backwards after wait")
}
}
if now == s.lastStamp {
s.seq = (s.seq + 1) & maxSeq
if s.seq == 0 {
// 当前毫秒序列号耗尽,等到下一毫秒
for now <= s.lastStamp {
now = time.Now().UnixMilli()
}
}
} else {
s.seq = 0
}
s.lastStamp = now
id := ((now - epoch) << (workerIDBits + seqBits)) |
(s.workerID << seqBits) |
s.seq
return id, nil
}
|
业务层通过 Wire 注入单例 *Snowflake,DAO 在 BeforeCreate 钩子中填充主键:
1
2
3
4
5
6
7
8
9
10
| func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.ID == 0 {
id, err := sf.NextID()
if err != nil {
return err
}
u.ID = id
}
return nil
}
|
五个坑
WorkerID 10 位看着够用,但我们最初把多个服务混在同一号段里,压测时出现跨服务 WorkerID 碰撞。后来按服务前缀切分号段,配置中心统一管。
时钟回拨的阈值不能设太大,也不能直接 panic。5ms 内等待,超过就返回错误让上游降级,比如重试到其他实例。
GORM 的 BeforeCreate 在批量 Create 时每条记录都会调一次,Snowflake 单例的锁竞争要留心。实测万级批量写入时锁等待可接受,再大的批量建议分片。
41 位时间戳能用大约 69 年,epoch 选 2024 年足够;系统要跑到 2090 年以后就得重新评估。
还有一条纪律:别把 WorkerID 写死在配置文件里。Pod 重建后复用了旧 WorkerID、旧实例还活着,就撞了。StatefulSet + 下标派生是目前我们最稳的方案。
后来
Snowflake 原理不复杂,难的是落地那几件事:WorkerID 分配、时钟回拨、批量写入的锁竞争。这套东西在统一认证中心跑了大半年,数千家机构的日常认证请求里没出过 ID 重复,趋势也没乱过。比起自增主键,分库分表和排序场景都省心不少。
封面图:yellowcloud / Flickr · CC BY 2.0