分布式 ID 生成
问题
分库分表后如何保证主键全局唯一?雪花算法和号段模式分别是什么原理?各方案如何选择?
答案
为什么需要分布式 ID
分库分表后,各个库的自增 ID 会重复。需要一种全局唯一的 ID 生成方案。
分布式 ID 的要求:
| 要求 | 说明 |
|---|---|
| 全局唯一 | 任何节点生成的 ID 不能重复 |
| 趋势递增 | 有利于 B+ 树索引写入性能 |
| 高性能 | 单机万级/秒以上 |
| 高可用 | 不能单点故障 |
| 信息安全 | 不暴露业务量(不能连续) |
方案对比
| 方案 | 唯一性 | 有序性 | 性能 | 可用性 | 长度 |
|---|---|---|---|---|---|
| UUID | ✅ | ❌ 无序 | 高(本地) | 高 | 36 字符 |
| 自增 + 步长 | ✅ | ✅ | 高 | 中 | 8 字节 |
| 雪花算法 | ✅ | ✅ 趋势递增 | 高(本地) | 高 | 8 字节 |
| 号段模式 | ✅ | ✅ | 高 | 高 | 8 字节 |
| Redis INCR | ✅ | ✅ | 中 | 中(依赖 Redis) | 8 字节 |
UUID
550e8400-e29b-41d4-a716-446655440000
| 优势 | 劣势 |
|---|---|
| 本地生成,无网络调用 | 36 字符,太长 |
| 全局唯一 | 无序,B+ 树频繁页分裂 |
| 无依赖 | 不可读 |
结论:不适合做数据库主键(太长、无序),适合做分布式环境中的唯一标识符。
数据库自增 + 步长
-- 两个库设置不同起始值和步长
-- 库 1:1, 3, 5, 7, 9 ...
SET auto_increment_offset = 1;
SET auto_increment_increment = 2;
-- 库 2:2, 4, 6, 8, 10 ...
SET auto_increment_offset = 2;
SET auto_increment_increment = 2;
| 优势 | 劣势 |
|---|---|
| 实现简单 | 扩容困难(新增库需调整步长) |
| ID 有序 | 依赖数据库 |
雪花算法(Snowflake)
Twitter 开源的分布式 ID 生成算法,生成 64 位 Long 型 ID:
0 | 0000000000 0000000000 0000000000 0000000000 0 | 00000 | 00000 | 000000000000
1 | 41 位时间戳 | 5位 | 5位 | 12 位序列号
符号| 毫秒级(约 69 年) |数据中心| 机器ID| 每毫秒 4096 个
| 部分 | 位数 | 说明 |
|---|---|---|
| 符号位 | 1 | 固定 0 |
| 时间戳 | 41 | 毫秒级,可用约 69 年 |
| 数据中心 ID | 5 | 支持 32 个数据中心 |
| 机器 ID | 5 | 每个数据中心 32 台机器 |
| 序列号 | 12 | 每毫秒 4096 个 ID |
理论 QPS:单机每秒可生成 409.6 万 个 ID。
public class SnowflakeIdGenerator {
private final long datacenterId;
private final long machineId;
private long sequence = 0L;
private long lastTimestamp = -1L;
// 起始时间戳(2024-01-01)
private static final long EPOCH = 1704067200000L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成 ID");
}
if (timestamp == lastTimestamp) {
// 同一毫秒内,序列号递增
sequence = (sequence + 1) & 0xFFF; // 12 位
if (sequence == 0) {
// 序列号溢出,等待下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << 22)
| (datacenterId << 17)
| (machineId << 12)
| sequence;
}
}
雪花算法的问题:
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 时钟回拨 | NTP 校时导致时间回退 | 等待、报错、Leaf 的缓冲机制 |
| 机器 ID 分配 | 需要保证全局唯一 | ZooKeeper 分配、配置文件 |
号段模式
预先从数据库获取一段 ID 范围,在内存中分配:
-- 号段分配表
CREATE TABLE id_alloc (
biz_tag VARCHAR(50) PRIMARY KEY, -- 业务标识
max_id BIGINT NOT NULL, -- 当前最大 ID
step INT NOT NULL, -- 步长
update_time TIMESTAMP
);
-- 获取号段(原子操作)
UPDATE id_alloc SET max_id = max_id + step WHERE biz_tag = 'order';
-- 假设 step = 1000,更新后 max_id = 5000
-- 则本次获取的号段为 [4001, 5000]
双 Buffer 优化:当前号段用到 10% 时,异步加载下一个号段到备用 Buffer,实现无阻塞。
Leaf(美团开源)
Leaf 是美团开源的分布式 ID 生成服务,同时支持号段模式和雪花算法模式:
| 模式 | 特点 |
|---|---|
| Leaf-segment(号段) | 双 Buffer + DB、支持动态步长调整 |
| Leaf-snowflake(雪花) | ZooKeeper 管理 workerID、解决时钟回拨 |
各场景推荐
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 简单场景 | 雪花算法 | 本地生成,无依赖 |
| 大规模分布式 | Leaf 号段模式 | 高性能、高可用 |
| 要求连续 ID | 号段模式 | ID 趋势递增 |
| 不暴露信息 | 雪花算法 | ID 中编码了时间,但不连续 |
常见面试问题
Q1: 雪花算法时钟回拨怎么解决?
答案:
- 短暂等待:回拨时间 < 5ms,sleep 等待
- 拒绝服务:回拨时间过长,抛异常
- Leaf 方案:Leaf-snowflake 会在 ZooKeeper 记录上次时间戳,启动时比对,发现回拨则等待
- 百度 UidGenerator:预先生成 ID 放入 RingBuffer,消耗时从 Buffer 取,不实时依赖时钟
Q2: 号段模式的步长设多少合适?
答案:
根据 QPS 动态调整:
- 低 QPS(
<100/s):step = 1000 - 高 QPS(
>1000/s):step = 10000 或更大
Leaf 支持动态步长:根据上一个号段的消耗速度自动调整下一个号段的大小。
Q3: 为什么雪花算法不适合用订单号对外暴露?
答案:
雪花算法的 ID 包含时间戳和机器信息,虽然不是连续数字,但仍然可以:
- 从 ID 推算出大致的生成时间
- 通过 ID 的增长速度推算出业务量
如果需要对外暴露,可以:
- 对雪花 ID 做加密/混淆
- 使用独立的订单号(如
日期+随机数+校验位)
Q4: Redis INCR 生成 ID 有什么问题?
答案:
- 强依赖 Redis:Redis 宕机则无法生成 ID
- 持久化风险:Redis 使用 RDB 持久化,宕机可能丢失最新的 ID 值导致重复
- 性能瓶颈:每次生成都是网络调用
改进:Redis 每次获取一段(如 +1000),本地内存中分配,类似号段模式。