系统设计:分布式唯一 ID 生成器
专栏定位
这个系列为已经会用 AI 写代码的读者准备——重点是学会设计系统。每篇文章都是完整的系统设计过程,从需求分析到架构设计到关键技术决策,同时标注面试追问点。
阅读建议:初级读者重点看架构大图和核心模块;中级读者深入扩展点和方案对比。
背景与问题
为什么需要全局唯一 ID
分布式系统中,数据分散在多台机器、多个数据库实例上。单机自增 ID 在分库分表后会产生冲突:
DB-1: 1, 2, 3, 4, 5...
DB-2: 1, 2, 3, 4, 5... ← 冲突!
典型需要全局唯一 ID 的场景:
- 订单号、支付流水号(业务唯一标识)
- 分布式数据库主键(分库分表后)
- 消息队列 Message ID(去重、追踪)
- 日志 Trace ID(全链路追踪)
单机自增 ID 的问题
| 问题 | 说明 |
|---|---|
| 冲突 | 多实例同时自增,ID 重复 |
| 单点瓶颈 | 所有写请求依赖单台数据库 |
| 数据迁移困难 | 自增 ID 与业务数据耦合,分片后难以迁移 |
| 信息泄露 | 连续 ID 暴露业务量(如订单号) |
需求分析
功能需求
| 需求 | 描述 | 优先级 |
|---|---|---|
| 全局唯一 | 任意时间、任意节点生成的 ID 不重复 | P0 |
| 趋势递增 | ID 大致按时间递增,便于 B+ 树索引 | P0 |
| 低延迟 | 生成 ID 的耗时在毫秒级 | P0 |
| 高可用 | ID 生成服务不能成为单点故障 | P0 |
| 可反解 | 能从 ID 中解析出时间、机器等信息(可选) | P1 |
非功能需求
| 指标 | 目标值 |
|---|---|
| 生成延迟 | P99 < 1ms |
| 可用性 | 99.99% |
| 单机 QPS | > 10万 |
| ID 长度 | 64 位整数(主流)或 128 位字符串 |
核心指标估算
假设服务一个日活千万级的电商平台:
QPS 估算
| 场景 | 计算 | 峰值 QPS |
|---|---|---|
| 订单创建 | 1000万订单/天 ÷ 86400s × 10(峰值系数) | ~1,200 |
| 支付流水 | 与订单 1 | ~1,200 |
| 消息队列 | 内部消息,约为订单 5 倍 | ~6,000 |
| 日志追踪 | 每个请求产生多条日志 | ~50,000 |
| 合计 | ~6万 QPS |
ID 存储量
| 项目 | 计算 | 结果 |
|---|---|---|
| 日生成 ID 数 | 订单 + 支付 + 消息 + 日志 | ~10 亿/天 |
| 年生成 ID 数 | 10亿 × 365 | ~3650 亿 |
| 64 位 ID 存储/年 | 3650亿 × 8 字节 | ~292 GB |
10 亿/天 ≈ 12万 QPS 平均,峰值可达 50万 QPS。ID 生成器必须支撑这个量级。
总体架构
graph TB
subgraph "业务应用层"
App1["订单服务"]
App2["支付服务"]
App3["消息服务"]
App4["日志服务"]
end
subgraph "ID 生成层"
IDGen["ID 生成器集群<br/>多实例无状态"]
end
subgraph "ID 分配策略"
A["方案A<br/>数据库自增"]
B["方案B<br/>UUID"]
C["方案C<br/>Snowflake"]
D["方案D<br/>号段模式"]
E["方案E<br/>号段+缓存"]
end
App1 --> IDGen
App2 --> IDGen
App3 --> IDGen
App4 --> IDGen
IDGen -.-> A
IDGen -.-> B
IDGen -.-> C
IDGen -.-> D
IDGen -.-> E
style App1 fill:#e1f5fe
style App2 fill:#e1f5fe
style App3 fill:#e1f5fe
style App4 fill:#e1f5fe
style IDGen fill:#fff3e0
style A fill:#fce4ec
style B fill:#fce4ec
style C fill:#e8f5e9
style D fill:#e8f5e9
style E fill:#e8f5e9
方案详解
方案 A:数据库自增 ID
原理:利用 MySQL 的 AUTO_INCREMENT 生成唯一 ID。
sequenceDiagram
actor App as 业务应用
participant DB as MySQL
App->>DB: INSERT INTO id_table (stub) VALUES ('a')
DB-->>App: 返回自增 ID: 10001
实现代码:
CREATE TABLE id_table (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
stub CHAR(1) NOT NULL DEFAULT ''
);
-- 每次插入获取新 ID
INSERT INTO id_table (stub) VALUES ('a');
SELECT LAST_INSERT_ID();
| 优点 | 缺点 |
|---|---|
| 实现简单,无需额外服务 | 单点瓶颈,QPS 上限约几千 |
| 严格递增,连续无空洞 | 数据库挂了无法生成 ID |
| 天然唯一,无需冲突处理 | 不适合分库分表场景 |
适用场景:低并发、单体应用、原型验证。
方案 B:UUID / GUID
原理:基于时间戳、机器 MAC 地址、随机数生成 128 位唯一字符串。
示例:550e8400-e29b-41d4-a716-446655440000
└─时间─┘ └─版本┘ └─变体┘ └────随机数────┘
UUID 版本对比:
| 版本 | 生成依据 | 特点 |
|---|---|---|
| V1 | 时间戳 + MAC 地址 | 有时间顺序,暴露 MAC 地址 |
| V4 | 完全随机 | 最常用,无时间顺序 |
| V7 | 时间排序 + 随机 | 2022 年新 RFC,支持排序 |
| 优点 | 缺点 |
|---|---|
| 本地生成,无网络依赖 | 128 位太长,存储和索引成本高 |
| 理论上不会冲突 | 无序,数据库插入性能差(随机写) |
| 高可用,无需中心服务 | 不可读,无法从 ID 获取信息 |
| 生成速度极快 | 不适合做主键(页分裂严重) |
适用场景:日志 ID、临时令牌、对长度不敏感的场景。
关键认知:UUID V4 完全随机,插入 MySQL InnoDB 时会导致频繁的页分裂,性能比自增 ID 差 5-10 倍。UUID V7 解决了排序问题,是更好的选择。
方案 C:Snowflake 算法
原理:Twitter 开源的 64 位 ID 生成算法,将 ID 划分为多个部分。
graph LR
subgraph "64 位 Snowflake ID 结构"
direction LR
S1["符号位<br/>1 bit<br/>固定为 0"]
S2["时间戳<br/>41 bit<br/>毫秒级"]
S3["机器 ID<br/>10 bit<br/>最多 1024 节点"]
S4["序列号<br/>12 bit<br/>每毫秒 4096 个"]
end
style S1 fill:#ffebee
style S2 fill:#e8f5e9
style S3 fill:#e3f2fd
style S4 fill:#fff3e0
位分配详解:
| 字段 | 位数 | 说明 | 范围 |
|---|---|---|---|
| 符号位 | 1 bit | 固定为 0,保证正数 | 0 |
| 时间戳 | 41 bit | 自定义起始时间的毫秒偏移 | ~69 年 |
| 机器 ID | 10 bit | 数据中心 + 机器编号 | 0-1023 |
| 序列号 | 12 bit | 同一毫秒内自增 | 0-4095 |
计算能力:
- 每毫秒每节点可生成 4096 个 ID
- 单节点理论 QPS:4096 × 1000 = 409.6 万/秒
- 1024 节点集群理论 QPS:42 亿/秒
sequenceDiagram
actor App as 业务应用
participant SF as Snowflake 实例
App->>SF: 请求生成 ID
SF->>SF: 获取当前时间戳(毫秒)
SF->>SF: 与上一时间戳比较
alt 同一毫秒
SF->>SF: 序列号 +1
else 新毫秒
SF->>SF: 序列号归零
end
SF->>SF: 拼接:时间戳 | 机器ID | 序列号
SF-->>App: 返回 64 位 ID
优点:
- 本地生成,无网络开销(延迟 < 1ms)
- 趋势递增,适合数据库索引
- 可反解:从 ID 中提取时间和机器信息
- 高吞吐,单机百万级 QPS
缺点:
- 依赖机器时钟,时钟回拨会导致重复 ID
- 需要预先分配机器 ID,运维复杂
- 41 位时间戳约 69 年,存在溢出风险
适用场景:高并发分布式系统,是目前业界最主流的方案。
方案 D:号段模式(Leaf-segment)
原理:美团 Leaf 开源方案。从数据库批量申请一个 ID 号段,在内存中顺序分配,用完再申请。
sequenceDiagram
actor App as 业务应用
participant Leaf as Leaf 服务
participant DB as MySQL
Note over App,DB: 初始化:申请号段 [1, 1000]
App->>Leaf: 请求 ID
Leaf->>Leaf: 内存分配:返回 1
Leaf-->>App: ID = 1
App->>Leaf: 请求 ID
Leaf->>Leaf: 内存分配:返回 2
Leaf-->>App: ID = 2
Note over Leaf: ... 分配到 1000,号段用完
Leaf->>DB: UPDATE leaf_alloc SET max_id = 2000
DB-->>Leaf: 号段更新为 [1001, 2000]
App->>Leaf: 请求 ID
Leaf->>Leaf: 内存分配:返回 1001
Leaf-->>App: ID = 1001
数据库表结构:
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) NOT NULL PRIMARY KEY, -- 业务标识
max_id BIGINT NOT NULL DEFAULT 1, -- 当前最大号段
step INT NOT NULL DEFAULT 1000, -- 每次申请步长
description VARCHAR(256)
);
双 Buffer 优化:
- 号段使用到 10% 时,异步申请下一个号段
- 避免号段用完时的同步等待
| 优点 | 缺点 |
|---|---|
| 趋势递增,严格有序 | 号段用完时申请有延迟尖刺 |
| 数据库压力小(批量申请) | 需要额外 Leaf 服务部署 |
| ID 是连续整数,存储高效 | 突发流量下可能号段消耗过快 |
| 可自定义起始值和步长 | 服务重启可能丢失未用完号段 |
适用场景:需要严格递增、对数据库友好、可接受轻微延迟尖刺的业务。
方案 E:号段 + 缓存模式(Leaf-snowflake 改进)
原理:结合号段模式和 Snowflake 的优点,用 ZooKeeper/Etcd 分配机器 ID,本地 Snowflake 生成 ID。
graph TB
subgraph "注册中心"
ZK["ZooKeeper / Etcd"]
end
subgraph "Leaf 服务集群"
L1["Leaf 实例-1<br/>机器ID: 1"]
L2["Leaf 实例-2<br/>机器ID: 2"]
L3["Leaf 实例-N<br/>机器ID: N"]
end
subgraph "业务应用"
App1["订单服务"]
App2["支付服务"]
end
ZK -->|"分配机器ID"| L1
ZK -->|"分配机器ID"| L2
ZK -->|"分配机器ID"| L3
L1 -->|"提供 ID 生成 API"| App1
L2 -->|"提供 ID 生成 API"| App2
L3 -->|"提供 ID 生成 API"| App1
style ZK fill:#fff3e0
style L1 fill:#e8f5e9
style L2 fill:#e8f5e9
style L3 fill:#e8f5e9
style App1 fill:#e1f5fe
style App2 fill:#e1f5fe
工作流程:
- Leaf 实例启动时向 ZooKeeper 注册,获取唯一机器 ID
- 本地使用 Snowflake 算法生成 ID
- ZooKeeper 监控实例心跳,实例下线回收机器 ID
- 时钟回拨时,等待或从 ZooKeeper 重新校准
| 优点 | 缺点 |
|---|---|
| 无单点,可水平扩展 | 依赖 ZooKeeper/Etcd |
| 解决 Snowflake 机器 ID 分配问题 | 架构复杂度较高 |
| 可检测时钟回拨并处理 | 需要维护注册中心 |
| 兼具号段模式的有序性和 Snowflake 的高性能 | 网络分区时可能有问题 |
适用场景:大规模分布式系统,需要高可用、高吞吐、自动扩缩容。
方案对比总结
| 维度 | 数据库自增 | UUID V4 | Snowflake | 号段模式 | 号段+Snowflake |
|---|---|---|---|---|---|
| 唯一性 | 是 | 理论唯一 | 是 | 是 | 是 |
| 有序性 | 严格递增 | 完全无序 | 趋势递增 | 严格递增 | 趋势递增 |
| 长度 | 64 bit | 128 bit | 64 bit | 64 bit | 64 bit |
| 单机 QPS | ~1,000 | >100万 | >400万 | >10万 | >400万 |
| 生成延迟 | ~10ms | ~1μs | ~1μs | ~1ms | ~1μs |
| 网络依赖 | 强依赖 DB | 无 | 无 | 依赖 DB(批量) | 依赖 ZK(启动时) |
| 时钟依赖 | 无 | 无 | 强依赖 | 无 | 强依赖 |
| 可反解信息 | 无 | 无 | 时间+机器 | 无 | 时间+机器 |
| 运维复杂度 | 低 | 极低 | 中 | 中 | 高 |
| 推荐度 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
选型建议:
- 单体应用/低并发:数据库自增
- 日志/令牌/不敏感场景:UUID V7
- 高并发分布式系统(默认推荐):Snowflake
- 需要严格递增 + 批量优化:号段模式
- 超大规模 + 自动扩缩容:号段 + Snowflake
面试追问点
Q1:Snowflake 的时钟回拨问题怎么解决?
问题:NTP 同步或手动调整时间导致机器时钟回退,同一毫秒内可能生成重复 ID。
解决方案:
-
等待策略:发现回拨时,暂停生成直到时钟追上上次记录的时间戳。适合回拨幅度小(< 5ms)的情况。
-
备用位策略:预留几位作为「回拨序列号」,时钟回拨时启用备用位。牺牲少量容量换取容错。
-
历史时间戳缓存:记录最近 N 毫秒的时间戳序列,回拨时从历史序列中分配。实现复杂,极少使用。
-
Leaf 方案:依赖 ZooKeeper 检测,回拨超过阈值时报警并拒绝服务,等待人工介入或自动重启。
最佳实践:业务上允许 5ms 内的回拨等待,超过则抛异常。同时监控时钟回拨报警。
Q2:号段模式在取号段时如果 DB 挂了怎么办?
问题:号段用完需要向数据库申请新号段,此时数据库不可用会导致 ID 生成中断。
解决方案:
-
双 Buffer:当前号段用到 10% 时就异步加载下一个号段。正常情况下新号段早已准备好,DB 短暂故障不影响。
-
号段缓存持久化:将当前号段信息写入本地文件,服务重启时恢复,避免号段浪费。
-
降级方案:DB 长时间不可用时,切换到备用 ID 生成策略(如本地 Snowflake 临时模式),保证核心业务不中断。
-
数据库高可用:MySQL 主从 + Sentinel,或者使用 TiDB 等分布式数据库。
Q3:UUID 为什么不适合做数据库主键?
核心原因:InnoDB 使用 B+ 树索引,数据按主键顺序存储。
| 自增 ID 插入 | UUID 插入 |
|---|---|
| 顺序追加到页尾 | 随机插入到各页 |
| 几乎无页分裂 | 频繁页分裂 |
| 磁盘顺序写,效率高 | 磁盘随机写,效率低 |
| 缓存命中率高 | 缓存频繁失效 |
量化影响:
- UUID 做主键,插入性能比自增 ID 差 5-10 倍
- 数据页利用率从 ~90% 降到 ~50%
- 索引文件大小增加约 2 倍
替代方案:使用 Snowflake(趋势递增)或 UUID V7(时间排序)。
Q4:如何设计一个支持 64 位和 128 位两种 ID 格式的系统?
设计思路:
-
统一接口层:对外暴露
generateId(format)接口,format参数指定INT64或INT128。 -
内部路由:
INT64-> Snowflake 生成器INT128-> 扩展版 Snowflake 或 UUID V7
-
128 位扩展方案:
| 1 bit | 48 bit 时间戳 | 16 bit 数据中心 | 32 bit 机器ID | 31 bit 序列号 |
- 时间戳精度提升到微秒
- 支持更多机器节点(2^32 = 42 亿)
- 序列号空间更大,应对更高并发
- 兼容性处理:
- 数据库用
VARCHAR(36)存储,兼容两种格式 - 或分两个字段
id_int64和id_int128,根据业务选择
- 数据库用
Q5:如果 ID 要支持排序且能反解出时间信息,怎么设计?
需求拆解:
- 排序:ID 按时间趋势递增
- 反解:从 ID 中提取生成时间
方案:
-
Snowflake 天然满足:
- 高位是时间戳,天然有序
- 反解公式:
timestamp = (id >> 22) + custom_epoch
-
自定义编码(更灵活):
| 32 bit 秒级时间戳 | 16 bit 业务编码 | 16 bit 机器ID |
- 32 位时间戳支持到 2106 年
- 业务编码区分订单、支付、消息等
- 反解时直接取高 32 位即为时间戳
- 字符串格式(可读性优先):
202604251530009876543210
└─┬─┘└┬┘└┬┘└┬─┘└──┬───┘
年月日时分秒 毫秒 序列号
- 优点:人类可读,直接看出时间
- 缺点:字符串存储和比较开销大
扩展思考
如果 10 万 QPS 的 ID 生成需求,选哪个方案?
| 方案 | 10万 QPS 表现 | 结论 |
|---|---|---|
| 数据库自增 | 单库上限 ~1000 QPS,需要 100+ 个数据库实例 | ❌ 不可行 |
| UUID V4 | 本地生成,10万 QPS 无压力,但无序 | ⚠️ 可用但不推荐 |
| Snowflake | 单实例 400万 QPS,1 个实例搞定 | ✅ 首选 |
| 号段模式 | 单实例 10万+ QPS,但号段申请有延迟尖刺 | ✅ 可用 |
| 号段+Snowflake | 完全无压力,但运维成本高 | ✅ 大规模推荐 |
推荐:
- 10万 QPS:Snowflake 单机部署,简单高效
- 100万 QPS:Snowflake 集群(3-5 实例)+ 负载均衡
- 1000万 QPS:号段 + Snowflake + 自动扩缩容
实际生产中的注意事项
- 机器 ID 分配:小规模用配置文件,大规模用 ZooKeeper/Etcd 自动分配
- 时间戳起始点:自定义 epoch(如 2024-01-01),延长可用时间
- 监控报警:时钟回拨、QPS 突增、ID 耗尽(接近最大值)
- 单元化部署:多地域部署时,机器 ID 中预留地域位
专栏导航
本文是「系统设计专栏」第 2 篇:分布式唯一 ID 生成器
上一篇:短链服务
下一篇:一致性哈希
© 本文著作权归作者所有,转载请注明出处。