【架构实战】分布式ID生成:从自增ID到雪花算法
一、背景:分库分表后,自增ID的噩梦
2019年我们把订单表做了分库分表,按用户ID哈希分了16个库。
上线第二天,运营反馈:后台看到的订单号会跳变。查了一下午才搞清楚:MySQL自增ID在每个库里独立计数,导致不同分库里的订单ID重复了。
分库前(单库):
orders 表: AUTO_INCREMENT → 1001, 1002, 1003... 全局唯一
分库后(16个库):
db_00.orders: AUTO_INCREMENT → 1001, 1002, 1003...
db_01.orders: AUTO_INCREMENT → 1001, 1002, 1003... ← 与db_00重复了!
那天我们手动跑脚本给所有订单号加了个库前缀才紧急修复。但这只是临时方案,根本问题没解决。
分库分表之后,数据库自增ID彻底失效,必须要有全局唯一的ID生成方案。
二、分布式ID的核心要求
分布式ID必须满足:
1. 全局唯一:最基本要求,任何两个ID不能重复
2. 趋势递增:索引友好,减少B+树页分裂
3. 高性能:生成速度不能成为系统瓶颈
4. 高可用:ID生成服务不能有单点故障
5. 信息安全:不能暴露业务量(如今天第几个订单)
三、方案演进:从青铜到王者
3.1 青铜:UUID
java
String id = UUID.randomUUID().toString();
// 例如:550e8400-e29b-41d4-a716-446655440000
优点:本地生成,无网络开销,实现超简单。
缺点(致命):
-
字符串太长(36字节),占用大量存储空间
-
完全无序,作为主键会导致B+树频繁页分裂,写入性能下降50%以上
-
不包含时间信息,不便于排查问题
UUID作为主键插入B+树:
每次插入都是随机位置 → 页分裂频繁 → 磁盘碎片 → 性能灾难趋势递增ID作为主键插入B+树:
每次插入到最后 → 极少页分裂 → 顺序写入 → 性能优秀
结论:可以用作业务编号但绝不适合做数据库主键。
3.2 白银:数据库号段模式
原理:用一个DB表集中分配ID号段,服务每次批量获取一段ID,用完后再次申请。
sql
-- ID号段表
CREATE TABLE id_generator (
biz_type VARCHAR(64) PRIMARY KEY COMMENT '业务类型',
max_id BIGINT NOT NULL COMMENT '当前已分配的最大ID',
step INT NOT NULL DEFAULT 1000 COMMENT '每次分配的号段长度',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'
);
-- 获取号段的原子操作
UPDATE id_generator
SET max_id = max_id + step, version = version + 1
WHERE biz_type = 'order' AND version = #{oldVersion};
-- 如果update影响行数>0 → 分配成功,号段 = [max_id - step + 1, max_id]
java
@Service
public class DbSegmentIdGenerator {
private volatile Segment currentSegment;
private volatile Segment nextSegment; // 双Buffer优化
@PostConstruct
public void init() {
currentSegment = fetchSegment();
nextSegment = fetchSegment(); // 预加载下一个号段
// 异步预加载线程
Executors.newSingleThreadScheduledExecutor()
.scheduleWithFixedDelay(this::preloadSegment, 0, 1, TimeUnit.SECONDS);
}
public synchronized long nextId() {
if (!currentSegment.hasNext()) {
// 号段用完,切换到预加载的号段
currentSegment = nextSegment;
nextSegment = fetchSegment();
}
return currentSegment.next();
}
private Segment fetchSegment() {
// 执行 UPDATE id_generator SET max_id = max_id + step
// 返回号段 [newMaxId - step + 1, newMaxId]
}
}
优点:ID趋势递增,本地缓存号段性能高,每次DB交互可分配1000个ID。
缺点:依赖DB,DB宕机时号段耗完后无法分配;多业务需要多张表。
3.3 黄金:Redis生成
java
// 使用Redis的INCRBY原子操作
long id = redisTemplate.opsForValue()
.increment("id:order", 1); // 每次+1,返回新ID
优点:性能极高,实现简单。
缺点:
- Redis不持久化会丢ID(重启后从0开始)
- Redis持久化(RDB/AOF)有性能损耗
- 主从切换可能产生重复ID
3.4 王者:雪花算法(Snowflake)
原理:一个64位Long整数,各段含义如下:
┌─┬──────────────────────────────────────────────┬──────────┬────────────┐
│0│ 41位时间戳(毫秒) │ 10位机器码│ 12位序列号 │
└─┴──────────────────────────────────────────────┴──────────┴────────────┘
1位 41位 10位 12位
(保留)
各部分说明:
- 第1位:固定0(保证正数)
- 41位时间戳:从自定义起始时间到现在的毫秒数,可用69年
- 10位机器码:最多1024个节点(5位机房+5位机器ID)
- 12位序列号:同一毫秒内最多4096个ID
QPS = 4096个/毫秒 × 1000 = 约400万/秒
java
public class SnowflakeIdGenerator {
// 起始时间戳(2020-01-01 00:00:00)
private static final long START_TIMESTAMP = 1577836800000L;
// 各部分位数
private static final long DATA_CENTER_ID_BITS = 5L; // 5位机房ID
private static final long WORKER_ID_BITS = 5L; // 5位机器ID
private static final long SEQUENCE_BITS = 12L; // 12位序列号
// 最大值
private static final long MAX_DATA_CENTER_ID = ~(-1L << DATA_CENTER_ID_BITS); // 31
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); // 31
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); // 4095
// 偏移量
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; // 12
private static final long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; // 17
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS; // 22
private final long dataCenterId;
private final long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long dataCenterId, long workerId) {
if (dataCenterId > MAX_DATA_CENTER_ID || dataCenterId < 0) {
throw new IllegalArgumentException("机房ID超出范围: 0-" + MAX_DATA_CENTER_ID);
}
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException("机器ID超出范围: 0-" + MAX_WORKER_ID);
}
this.dataCenterId = dataCenterId;
this.workerId = workerId;
}
public synchronized long nextId() {
long currentTimestamp = System.currentTimeMillis();
// 时钟回拨检测
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException(
"时钟回拨: 上次时间=" + lastTimestamp + ", 当前时间=" + currentTimestamp);
}
if (currentTimestamp == lastTimestamp) {
// 同一毫秒内,序列号递增
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0) {
// 序列号用完,等待下一毫秒
currentTimestamp = waitNextMillis(lastTimestamp);
}
} else {
// 不同毫秒,序列号重置
sequence = 0L;
}
lastTimestamp = currentTimestamp;
// 组装ID
return ((currentTimestamp - START_TIMESTAMP) << TIMESTAMP_SHIFT)
| (dataCenterId << DATA_CENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
3.5 雪花算法的时钟回拨问题
问题:服务器时钟被调回过去,可能产生重复ID。
时间线:
10:00:00.000 → 生成了ID=100
10:00:00.001 → 生成了ID=101
突然NTP同步,时钟回拨到 09:59:59.500
10:00:00.000 → 又生成了ID=100 ← 重复了!
解决方案:
java
// 方案1:短期回拨等待
private synchronized long nextIdWithFallback() {
long currentTimestamp = System.currentTimeMillis();
if (currentTimestamp < lastTimestamp) {
long offset = lastTimestamp - currentTimestamp;
if (offset <= 5) { // 回拨5ms以内,等待追平
try {
Thread.sleep(offset + 1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
currentTimestamp = System.currentTimeMillis();
} else {
// 回拨超过5ms,使用备用workerId
return generateWithBackupWorkerId(currentTimestamp);
}
}
// ... 正常生成逻辑
}
// 方案2:记录最后生成的ID,时钟回拨时用历史最大ID+1
// 方案3:放弃本节点ID生成,切换到备用ID生成器
生产环境推荐:使用美团Leaf或百度UidGenerator,它们已解决时钟回拨问题。
四、方案对比
| 方案 | 唯一性 | 趋势递增 | 性能(QPS) | 高可用 | 运维复杂度 | 适用场景 |
|---|---|---|---|---|---|---|
| UUID | ✅ | ❌ | 极高 | ✅ | 0 | 非DB主键场景 |
| DB号段 | ✅ | ✅ | 中(依赖DB) | ❌ | 低 | 中小规模 |
| Redis | ✅ | ✅ | 高 | 中等 | 低 | 已有Redis |
| 雪花原生 | ✅ | ✅ | 极高(400万/s) | ✅ | 中 | 大型分布式 |
| 美团Leaf | ✅ | ✅ | 极高 | ✅ | 高 | 企业级 |
五、美团Leaf方案简介
美团开源了两种模式:
Leaf-Segment(号段模式):
┌─────────┐ ┌─────────┐
│ Leaf-Svr│ │ Leaf-Svr│
│ (主) │ ←─→ │ (从) │
└────┬────┘ └────┬────┘
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ 服务A │ │ 服务B │
│ (获取 │ │ (获取 │
│ 号段) │ │ 号段) │
└─────────┘ └─────────┘
- 双Buffer预加载:当前号段用完前已预加载下一个
- 高可用:多个Leaf Server组成集群
Leaf-Snowflake(雪花模式):
- 通过ZooKeeper自动注册workerId
- 内置时钟回拨解决方案
六、总结
方案选择决策树:
你的规模?
│
├── 单库单表 → 直接MySQL自增ID
│
├── 分库分表 < 10个节点 → DB号段模式
│ └── 优点:实现简单,趋势递增,索引友好
│
├── 分库分表 > 10个节点 → 雪花算法
│ └── 优点:高性能,高可用,本地生成
│
└── 企业级 > 100个节点 → 美团Leaf
└── 优点:成熟方案,高可用,时钟回拨已解决
三条关键经验:
- 不要用UUID做主键:B+树的噩梦,写入性能下降50%以上
- 雪花算法的workerId不能手动配置:机器多了必出错,用ZK或DB自动分配
- 时钟回拨是真实存在的问题:NTP同步、虚拟机迁移都可能触发,生产环境必须处理
分布式ID看起来简单,却是分库分表后第一个遇到的坑。方案选对,事半功倍;方案选错,数据迁移就是噩梦。
个人观点,仅供参考