Post

分布式 ID 生成方案:原理、差异与落地经验

分布式 ID 生成方案:原理、差异与落地经验

这篇文稿讲什么

读完之后,你能搞清楚五种主流 ID 方案的内部结构(精确到每一个 bit),理解它们为什么这样设计,知道每种方案的性能边界和踩坑点,并且能在面试中把任何一个方案讲到第三层追问。


背景问题

在单机单表时代,MySQL 的 AUTO_INCREMENT(自增)够用了——数据库帮你管顺序,你什么都不用操心。

但当你面对这些问题时,自增就不够了:

  • 多个数据库实例同时写入,ID 会碰撞
  • 分库分表后,全局顺序无法保证
  • ID 暴露了业务量(竞争对手看你的 ID 就知道你有多少订单)
  • 高并发插入时,自增锁成为瓶颈

于是你需要一个不依赖中心协调、全局唯一、性能好的 ID 生成方案。


先看全局:五种方案一张表

方案 长度 有序性 去中心化 可读性 B+ 树友好 生成速度
MySQL 自增 ID 64 bit 严格递增 否 高 极好 极快
MongoDB ObjectId 96 bit 秒级递增 是 中 好 极快
Snowflake ID 64 bit 毫秒级递增 需分配 worker 中 极好 极快
UUIDv4 128 bit 完全随机 是 低 差 极快
UUIDv7 128 bit 毫秒级递增 是 低 好 极快

方案一:MySQL 自增 ID(AUTO_INCREMENT)

一句话说清楚

数据库每插入一行,自动给你一个比上一行大 1 的数字。

内部原理

MySQL InnoDB 引擎内部维护一个计数器。每次 INSERT 时,从内存中读取当前值,加 1,写回,然后把这个值填到新行的自增列里。

这个过程有一个自增锁(AUTO-INC Lock))。在 MySQL 8.0 之前,这个锁是表级别的——同一张表同一时刻只能有一个 INSERT 在分配 ID。MySQL 8.0 引入了 innodb_autoinc_lock_mode = 2(默认值),改成轻量级互斥锁,只在分配 ID 的瞬间持锁,不再锁到事务结束。

为什么 B+ 树喜欢它

InnoDB 的聚簇索引(Clustered Index)就是一棵 B+ 树。主键决定了数据在磁盘上的物理排列顺序。

自增 ID 每次都比上一个大,所以新行永远追加到 B+ 树的最右侧叶子节点。这叫顺序追加(append-only)。

顺序追加的好处:

  • 不需要移动已有数据页
  • 不需要页分裂(page split)
  • 缓冲池(Buffer Pool)命中率高
  • 写入 IO 是顺序的,SSD/HDD 都友好

如果主键是随机的(比如 UUIDv4),新行可能插入到 B+ 树的任意位置,导致:

  • 频繁页分裂——一个满页要拆成两个页,涉及数据搬移
  • 页内碎片——分裂后页内有空洞,空间利用率下降
  • 随机 IO——写入位置在磁盘上不连续

为什么在分布式系统中不够用

1
2
实例 A: INSERT → ID = 1001
实例 B: INSERT → ID = 1001  ← 碰撞!

两个 MySQL 实例各自维护自己的计数器,全局不唯一。

解决方案有几种:

  • 步长法:实例 A 只生成奇数(步长 2,起始 1),实例 B 只生成偶数(步长 2,起始 2)。简单但不灵活,扩容缩容时步长要重新分配。
  • 号段法:每次从数据库批量申请一段 ID(比如 1001-2000),用完了再去申请下一段。美团 Leaf、百度 UidGenerator 都是这个思路。
  • 直接放弃:换成不依赖中心的方案(雪花、UUID)。

Go 代码示例

1
2
3
4
5
6
7
8
9
10
11
// GORM 里用自增主键
type Order struct {
    ID        int64     `gorm:"primaryKey;autoIncrement"` // 自增
    CustomerID string   `gorm:"column:customer_id"`
    Status     string   `gorm:"column:status"`
    CreatedAt  time.Time
}

// 插入后 GORM 会自动回填 ID
db.Create(&order)
fmt.Println(order.ID) // 数据库分配的自增值

踩坑点

  1. ID 不连续 — 事务回滚、INSERT IGNORE 冲突都会消耗自增值但不插入数据,导致 ID 出现空洞。不要假设 ID 是连续的。
  2. 重启后跳跃 — MySQL 8.0 之前,自增值存在内存里,重启后从 SELECT MAX(id) 重新开始。MySQL 8.0 把自增值持久化到了 redo log,解决了这个问题。
  3. 分库分表后失效 — 多个库各自自增,全局不唯一。

方案二:MongoDB ObjectId

一句话说清楚

12 字节(96 位)的标识符,把时间戳、机器随机值和进程计数器拼在一起,不需要任何中心协调就能保证全局唯一。

内部结构(精确到 bit)

1
2
3
4
|<---- 4 字节 ---->|<------ 5 字节 ------>|<-- 3 字节 -->|
|   时间戳(秒)     |    随机值 + 进程ID    |   递增计数器  |
|  1970-01-01 起   |   每个进程启动时生成   |  同一进程内   |
|  的 Unix 秒数    |   一次,之后不变       |  递增         |

把这 12 字节写成十六进制,就是你常见的 507f1f77bcf86cd799439011 这样的 24 字符字符串。

逐段拆解

第 1 段:时间戳(4 字节 / 32 位)

存储的是 Unix 时间戳的秒数(不是毫秒)。从 1970-01-01 开始算。

1
2
3
当前时间:2026-05-20 15:00:00 UTC
Unix 秒数:1779368400
十六进制:  6A0F8A70

这是 ObjectId 的前 4 个字节。所以 ObjectId 天然按时间排序——先创建的 ID 值更小。

第 2 段:随机值 + 进程 ID(5 字节 / 40 位)

MongoDB 4.0+ 的做法:

  • 3 字节随机值:每个进程启动时生成一次
  • 2 字节进程 ID(PID)

这 5 字节在进程生命周期内不变。它的作用是区分不同机器和进程——即使两个进程在同一秒生成 ObjectId,因为这 5 字节不同,ID 也不会碰撞。

第 3 段:递增计数器(3 字节 / 24 位)

同一个进程在同一秒内,每生成一个 ObjectId,计数器加 1。

3 字节 = 24 位,最大值 16,777,215(约 1677 万)。也就是说同一个进程在同一秒内最多生成约 1677 万个 ObjectId,用完才会碰撞。实际场景中几乎不可能达到。

为什么 MongoDB 选了秒级时间戳而不是毫秒级?

这是一个 trade-off:

  • 秒级时间戳用 4 字节就够了
  • 如果用毫秒级,需要 8 字节(int64),12 字节就不够放了
  • 秒级 + 3 字节计数器的组合,每秒 1677 万的容量已经远远够用
  • 保持 12 字节的紧凑尺寸,节省存储空间

Go 中使用

1
2
3
4
5
6
7
8
9
import "go.mongodb.org/mongo-driver/bson/primitive"

// 生成一个新的 ObjectId
id := primitive.NewObjectID()
fmt.Println(id.Hex())        // "507f1f77bcf86cd799439011"
fmt.Println(id.Timestamp())  // 提取时间戳部分

// 从十六进制字符串解析
id, err := primitive.ObjectIDFromHex("507f1f77bcf86cd799439011")

和雪花 ID 的核心区别

这两者经常被混淆,但设计哲学完全不同:

维度 MongoDB ObjectId Snowflake ID
长度 12 字节(96 位) 8 字节(64 位)
时间精度 秒 毫秒
机器标识 随机值(不需要分配) worker ID(需要预分配)
序列号 3 字节(1677 万/秒) 12 位(4096/毫秒)
协调需求 无 必须分配 worker ID
存储 varchar(24) 或 12 字节二进制 bigint(8 字节)

一句话总结:ObjectId 用”随机性”换”去中心化”,Snowflake 用”预分配 worker ID”换”更强的有序性和更紧凑的尺寸”。


方案三:雪花 ID(Snowflake)

一句话说清楚

一个 64 位整数,把时间戳、机器标识和序列号编码进同一个数字里,既全局唯一又趋势递增,而且可以用作数据库主键(BIGINT)。

诞生背景

这是 Twitter 在 2010 年为了解决分布式系统中 ID 生成问题而设计的。当时 Twitter 的发号器(由 ZooKeeper 管理)成了瓶颈,他们需要一个不依赖外部服务、每个进程本地就能生成的方案。

内部结构(精确到 bit)

1
2
3
|0|<---- 41 位 ----->|<-- 10 位 -->|<-- 12 位 -->|
| |    时间戳(ms)      |  worker ID  |   序列号    |
| |  毫秒级 Unix 时间  | 机器+进程标识| 每毫秒内递增 |
  • 1 位符号位:固定为 0,保证 ID 是正数
  • 41 位时间戳:毫秒级 Unix 时间,可以表示约 69 年(2^41 / (365.25 * 24 * 3600 * 1000) ≈ 69.7 年)
  • 10 位 worker ID:可以表示 1024 个不同的节点。通常拆成 5 位数据中心 ID + 5 位机器 ID
  • 12 位序列号:同一毫秒同一节点内,每生成一个 ID 加 1。最大值 4095,即同一毫秒最多 4096 个

逐段拆解

时间戳部分(41 位)

不是存储绝对的 Unix 时间戳,而是存储一个相对时间:

1
实际时间戳 = 当前 Unix 毫秒时间 - 自定义起始纪元(epoch)

起始纪元通常设为项目上线时间(比如 2020-01-01),这样 41 位可以用更久。

1
2
3
4
当前时间:2026-05-20 15:00:00.000 UTC = 1779368400000 ms
自定义纪元:2020-01-01 00:00:00.000 UTC = 1577836800000 ms
相对时间:  1779368400000 - 1577836800000 = 201531600000 ms
二进制:    0010111101000000110101100100110100000000000

worker ID(10 位)

10 位 = 1024 个唯一值。分配方式通常有三种:

  1. 配置文件手动指定(最简单,容器化场景下麻烦)
  2. 数据库自增分配(依赖数据库,但分配完就不用了)
  3. ZooKeeper 临时节点(经典方案,但引入了 ZK 依赖)

这个 worker ID 是雪花 ID 最大的运维负担。 实例频繁上下线的容器化环境中,worker ID 的分配和回收本身就是需要治理的问题。

序列号(12 位)

同一毫秒内每生成一个 ID,序列号加 1。当序列号到 4095 时,如果同一毫秒内还要生成 ID,就需要等到下一毫秒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 伪代码:雪花 ID 生成逻辑
func NextID() int64 {
    currentTime := now() - epoch

    if currentTime == lastTime {
        sequence = (sequence + 1) & 0xFFF  // 12 位掩码
        if sequence == 0 {
            // 序列号用完了,等下一毫秒
            currentTime = waitNextMillis(lastTime)
        }
    } else {
        sequence = 0  // 新的毫秒,序列号重置
    }

    lastTime = currentTime

    return (currentTime << 22) | (workerID << 12) | sequence
}

为什么 B+ 树喜欢它

因为前 41 位是时间戳,高位在前,生成的 ID 天然按时间递增。这和自增 ID 的效果类似——新行追加到 B+ 树最右侧叶子节点。

时钟回拨问题

这是雪花 ID 最著名的陷阱。

服务器的系统时钟可能因为 NTP 校准、闰秒调整、虚拟机迁移等原因往回跳。如果时钟回跳了,新生成的时间戳比上一次的小,就可能生成重复的 ID。

1
2
正常:  时间戳 100 → ID xxx
时钟回跳:时间戳 99  → ID yyy(可能和之前的 ID 碰撞!)

常见的处理方式:

  • 拒绝生成 — 检测到时钟回拨就报错,等时钟追上来
  • 等待 — 回跳幅度小(几毫秒内)就等一等
  • 备用位 — 预留几位记录时钟回跳次数(百度 UidGenerator 的做法)

Go 中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
import "github.com/bwmarrin/snowflake"

// 创建节点(worker ID 必须在 0-1023 之间)
node, err := snowflake.NewNode(1) // worker ID = 1
if err != nil {
    panic(err)
}

// 生成 ID
id := node.Generate()
fmt.Println(id.Int64())  // 1234567890123456789(int64)
fmt.Println(id.String()) // "1234567890123456789"
fmt.Println(id.Time())   // 提取时间戳部分

方案四:UUIDv4

一句话说清楚

128 位的随机数,几乎不可能碰撞,但完全没有顺序,不适合做数据库主键。

内部结构

1
2
3
4
5
6
|<------------ 128 位 ------------>|
|xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx|

4 = 版本号(固定为 4,表示 v4)
y = 变体位(固定为 8, 9, a, b 中的一个)
x = 随机数
  • 122 位是随机数
  • 4 位固定为 0100(表示版本 4)
  • 2 位固定为 10(表示 RFC 4122 变体)
  • 剩余 2 位也是随机的
1
2
3
示例:f47ac10b-58cc-4372-a567-0e02b2c3d479
      |_________| |___| |___| |__________|
       随机        随机  4+变体   随机

碰撞概率

122 位随机空间 = 2^122 ≈ 5.3 × 10^36 种可能。

根据生日悖论,要产生 50% 的碰撞概率,需要生成约 2.7 × 10^18 个 UUID。假设每秒生成 10 亿个,大约需要 85 年才会有一半概率碰到一次碰撞。

实际工程中不需要担心 UUIDv4 的碰撞问题。

为什么不适合做数据库主键

问题一:完全无序,B+ 树崩溃

1
2
3
INSERT ID: f47ac10b-...  → 插入到 B+ 树中间
INSERT ID: 3a1b2c3d-...  → 插入到 B+ 树左边
INSERT ID: e5f6a7b8-...  → 插入到 B+ 树右边

每次插入都是随机位置,导致:

  • 页分裂频繁
  • 随机 IO
  • 缓冲池命中率低
  • 写入性能比自增 ID 差 3-5 倍(实测数据,不同硬件有差异)

问题二:太长

128 位 = 16 字节二进制,或 36 字节字符串。作为主键,每一行都要存这个值,每个二级索引都要存一份主键副本。空间开销大。

问题三:无时间信息

你无法从 UUIDv4 中提取创建时间。排查问题时,拿到一个 ID,看不出它是什么时候创建的。

Go 中使用

1
2
3
4
5
6
import "github.com/google/uuid"

id := uuid.New()            // 生成 UUIDv4
fmt.Println(id.String())    // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
fmt.Println(id.Version())   // 4
fmt.Println(id.Variant())   // RFC 4122

方案五:UUIDv7(RFC 9562)

一句话说清楚

128 位标识符,前 48 位是毫秒级时间戳,剩余位是随机数,既全局唯一又趋势递增,解决了 UUIDv4 的所有痛点。

为什么需要 UUIDv7

UUIDv4 有两个致命问题:无序、无时间信息。UUIDv7 的设计目标就是在保持 UUID 去中心化优势的同时,加入时间排序能力。

它是 2024 年发布的 RFC 9562 标准的一部分(和 UUIDv4 的 RFC 4122 同一系列)。

内部结构(精确到 bit)

1
2
3
|<--- 48 位 --->|<-4->|<-- 2 ->|<------ 62 位 ------>|
|  时间戳(ms)    | ver |variant |      随机数          |
|  毫秒级 Unix   | 0111|  10    |  剩余随机位          |
  • 48 位时间戳:毫秒级 Unix 时间,可以表示约 8927 年
  • 4 位版本号:固定为 0111(7)
  • 2 位变体位:固定为 10(RFC 4122 变体)
  • 62 位随机数:同一毫秒内的唯一性保障

逐段拆解

时间戳(48 位)

和雪花 ID 一样是毫秒级,但用的是 48 位而不是 41 位,可以表示更长时间范围。

1
2
当前时间:2026-05-20 15:00:00.000 UTC = 1779368400000 ms
十六进制:0x19E0A4B2E00

这个值放在 UUID 的前 6 个字节,所以生成的 ID 天然按时间排序。

同一毫秒内的唯一性(62 位随机数)

62 位随机空间极大,碰撞概率可以忽略。和雪花 ID 的区别是:雪花 ID 用 12 位递增序列号保证同一毫秒内的唯一性,UUIDv7 用 62 位随机数。

trade-off 是:

  • 雪花 ID:严格递增,但每毫秒最多 4096 个
  • UUIDv7:概率唯一,但同一毫秒内没有数量限制

单调性保障(Monotonicity)

RFC 9562 建议实现者在同一毫秒内使用递增计数器替代部分随机位,保证同一毫秒内生成的 ID 也是递增的。google/uuid 库的 Go 实现就是这样做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// google/uuid 库内部(简化)
func NewV7() (UUID, error) {
    ms := time.Now().UnixMilli()

    if ms == lastMs {
        // 同一毫秒:用计数器递增,保证单调性
        counter++
    } else {
        // 新的毫秒:重置计数器
        lastMs = ms
        counter = randomBits() // 用随机数初始化
    }

    // 把时间戳 + 计数器编码进 128 位
    // ...
}

这意味着 UUIDv7 不是”纯随机”,而是”时间戳 + 单调递增随机数”的组合。

和 MongoDB ObjectId 的对比

这两个经常被拿来做比较:

维度 UUIDv7 MongoDB ObjectId
长度 16 字节(128 位) 12 字节(96 位)
时间精度 毫秒 秒
随机位 62 位 24 位(计数器)
每秒容量 无限制(概率唯一) ~1677 万(确定唯一)
标准 RFC 9562 MongoDB 自定义
跨平台通用性 高(任何语言/数据库) 低(MongoDB 生态内)

UUIDv7 用更大的随机空间换来了更好的跨平台通用性。ObjectId 用更紧凑的尺寸换来了 MongoDB 生态内的极致性能。

和雪花 ID 的对比

维度 UUIDv7 Snowflake ID
长度 128 位(16 字节) 64 位(8 字节)
存储 varchar(36) 或 binary(16) bigint(8 字节)
协调需求 无 需要分配 worker ID
时钟回拨风险 无(不存 worker ID) 有
单毫秒容量 概率唯一,无限制 4096
B+ 树友好度 好 极好
可排序性 好 极好

一句话总结:雪花 ID 更紧凑但有运维负担(worker ID + 时钟回拨),UUIDv7 更大但零运维。

Go 中使用

1
2
3
4
5
6
7
8
9
10
11
12
import "github.com/google/uuid"

// 生成 UUIDv7
id := uuid.Must(uuid.NewV7())
fmt.Println(id.String())  // "0196e4b8-7c3a-7a1e-9c3d-5e7f8a9b0c1d"

// 提取时间戳
ts := id.Time()
fmt.Println(ts) // 2026-05-20 15:00:00 +0000 UTC

// 版本号
fmt.Println(id.Version()) // 7

一行代码,本地生成,不依赖任何外部服务。


核心原理对比:同一个时间点,五种 ID 长什么样?

假设在 2026-05-20 15:00:00.123 UTC 同时生成五种 ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MySQL 自增 ID:    100042
                   ↑ 纯数字,和时间无关

MongoDB ObjectId:  782f8a70 4a2b3c1d5e 000001
                   ↑秒级时间戳  ↑随机+PID  ↑计数器

Snowflake ID:     0|01011110100000011010110010011010000000110|0000000001|000000000001
                   ↑符号位     ↑41位毫秒时间戳              ↑worker=1 ↑seq=1

UUIDv4:           f47ac10b-58cc-4372-a567-0e02b2c3d479
                   ↑完全随机,和时间无关

UUIDv7:           0196e4b8-7c3a-7123-b456-789abcde0123
                   ↑48位毫秒时间戳  ↑ver=7  ↑随机数+单调递增

B+ 树到底为什么在意 ID 的顺序?

这是面试高频追问点,值得专门讲清楚。

InnoDB 的聚簇索引长这样:

1
2
3
        [100 | 200 | 300]         ← 非叶子节点(索引)
       /      |      |     \
  [101-150] [151-200] [201-250] [251-300]  ← 叶子节点(数据行)

顺序追加(自增/雪花/UUIDv7):

新 ID = 301,追加到最右边的叶子节点 [251-300]。 如果这个页满了,只需要分裂一次,新页也追加在最右边。 整体是顺序 IO。

随机插入(UUIDv4):

新 ID = 173,需要插入到 [151-200] 这个中间页。 如果这个页满了,要分裂,数据要搬移,可能触发上层节点的分裂。 整体是随机 IO,而且碎片化严重。

实测差距:在相同硬件和数据量下,UUIDv4 作为主键的 INSERT 性能比自增 ID 低 30%-60%,索引空间占用多 20%-30%。


选型时的落地问题

存储层的取舍

UUIDv7 的标准文本形式是 36 字符,也可以按 16 字节二进制存储。若已有系统用 string 类型传递 ID,文本形式改动较小;若索引体积或写入性能成为实际瓶颈,可以评估 BINARY(16)。是否迁移应根据现有表规模和测试结果决定。

常见误区

误区一:UUID 碰撞了怎么办?

UUIDv4 的碰撞概率极低(2^122 种可能),工程上不需要处理。如果你真的担心,在数据库层加一个唯一索引就够了——万一碰撞,DB 层会拒绝插入,应用层捕获异常重试即可。

误区二:雪花 ID 是严格递增的

不是。雪花 ID 是趋势递增——整体时间往前走,ID 就往前走。但不同节点之间、甚至同一节点不同毫秒之间,可能出现后生成的 ID 比先生成的小(比如节点 A 的时间比节点 B 快几毫秒)。

误区三:UUIDv7 是完全随机的

不是。UUIDv7 的前 48 位是时间戳,是确定性的。剩余 62 位是随机的,但好的实现(如 google/uuid)在同一毫秒内会用递增计数器替代部分随机位,保证单调性。

误区四:ObjectId 不会碰撞

理论上可能。如果同一秒、同一进程、计数器恰好溢出重置后产生相同值,就会碰撞。只是概率极低,实际不会发生。

误区五:自增 ID 的性能是最好的

在单实例写入时确实最好。但 MySQL 8.0 的 innodb_autoinc_lock_mode = 2 模式下,高并发批量插入时自增值可能不连续(虽然不会碰撞),而且如果用 INSERT ... ON DUPLICATE KEY UPDATE,自增值也会被消耗。


面试怎么答

Q:订单号是怎么产生的?需要具备什么性质?

如果订单号需要在入库前生成,又希望大致按时间排序,可以考虑 UUIDv7。它可由服务实例本地生成,不需要中心化发号器;但是否需要不可预测、能否从 ID 反推时间,应由业务要求决定。

Q:为什么不用自增 ID?

分库分表后自增 ID 全局不唯一,需要额外引入号段或步长机制,增加运维复杂度。UUIDv7 完全去中心化,每个实例本地生成即可。

Q:为什么不用雪花 ID?

如果已有接口使用 string 类型,改用 int64 可能扩大改动面;如果不想维护 worker ID 分配,UUIDv7 也更省事。不过它占用更多存储空间,仍要结合业务约束和索引成本选择。

Q:MongoDB ObjectId 和雪花 ID 差异在哪?

最核心的差异是”怎么保证不同节点生成的 ID 不碰撞”。ObjectId 靠的是 5 字节的随机值——每个进程启动时随机生成一次,碰撞概率极低但理论上存在。雪花 ID 靠的是 10 位 worker ID——由外部系统预分配,每个节点有唯一编号,碰撞概率为零。

这个设计差异导致了连锁反应:ObjectId 不需要任何协调,但 96 位比雪花的 64 位大了 50%;雪花更紧凑,但需要 worker ID 管理机制,而且有时钟回拨风险。

Q:除了数据库唯一索引,还有其他方式验证唯一性吗?

算法层 UUIDv7 有 122 位的随机空间,单毫秒内碰撞概率可以忽略。数据库层有唯一索引兜底。两层保障足够了,不需要额外的去重查询。


一句话总结

场景 推荐方案
单库单表,量不大 MySQL 自增 ID
MongoDB 生态内 ObjectId
分布式系统,64 位是硬约束 Snowflake ID
分布式系统,代码已用 string UUIDv7
非主键标识(日志 trace、临时 token) UUIDv4
This post is licensed under CC BY 4.0 by the author.