分布式 ID 生成方案横评 Snowflake、号段与 Leaf
订单号、文章 ID、流水号,看似小事,选错方案会在扩容、对账、防攻击三个阶段反复付账。自增 ID 会泄露业务量并可被遍历;UUID 无序导致索引膨胀;数据库自增在分库后直接冲突。本文按同一套指标横评三类主流方案,并给出选型判据。
一、评价维度先定清楚
选型要问六个问题:是否趋势递增(影响 B+ 树索引局部性)、是否单调(同一节点内是否严格递增)、长度与可传输性(能否放进 URL 与日志)、是否可预测(对外暴露是否会被遍历)、依赖什么基础设施(时钟、ZK、DB)、宕机表现(是降级还是不可用)。
没有全绿的方案,只有匹配场景的取舍。
二、Snowflake 及其四个必须处理的坑
标准结构是 1 位符号 + 41 位毫秒时间戳 + 10 位节点 + 12 位序列,64 位整型,趋势递增。
type Node struct {
mu sync.Mutex
epoch int64
nodeID int64
lastStamp int64
seq uint32
}
func (n *Node) Next() int64 {
n.mu.Lock()
defer n.mu.Unlock()
now := time.Now().UnixMilli()
if now < n.lastStamp { // 时钟回拨
if n.lastStamp-now <= 5 {
time.Sleep(time.Duration(n.lastStamp-now+1) * time.Millisecond)
now = time.Now().UnixMilli()
} else {
panic("clock moved backwards too far")
}
}
if now == n.lastStamp {
n.seq = (n.seq + 1) & 0xFFF
if n.seq == 0 { // 当前毫秒序号用尽
for now <= n.lastStamp {
now = time.Now().UnixMilli()
}
}
} else {
n.seq = 0
}
n.lastStamp = now
return (now-n.epoch)<<22 | n.nodeID<<12 | int64(n.seq)
}
四个坑:
- 时钟回拨。NTP 校时或虚拟机迁移会让时间倒退,直接生成重复 ID。小回拨等待追平,大回拨必须拒绝服务并告警,不能"假装没事";
- 工作机 ID 分配。10 位节点号若靠人工配环境变量,容器漂移时会撞车。要么从注册中心申请并租约续期,要么用 IP 末段派生并做冲突检测;
- 单节点序列上限。每毫秒 4096 个是硬顶,热点节点要预留余量或按业务拆多个 worker id;
- 对外可预测。Snowflake 大致包含时间与序号,竞争对手能推算量级。对外暴露的 ID 不要用它,要么加密编码,要么另发短期业务编号。
三、号段模式:把 DB 压力摊平
核心是"一次取一段,用完再来取",把每行一次 DB 交互变为每 10000 行一次。
CREATE TABLE id_alloc (
biz_tag VARCHAR(64) PRIMARY KEY,
max_id BIGINT NOT NULL,
step INT NOT NULL,
version BIGINT NOT NULL
);
UPDATE id_alloc SET max_id = max_id + step, version = version + 1
WHERE biz_tag = ? AND version = ?; -- CAS 取段
SELECT max_id, step FROM id_alloc WHERE biz_tag = ?;
工程要点是双 buffer:当前号段消耗到 10% 左右就异步预取下一段,避免取号瞬间的毛刺延迟。号段大小要平衡:过大浪费(重启丢段),过小退化为频繁打 DB。
优点是不依赖时钟、实现简单;缺点是 ID 会跳号(重启丢段),且强依赖一张数据库表。对账要求"ID 必须连续"的系统不适合,但绝大多数业务只要求唯一与趋势递增。
四、Leaf 的混合思路
Leaf 同时提供两种模式并做了工程化处理。Leaf-segment 是号段模式 + 双 buffer + 通过 update ... where version=? 乐观锁取段,把 DB 做成高可用(一主多从,每次取段轮换数据源)。Leaf-snowflake 解决的是原生 Snowflake 最难的部分:用 ZK 做工作机注册、顺序节点分配 worker id,并持续监听各节点时间戳,检测到回拨时报警且拒绝出号。
对已有 ZK 基础设施的团队,Leaf 的成本主要在那套 ZK 运维上;没有 ZK 的团队用它反而更重。
五、别忽略的两种轻量方案
UUID v7(较新规范,已被 PostgreSQL 18 等实现)把 Unix 毫秒时间戳放前缀,解决了 v4 的索引随机写问题。适合无法部署中心化发号服务、又需要跨系统合并数据的场景。代价是 128 位不便放进整型字段与索引。
应用内嵌号段 + 本地缓存适合中小规模:单实例 + 一张分配表就能跑,不需要独立服务。规模上来再演进为独立服务。
六、必须配套的三件事
无论选哪种方案,都有三件事要一起做:
- 对外 ID 与内部 ID 分离。数据库主键用有序内部 ID(自增或号段),对外暴露用不可预测的短码。常见的做法是把内部 ID 用可逆编码转成短码:
// 内部 int64 主键 + 对外 hashid 风格短码
type Article struct {
ID int64 `gorm:"primaryKey" json:"-"`
Code string `gorm:"type:varchar(16);uniqueIndex" json:"code"`
}
这同时解决了遍历风险与 URL 美观问题,且切换发号方案不影响外部接口。
-
ID 解析能力。跨系统排查时需要从 ID 反解出时间或业务标签。Snowflake 与 UUIDv7 天然支持,号段方案建议在字段中留前缀。
-
压测与容量。号段方案的取段 QPS、Snowflake 的每毫秒序列上限,都要实测。特别是"重启风暴"场景:滚动发布时几十个实例同时取号段,很容易把分配表所在的 DB 打成瓶颈。
小结
三条判据基本够用:内部主键要趋势递增(B+ 树友好),选号段或 Snowflake 都可以;对外暴露必须不可预测,一律走短码或独立编码;基础设施决定实现——有时钟同步保障且能吃下 ZK 运维就 Leaf-snowflake,只有 MySQL 就用双 buffer 号段,需要跨系统合并又不能部署服务就用 UUIDv7。真正引发事故的往往不是算法本身,而是时钟回拨、节点 ID 撞车、以及"把可预测的 ID 直接暴露到公网"这三件事。
评论(0)
登录 后参与评论。