系统设计:分布式 ID 生成器
在分布式系统中,如何高效、可靠地生成全局唯一且趋势递增的 ID?
背景与需求
为什么不能用数据库自增 ID?
在单机时代,MySQL 的 auto_increment 完全够用。但在分布式系统中,自增 ID 面临几个问题:
- 单点瓶颈:所有插入都依赖单台数据库,无法水平扩展
- 数据迁移困难:分库分表后,各库自增 ID 可能冲突
- 信息暴露:自增 ID 会暴露业务规模(如订单总量)
- 非趋势递增:分库场景下 ID 不连续,对 B+ 树不友好
核心需求
| 需求 | 说明 | 优先级 |
|---|---|---|
| 全局唯一 | 任何时刻、任何节点生成的 ID 都不重复 | P0 |
| 趋势递增 | 大致按时间递增,对索引友好 | P1 |
| 高性能 | 支持每秒百万级生成 | P1 |
| 高可用 | 不存在单点故障 | P1 |
| 可扩展 | 方便增加节点,不影响已有服务 | P2 |
| 信息安全 | ID 不暴露业务信息 | P2 |
方案演进
方案一:UUID
import uuid
# UUID v4:完全随机
uid = uuid.uuid4() # e.g., 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
# UUID v1:基于时间戳 + MAC 地址
uid1 = uuid.uuid1()
优点:
- 本地生成,无网络依赖,性能极高
- 全局唯一,理论冲突概率趋近于 0
缺点:
- 无序:完全随机,对数据库索引极不友好(页分裂严重)
- 过长:36 个字符(含横线),存储和传输成本高
- 信息可读性差:无法从 ID 中推断生成时间或来源
适用场景:日志 trace_id、文件名等不需要排序的场景。
方案二:数据库分段(号段模式)
核心思想:一次性从数据库申请一个号段(如 1-1000),用完再申请,减少数据库访问次数。
-- 号段表
create table id_segment (
biz_tag varchar(50) primary key,
max_id bigint not null,
step int not null default 1000,
version int not null default 0
);
-- 初始化
insert into id_segment values ('order', 0, 1000, 0);
获取号段的逻辑:
import threading
class SegmentIdGenerator:
def __init__(self, db, biz_tag, step=1000):
self.db = db
self.biz_tag = biz_tag
self.step = step
self.current_id = 0
self.max_id = 0
self.lock = threading.Lock()
def get_id(self):
with self.lock:
if self.current_id >= self.max_id:
self.load_next_segment()
self.current_id += 1
return self.current_id
def load_next_segment(self):
while True:
row = self.db.query(
"SELECT max_id, version FROM id_segment WHERE biz_tag = %s",
self.biz_tag
)
old_max, version = row['max_id'], row['version']
new_max = old_max + self.step
affected = self.db.execute(
"UPDATE id_segment SET max_id = %s, version = version + 1 "
"WHERE biz_tag = %s AND version = %s",
new_max, self.biz_tag, version
)
if affected == 1:
self.current_id = old_max
self.max_id = new_max
break
优点:
- 趋势递增(segments 之间连续)
- 生成性能高(内存中分配,QPS 可达数万)
- 数据库压力小(一次分配 1000 个号)
缺点:
- 仍存在数据库单点(可主从+哨兵缓解)
- 号段用完时的切换瞬间可能出现延迟尖刺
方案三:Snowflake(雪花算法)
Twitter 开源的分布式 ID 生成方案,是业界最广泛使用的方案。
结构(64 位)
| 符号位 | 时间戳(41位) | 数据中心 ID(5位) | 机器 ID(5位) | 序列号(12位) |
|--------|----------------|--------------------|----------------|----------------|
| 1 bit | 41 bits | 5 bits | 5 bits | 12 bits |
| 0 | 毫秒级时间戳 | 0-31 | 0-31 | 0-4095 |
- 时间戳位:约可使用 69 年(从自定义的纪元开始)
- 数据中心 + 机器:共 10 位,支持 1024 个节点
- 序列号:每毫秒每节点最多生成 4096 个 ID,理论 QPS = 4096 * 1000 ≈ 400 万
Python 实现
import time
import threading
class Snowflake:
def __init__(self, datacenter_id: int, worker_id: int, epoch=1609459200000):
self.epoch = epoch
self.datacenter_id = datacenter_id & 0x1F
self.worker_id = worker_id & 0x1F
self.sequence = 0
self.last_timestamp = -1
self.lock = threading.Lock()
self.worker_id_bits = 5
self.datacenter_id_bits = 5
self.sequence_bits = 12
self.worker_id_shift = self.sequence_bits
self.datacenter_id_shift = self.sequence_bits + self.worker_id_bits
self.timestamp_shift = (self.sequence_bits + self.worker_id_bits +
self.datacenter_id_bits)
self.sequence_mask = (1 << self.sequence_bits) - 1
def _current_time_ms(self) -> int:
return int(time.time() * 1000)
def _wait_next_millis(self, last_ms: int) -> int:
ms = self._current_time_ms()
while ms <= last_ms:
ms = self._current_time_ms()
return ms
def next_id(self) -> int:
with self.lock:
timestamp = self._current_time_ms()
if timestamp < self.last_timestamp:
raise Exception("Clock moved backwards")
if timestamp == self.last_timestamp:
self.sequence = (self.sequence + 1) & self.sequence_mask
if self.sequence == 0:
timestamp = self._wait_next_millis(self.last_timestamp)
else:
self.sequence = 0
self.last_timestamp = timestamp
return ((timestamp - self.epoch) << self.timestamp_shift |
self.datacenter_id << self.datacenter_id_shift |
self.worker_id << self.worker_id_shift |
self.sequence)
Snowflake 的关键问题
1. 时钟回拨
如果服务器时间被 NTP 同步回调,last_timestamp > current_timestamp,会导致 ID 重复。
解决方案:
- 等待:阻塞到时间追上(适合小幅度回拨)
- 异常抛出:大幅度回拨时直接报错,人工介入
- 美团的 Leaf:采用「双 buffer」机制,时钟回拨时切换到备用号段
2. 机器 ID 分配
需要保证每个节点的 datacenter_id + worker_id 唯一。
解决方案:
- Zookeeper 分配:启动时向 ZK 注册,获取唯一 worker ID
- 配置文件:小规模部署直接手动配置
- 容器化:通过环境变量注入
3. JavaScript 兼容
JS 的 Number 最大安全整数是 2^53-1,Snowflake 的 64 位可能溢出。
解决方案:返回时将 ID 转为字符串,或缩短时间戳位数。
方案四:美团的 Leaf(号段 + Snowflake 双模式)
美团开源的分布式 ID 生成服务。
Leaf-segment(号段模式优化版)
- 双 buffer 机制:当前号段用到 90% 时,异步加载下一个号段
- 数据库高可用:多机房部署,容灾自动切换
- 趋势递增:ID 单调递增,适合数据库主键
Leaf-snowflake(雪花算法优化版)
- 使用 Zookeeper 持久顺序节点分配 worker_id
- 弱依赖 ZK:ZK 挂掉不影响已有节点发号
- 解决时钟回拨:启动时向 ZK 上报时间戳
方案对比
| 方案 | 趋势递增 | 性能 | 高可用 | 长度 | 信息暴露 | 适用场景 |
|---|---|---|---|---|---|---|
| UUID v4 | ❌ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 36 字符 | 无 | Trace ID、文件名 |
| 号段模式 | ✅ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 64bit | 弱 | 数据库主键 |
| Snowflake | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 64bit | 可推时间 | 高并发、时间排序 |
| Leaf | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 64bit | 弱 | 大规模、企业级 |
面试答题框架
第一步:明确需求(30秒)
确认是否需要趋势递增、QPS 要求、高可用级别。
第二步:分析方案(1分钟)
列举 UUID、号段模式、Snowflake、Leaf 四种方案的优缺点。
第三步:深入核心方案(5分钟)
画 Snowflake 的 64 位结构图,讲解时钟回拨和 worker ID 分配。
第四步:总结 trade-off(1分钟)
强趋势递增选号段模式,极高性能选 Snowflake,企业级选 Leaf。
常见问题
Q:Snowflake 的 4096/毫秒够用吗?
单机单业务线通常足够(400 万 QPS)。不够时增加序列号位数或部署更多 worker。
Q:为什么不用 Redis 的 INCR?
单点 + 网络 RTT,性能不如本地生成。
Q:分库分表场景下 ID 设计要注意什么?
用全局唯一 ID(Snowflake/Leaf)作为逻辑主键,shard key 用独立业务字段。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。