深蓝黑背景品牌封面:左侧大标题「分布式 ID 一次讲透」,副标题「UUID、雪花算法与号段模式的取舍」;右侧原创装饰——一条 64 位长条被切成四段,分别标注符号位、时间戳、机器位、序列号,直观展示雪花算法的位结构;左上标注「AI 工程知识专栏」,右上标注「Day 10 · 10-08」 图 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,拒绝发号

对比图:分库分表后数据库自增主键失效(各分片会从 1 开始撞号),需要全局 ID 方案,四种主流方案各有取舍。UUID:本地生成零依赖、全球唯一,但 128 位太长、完全无序,当主键会让聚簇索引频繁页分裂,写入性能差。雪花算法:64 位 long = 1 位符号 + 41 位毫秒时间戳 + 10 位机器 + 12 位序列,趋势递增、本地生成、每毫秒每机器可出 4096 个,短板是依赖时钟(时钟回拨会重复)和机器位分配(部署时要保证不撞)。号段模式:数据库一张发号表,服务每次批量领 1000 个号在内存里发,数据库压力降为千分之一,多个发号服务用不同步长隔离,依赖数据库高可用。Redis 自增:INCR 命令天然原子,单点即全局唯一且递增,实现最简单,短板是引入 Redis 依赖与可用性问题。右侧展开雪花位结构条。底部选型:要简单选 UUID(只用于非主键场景),要性能与有序选雪花(配时钟回拨防护),要绝对有序与可解释选号段,已有 Redis 且量不大可用自增 图 2:四种方案的取舍对比与雪花位结构。

怎么选,按约束对号入座。

要最简单、且不做主键:UUID。要性能与有序,能接受运维机器位:雪花算法,务必配时钟回拨防护。要绝对递增、可读可解释,能接受依赖数据库:号段模式。团队已有 Redis、发号量不大、想今天上线:INCR 自增。真到了超大规模,常见做法是组合——号段思想加雪花结构,段由中心分配、段内本地发。

几个容易误判的点。

一,以为 UUID「全球唯一」就能当主键,忽略无序对聚簇索引的杀伤。二,上了雪花却不管时钟,容器漂移加上 NTP 校时一抖就可能重号。三,把号段模式理解成「每发一个号查一次库」,那就退化成数据库瓶颈了——关键在批量领取。四,追求「绝对有序」:分布式下全局单调递增与高性能不可兼得,趋势递增就够索引用了。

回到那道分库分表面试题:下次它再问你「主键怎么办」,你可以把四个方案摆上桌面,按唯一、有序、可用、成本四个约束逐个消去——这比背出「用雪花算法」四个字有说服力得多。

你的系统现在用哪种发号方案?踩过时钟回拨或者号段浪费的坑吗?欢迎聊聊。