🔍 登录 注册

分布式 ID 生成方案横评 Snowflake、号段与 Leaf

打 打工人 2026-10-23 👁 0 ⏱ 5min

分布式 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)
}

四个坑:

  1. 时钟回拨。NTP 校时或虚拟机迁移会让时间倒退,直接生成重复 ID。小回拨等待追平,大回拨必须拒绝服务并告警,不能"假装没事";
  2. 工作机 ID 分配。10 位节点号若靠人工配环境变量,容器漂移时会撞车。要么从注册中心申请并租约续期,要么用 IP 末段派生并做冲突检测;
  3. 单节点序列上限。每毫秒 4096 个是硬顶,热点节点要预留余量或按业务拆多个 worker id;
  4. 对外可预测。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 位不便放进整型字段与索引。

应用内嵌号段 + 本地缓存适合中小规模:单实例 + 一张分配表就能跑,不需要独立服务。规模上来再演进为独立服务。

六、必须配套的三件事

无论选哪种方案,都有三件事要一起做:

  1. 对外 ID 与内部 ID 分离。数据库主键用有序内部 ID(自增或号段),对外暴露用不可预测的短码。常见的做法是把内部 ID 用可逆编码转成短码:
// 内部 int64 主键 + 对外 hashid 风格短码
type Article struct {
    ID   int64  `gorm:"primaryKey" json:"-"`
    Code string `gorm:"type:varchar(16);uniqueIndex" json:"code"`
}

这同时解决了遍历风险与 URL 美观问题,且切换发号方案不影响外部接口。

  1. ID 解析能力。跨系统排查时需要从 ID 反解出时间或业务标签。Snowflake 与 UUIDv7 天然支持,号段方案建议在字段中留前缀。

  2. 压测与容量。号段方案的取段 QPS、Snowflake 的每毫秒序列上限,都要实测。特别是"重启风暴"场景:滚动发布时几十个实例同时取号段,很容易把分配表所在的 DB 打成瓶颈。

小结

三条判据基本够用:内部主键要趋势递增(B+ 树友好),选号段或 Snowflake 都可以;对外暴露必须不可预测,一律走短码或独立编码;基础设施决定实现——有时钟同步保障且能吃下 ZK 运维就 Leaf-snowflake,只有 MySQL 就用双 buffer 号段,需要跨系统合并又不能部署服务就用 UUIDv7。真正引发事故的往往不是算法本身,而是时钟回拨、节点 ID 撞车、以及"把可预测的 ID 直接暴露到公网"这三件事。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © 打工人

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章