分布式 ID 生成实战:雪花算法与号段模式选型

在分库分表与高并发订单场景下,数据库自增主键既无法保证全局唯一,也会在水平扩容时引发主键冲突。分布式 ID 生成由此成为后端架构的必修课。本文实战拆解两类主流方案——雪花算法(Snowflake)与号段模式(Segment),给出可直接落地的 Java 代码、时钟回拨应对与选型清单,帮你把「唯一 ID」这件事一次做对。

一、为什么自增主键撑不住分布式系统

单机 MySQL 的自增主键(AUTO_INCREMENT)依赖单库单表的计数器,跨库之后计数器彼此独立,拼接出的主键必然重复。更麻烦的是,业务往往还要求 ID「趋势递增」以利于 B+Tree 索引的写入性能,同时希望生成过程高吞吐、高可用、不依赖强外部组件。我们把分布式 ID 的核心诉求拆成五点:

① 全局唯一,绝不允许碰撞;② 趋势递增,对索引友好;③ 高性能,单机百万级 QPS 不在话下;④ 高可用,单点故障不影响发号;⑤ 最好能藏一点业务含义(如分库位、时间戳)。下面两套方案,正是在这些诉求之间做不同取舍。

二、雪花算法 Snowflake:本地拼装出唯一 ID

2.1 64 位结构拆解

Snowflake 把 64 位长整型切成三段(符号位恒为 0):最高 41 位放「相对自定义纪元的时间戳(毫秒)」,中间 10 位放「机器 ID」(通常 5 位数据中心 + 5 位工作节点),最低 12 位放「同一毫秒内的序列号」。这意味着单节点每毫秒最多发 4096 个 ID,理论上限约 409 万/秒,对绝大多数业务绰绰有余。

// 自定义 epoch:2020-01-01 08:00:00 (北京时间),单位毫秒
private final long EPOCH = 1577836800000L;
private final long WORKER_BITS = 5L;
private final long DATACENTER_BITS = 5L;
private final long SEQUENCE_BITS = 12L;

private final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);   // 4095
private final long WORKER_SHIFT = SEQUENCE_BITS;              // 12
private final long DATACENTER_SHIFT = SEQUENCE_BITS + WORKER_BITS;       // 17
private final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_BITS + DATACENTER_BITS; // 22

private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;

public synchronized long nextId() {
    long ts = System.currentTimeMillis();
    if (ts < lastTimestamp) {
        // 时钟回拨:交给调用方兜底,避免产生重复 ID
        throw new RuntimeException("Clock moved backwards, refuse to generate id");
    }
    if (ts == lastTimestamp) {
        sequence = (sequence + 1) & SEQUENCE_MASK;
        if (sequence == 0) {          // 当前毫秒序列耗尽,自旋到下一毫秒
            ts = waitNextMillis(lastTimestamp);
        }
    } else {
        sequence = 0L;
    }
    lastTimestamp = ts;
    return ((ts - EPOCH) << TIMESTAMP_SHIFT)
         | (datacenterId << DATACENTER_SHIFT)
         | (workerId << WORKER_SHIFT)
         | sequence;
}

private long waitNextMillis(long last) {
    long t = System.currentTimeMillis();
    while (t <= last) t = System.currentTimeMillis();
    return t;
}

这段代码里最容易被忽略的是 synchronized 与「序列耗尽就等到下一毫秒」的逻辑:它保证了同一节点在同一毫秒内不会发出重复序列,而跨节点则靠不同的机器 ID 彻底隔离。机器 ID 的分配可以写死在配置里,也可以用 Redis 缓存设计 或 ZooKeeper 抢注一个临时序号,关键在于「全局不重复」。

2.2 时钟回拨:最棘手的坑

雪花算法把时间戳放在高位,一旦服务器时钟因为 NTP 校准等原因「往回拨」,就可能和上一毫秒生成的 ID 撞车。生产环境里最常见的兜底有三招:第一,发现回拨就直接抛异常,让上游重试或降级;第二,小幅度回拨(如几十毫秒内)就「等」到超过 lastTimestamp 再发;第三,像美团 Leaf 那样用 ZooKeeper 记录每台机器用过的最大时间戳,回拨时拒绝启动。无论哪种,核心原则都是「宁可不可用,也不要发重复 ID」。

三、号段模式 Segment:一次向数据库借一整段

3.1 基本思路与建表

与雪花「本地拼装」相反,号段模式把「当前用到哪」这件事交给数据库统一记账。服务启动时向 DB 申请一段区间 [max_id+1, max_id+step],在内存里自增发号,用完再去 DB 续下一段。这样绝大多数请求根本不打 DB,只有「号段切换」那一刻才访问一次数据库。

CREATE TABLE id_segment (
  biz_tag     VARCHAR(64) PRIMARY KEY,   -- 业务标识,如 order / user
  max_id      BIGINT NOT NULL,           -- 当前已分配的最大 ID
  step        INT    NOT NULL,           -- 每次申请的号段长度
  update_time DATETIME NOT NULL
);

-- 乐观更新:先让 max_id 前进一个 step,再回读新上界
UPDATE id_segment SET max_id = max_id + #{step} WHERE biz_tag = 'order';
SELECT max_id, step FROM id_segment WHERE biz_tag = 'order';
-- 本段可用区间即为 (old_max_id, new_max_id],缓存在 JVM 内存中发号

这里特意用「乐观 UPDATE + 回读」替代 SELECT ... FOR UPDATE 行锁,是为了降低数据库压力;高并发下哪怕偶尔多节点同时更新,只要 step 足够大、且发号前做内存边界校验,就能在「偶尔浪费几个号」和「锁表阻塞」之间取得平衡。

3.2 双 Buffer:让取号几乎零等待

号段模式最大的隐患是「当前段用尽、下一段还没申请回来」的瞬时阻塞。工业实现普遍用「双 Buffer」:当当前段用到 10% 时,就异步把下一段提前拉进内存;当前段耗尽时直接切换到已就绪的备用段,全程无感。

// 简化版双 Buffer 发号核心
volatile Segment[] buffers = new Segment[2];
volatile int current = 0;

public long nextId() {
    Segment cur = buffers[current];
    long id = cur.take();
    // 用到 10% 且备用段为空 → 异步预取
    if (cur.remaining() * 10 <= cur.step && buffers[1 - current] == null) {
        asyncLoadNextSegment();
    }
    // 当前段耗尽且备用段就绪 → 无缝切换
    if (cur.remaining() == 0 && buffers[1 - current] != null) {
        current = 1 - current;
    }
    return id;
}

双 Buffer 把「同步等 DB」变成了「异步预热」,是号段模式能不能扛住秒杀级流量的关键。预取失败的兜底则是告警 + 短暂阻塞发号,绝不能为了不阻塞而发出重复 ID——这与我们前面强调的幂等设计思路一脉相承:发号接口本身也必须是幂等且安全的。

3.3 高可用与容灾

号段模式强依赖 DB,因此数据库要做主从、号段要用尽前提前告警,初始段也要留足余量。更稳的做法是把号段服务本身做成无状态集群,前面挂一个 分库分表 式的负载均衡,任何单节点挂掉都不影响整体发号。

四、两类方案横向对比

维度雪花算法 Snowflake号段模式 Segment
全局唯一依赖机器 ID 不冲突依赖 DB 单点记账
趋势递增是(时间戳占高位)是
单机吞吐极高(约 400w/s)高(内存发号)
外部依赖弱(仅需机器 ID)强依赖数据库
时钟敏感是(回拨可能致命)否
ID 形态64 位长整型长整型,可定制长度
运维复杂度中(机器 ID 治理)低(一张表即可)

一句话总结:雪花胜在「不依赖 DB、吞吐极致」,代价是「怕时钟回拨、机器 ID 要治理」;号段胜在「好理解、易容灾」,代价是「强依赖 DB、要处理好号段切换」。

五、工业级怎么落地:以美团 Leaf 为例

美团开源的 Leaf 同时实现了两套:Leaf-segment 在号段模式上叠加了双 Buffer、DB 高可用与监控;Leaf-snowflake 则用 ZooKeeper 给每台机器发 workerId,并把「用过的最后时间戳」写进 ZK,重启或回拨时据此拒绝危险节点。它给我们的启示是:两个方案并非二选一,你可以按业务线分别选型,甚至让一套系统同时提供两种发号能力。

六、选型清单:我到底该用哪个

你的场景推荐方案
单体或小团队,不想引组件号段模式(一张表就能跑)
超高并发、强趋势递增雪花算法 Snowflake
既要高可用、又不想被时钟绑架号段模式 + 双 Buffer
追求省心、工业级美团 Leaf(两套都支持)
多数据中心强一致号段模式(DB 可控)或 Leaf-snowflake

对大多数从 0 到 1 的团队,我的建议是先上号段模式:它只用一张表、逻辑直观、容灾清楚,等业务量真的冲到瓶颈再考虑把热点业务线切到雪花或 Leaf。过早优化机器 ID 治理,往往得不偿失。

七、实战避坑清单

坑现象解法
机器 ID 冲突不同节点生成相同 ID用 Redis/ZK 抢注或启动配置保证唯一
时钟回拨ID 异常甚至重复抛异常/自旋等待/用 ZK 记录历史时间
号段用尽未续取号瞬时阻塞双 Buffer 提前异步加载 + 用尽告警
ID 暴露业务量从订单 ID 反推成交量步长随机化,或不在 ID 中编码业务量
号段重叠分配多节点同段乐观更新 + 内存边界校验,必要时行锁

分布式 ID 看似只是一个「发号码」的小功能,却牵扯时钟、协调、容灾与并发四件大事。把本文的 Snowflake 与 Segment 两段代码跑通,再结合上方选型与避坑清单,你就能在绝大多数业务里,把「全局唯一且趋势递增」这件基础却要命的事一次做对。

上一篇 PostgreSQL 物化视图实战:预计算加速慢查询
下一篇 SELinux 导致服务异常:实战排查与修复
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]