分布式ID生成
2026/1/21大约 5 分钟约 1434 字
分布式 ID 生成
分布式系统中,全局唯一 ID 的生成是基础问题。本文介绍两种主流方案:雪花算法和号段模式。
1. 设计目标
- 全局唯一:分布式环境不重复
- 趋势递增:利于数据库索引
- 高性能:高并发下低延迟
- 高可用:无单点故障
2. 两种主流方案
| 方案 | 原理 | 依赖 | 适用场景 |
|---|---|---|---|
| 雪花算法 | 时间戳 + 机器 + 序列号 | ZK(可选) | 高并发、对时间有序 |
| 号段模式 | 数据库预分配号段 | MySQL | 对时钟敏感、需连续 ID |
大厂使用情况
| 公司 | 方案 | 说明 |
|---|---|---|
| 美团 | Leaf(两种都有) | Snowflake + Segment 各占一半 |
| 淘宝 | 雪花 + 基因法 | 订单号嵌入分片基因 |
| 滴滴 | 雪花变种 | 订单号包含城市编码 |
| 百度 | UidGenerator | 优化版雪花,RingBuffer 预生成 |
3. 雪花算法(Snowflake)
3.1 基本结构(64 位)
0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
│ │ │ │ │
│ │ │ │ └── 序列号 (12位)
│ │ │ └────────────── 机器ID (5位)
│ │ └────────────────────── 数据中心ID (5位)
│ └─────────────────────────────────────────────────── 时间戳 (41位)
└──────────────────────────────────────────────────────────────────────────── 符号位 (1位)| 部分 | 位数 | 容量 |
|---|---|---|
| 时间戳 | 41 位 | 约 69 年 |
| 数据中心 + 机器 | 10 位 | 1024 台机器 |
| 序列号 | 12 位 | 每毫秒 4096 个 |
理论容量:1024 × 4096 × 1000 = 41.9 亿/秒
3.2 核心代码
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
// 同一毫秒内序列号递增
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 0xFFF; // 12位
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// 位运算拼接
return ((timestamp - START_TIMESTAMP) << 22)
| (datacenterId << 17)
| (workerId << 12)
| sequence;
}4. 基因法(分库分表优化)
4.1 问题:读扩散
分库分表后,通过订单号查询不知道该查哪个分片:
SELECT * FROM t_order WHERE order_no = 123456789;
→ 不知道在哪个分片 → 广播所有分片 → 读扩散4.2 解决方案:嵌入分片基因
在 ID 中嵌入分片信息,通过 ID 直接定位分片:
改造前的 ID:时间戳(41) + 机器(10) + 序列(12)
改造后的 ID:时间戳(41) + 机器(6) + 基因(4) + 序列(12)
│
userId % 16 = 分片号4.3 路由示例
// 从订单号提取分片
int gene = (int) (orderId & 0xF); // 取最后 4 位
int shard = gene; // 直接定位到 shard_05
// 不再需要广播所有分片4.4 基因位数选择
| 分片数 | 基因位数 | 大厂参考 |
|---|---|---|
| 16 | 4 位 | 中小公司 |
| 64 | 6 位 | 中大公司 |
| 1024 | 10 位 | 淘宝 |
注意:基因位数影响后续扩容,建议预留足够空间。
5. 时钟回拨问题
5.1 什么是时钟回拨
服务器时间倒退(NTP 同步、手动调整等),导致生成重复 ID。
5.2 Leaf 的解决方案
美团 Leaf 通过 ZooKeeper 解决:
- 自动分配 workerId:通过 ZK 顺序节点分配,避免冲突
- 持久化时间戳:定期上报时间戳到 ZK
- 启动时检测:重启后从 ZK 读取上次时间戳,检测回拨
// 启动时检测
long zkTimestamp = getLastTimestampFromZK();
long currentTime = System.currentTimeMillis();
if (currentTime < zkTimestamp) {
// 检测到回拨,等待或拒绝启动
throw new RuntimeException("时钟回拨");
}5.3 ZK 依赖说明
| 时机 | ZK 状态 | 影响 |
|---|---|---|
| 启动时 | 必须可用 | 获取 workerId |
| 运行时 | 可断开 | 不影响 ID 生成 |
6. 号段模式(Segment)
6.1 核心思想
一次从数据库获取一批 ID(号段),本地消费,用完再取。
Leaf 实例 1: 拿到 [1, 1000],本地发放
Leaf 实例 2: 拿到 [1001, 2000],本地发放
...6.2 数据库表设计
CREATE TABLE leaf_alloc (
biz_tag VARCHAR(128) PRIMARY KEY, -- 业务标识
max_id BIGINT NOT NULL, -- 当前最大 ID
step INT NOT NULL, -- 号段步长
update_time TIMESTAMP
);6.3 获取号段(原子更新)
UPDATE leaf_alloc
SET max_id = max_id + step
WHERE biz_tag = 'order';
-- 返回号段 [max_id - step + 1, max_id]6.4 双 Buffer 优化
Buffer 1: [1, 1000] ← 当前使用
Buffer 2: [1001, 2000] ← 预加载(Buffer 1 用到 80% 时触发)
用完 Buffer 1 → 无缝切换到 Buffer 2
后台继续加载 Buffer 36.5 BIGINT 容量
有符号 BIGINT: 最大 9.2 × 10^18(约 92 亿亿)
假设每秒 100 万 ID:
可用时间 = 92 亿亿 / (100万 × 86400 × 365) ≈ 29 万年7. Segment 高可用
7.1 主从同步问题
正常运行时只写主库,主从同步延迟不影响。
但主从切换时可能出问题:
主库 max_id = 2000,从库 max_id = 1500(同步延迟)
主库挂了,切换到从库
从库继续分配 → 可能和之前的号段重叠7.2 解决方案
- 半同步复制:至少一个从库确认后才返回
- 切换时跳号:人工调大 max_id
- 双写模式:两套独立 DB,任一可用即可
双写架构:
┌─────────────┐ ┌─────────────┐
│ MySQL 1 │ │ MySQL 2 │
└──────┬──────┘ └──────┬──────┘
│ ┌──────┐ │
└──────┤ Leaf ├─────┘
│ 双写 │
└──────┘7.3 断档问题
号段没用完实例就挂了,会产生 ID 空洞:
拿到 [1001, 2000],用到 1050 挂了
→ 1051~2000 废弃
→ 新实例拿 [2001, 3000] 继续影响不大:ID 唯一性和趋势递增不受影响。
8. 两种方案对比
| 对比项 | Snowflake | Segment |
|---|---|---|
| 依赖 | ZK | MySQL |
| 性能 | 更高(纯本地) | 高(偶尔访问 DB) |
| 趋势递增 | 是(同机器) | 是(严格递增) |
| 时钟依赖 | 依赖 | 不依赖 |
| ID 连续性 | 不连续 | 可连续(有空洞) |
| 部署复杂度 | 中 | 低 |
9. 面试总结
雪花算法:时间戳(41) + 机器(10) + 序列(12) = 64 位,通过 ZK 分配 workerId 解决时钟回拨。
号段模式:数据库预分配号段,本地消费,双 Buffer 优化,双写保证高可用。
基因法:在 ID 中嵌入分片信息,避免读扩散,压缩机器位而非序列位。
选择建议:
- 高并发、对时间有序 → Snowflake
- 对时钟敏感、需连续 ID → Segment
- 分库分表场景 → 配合基因法
