【架构实战】分布式ID生成方案:雪花算法与业务ID设计
Snowflake、号段模式、Leaf、实战对比
一、从一个真实的故事说起
2023年某电商大促,订单系统在流量洪峰下突然报错------"主键冲突"。
开发团队排查后发现,问题出在数据库的自增ID上。由于订单表数据量太大,他们采用了分库分表策略,将订单数据分散到16个数据库实例。每个数据库实例配置了不同的自增起始值和步长:
DB1: 起始值1,步长16 → 1, 17, 33, 49...
DB2: 起始值2,步长16 → 2, 18, 34, 50...
...
DB16: 起始值16,步长16 → 16, 32, 48, 64...
这个方案在单机房运行良好,但为了提高可用性,他们在另一个机房部署了相同的16个数据库实例,配置了同样的起始值和步长。结果两个机房的数据库生成了相同的ID,合并数据时发生主键冲突。
"我们不是配置了不同的步长吗?为什么还会冲突?"
"步长只在同一机房内保证不冲突,跨机房没有考虑。而且随着业务增长,如果要扩容到32个分库,现有方案完全无法支持。"
这个故事告诉我们:分布式ID生成不是简单的"唯一性"问题,还需要考虑性能、可用性、扩展性、信息密度等多个维度。
二、分布式ID的核心要求
在设计分布式ID方案之前,我们需要明确核心要求:
2.1 全局唯一性
这是最基本的要求,所有业务场景都必须满足。但"全局"的范围需要明确:
- 系统级全局:整个系统内唯一(如订单ID)
- 业务级全局:某个业务范围内唯一(如用户ID)
- 租户级全局:某个租户范围内唯一(如SaaS系统)
2.2 有序性
有序性影响数据库性能:
- 完全有序:按生成时间严格递增(如Snowflake)
- 趋势有序:短时间内生成的ID大致有序(如号段模式)
- 无序:随机生成(如UUID)
为什么有序性重要?
MySQL InnoDB使用B+树索引,主键有序时,新数据直接追加到索引末尾,性能最高。主键无序时,新数据可能插入到索引中间,导致页分裂,性能下降。
2.3 性能要求
不同业务场景对性能要求不同:
- 低频业务:每秒生成几十个ID即可(如用户注册)
- 中频业务:每秒生成几千个ID(如订单创建)
- 高频业务:每秒生成几十万个ID(如消息推送)
2.4 信息密度
ID是否需要携带业务信息:
- 纯数字ID:不携带任何信息(如Snowflake)
- 业务编码ID:携带业务信息(如订单号=时间+渠道+序号)
- 混合ID:部分携带信息(如用户ID=注册时间+序号)
三、常见方案对比
3.1 UUID
原理:基于随机数生成128位唯一标识符。
java
String uuid = UUID.randomUUID().toString();
// 结果:550e8400-e29b-41d4-a716-446655440000
优点:
- 本地生成,无网络开销
- 性能极高,每秒可生成百万级
- 无单点故障
缺点:
- 无序,导致数据库索引性能差
- 过长(36字符),存储和传输开销大
- 无业务语义,难以排查问题
适用场景:临时标识、非主键场景
3.2 数据库自增ID
原理:利用数据库的auto_increment特性生成ID。
sql
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
...
);
优点:
- 实现简单,数据库原生支持
- 完全有序,索引性能好
- 单调递增,便于分页
缺点:
- 单点问题,数据库故障导致ID生成失败
- 性能瓶颈,受限于数据库写入性能
- 分库分表场景下难以保证全局唯一
适用场景:单库单表、低并发场景
3.3 号段模式
原理:从数据库批量获取ID号段,本地分配。
java
public class SegmentIDGenerator {
private long maxId; // 当前号段最大值
private long currentId; // 当前分配到的ID
private int step; // 号段大小
public synchronized long nextId() {
if (currentId >= maxId) {
// 号段用完,从数据库获取新号段
Segment segment = db.getSegment(step);
currentId = segment.getStart();
maxId = segment.getEnd();
}
return currentId++;
}
}
数据库表设计:
sql
CREATE TABLE id_segment (
biz_tag VARCHAR(64) PRIMARY KEY, -- 业务标识
max_id BIGINT NOT NULL, -- 当前最大ID
step INT NOT NULL, -- 号段大小
version INT NOT NULL -- 乐观锁版本号
);
-- 获取号段
UPDATE id_segment
SET max_id = max_id + step, version = version + 1
WHERE biz_tag = 'order' AND version = ?;
优点:
- 性能高,本地分配无网络开销
- 趋势有序,对数据库友好
- 支持多业务独立配置
缺点:
- 单点问题,数据库故障导致ID生成失败
- 号段内ID可能浪费(服务重启)
- 不适合单机高并发场景(号段竞争)
适用场景:中频业务、多业务场景
3.4 雪花算法(Snowflake)
原理:将64位long类型ID划分为多个部分:
0 - 41位时间戳 - 10位机器ID - 12位序号
| 1位符号位 | 41位时间戳 | 10位机器ID | 12位序号 |
|----------|-----------|-----------|---------|
| 0 | 41位 | 10位 | 12位 |
- 符号位:1位,始终为0
- 时间戳:41位,毫秒级时间戳,可用69年
- 机器ID:10位,支持1024台机器
- 序号:12位,每毫秒最多生成4096个ID
java
public class SnowflakeIDGenerator {
private final long workerId; // 机器ID
private final long datacenterId; // 数据中心ID
private long sequence = 0; // 序号
private long lastTimestamp = -1L; // 上次生成时间戳
// 各部分的位数
private final long sequenceBits = 12L;
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
// 各部分的偏移量
private final long workerIdShift = sequenceBits;
private final long datacenterIdShift = sequenceBits + workerIdBits;
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成ID");
}
if (timestamp == lastTimestamp) {
// 同一毫秒内,序号递增
sequence = (sequence + 1) & ((1 << sequenceBits) - 1);
if (sequence == 0) {
// 序号溢出,等待下一毫秒
timestamp = tilNextMillis(lastTimestamp);
}
} else {
// 新的一毫秒,序号重置
sequence = 0;
}
lastTimestamp = timestamp;
// 组装ID
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
优点:
- 本地生成,无网络开销
- 性能极高,单机每秒可生成400万ID
- 完全有序,按时间递增
- 无单点故障
缺点:
- 时钟回拨问题,可能导致ID重复
- 机器ID分配需要外部协调
- ID长度固定,无法携带业务信息
适用场景:高频业务、分布式场景
四、Leaf:美团开源的分布式ID方案
Leaf是美团开源的分布式ID生成系统,结合了号段模式和雪花算法的优点。
4.1 Leaf Segment(号段模式)
Leaf对号段模式进行了优化:
优化一:双Buffer预加载
java
public class SegmentIDGenerator {
private Segment[] segments = new Segment[2]; // 双Buffer
private int currentSegment = 0; // 当前使用的Buffer
private volatile boolean isNextSegmentReady = false; // 下一个Buffer是否就绪
public long nextId() {
Segment segment = segments[currentSegment];
long id = segment.nextId();
// 当使用量达到阈值,异步加载下一个号段
if (segment.isThresholdReached() && !isNextSegmentReady) {
asyncLoadNextSegment();
}
// 当前号段用完,切换到下一个号段
if (segment.isExhausted()) {
synchronized (this) {
if (segment.isExhausted()) {
currentSegment = (currentSegment + 1) % 2;
isNextSegmentReady = false;
}
}
}
return id;
}
}
优化二:数据库高可用
Leaf使用主从数据库,主库故障时自动切换到从库:
yaml
leaf:
segment:
enable: true
db:
master:
url: jdbc:mysql://master:3306/leaf
username: root
password: root
slave:
url: jdbc:mysql://slave:3306/leaf
username: root
password: root
4.2 Leaf Snowflake(雪花算法)
Leaf对雪花算法的优化主要在机器ID分配:
使用ZooKeeper分配机器ID
java
public class SnowflakeIDGenerator {
private ZooKeeper zk;
private String zkPath = "/leaf/snowflake/workers";
public void init() {
// 在ZooKeeper创建临时顺序节点
String nodePath = zk.create(zkPath + "/worker-",
null,
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
// 从节点路径提取workerId
String nodeName = nodePath.substring(nodePath.lastIndexOf('/') + 1);
workerId = Integer.parseInt(nodeName.replace("worker-", ""));
}
}
时钟回拨处理
Leaf对时钟回拨的处理更加友好:
java
public long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
// 小幅回拨,等待时钟追上
try {
Thread.sleep(offset * 2);
timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨超过阈值");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
} else {
// 大幅回拨,从ZooKeeper获取新的workerId
reassignWorkerId();
}
}
// ... 正常生成ID
}
4.3 Leaf部署架构
┌─────────────┐
│ Nginx │
│ 负载均衡 │
└──────┬──────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ Leaf-1 │ │ Leaf-2 │ │ Leaf-3 │
│ Segment │ │ Segment │ │ Segment │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────────┼─────────────────┘
│
┌──────▼──────┐
│ MySQL │
│ 主从复制 │
└─────────────┘
五、实战案例:订单ID设计
5.1 业务需求
某电商平台需要设计订单ID,要求:
- 全局唯一,支持分库分表
- 有序性,便于数据库索引
- 携带业务信息,便于排查问题
- 长度适中,不超过20位
5.2 ID结构设计
订单ID = 时间戳(8位) + 渠道ID(2位) + 分库ID(2位) + 序号(6位)
示例:202411110101000001
└──────┘└─┘└─┘└────┘
时间 渠道 分库 序号
- 时间戳:8位,格式yyyyMMdd
- 渠道ID:2位,01=APP,02=Web,03=小程序
- 分库ID:2位,对应分库编号
- 序号:6位,每天从1开始,最多100万单
5.3 代码实现
java
public class OrderIDGenerator {
private final int channelId; // 渠道ID
private final int dbShardId; // 分库ID
private final String currentDate; // 当前日期
private int sequence = 0; // 当天序号
public OrderIDGenerator(int channelId, int dbShardId) {
this.channelId = channelId;
this.dbShardId = dbShardId;
this.currentDate = formatDate(new Date());
}
public synchronized String nextId() {
String date = formatDate(new Date());
// 日期变更,重置序号
if (!date.equals(currentDate)) {
this.sequence = 0;
}
// 序号递增
this.sequence++;
// 组装ID
return String.format("%s%02d%02d%06d",
date, channelId, dbShardId, sequence);
}
private String formatDate(Date date) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
return sdf.format(date);
}
}
5.4 ID解析
java
public class OrderIDParser {
public static void parse(String orderId) {
String date = orderId.substring(0, 8);
int channelId = Integer.parseInt(orderId.substring(8, 10));
int dbShardId = Integer.parseInt(orderId.substring(10, 12));
int sequence = Integer.parseInt(orderId.substring(12, 18));
System.out.println("下单日期: " + date);
System.out.println("下单渠道: " + getChannelName(channelId));
System.out.println("分库编号: " + dbShardId);
System.out.println("当天序号: " + sequence);
}
private static String getChannelName(int channelId) {
switch (channelId) {
case 1: return "APP";
case 2: return "Web";
case 3: return "小程序";
default: return "未知";
}
}
}
// 示例
OrderIDParser.parse("202411110101000001");
// 输出:
// 下单日期: 20241111
// 下单渠道: APP
// 分库编号: 1
// 当天序号: 1
六、踩坑实录
踩坑一:雪花算法时钟回拨
问题:服务器时钟同步时回拨,导致生成的ID重复。
java
// 时钟回拨前
lastTimestamp = 1699888888888L;
long id1 = generator.nextId(); // 正常
// 时钟回拨
lastTimestamp = 1699888888888L;
timestamp = 1699888888000L; // 回拨888ms
// 再次生成ID
long id2 = generator.nextId(); // 可能与id1重复
解决方案:
java
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
// 方案一:小幅回拨,等待时钟追上
if (offset <= 5) {
try {
wait(offset << 1);
timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨超过阈值");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// 方案二:大幅回拨,使用备用workerId
else {
workerId = getBackupWorkerId();
}
}
// ... 正常生成ID
}
踩坑二:号段模式号段浪费
问题:服务重启时,未使用的ID浪费。
java
// 服务重启前
maxId = 1000;
currentId = 950;
// 剩余50个ID未使用
// 服务重启后
// 从数据库获取新号段
maxId = 2000;
currentId = 1000;
// 之前的50个ID永久浪费
解决方案:记录已分配的最大ID
java
public class SegmentIDGenerator {
private long maxId;
private long currentId;
private long allocatedMaxId; // 已分配的最大ID
public synchronized long nextId() {
if (currentId >= maxId) {
// 保存已分配的最大ID
db.updateAllocatedMaxId(bizTag, allocatedMaxId);
// 获取新号段
Segment segment = db.getSegment(step, allocatedMaxId);
currentId = segment.getStart();
maxId = segment.getEnd();
}
long id = currentId++;
allocatedMaxId = Math.max(allocatedMaxId, id);
return id;
}
}
踩坑三:机器ID分配冲突
问题:多台机器配置了相同的workerId,导致ID重复。
yaml
# application.yml
snowflake:
workerId: 1 # 所有机器都配置为1
解决方案:使用ZooKeeper或Redis自动分配
java
// 使用Redis分配workerId
public class WorkerIdAllocator {
private Jedis jedis;
public int allocateWorkerId() {
// 使用Redis的INCR命令分配
Long workerId = jedis.incr("snowflake:worker_id");
// 超过最大值,循环使用
if (workerId > 1023) {
jedis.set("snowflake:worker_id", "0");
workerId = 0L;
}
return workerId.intValue();
}
}
踩坑四:ID长度超出预期
问题:Snowflake生成的ID是19位,超出数据库字段长度。
sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY, -- BIGINT最大值是9223372036854775807,19位
...
);
-- 但业务要求ID不超过18位
解决方案:调整Snowflake的位数分配
java
// 标准Snowflake:41位时间戳 + 10位机器ID + 12位序号 = 63位
// 调整后: 38位时间戳 + 8位机器ID + 12位序号 = 58位
// 时间戳位数减少,可用时间变短
// 38位时间戳 ≈ 8.5年
// 需要设置起始时间戳
private final long twepoch = 1704067200000L; // 2024-01-01 00:00:00
七、方案选型指南
| 方案 | 性能 | 有序性 | 可用性 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|
| UUID | 极高 | 无序 | 极高 | 极低 | 临时标识、非主键 |
| 数据库自增 | 低 | 完全有序 | 低 | 极低 | 单库、低并发 |
| 号段模式 | 高 | 趋势有序 | 中 | 中 | 中频业务、多业务 |
| 雪花算法 | 极高 | 完全有序 | 高 | 中 | 高频业务、分布式 |
| Leaf Segment | 高 | 趋势有序 | 高 | 高 | 生产环境、多业务 |
| Leaf Snowflake | 极高 | 完全有序 | 高 | 高 | 生产环境、高频业务 |
选型建议:
- 低并发单库场景:直接使用数据库自增ID
- 中并发多业务场景:使用Leaf Segment
- 高并发分布式场景:使用Leaf Snowflake
- 需要携带业务信息:自定义ID结构,结合雪花算法或号段模式
八、总结
分布式ID生成看似简单,实则需要考虑多个维度:
- 唯一性:全局唯一是基本要求,但需要明确"全局"的范围
- 有序性:影响数据库索引性能,需要权衡完全有序和趋势有序
- 性能:不同业务场景对性能要求不同,需要选择合适的方案
- 可用性:单点故障、时钟回拨等问题需要提前考虑
- 扩展性:业务增长时,方案是否支持平滑扩容
九、思考题
-
如果你的业务需要支持"短链接"(如t.cn/abc123),你会如何设计ID生成方案?需要考虑哪些约束?
-
在多机房部署场景下,如何保证雪花算法生成的ID全局唯一?如果机房之间的时钟不同步怎么办?
-
对于金融业务(如交易流水号),ID生成方案需要满足哪些额外要求?如何保证ID生成的审计追溯能力?
十、个人观点
在我参与过的多个项目中,分布式ID最常见的误区是:过度追求性能,忽视可用性。
很多团队一上来就选择雪花算法,觉得性能最高。但雪花算法的时钟回拨问题在生产环境中经常发生,一旦发生,可能导致ID重复,后果严重。我的建议是:优先选择号段模式,除非性能确实不满足需求。
号段模式的性能已经足够支撑绝大多数业务(单机每秒几万ID),而且没有时钟回拨问题。即使数据库故障,也可以通过双Buffer预加载,保证短时间内的可用性。
另一个误区是:忽视ID的业务语义。纯数字ID虽然简单,但在排查问题时很不方便。比如看到订单号"1699888888888888888",完全不知道是哪个渠道、哪个时间下单的。而看到"202411110101000001",一眼就能看出是2024年11月11日APP渠道的订单。
我的建议是:在满足性能和可用性的前提下,尽量让ID携带业务信息。可以通过自定义ID结构,或者维护ID到业务信息的映射表。
最后,分布式ID是基础设施,一旦上线很难更换。建议在项目初期就充分评估业务需求,选择合适的方案,并预留扩展空间。
作者:架构实战系列 | 字数:约4800字