分布式 ID 的四种做法:从 UUID 的页分裂到雪花的时间回拨
分布式 ID 看着是个小问题,但它同时牵着三件事:数据库写入性能、分库分表的扩容能力、以及订单号能不能被猜到。
选错方案,代价往往在半年后以"数据库写不进去了"的形式爆出来。
结论先说
| 方案 | 有序性 | 性能 | 依赖 | 适用 |
|---|---|---|---|---|
| UUID | 完全无序 | 本地生成,极高 | 无 | 链路追踪、请求 ID、不要做主键 |
| 数据库自增 / 步长 | 严格递增 | 受 DB 限制(千级 TPS) | DB | 小规模、内部系统 |
| 号段模式(Leaf-segment) | 趋势递增 | 高(一次取一批) | DB(弱依赖) | 分库分表主键首选 |
| 雪花(Snowflake) | 趋势递增 | 极高(本地生成) | 时钟 | 高并发、订单号 |
| Redis INCR | 严格递增 | 高 | Redis | 规模不大、可接受依赖 |
一句话选择:
要趋势递增 + 高可用 → 号段;要极致性能 + 能接受时钟依赖 → 雪花;要不可猜测 → 雪花 + 随机后缀或独立序列。
一、先看 UUID 到底错在哪
String id = UUID.randomUUID().toString(); // 550e8400-e29b-41d4-a716-446655440000
它的问题不是"太长",而是完全随机。
InnoDB 的聚簇索引按主键排序。自增主键永远追加在最右侧的叶子页,几乎总在内存里;UUID 主键则每次插到随机位置:
自增主键插入:
[1..100] [101..200] [201..300] ← 永远往最后一个页追加,页利用率 15/16
UUID 主键插入:
[0x3a..] [0x7f..] [0xc1..]
↑ 新记录插这里 → 页满了 → 页分裂 → 一半数据挪到新页
后果是三重叠加:
| 问题 | 量化影响 |
|---|---|
| 页分裂 | 每次分裂产生两个半满页,空间利用率掉到 50%~70% |
| 随机 IO | 目标页大概率不在 Buffer Pool,每次插入一次物理读 |
| 索引膨胀 | 36 字节字符串 vs 8 字节 bigint,二级索引全部跟着变胖 |
实测数据(5000 万行订单表):
| 主键类型 | 表大小 | 插入 100 万行耗时 |
|---|---|---|
| bigint 自增 | 约 12 GB | 约 90 s |
| char(36) UUID | 约 28 GB | 约 420 s |
如果一定要用 UUID,用 UUID v7(时间有序,前 48 位是毫秒时间戳),至少能保证趋势递增。MySQL 8.0 可以用 UUID_TO_BIN(uuid, 1) 把时间部分前置并转成 16 字节二进制。
INSERT INTO t (id) VALUES (UUID_TO_BIN(UUID(), 1)); -- 存 BINARY(16)
二、号段模式:分库分表场景的最优解
2.1 思路
不在每次请求时问数据库要 ID,而是一次要一批(一个号段),在内存里发。
DB 表:
+----+-----------+---------+-------+---------------------+
| id | biz_tag | max_id | step | update_time |
+----+-----------+---------+-------+---------------------+
| 1 | order | 1000000 | 5000 | 2026-11-01 10:00:00 |
+----+-----------+---------+-------+---------------------+
取号段:UPDATE id_alloc SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id FROM id_alloc WHERE biz_tag = 'order';
→ 拿到 [1000001, 1005000],本地发完再来取
一次 DB 交互发 5000 个 ID,DB 的压力被摊薄 5000 倍。
2.2 双 buffer 预加载
单 buffer 的问题:号段用完的那一刻才去取,取的过程(几毫秒)里所有请求都要等。
解决办法是双 buffer:当前号段用到 10% 时,异步预加载下一段。
┌─────────────┬─────────────┐
│ current │ next │
│ [1..5000] │ [5001..10000] ← 用到 4500 时异步填充
└─────────────┴─────────────┘
↑ 用完直接切换,零等待
public class SegmentBuffer {
private final AtomicLong cursor = new AtomicLong(0);
private volatile long max = 0; // 当前号段上界
private volatile boolean loading = false;
public long nextId() {
long id = cursor.incrementAndGet();
if (id > max) { // 号段耗尽,同步兜底加载
synchronized (this) {
if (cursor.get() > max) {
if (!loadNextSegment()) {
throw new IllegalStateException("no segment available");
}
return cursor.incrementAndGet();
}
}
return nextId();
}
if (id >= max * 0.9 && !loading) { // 用量过 90%,异步预加载
loading = true;
CompletableFuture.runAsync(this::loadNextSegment)
.whenComplete((v, e) -> loading = false);
}
return id;
}
/** 从 DB 取一段 [max+1, max+step],返回是否成功 */
private boolean loadNextSegment() {
long newMax = allocator.nextMax("order"); // UPDATE ... SET max_id = max_id + step
if (newMax <= 0) return false;
this.max = newMax;
return true;
}
}
要点:95% 之后的请求一定不能等异步加载,所以 id > max 时再加一层同步兜底;异步只负责"提前铺路",不负责正确性。
2.3 号段模式的取舍
| 优点 | 缺点 |
|---|---|
| 趋势递增,对 B+ 树友好 | ID 不连续(跨号段跳变),不能用来做"今天第几单"这类语义 |
| DB 压力极小 | 重启会浪费剩余号段(可接受) |
| 可水平扩展(多 biz_tag) | DB 是弱依赖,短暂不可用不影响发号(有缓存) |
| step 可动态调整(按 QPS 自动放大) | 需要额外一张表和一段代码 |
动态 step 是个实用技巧:如果发现当前号段 5 分钟内就被用完了,下次自动把 step 翻倍;反之缩小。美团 Leaf 就是这么做的。
三、雪花算法:41 位时间戳撑 69 年
3.1 位分配
0 | 0000000000 0000000000 0000000000 0000000000 0 | 00000 00000 | 000000000000
↑ | 41 bit 时间戳 | 10 bit | 12 bit
符号位 (毫秒) | 机器 ID | 序列号
| 部分 | 位数 | 能力 |
|---|---|---|
| 时间戳 | 41 | 2^41 / 1000 / 86400 / 365 ≈ 69.7 年 |
| 机器 ID | 10 | 1024 个节点(可拆成 5 位机房 + 5 位机器) |
| 序列号 | 12 | 单节点每毫秒 4096 个 |
| 合计 | 63 | 单节点理论 409.6 万 ID/秒 |
3.2 一个能用的实现
public class Snowflake {
private static final long EPOCH = 1704067200000L; // 2024-01-01,自定义起点
private static final long WORKER_BITS = 10L;
private static final long SEQ_BITS = 12L;
private static final long MAX_WORKER = ~(-1L << WORKER_BITS); // 1023
private static final long MAX_SEQ = ~(-1L << SEQ_BITS); // 4095
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public Snowflake(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER) throw new IllegalArgumentException("workerId");
this.workerId = workerId;
}
public synchronized long nextId() {
long now = System.currentTimeMillis();
if (now < lastTimestamp) {
long offset = lastTimestamp - now;
if (offset <= 5) {
// 小幅回拨:等时钟追上
sleep(offset);
now = System.currentTimeMillis();
if (now < lastTimestamp) throw new IllegalStateException("clock moved backwards");
} else {
// 大幅回拨:拒绝服务,报警
throw new IllegalStateException("clock moved backwards " + offset + "ms");
}
}
if (now == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQ;
if (sequence == 0) { // 本毫秒 4096 个用完了
now = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = now;
return ((now - EPOCH) << (WORKER_BITS + SEQ_BITS))
| (workerId << SEQ_BITS)
| sequence;
}
private long waitNextMillis(long last) {
long now = System.currentTimeMillis();
while (now <= last) now = System.currentTimeMillis();
return now;
}
private void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
3.3 时钟回拨:雪花唯一的致命伤
System.currentTimeMillis() 可能倒退,原因有三:
| 原因 | 幅度 | 应对 |
|---|---|---|
| NTP 校时(正常微调) | 几毫秒 | 等待追上 |
| NTP 大步跳变(首次同步) | 秒级 | 拒绝发号 + 报警 |
| 虚拟机迁移 / 手动改时间 | 分钟级 | 拒绝发号 + 人工介入 |
生产上的三种加固:
ntpdate换成chronyd的 slew 模式,让时间平滑调整而不是跳变;- workerId 用外部分配(ZK/etcd/DB 自增),重启换 ID,避免"同一个 workerId 用了旧时间戳";
- 回拨超过阈值直接熔断,宁可这几秒不可用,也不能发出重复 ID。
雪花 ID 一旦重复,往往是"订单号撞了""主键冲突",追查成本极高。宁停勿重。
3.4 workerId 怎么分配
| 方式 | 说明 | 风险 |
|---|---|---|
| 配置文件写死 | 最简单 | K8s 里 Pod 重建会漂移 |
| 环境变量(StatefulSet 序号) | POD_NAME 后缀解析 | 只适用于 StatefulSet |
| ZK/etcd 临时节点 | 注册时分配,断开释放 | 引入额外依赖 |
| DB 自增 + 租约 | 简单可靠 | 需要续约机制 |
| IP 后 10 位 | 零依赖 | 容器 IP 复用会撞 |
容器环境下最稳的是"DB/ZK 分配 + 心跳续约",不要用 IP 取模。
四、数据库自增与 Redis:简单但都有天花板
4.1 数据库自增
-- 双主:一个从 1 开始步长 2,一个从 2 开始步长 2
SET @@auto_increment_increment = 2;
SET @@auto_increment_offset = 1;
| 优点 | 缺点 |
|---|---|
| 严格递增、绝对唯一 | 单点瓶颈,千级 TPS 就到顶 |
| 零代码 | 扩容困难(加节点要改步长) |
| —— | 强依赖 DB,DB 挂了发号就挂 |
4.2 Redis INCR
Long id = redisTemplate.opsForValue().increment("id:order");
快,但要注意:
INCR是单线程串行的,单 key 上限约 10 万 QPS,再高就要分片(分片后又失去全局有序);- 持久化有窗口:RDB 快照 + 主从异步复制,主库宕机可能回退 → 发出重复 ID;
- 想安全必须开 AOF
everysec+WAIT,性能就打折了。
结论:Redis 发号适合"内部、可容忍极小概率重复、规模不大"的场景,不适合订单主键。
五、选型决策表
ID 要不要给用户看?
├─ 要(订单号、支付单号)
│ ├─ 能不能被猜出业务量?能接受 → 雪花
│ └─ 不能接受 → 雪花 + 混淆(异或/置换表),或"日期 + 随机 + 序列"
└─ 不要(内部主键、消息 ID)
├─ 有没有分库分表?
│ ├─ 有 → 号段模式(趋势递增,扩容友好)
│ └─ 没有 → 数据库自增就够
└─ 是不是纯链路追踪?
└─ UUID / ULID 随便用(不入库当主键)
补充两个常被忽略的点:
- 分库分表后,ID 里最好带分片信息。比如雪花 ID 的 workerId 直接对应分片号,路由时不用再算哈希。
- ID 长度要留余量。
bigint是 19 位十进制,如果业务要求"12 位订单号",那雪花的时间戳起点就要往后挪或压缩位数——这个决定一旦上线就改不了。
六、小结
| 方案 | 一句话记住它 |
|---|---|
| UUID | 唯一但不有序,做主键就是给 B+ 树添堵 |
| 数据库自增 | 简单可靠但有天花板,扩容时最痛 |
| 号段 | 用"批发"换性能,分库分表场景的最优解 |
| 雪花 | 性能天花板最高,代价是把命交给了时钟 |
| Redis | 快,但持久化的窗口就是重复 ID 的窗口 |
真正要做的决策不是"选哪个算法",而是先回答三个问题:要不要有序?能不能依赖外部组件?ID 会不会被用户看到?
系列内链
- 关联:[05-数据库与MySQL/01-InnoDB存储引擎与BufferPool](../05-数据库与MySQL/01-InnoDB存储引擎与BufferPool.md)——UUID 主键为什么导致页分裂
- 关联:[05-数据库与MySQL/08-分库分表方案](../05-数据库与MySQL/08-分库分表方案.md)——分片键与 ID 的耦合
- 关联:[08-微服务与架构/07-接口幂等设计](07-接口幂等设计.md)——唯一 ID 是幂等的基础
- 关联:[15-中间件与其他/03-ZooKeeper选举与分布式协调](../15-中间件与其他/03-ZooKeeper选举与分布式协调.md)——workerId 分配的常见实现
- 点赞
- 收藏
- 关注作者
评论(0)