微服务

分布式 ID 生成方案对比

订单号为什么不能用自增主键?因为自增 ID 会暴露业务量、容易被遍历。这篇文章对比 UUID、数据库自增、号段模式、雪花算法和 Redis 自增五种方案,重点讲清雪花算法的时钟回拨问题。

2026-06-11 约 10 分钟 3905 字 分布式 唯一ID 雪花算法 架构设计

一、为什么自增主键不能直接当订单号

用数据库自增主键当订单号,是最省事的做法——插入时不用管,数据库自己发号。但它有三个问题:

问题 说明
暴露业务量 竞品下两单,看订单号从 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_* 四个变量后自动开启。