分布式 ID 生成方案对比
订单号为什么不能用自增主键?因为自增 ID 会暴露业务量、容易被遍历。这篇文章对比 UUID、数据库自增、号段模式、雪花算法和 Redis 自增五种方案,重点讲清雪花算法的时钟回拨问题。
一、为什么自增主键不能直接当订单号
用数据库自增主键当订单号,是最省事的做法——插入时不用管,数据库自己发号。但它有三个问题:
| 问题 | 说明 |
|---|---|
| 暴露业务量 | 竞品下两单,看订单号从 1001 变成 1003,就知道你一天卖了多少 |
| 容易被遍历 | 订单号连续 → 攻击者可以按顺序遍历,批量试探他人订单 |
| 分库分表失效 | 单库自增在多库下会冲突,无法直接用于分片场景 |
所以对外暴露的 ID(订单号、支付单号)通常需要单独生成,而不是直接用主键。
二、五种方案对比
| 方案 | 唯一性 | 有序性 | 性能 | 依赖 | 适用场景 |
|---|---|---|---|---|---|
| UUID | 高 | ❌ 完全无序 | 极高(本地生成) | 无 | 日志追踪、临时标识 |
| 数据库自增 | 高 | ✅ 严格递增 | 低(受单库写入限制) | 数据库 | 单库小系统 |
| 号段模式 | 高 | ✅ 趋势递增 | 高(批量取号) | 数据库 | 中等规模 |
| 雪花算法 | 高 | ✅ 趋势递增 | 极高(本地生成) | 时钟 | 主流方案 |
| Redis 自增 | 高 | ✅ 严格递增 | 高 | Redis | 需要严格递增 |
三、UUID:简单但不好用
String id = UUID.randomUUID().toString().replace("-", "");
// 结果类似: 7f3a9c2e4b1d4f8e9a6c5d3b2e1f0a9c
优点:本地生成、无网络开销、绝对唯一。
缺点:
| 缺点 | 影响 |
|---|---|
| 无序 | 作为 MySQL 主键会导致页分裂,插入性能差 |
| 太长 | 32 位字符串,占空间,索引体积大 |
| 不可读 | 无法从 ID 看出时间信息 |
无序是致命伤。InnoDB 的聚簇索引按主键顺序存储数据,随机主键会让新数据不断插入到已有的页中间,造成页分裂和大量数据移动。
UUID 适合做日志 traceId、幂等键这类不需要排序、不需要做主键的场景。做业务主键并不合适。
四、号段模式:批量取号
思路是一次从数据库取一批 ID 到内存,用完了再取:
CREATE TABLE id_segment (
biz_type VARCHAR(50) PRIMARY KEY COMMENT '业务类型,如 order',
max_id BIGINT NOT NULL COMMENT '当前已分配的最大 ID',
step INT NOT NULL COMMENT '每次取多少个',
version INT NOT NULL DEFAULT 0
);
// 一次取 1000 个,内存里发号
public class SegmentIdGenerator {
private final AtomicLong current = new AtomicLong(0);
private volatile long max = 0;
public long nextId() {
long id = current.incrementAndGet();
if (id > max) {
synchronized (this) {
if (id > max) {
// 取新号段:UPDATE ... SET max_id = max_id + step WHERE biz_type = ?
long[] segment = loadNextSegment();
current.set(segment[0]);
max = segment[1];
id = current.incrementAndGet();
}
}
}
return id;
}
}
要点:
- 数据库只需
UPDATE一次,就够用 1000 次;数据库压力降低几个数量级 - 用
version乐观锁防止并发取号冲突 - 服务重启会浪费剩余号段,这是可接受的代价(只是 ID 不连续,不影响唯一性)
优点:趋势递增、性能好、不依赖时钟。 缺点:依赖数据库(可用性)、ID 不连续、需要处理双 buffer 避免取号时阻塞。
五、雪花算法:主流方案
雪花算法(Snowflake)生成一个 64 位整数,结构如下:
0 | 0000000000 0000000000 0000000000 0000000000 0 | 00000 | 00000 | 000000000000
↑ | 41 位时间戳 | 5位 | 5位 | 12 位序列号
符号位 数据中心 机器ID
| 部分 | 位数 | 作用 |
|---|---|---|
| 符号位 | 1 | 固定 0,保证正数 |
| 时间戳 | 41 | 毫秒级,可用约 69 年 |
| 数据中心 ID | 5 | 最多 32 个数据中心 |
| 机器 ID | 5 | 每机房最多 32 台机器 |
| 序列号 | 12 | 同一毫秒内最多 4096 个 ID |
核心优势:
- 本地生成,无网络开销 —— 单机每秒可生成 400 万个
- 趋势递增 —— 高位是时间戳,对数据库友好
- 可反解时间 —— 从 ID 能算出生成时间
关键问题:时钟回拨
这是雪花算法唯一的、也是必须处理的坑。
雪花算法依赖系统时钟,如果机器时间往回跳(比如 NTP 校时),就可能生成重复 ID:
T = 1000ms 机器生成 ID: ...1000 000 001 000000000001
此时 NTP 校时,时钟回拨到 900ms
T = 900ms 再生成 ID: ...900 000 001 000000000001
↑ 时间戳变小,如果序列号也重置,就可能撞上已生成的 ID
处理方式:
| 方案 | 做法 | 评价 |
|---|---|---|
| 拒绝等待 | 发现回拨,直接抛异常或等到时间追上 | ✅ 简单可靠,推荐 |
| 使用上次时间戳 | 回拨时仍用上次的时间戳,靠序列号递增 | ⚠️ 序列号会很快用尽 |
| 备用机器 ID | 回拨时切换一组 workerId | ⚠️ 需要额外管理 |
| 用逻辑时钟 | 不依赖系统时钟,自增毫秒 | 复杂,较少用 |
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen(); // 自旋等待时钟追上
}
return timestamp;
}
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
// 小幅回拨:等待追平
timestamp = tilNextMillis(lastTimestamp);
} else {
// 大幅回拨:直接失败,避免生成重复 ID
throw new IllegalStateException("时钟回拨 " + offset + "ms,拒绝生成 ID");
}
}
// ... 正常生成逻辑
}
判断标准:回拨幅度小(几毫秒)可以等待;幅度大(几百毫秒以上)说明机器时钟有严重问题,应该报警而不是硬扛。
机器 ID 怎么分配
5 位机器 ID 只支持 32 台机器,大集群不够用。实践中:
| 方式 | 做法 |
|---|---|
| 配置文件指定 | 每台机器配一个唯一 ID(简单但易错) |
| 从数据库分配 | 启动时从库取一个未使用的 workerId |
| 从注册中心分配 | 用 ZooKeeper / Nacos 的临时节点自动分配 |
| 用 IP / 主机名哈希 | 自动推导,但要处理冲突 |
推荐用注册中心自动分配:机器上下线自动管理,避免人工配置出错导致 ID 冲突。
六、Redis 自增:需要严格递增时用
long id = redis.incr("id:order"); // 原子自增
优点:严格递增、原子操作、性能好。
缺点:
- 依赖 Redis(可用性问题)
- 网络往返开销(可以用
INCRBY批量取缓解,类似号段模式) - Redis 持久化策略不当可能丢号或重复
适合:需要严格递增且量不大的场景(如流水号)。高并发下建议批量取号,减少往返。
七、怎么选
| 场景 | 推荐方案 |
|---|---|
| 日志 traceId、幂等键 | UUID |
| 单库小系统 | 数据库自增 |
| 中等规模、要趋势递增 | 号段模式 |
| 高并发、分布式 | 雪花算法 |
| 需要严格递增的流水号 | Redis 自增(批量取号) |
一个容易被忽略的点:ID 不该暴露业务信息
有些设计会把业务类型编码进 ID(比如订单号前缀 OD、支付单号前缀 PY),这是好的——便于排查和识别。但要避免把敏感信息(用户 ID、地区、金额)编进 ID,那会带来信息泄露风险。
小结
- 自增主键不适合做对外 ID:暴露业务量、易被遍历、分库分表失效
- 五种方案各有场景:UUID(无序,适合 traceId)、数据库自增(单库)、号段模式(批量取号)、雪花算法(主流)、Redis 自增(严格递增)
- UUID 做 MySQL 主键会导致页分裂,性能差
- 号段模式的核心是一次取一批,把数据库压力降低几个数量级
- 雪花算法 = 1 位符号 + 41 位时间戳 + 5 位数据中心 + 5 位机器 + 12 位序列号
- 时钟回拨是雪花算法唯一的坑:小回拨等待追平,大回拨应该抛异常报警
- 机器 ID 建议从注册中心自动分配,避免人工配置出错
- 对外 ID 可以带业务前缀,但不要把敏感信息编进 ID
分布式 ID 看起来只是"生成一个不重复的数",但有序性、时钟依赖、机器管理这三件事决定了它在生产环境能不能可靠运行。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。