分布式 ID 的四种做法:从 UUID 的页分裂到雪花的时间回拨

举报
yd_254019812 发表于 2026/11/06 12:17:09 2026/11/06
【摘要】 分布式 ID 看着是个小问题,但它同时牵着三件事:数据库写入性能、分库分表的扩容能力、以及订单号能不能被猜到。 选错方案,代价往往在半年后以"数据库写不进去了"的形式爆出来。 结论先说 | 方案 | 有序性 | 性能 | 依赖 | 适用 | |---|---|---|---|---| | UUID | 完全无序 | 本地生成,极高 | 无 | 链路追踪、请求 ID、不要做主键 | | 数据库...

分布式 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 大步跳变(首次同步) | 秒级 | 拒绝发号 + 报警 |

| 虚拟机迁移 / 手动改时间 | 分钟级 | 拒绝发号 + 人工介入 |

生产上的三种加固:

  1. ntpdate 换成 chronyd 的 slew 模式,让时间平滑调整而不是跳变;
  2. workerId 用外部分配(ZK/etcd/DB 自增),重启换 ID,避免"同一个 workerId 用了旧时间戳";
  3. 回拨超过阈值直接熔断,宁可这几秒不可用,也不能发出重复 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 随便用(不入库当主键)

补充两个常被忽略的点:

  1. 分库分表后,ID 里最好带分片信息。比如雪花 ID 的 workerId 直接对应分片号,路由时不用再算哈希。
  2. 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 分配的常见实现
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。