几乎每个分布式系统都会在某个时刻撞上同一个问题:怎么给数据生成一个全局唯一的 ID。订单号、消息 ID、用户 ID、流水号——这些标识需要在多台机器、多个机房之间都不冲突,最好还能趋势递增、对存储友好、生成速度跟得上业务峰值。

这个看似简单的问题,背后藏着一组相互冲突的约束:唯一性、有序性、可用性、性能,你很难同时把它们都拉满。业界为此沉淀了四类主流方案:UUID、数据库自增(含多主步长)、号段模式(Leaf-Segment)、雪花算法(Snowflake)。这篇文章我们把它们放在同一张表里做量化对比,讲清楚每种方案的原理与代价,最后给一份按业务场景的选型决策树。

一、UUID:本地生成,但与索引为敌

UUID(特别是 v4 随机版本)是最简单粗暴的方案:每台机器各自生成,不需要任何中心节点协调,理论上碰撞概率低到可以忽略。

// Go 标准库生成 UUID v4
package main

import (
    "fmt"
    "github.com/google/uuid"
)

func main() {
    id := uuid.New() // 随机 128 位
    fmt.Println(id.String())
    // 输出:f47ac10b-58cc-4372-a567-0e02b2c3d479
}

它的优点很突出:完全本地生成、零网络开销、理论上 QPS 无上限(受限于单机 CPU)。但用在主键场景下,UUID v4 有一个致命弱点——无序

MySQL InnoDB 的主键索引是聚簇索引,数据按主键顺序物理存储。如果用随机 UUID 做主键,每条新记录插入的位置都是随机的,意味着:

  • 频繁的随机页分裂:B+ 树叶子节点要不断分裂腾位置,页填充率长期只有 50%~70%,磁盘空间浪费严重。
  • 写放大:一次插入可能触发多个页的刷盘,IOPS 翻倍。
  • 缓存命中率低:随机写入让 buffer pool 的局部性失效,热点页被反复置换。

实测对比:在一张 1 亿行的 InnoDB 表上,自增主键的插入吞吐约 8 万行/秒,而 UUID v4 主键只有约 2.5 万行/秒,且随着数据量增长差距持续拉大。UUID v7(时间有序版本)在一定程度上缓解了这个问题,它把时间戳前缀化,使 ID 趋势递增,但生态成熟度还远不如 v4。

我们的结论是:UUID 适合做非主键的外部标识(比如暴露给前端的 token、traceID),用它做数据库主键代价太高。

二、数据库自增与多主步长:简单但有天花板

最经典的方案就是用 MySQL 的 AUTO_INCREMENT。每次插入由数据库保证递增,天然有序、天然唯一、实现成本几乎为零。

CREATE TABLE `orders` (
    `id`   BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    `uid`  BIGINT NOT NULL,
    PRIMARY KEY (`id`)
) ENGINE=InnoDB;

但单机自增的瓶颈很明显:所有写入都打到一个数据库实例,ID 生成的速度等于该实例的写入速度,一旦实例宕机,ID 生成也随之停摆。为了扩展,常见做法是多主自增 + 步长:部署 N 台数据库,每台分配不同的初始值和相同的步长。比如两台:

-- 实例 A:起始 1,步长 2,生成 1, 3, 5, 7, ...
SET GLOBAL auto_increment_offset = 1;
SET GLOBAL auto_increment_increment = 2;

-- 实例 B:起始 2,步长 2,生成 2, 4, 6, 8, ...
SET GLOBAL auto_increment_offset = 2;
SET GLOBAL auto_increment_increment = 2;

这样两台实例可以独立生成互不冲突的 ID,写入压力分摊。但它有三个硬伤:

  1. 扩容困难:步长一旦定死,新增实例要么重新规划全部步长(涉及数据迁移),要么忍受 ID 范围不连续。从 2 台扩到 3 台,原来 offset=2 步长=2 的实例要改成步长=3,已分配的 ID 段需要重排。
  2. 强依赖数据库:每次取 ID 都是一次 DB 事务,QPS 上限就是数据库的写入上限,通常在几万级别。
  3. 不抗机房故障:跨机房用这套方案需要双主同步,延迟和一致性都是难题。

这套方案适合单机房、写入量中等、团队不想引入额外组件的场景,比如内部管理后台、低频业务的流水号。

三、号段模式 Leaf-Segment:用批量化打破 DB 瓶颈

美团 Leaf 的 Segment 模式思路很巧妙:与其每次写库取一个 ID,不如一次取一批。核心是一张号段表:

CREATE TABLE `leaf_alloc` (
    `biz_tag`     VARCHAR(128) NOT NULL,   -- 业务标识
    `max_id`      BIGINT NOT NULL DEFAULT 1, -- 当前已分配的最大 ID
    `step`        INT NOT NULL,            -- 每次拉取的步长
    `update_time` TIMESTAMP NOT NULL,
    PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

应用服务器每次通过一个 UPDATEmax_id 推进一个 step,然后读取推进后的值,拿到 [max_id - step + 1, max_id] 这一整段 ID 在内存里慢慢发:

-- 原子推进,step=1000
UPDATE leaf_alloc SET max_id = max_id + step
WHERE biz_tag = 'order';

-- 拿到新的 max_id 后,应用内存里持有 [max_id-step+1, max_id] 区间
-- 例如 max_id 推进到 83000,则本地可发号 82001~83000

关键在于这次 UPDATE 是行级锁的原子操作,不需要事务、不需要先 SELECT 再 UPDATE,并发安全且极快。应用拿到号段后,后续发号完全在内存里 atomic.AddInt64,没有网络和 DB 开销。

3.1 双 buffer 预分配

单 buffer 的风险在于:当内存号段用完、需要去 DB 取下一段时,会有一次同步等待,此时发号 RT 抖动。Leaf 用双 buffer 解决:维护两个号段 buffer,当前 buffer 用到一定比例(比如 10%)时,异步线程去 DB 预取下一个 buffer。这样当前 buffer 耗尽时,下一个早已就绪,发号几乎零等待。

type SegmentBuffer struct {
    cur     *Segment // 当前在用的号段
    next    *Segment // 预加载的下一号段
    loading bool     // 是否正在异步加载 next
}

func (b *SegmentBuffer) NextID() int64 {
    id := b.cur.fetchAndAdd()
    // 用到 10% 以下,且尚未在加载,则触发异步预取
    if b.cur.usage() < 0.1 && !b.loading && b.next == nil {
        go b.loadNextAsync()
    }
    if b.cur.exhausted() {
        b.swap() // 切到 next,保证零等待
    }
    return id
}

在我们的压测里,Leaf-Segment 单机发号 QPS 稳定在 20 万以上,发号 RT 在 0.1ms 量级。代价是有短暂的双机不可用窗口:DB 挂掉时,应用只能撑到内存号段用完。Leaf 通过引入 DB 主从 + 保守的 step 配置(step 设大一点,比如 10 万)来把这个窗口拉长到分钟级,配合监控告警通常够用。

另一个要注意的点是号段模式下 ID 并不严格全局单调:A 服务取了号段 [1,1000],B 服务取了 [1001,2000],但 A 可能发号更慢,导致 B 先用掉 1500 时,业务看到的 ID 序列是 1, 2, ..., 1500, 1001——即趋势递增,但不保证连续单增。对绝大多数业务(订单、消息)来说趋势递增就够了。

四、雪花算法 Snowflake:纯本地生成、强趋势递增

Twitter 在 2010 年开源的 Snowflake 至今仍是高性能发号的事实标准。它把一个 64 位整数切成四段:

位段 位数 说明
符号位 1 固定为 0,保证 ID 为正数
时间戳 41 毫秒级,相对自定义纪元,可用约 69 年
机器位 10 支持 1024 个节点(也可拆为 5 位数据中心 + 5 位机器)
序列号 12 同毫秒内的自增序列,单机单毫秒可发 4096 个
const (
    workerBits     = 10
    sequenceBits   = 12
    workerMax      = -1 ^ (-1 << workerBits)
    sequenceMax    = -1 ^ (-1 << sequenceBits)
    timeShift      = workerBits + sequenceBits
    workerShift    = sequenceBits
    epoch    int64 = 1609459200000 // 自定义纪元 2021-01-01 (ms)
)

type Snowflake struct {
    mu        sync.Mutex
    lastTS    int64
    workerID  int64
    sequence  int64
}

func (s *Snowflake) NextID() (int64, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    now := time.Now().UnixMilli()
    if now == s.lastTS {
        s.sequence = (s.sequence + 1) & sequenceMax
        if s.sequence == 0 {
            // 同一毫秒序列耗尽,等待下一毫秒
            for now <= s.lastTS {
                now = time.Now().UnixMilli()
            }
        }
    } else if now < s.lastTS {
        return 0, errors.New("clock moved backwards")
    } else {
        s.sequence = 0
    }
    s.lastTS = now

    return (now-epoch)<<timeShift |
        (s.workerID << workerShift) |
        s.sequence, nil
}

Snowflake 的理论吞吐是 每节点每秒 4096 × 1000 ≈ 400 万 ID(受序列号限制),实际生产里受锁竞争影响单机也能到几十万 QPS。它最大的优势是完全本地生成、零网络依赖、ID 趋势严格递增,天然适配 B+ 树主键。

4.1 时钟回拨:Snowflake 的阿喀琉斯之踵

Snowflake 强依赖机器时钟的单调性。一旦发生时钟回拨(NTP 同步、虚拟机迁移、手动改时间),now < s.lastTS,可能出现两种灾难:要么生成重复 ID(同毫秒同序列),要么直接报错拒绝发号。生产环境里时钟回拨并不罕见,尤其是云主机和容器环境。

业界有三种主流应对方案:

  • 等待策略:回拨幅度很小(比如小于 5ms)时,sleep 等待时钟追平再发号。简单但会引入抖动,回拨幅度大时阻塞严重。百度 UidGenerator 的早期版本采用过类似思路。
  • 借用未来位:把生成时间戳的逻辑改为“上次时间戳 + 1ms”,即人为让 ID 的时间戳走到未来一点点,等真实时钟追上。代价是 ID 在一段时间内会略超前于真实时间,但保证了不重复且不阻塞。需要配合序列号扩展位来吸收“借”的时间。
  • 异常报警 + 拒绝发号:回拨超过阈值(如 10ms)直接拒绝并触发告警,让运维介入。Leaf-Snowflake 采用了类似策略,并通过 ZooKeeper 注册 workerID 同时记录上次时间戳,重启时校验时间。

我们生产环境的做法是组合拳:回拨小于 2ms 时 sleep 等待;2~10ms 时借用未来位并打点告警;超过 10ms 直接熔断拒绝,避免脏数据。同时所有节点强制开启 NTP 但配置 tinker panic 0 避免大幅度跳变。

五、Leaf 与 UidGenerator 的设计差异

同样是业界知名的方案,美团 Leaf 和百度 UidGenerator 的取舍截然不同。

美团 Leaf 是双模式系统,同时提供 Segment(号段)和 Snowflake 两种模式,由同一个服务统一管理。Segment 模式靠 DB 提供高可用、ID 趋势递增、对时钟不敏感;Snowflake 模式靠 ZooKeeper 分配 workerID、监控时钟、提供严格递增。运维可以根据业务对有序性和性能的诉求切换模式。Leaf 强调服务化,应用通过 RPC 调用 Leaf Server 拿 ID,发号逻辑集中、可观测、可限流。

百度 UidGenerator 则走嵌入式 Snowflake 路线。它对 Snowflake 的位段做了重新分配:把 workerID 扩到 22 位(基于数据库自增分配,天然避免重复),时间戳保留 41 位但精度变为秒级而非毫秒级,序列号占余下位数。秒级时间戳的好处是单机可用年限大幅延长(约 139 年),且因为时间精度变粗,时钟回拨的窗口也相应被压缩——只要回拨不超过 1 秒,对发号无影响。UidGenerator 是作为一个 jar 包嵌入应用进程的,每个应用实例独立发号,没有中心服务,但代价是 workerID 必须靠数据库一次性分配。

六、四方案横向对比

把四类方案放在同一维度下对比:

维度 UUID 数据库自增/多主 号段模式 Leaf-Segment Snowflake
单机吞吐 (QPS) 百万级 万级(DB 写上限) 20 万+ 数十万~百万级
趋势递增 否(v4 随机) 严格递增 趋势递增(不连续) 严格趋势递增
依赖复杂度 单 DB DB(+ 主从) 无(或 ZK/Etcd 分配 workerID)
可用性 极高(纯本地) 低(DB 单点) 中(DB 挂则号段耗尽即停) 极高(纯本地,时钟回拨除外)
主键友好性 差(随机页分裂)
长度 128 位 64 位 64 位 64 位

七、选型决策树

综合上面四类方案,我们给出一套按业务场景倒推的选型决策树:

1. 这个 ID 是否作为数据库主键?
   ├─ 否 → 优先 UUID(外部 token、traceID 等不需要有序)
   └─ 是 → 进入 2

2. 业务是否有趋势递增要求(影响 B+ 树写入性能)?
   ├─ 否 → UUID v7 或随机方案尚可
   └─ 是 → 进入 3

3. 峰值 QPS 量级?
   ├─ < 1 万 → 数据库自增(多主步长抗单点)
   ├─ 1 万 ~ 20 万 → Leaf-Segment 号段模式
   └─ > 20 万 → 进入 4

4. 能否容忍对机器时钟的依赖?
   ├─ 能(机房 NTP 可控、可处理回拨)→ Snowflake
   │   · 需要严格递增、可观测 → 美团 Leaf-Snowflake
   │   · 嵌入式、长生命周期 → 百度 UidGenerator
   └─ 否(跨机房、时钟不可控)→ Leaf-Segment 多机房部署

八、写在最后

分布式 ID 没有“银弹”。UUID 简单却与索引为敌,数据库自增可靠却有天花板,号段模式高效却扛不住 DB 长时间故障,Snowflake 极致却惧怕时钟回拨。每一个选型都是在这几组矛盾之间做权衡。

我们的经验是:先用业务约束把方案圈出来,再用量化指标(峰值 QPS、可用性 SLA、运维成本)做最终裁决。比如我们的订单系统选了 Leaf-Segment——订单写入峰值 8 万 QPS、要求趋势递增、跨机房部署,号段模式完美匹配;而日志系统的 traceID 选了 UUID v4——不需要有序、不需要进主键、纯本地生成。不同业务,答案不同。

下一篇我们会展开讲 Leaf-Segment 在多机房场景下的部署细节,包括如何用机房标识位让 ID 天然区分来源、以及双 buffer 在 DB 抖动时的真实表现数据。