分布式 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) // 数据库分配的自增值
踩坑点
- ID 不连续 — 事务回滚、INSERT IGNORE 冲突都会消耗自增值但不插入数据,导致 ID 出现空洞。不要假设 ID 是连续的。
- 重启后跳跃 — MySQL 8.0 之前,自增值存在内存里,重启后从
SELECT MAX(id)重新开始。MySQL 8.0 把自增值持久化到了 redo log,解决了这个问题。 - 分库分表后失效 — 多个库各自自增,全局不唯一。
方案二: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 个唯一值。分配方式通常有三种:
- 配置文件手动指定(最简单,容器化场景下麻烦)
- 数据库自增分配(依赖数据库,但分配完就不用了)
- 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 |