在分库分表与高并发订单场景下,数据库自增主键既无法保证全局唯一,也会在水平扩容时引发主键冲突。分布式 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 两段代码跑通,再结合上方选型与避坑清单,你就能在绝大多数业务里,把「全局唯一且趋势递增」这件基础却要命的事一次做对。




