分布式 ID 如何设计:从自增 ID 到 Snowflake 与号段模式

单体系统里,数据库自增主键通常已经够用。真正进入多实例、分库分表或跨系统写入后,问题才变成:

多个节点如何在没有单点冲突的情况下,高性能地生成全局唯一 ID?

分布式 ID 没有统一最优解。设计时需要在唯一性、性能、可用性、排序能力和运维成本之间取舍。

分布式 ID 方案选择

一、先明确设计目标

一个可用的分布式 ID 通常需要满足以下条件:

目标 说明
全局唯一 不同服务、不同节点生成的 ID 不能重复
高性能 生成 ID 不应成为业务链路瓶颈
高可用 某个节点或依赖短暂故障时仍能工作
趋势递增 新 ID 大体更大,减少数据库索引随机写入
长度可控 常用 BIGINT 比长字符串更节省索引空间
不泄露业务 不直接编码手机号、订单量、地区等敏感信息

这里要区分两个概念:

  • 趋势递增:总体上越晚生成的 ID 越大。
  • 全局严格递增:所有节点生成的 ID 都能按真实发生顺序排列。

后者需要全局协调,代价明显更高。大部分业务只需要唯一和趋势递增。

二、常见方案对比

方案 优点 主要问题 适用场景
数据库自增 简单、严格递增、索引友好 依赖单库,分库后冲突 单库单表
UUIDv4 本地生成,无中心节点 无序、128 位、索引局部性差 文件名、追踪 ID
UUIDv7 时间有序,无 workerId 仍是 128 位 跨系统资源 ID
Redis INCR 严格递增,实现简单 每次都要远程访问 Redis 中小规模集中发号
Snowflake 64 位、本地生成、吞吐高 workerId、时钟回拨 常规微服务
号段模式 不依赖机器时钟、性能稳定 需要号段服务和数据库 高并发、容器化系统

UUIDv7 为什么值得关注

传统 UUIDv4 的随机部分位于整个 ID 中,写入 B+ 树索引时局部性较差。

UUIDv7 将 Unix 毫秒时间戳放在高位,使 ID 可以按时间大致排序,同时保留足够的随机空间。它不需要分配 workerId,比较适合跨团队、跨系统生成资源标识。

但 UUIDv7 仍然是 128 位。如果数据库核心表更重视索引大小和写入性能,64 位 Snowflake 或号段 ID 通常更合适。

三、Snowflake:本地生成 64 位 ID

Snowflake 的经典结构如下:

Snowflake 64 位结构

0 | 41 位时间戳 | 10 位 workerId | 12 位序列号

对应能力:

  • 10 位 workerId:最多 1024 个生成节点。
  • 12 位序列号:单节点每毫秒最多 4096 个 ID。
  • 41 位毫秒时间戳:大约可使用 69 年。
  • ID 使用 64 位有符号整数,最高符号位固定为 0。

Java 示例

下面是一个简化实现,适合理解原理:

public final class SnowflakeIdGenerator {

    private static final long EPOCH = 1735689600000L; // 2025-01-01 UTC

    private static final long WORKER_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_WORKER_ID = (1L << WORKER_BITS) - 1;
    private static final long SEQUENCE_MASK = (1L << SEQUENCE_BITS) - 1;

    private static final long WORKER_SHIFT = SEQUENCE_BITS;
    private static final long TIMESTAMP_SHIFT = WORKER_BITS + SEQUENCE_BITS;

    private final long workerId;

    private long sequence;
    private long lastTimestamp = -1L;

    public SnowflakeIdGenerator(long workerId) {
        if (workerId < 0 || workerId > MAX_WORKER_ID) {
            throw new IllegalArgumentException("invalid workerId: " + workerId);
        }
        this.workerId = workerId;
    }

    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();

        if (timestamp < lastTimestamp) {
            throw new IllegalStateException(
                    "clock moved backwards: " + (lastTimestamp - timestamp) + "ms"
            );
        }

        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & SEQUENCE_MASK;

            if (sequence == 0) {
                timestamp = waitNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0;
        }

        lastTimestamp = timestamp;

        return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
                | (workerId << WORKER_SHIFT)
                | sequence;
    }

    private long waitNextMillis(long previousTimestamp) {
        long timestamp;
        do {
            Thread.onSpinWait();
            timestamp = System.currentTimeMillis();
        } while (timestamp <= previousTimestamp);

        return timestamp;
    }
}

生产环境不建议直接复制这段代码上线。真正困难的不是位运算,而是 workerId 分配和时钟异常处理。

四、Snowflake 的两个核心风险

1. workerId 冲突

两台机器使用相同 workerId,并且在同一毫秒生成相同序列号,就可能产生重复 ID。

常见分配方式:

  1. 配置中心静态分配。
  2. ZooKeeper、etcd 或数据库租约分配。
  3. Kubernetes StatefulSet 使用稳定的 Pod 序号。
  4. 独立 ID 服务统一管理 workerId。

不推荐直接使用:

workerId = IP 最后一段 % 1024

原因包括:

  • 不同网段可能出现相同尾号。
  • 容器 IP 会变化和复用。
  • 同一主机可能运行多个生成进程。
  • 扩缩容时难以审计是否冲突。

2. 时钟回拨

NTP 校时、虚拟机迁移或人工修改系统时间,都可能导致当前时间小于上次生成 ID 的时间。

常见处理策略:

  • 小幅回拨:短暂等待时间追平。
  • 中等回拨:切换到预留 workerId。
  • 大幅回拨:拒绝发号并告警。
  • 使用逻辑时间:保证生成器时间不倒退。

最危险的做法是忽略回拨,继续使用更小的时间戳生成 ID。

Snowflake 通常只能保证单节点递增和全局趋势递增,不能替代业务时间字段。表中仍然应该保留:

created_at DATETIME(3) NOT NULL

五、号段模式:数据库批量分配,本地发号

号段模式不为每个 ID 访问数据库,而是一次申请一批:

服务 A:10001 ~ 11000
服务 B:11001 ~ 12000

服务在内存中使用 AtomicLong 递增。号段快用完时,再向数据库申请下一段。

号段模式双缓冲

号段表

CREATE TABLE id_alloc (
    biz_tag     VARCHAR(64) PRIMARY KEY,
    max_id      BIGINT NOT NULL,
    step        INT NOT NULL,
    version     BIGINT NOT NULL DEFAULT 0,
    updated_at  DATETIME(3) NOT NULL
);

不同业务使用不同 biz_tag

order
user
payment
coupon

申请号段时,需要在事务中锁定记录并更新 max_id

BEGIN;

SELECT max_id, step
FROM id_alloc
WHERE biz_tag = 'order'
FOR UPDATE;

UPDATE id_alloc
SET max_id = max_id + step,
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP(3)
WHERE biz_tag = 'order';

COMMIT;

假设更新前:

max_id = 10000
step   = 1000

当前服务获得:

10001 ~ 11000

数据库中保存的新 max_id 为 11000。

为什么要双缓冲

如果等当前号段完全耗尽后才访问数据库,请求会在切换时出现延迟尖峰。

更稳妥的方式是:

  1. 当前号段使用到 70%~80%。
  2. 后台线程提前申请下一号段。
  3. 当前号段耗尽后立即切换备用号段。
  4. 再异步补充新的备用号段。

这样数据库短暂不可用时,服务仍能继续消费已经缓存的 ID。

号段浪费是不是问题

服务重启后,未使用完的号段可能被跳过,例如:

已经使用:10001 ~ 10320
未使用:10321 ~ 11000

这些 ID 会形成空洞,但不会重复。

分布式 ID 应该追求唯一,不应该追求绝对连续。

连续 ID 会增加系统协调成本,还可能暴露业务量。

六、实际项目如何选择

单库单表

直接使用数据库自增主键。

不要因为项目使用了 Spring Cloud,就强行引入分布式 ID。

普通 Java 微服务

优先使用成熟的 Snowflake 实现:

  • 本地生成,延迟低。
  • 使用 BIGINT 即可存储。
  • 不需要每次访问远程 ID 服务。

前提是能可靠解决 workerId 和时钟回拨。

Kubernetes 动态扩缩容

更适合号段模式,尤其是 Pod 数量变化频繁时。

Snowflake 也可以使用,但需要稳定分配并回收 workerId,不能简单依赖容器 IP。

跨系统资源标识

可以考虑 UUIDv7:

  • 不需要中心服务。
  • 不需要 workerId。
  • 时间有序性好于 UUIDv4。
  • 适合公开资源 ID、同步数据和跨组织写入。

日志追踪 ID

UUID、ULID、UUIDv7 或 Snowflake 都可以。

追踪 ID 通常不作为高频业务表的聚簇主键,重点是生成方便和跨服务传递。

七、数据库与接口层注意事项

1. 前端精度

JavaScript 的 Number 最大安全整数是:

2^53 - 1

Snowflake ID 经常超过这个范围。后端返回 JSON 时应将 ID 转成字符串:

{
  "id": "1991234567890123456"
}

Spring Boot 可以只对 ID 字段配置序列化:

@JsonSerialize(using = ToStringSerializer.class)
private Long id;

不建议无差别地把所有 long 字段都转成字符串。

2. 不用 ID 代替时间

多节点 Snowflake 不能保证严格业务顺序。排序时应该使用:

ORDER BY created_at, id

3. 不编码敏感业务信息

不建议设计成:

日期 + 地区 + 用户 ID + 商品类型 + 序列号

这会导致:

  • ID 结构与业务强耦合。
  • 字段位数很快不够用。
  • 暴露用户、地区和业务规模信息。
  • 未来难以迁移和兼容。

4. 主键与外部编号可以分开

数据库主键可以使用 Snowflake 或号段 ID,展示给用户的订单编号可以单独生成:

数据库主键:1991234567890123456
订单编号:ORD20260717000182

展示编号可以调整格式,内部主键保持稳定。

八、常见错误

使用当前毫秒直接作为 ID

long id = System.currentTimeMillis();

同一毫秒内多个请求会立刻冲突。

要求 ID 连续无空洞

任何缓存、批量分配、失败重试和服务重启都可能产生空洞。要求绝对连续,实际上是在引入全局串行化。

自研算法但没有故障测试

至少需要覆盖:

  • 多线程并发。
  • 多节点 workerId 冲突。
  • 时钟回拨。
  • 服务重启。
  • 数据库故障。
  • 号段切换。
  • 网络超时后重复申请。
  • ID 服务降级与恢复。

把 ID 服务做成新的单点

集中式 ID 服务必须具备:

  • 多实例部署。
  • 本地缓存或号段缓存。
  • 数据库高可用。
  • 超时、重试和熔断。
  • 唯一性监控和告警。

九、推荐落地方案

对于大多数 Java 业务系统,可以按以下顺序决策:

  1. 单库单表:使用数据库自增。
  2. 常规微服务:使用成熟 Snowflake 实现。
  3. 容器动态扩缩容、不能接受时钟风险:使用号段模式。
  4. 跨系统且不希望协调 workerId:使用 UUIDv7。
  5. 无论选择哪种方案,都单独保存 created_at
  6. 64 位 ID 返回前端时统一按字符串处理。
  7. 不追求连续,不把业务隐私编码进 ID。

真正可靠的分布式 ID 设计,不是位数划分得多漂亮,而是故障发生时仍然不会重复。

参考资料

  1. IETF, RFC 9562: Universally Unique IDentifiers (UUIDs)
    https://www.rfc-editor.org/rfc/rfc9562.html

  2. Twitter Engineering, Announcing Snowflake
    https://blog.x.com/engineering/en_us/a/2010/announcing-snowflake

  3. 美团技术团队,Leaf——美团点评分布式 ID 生成系统
    https://tech.meituan.com/2017/04/21/mt-leaf.html

  4. 美团技术团队,Leaf:美团分布式 ID 生成服务开源
    https://tech.meituan.com/2019/03/07/open-source-project-leaf.html

  5. Meituan-Dianping, Leaf GitHub Repository
    https://github.com/Meituan-Dianping/Leaf