图 1:本文封面,用雪花算法的位结构点题。
分库分表的文章里总会带一句「主键要用分布式 ID」,然后一笔带过。这句话值得单独讲透:为什么单机自增主键一到分片就失效,主流的四种方案各自拿什么换了什么。
问题从哪来:自增主键为什么失效。
单机时代,主键交给数据库 AUTO_INCREMENT,简单、唯一、递增。分成八个库之后,每个库都从 1 开始自增,八个库里都有「第 1001 号订单」——撞号了。分布式 ID 方案要补的就是三个硬指标:全局唯一、趋势递增(为了索引写入友好)、高可用(发号服务不能成为单点瓶颈)。所有方案的差异,都是这三个指标加上「实现成本」之间的取舍。
方案一:UUID,最简单的先上。
本地生成、零依赖、全球唯一,一个函数调用就完事。代价有两个:128 位太长,存储与索引都吃亏;更要命的是完全无序——InnoDB 的聚簇索引按主键物理排序,随机主键让每次插入都落在随机位置,页分裂频繁,写入性能明显差于递增主键。结论:UUID 适合做非主键的业务标识(请求 ID、文件名),别拿它当大表主键。
方案二:雪花算法,性能与有序的折中。
把 64 位长整型切成四段:1 位符号位(固定为 0)、41 位毫秒时间戳(够用约 69 年)、10 位机器位(1024 个节点)、12 位序列号(每毫秒每节点 4096 个)。时间戳在高位,所以 ID 整体趋势递增;本地生成,不依赖任何外部服务;每节点每毫秒四千多个的产能,绝大多数业务够用。
短板同样明显:一是依赖时钟,机器时钟回拨可能发出重复的 ID,生产实现必须检测回拨并拒绝或等待;二是机器位要靠部署时分配,节点扩容、容器漂移时保证不撞需要配套的分配机制。
方案三:号段模式,拿数据库批量发号。
数据库里一张发号表,记录当前发到哪了。发号服务每次不是领一个号,而是批量领走一段(比如 1000 个),在内存里逐个发,发完再领下一段。数据库压力降为千分之一,ID 严格递增、可解释(能看出大概的生成顺序)。多个发号服务用不同步长隔离,互不冲突。短板是依赖数据库高可用,以及服务重启会浪费段内未发的号。
方案四:Redis 自增,最省事的一个。
INCR 命令天然原子,单点执行就是全局唯一且递增,实现成本几乎为零。短板是引入了 Redis 依赖:Redis 挂了发号就停,持久化策略不当还可能重启后回拨重号。已有 Redis 且发号量不大的团队可以用,量大了还是得回到号段或雪花。
写个雪花算法跑一遍。
下面是一个带时钟回拨检测的简易雪花实现,用 Node.js 22.15.0 直接运行(node snowflake.js),实测生成一万个 ID 并校验唯一性与递增性:
'use strict';
// 雪花算法:1 位符号 + 41 位毫秒时间戳 + 10 位机器位 + 12 位序列号
class Snowflake {
constructor(workerId, epoch = 1735689600000n) { // epoch:自定义起始毫秒
if (!Number.isInteger(workerId) || workerId < 0 || workerId > 1023) {
throw new Error('workerId 必须在 0..1023(10 位机器位)');
}
this.workerId = BigInt(workerId);
this.epoch = epoch;
this.lastTs = -1n; // 上一次发号的相对毫秒
this.seq = 0n;
}
nextId(nowMs = Date.now()) {
const ts = BigInt(nowMs) - this.epoch;
if (ts < this.lastTs) throw new Error(`时钟回拨 ${this.lastTs - ts}ms,拒绝发号`);
if (ts === this.lastTs) {
this.seq = (this.seq + 1n) & 4095n; // 12 位序列,回绕说明本毫秒发完
if (this.seq === 0n) throw new Error('单毫秒 4096 个序号耗尽');
} else {
this.seq = 0n;
}
this.lastTs = ts;
return (ts << 22n) | (this.workerId << 12n) | this.seq;
}
}
const sf = new Snowflake(7);
// 模拟连续 3 个毫秒,每毫秒发 4000 个,共 12000 个 ID
const t0 = 1735689600000 + 1000;
const ids = [];
for (let ms = 0; ms < 3; ms++) {
for (let i = 0; i < 4000; i++) ids.push(sf.nextId(t0 + ms));
}
const unique = new Set(ids).size === ids.length;
const sorted = ids.every((v, i) => i === 0 || v > ids[i - 1]);
console.log('生成', ids.length, '个 ID,全局唯一:', unique, ',严格递增:', sorted);
// 解码第一个 ID,验证位结构
const id = ids[0];
console.log('示例 ID:', id.toString(),
'| 序列', Number(id & 4095n),
'| 机器', Number((id >> 12n) & 1023n),
'| 相对毫秒', Number(id >> 22n));
// 时钟回拨:时间倒拨 5 毫秒必须抛错
try { sf.nextId(t0 - 5); console.log('回拨未被拦截(异常)'); }
catch (e) { console.log('时钟回拨检测:', e.message); }
实测输出:
生成 12000 个 ID,全局唯一: true ,严格递增: true
示例 ID: 4194332672 | 序列 0 | 机器 7 | 相对毫秒 1000
时钟回拨检测: 时钟回拨 7ms,拒绝发号
图 2:四种方案的取舍对比与雪花位结构。
怎么选,按约束对号入座。
要最简单、且不做主键:UUID。要性能与有序,能接受运维机器位:雪花算法,务必配时钟回拨防护。要绝对递增、可读可解释,能接受依赖数据库:号段模式。团队已有 Redis、发号量不大、想今天上线:INCR 自增。真到了超大规模,常见做法是组合——号段思想加雪花结构,段由中心分配、段内本地发。
几个容易误判的点。
一,以为 UUID「全球唯一」就能当主键,忽略无序对聚簇索引的杀伤。二,上了雪花却不管时钟,容器漂移加上 NTP 校时一抖就可能重号。三,把号段模式理解成「每发一个号查一次库」,那就退化成数据库瓶颈了——关键在批量领取。四,追求「绝对有序」:分布式下全局单调递增与高性能不可兼得,趋势递增就够索引用了。
回到那道分库分表面试题:下次它再问你「主键怎么办」,你可以把四个方案摆上桌面,按唯一、有序、可用、成本四个约束逐个消去——这比背出「用雪花算法」四个字有说服力得多。
你的系统现在用哪种发号方案?踩过时钟回拨或者号段浪费的坑吗?欢迎聊聊。