跳到正文

唯一 ID 生成器系统设计:Snowflake、UUIDv7 与 ULID 对比

如何在分布式系统中生成唯一 ID:对比序列、UUIDv4、UUIDv7、ULID 和 Snowflake,再一步步设计一个 Snowflake 风格的生成器。

· 创始人兼开发者

发布于

架构图。Payment 服务和 Order 服务调用 ID 服务的 NextId(),该服务有三个实例,worker ID 为 0 到 2,从 etcd 集群租用 ID,并通过 NTP 同步时间。Order 服务向 Postgres 插入行。一条横条把 64 位分为 1、41、10、12。

你的订单服务多年来一直使用 Postgres 的 BIGSERIAL 主键。后来 orders 表被拆到四个分片上,每个分片的序列都发出了订单 10452,客服发现一个订单号背后有两位客户。又或者,移动应用在连上服务器之前就需要一个 ID。

一旦不止一台机器生成 ID,ID 就不再免费了。各种方案在大小、排序、每个 ID 的协调成本,以及对 B-tree 索引的友好程度之间做取舍。本文对比序列、ticket server、UUIDv4、UUIDv7、ULID 和 Snowflake 风格的 ID,然后设计一个 Snowflake 风格的生成器:位布局、worker ID 租约、时钟跳变和碰撞概率的计算。

分布式 ID 的需求

  • 永久唯一。 跨分片、跨区域、跨重启都不能重复。
  • 大致按时间排序, 这样 WHERE id > ? 分页和按时间排序的索引才能正常工作。Twitter 最初的 Snowflake 承诺在一秒内跨机器保持顺序。
  • 紧凑。 64 位能放进 BIGINT;128 位会让每个键的大小翻倍。
  • 每个 ID 无需协调。 Snowflake 的目标是每个进程每秒至少 10,000 个 ID,每个都在 2 ms 内返回。
  • 可以安全暴露, 不泄露超出预期的信息。

生成 ID 的六种方式

方案 大小 排序 协调 B-tree 插入
数据库序列 64 位 递增 每个 ID 都来自同一个数据库 右边缘
Ticket server 64 位 每台服务器内递增 每个 ID 或每段范围调用一次 右边缘
UUIDv4 128 位 无 无 随机页
UUIDv7 128 位 按毫秒 无 靠近右边缘
ULID 128 位 按毫秒 无 靠近右边缘
Snowflake 风格 64 位 按毫秒,大致有序 每个生成器一个 worker ID 靠近右边缘

数据库序列在由一个数据库写入所有行时是合适的,一旦多个数据库共用一个 ID 空间就会出问题。它会留下空洞,因为 nextval 从不回滚;在 PostgreSQL 中,CACHE 大于 1 时数值仍然不重复,但跨会话会乱序。连续的订单号还会暴露你的订单量。

Ticket server 把序列集中起来。Flickr 的 MySQL ticket server 是两台机器,一台发奇数,一台发偶数,客户端在两者之间轮流调用。租用 ID 块可以省去每个 ID 一次的调用,就像短链接服务设计里那样。

UUIDv7、ULID 和 Snowflake 都把时间戳放在最前面,所以同时创建的 ID 会排在一起;只有 Snowflake 能放进 64 位,代价是需要 worker ID 并依赖时钟。

Snowflake 的位布局

位数 字段 容量
1 符号位,始终为 0 作为有符号 64 位整数,ID 保持为正
41 自定义纪元起的毫秒数 2^41 ms,约 69.7 年
10 worker ID 1,024 个生成器
12 毫秒内的序号 每个 worker 每毫秒 4,096 个 ID

Twitter 2010 年的版本采用了这种划分,纪元设在 2010 年 11 月,所以它的 ID 会在 2080 年 7 月用完。如果从 Unix 纪元算起,41 位会在 2039 年 9 月用完,所以要把纪元设在接近上线的时间。每个实例保存自己最后一次的毫秒数和一个计数器:

// Next is called with g.mu held.
func (g *Generator) Next() (int64, error) {
	if time.Now().After(g.leaseDeadline) {
		return 0, ErrLeaseLost // another instance may own this worker ID now
	}
	now := time.Now().UnixMilli()
	if now < g.lastMs {
		if g.lastMs-now > 5 {
			return 0, ErrClockMovedBack
		}
		now = g.waitUntil(g.lastMs) // small step back: wait it out
	}
	if now == g.lastMs {
		g.seq = (g.seq + 1) & 0xFFF // 12 bits
		if g.seq == 0 {             // 4,096 IDs used in this millisecond
			now = g.waitUntil(g.lastMs + 1)
		}
	} else {
		g.seq = 0
	}
	g.lastMs = now
	return (now-epochMs)<<22 | g.workerID<<12 | g.seq, nil
}

一个 worker 每秒最多可发出 410 万个 ID;真正的瓶颈是外面的 RPC,所以让客户端批量获取 ID。ID 在纪元后约 25 天就会超过 2^53,超出 JavaScript 的 Number.MAX_SAFE_INTEGER,所以要像 X 和 Discord 的 API 那样,把它们作为 JSON 字符串发送。

这种划分是一份预算。Discord 用 42 位表示时间,并把节点位拆成 worker 和进程。Instagram 在每个 PostgreSQL 分片内部生成 ID,使用 13 位的分片 ID,这在你对数据库做分片时很有帮助。Sonyflake 以 10 ms 为单位计数,可以用 174 年,每个实例每 10 ms 发出 256 个 ID。

分配 worker ID

Snowflake 不需要每个 ID 协调,只需要每个进程协调一次:两个同时运行、worker ID 相同的生成器可能发出相同的 ID。

  1. 静态配置。 每台主机分配一个 WORKER_ID。适合规模固定的集群;只要复制了一份配置文件,就会产生重复。
  2. StatefulSet 序号。 Kubernetes 从 0 开始给 StatefulSet 的 Pod 编号,并给每个 Pod 打上 apps.kubernetes.io/pod-index 标签(自 v1.32 起稳定),再通过 downward API 传入。序号只在同一个集群内的同一个 StatefulSet 中唯一,强制删除 Pod 可能让两个 Pod 拥有相同的序号。
  3. etcd 或 ZooKeeper 中的租约。 启动时,实例用一个仅在键不存在时才会成功的事务,申请最小的空闲键,例如 /idgen/workers/17,租约为 10 秒,每 3 秒续期一次。实例一旦挂掉,键就会过期。Twitter 2010 年的 Snowflake 使用 ZooKeeper 的临时节点,并且在自己的时钟与同伴平均值相差超过 10 秒时拒绝启动。
env:
  - name: WORKER_ID
    valueFrom:
      fieldRef:
        fieldPath: metadata.labels['apps.kubernetes.io/pod-index']

租约需要两条规则。一是当最后一次成功的 keepalive 早于授予的 TTL 减去一个余量时,就停止发 ID,这样被暂停或被网络分区的实例会在别人认领它的 ID 之前停下来。二是保存一个 high-water mark:每隔几秒,把一个略超前于时钟的时间戳存到单独的键里,绝不发出超过它的 ID,并让下一个持有者等到自己的时钟超过它。etcd 用 Raft 保证这两者的一致。

当时钟倒退时

NTP 守护进程通常用 slew 调整时钟,也就是让时钟稍快或稍慢地走,但偏差较大时会直接 step:ntpd 默认在偏差超过 128 ms 时 step,而在 chrony 默认的闰秒模式下,遇到插入的闰秒,内核会把时钟拨回一秒。chrony 文档建议只在启动时 step:

makestep 1.0 3      # step (if off by over 1 s) only in the first three updates
leapsecmode slew    # absorb a leap second by slewing, not by stepping back

对于漏过的 step,选一条规则:

策略 生成器的行为 代价
Fail 拒绝请求,直到时钟超过 last_ms step 持续多久就报多久的错
Wait 睡眠,直到时钟超过 last_ms 几毫秒还行;长时间的 step 会让所有调用方卡住
逻辑时钟 从 last_ms 继续计数 从不阻塞;时间戳会跑到真实时间前面

Twitter 最初的版本选择了 Fail。先等几毫秒,再带着告警失败,是一个合理的默认做法。用永不倒退的 CLOCK_MONOTONIC 计时,可以保护正在运行的进程;high-water mark 则覆盖重启的情况。

UUIDv7 与 B-tree 索引

2024 年 5 月发布的 RFC 9562 取代了 RFC 4122,并定义了 UUIDv7:48 位的毫秒级 Unix 时间戳、4 位版本、2 位变体,以及 74 位随机数;如果需要毫秒内的严格顺序,这 74 位也可以用作计数器。这个时间戳可以用到 10889 年。

RFC 警告说,没有时间顺序的 UUID 索引局部性很差,对 B-tree 的影响可能非常严重。在 PostgreSQL 中,新的 v7 键进入最右侧的叶子页,那里的分裂会让页保持 90% 满,这是 B-tree 默认的 fillfactor。随机的 v4 键会命中任何一页:整个索引都成为工作集,满页会分裂,而且每次检查点之后,每个页的第一次修改都会把整页写入 WAL。MySQL 的 InnoDB 还会把主键复制到每个二级索引中,所以一个 16 字节的键会让它们全部变大。

PostgreSQL 18 在 2025 年 9 月加入了 uuidv7()。UUID 要存成 16 字节的二进制,而不是 36 个字符的文本。另外,v7 会暴露创建时间;RFC 建议凡是 ID 承担安全作用的地方都用 v4。

ULID

ULID 规范的结构与此相同:48 位的毫秒时间戳加 80 位随机数。它 26 个字符的 Crockford base32 文本不含 I、L、O 和 U,作为字符串也能正确排序,在 URL 中读起来也很顺。monotonic 模式在同一毫秒内加 1,这样能保持顺序,但下一个值就可以被猜到。ULID 是社区规范;对于数据库主键,UUIDv7 能提供同样的效果,而且有原生支持。

碰撞的概率有多大?

对于 n 个带 b 位随机数的 ID,发生任何碰撞的概率约为 n² / 2^(b+1)。带时间前缀的 ID 只可能在同一毫秒内碰撞,所以关键是速率,而不是总量。以每秒 100 万个 ID、持续一年、均匀分布来算:

方案 随机位数 可能与谁碰撞 一年内的概率
UUIDv4 122 任何其他 ID 约 100 亿分之一
ULID 80 同一毫秒的 ID 约 7,700 万分之一
UUIDv7 74 同一毫秒的 ID 约 120 万分之一
Snowflake 0 相同 worker ID 和相同毫秒 零,只要 worker ID 唯一且时钟向前走

随机方案以很小且可计算的概率失败。Snowflake 只会因为运维问题而失败,但一旦失败就是必然的。无论哪种方案,都要保留唯一约束。

Snowflake 风格 ID 生成器图

架构图。Payment 服务和 Order 服务调用 ID 服务的 NextId(),该服务有三个实例,worker ID 为 0 到 2,从 etcd 集群租用 ID,并通过 NTP 同步时间。Order 服务向 Postgres 插入行。一条横条把 64 位分为 1、41、10、12。

18 个组件 · 5 个连接

在 Archboard 中打开
图表内容
  • ID service · 3 replicas
  • id-gen-0 · worker ID 0
  • id-gen-1 · worker ID 1
  • id-gen-2 · worker ID 2
  • Payment service
  • Order service
  • orders · id BIGINT PRIMARY KEY
  • etcd cluster
  • NTP servers
  • 0
  • 41 bits · milliseconds since the custom epoch 2^41 ms ≈ 69.7 years
  • 10 bits · worker ID 1,024 instances
  • 12 bits · sequence 4,096 IDs per ms
  • NextId()
  • INSERT row with the new id
  • lease worker ID
  • time sync
Snowflake 风格的 ID 服务:三个实例从 etcd 租用 worker ID,通过 NTP 同步时间,并从内存中响应 NextId()。worker ID 唯一且时钟不倒退,才能保证每个 64 位 ID 唯一;条形图展示了位布局。

这张图的读法如下:

  • 客户端在三个实例中的任意一个上调用 NextId(),实例直接从内存中应答:最后一次的毫秒数和一个 12 位的计数器。
  • etcd 用 10 秒的租约保存每个 worker ID,还保存已存下的时间戳。
  • NTP 让各个时钟保持接近(虚线边);chrony 只在启动时 step。
  • Order 服务向 Postgres 写入行,递增的 ID 会落在最右侧的 B-tree 页上。
  • 条形图按比例展示 64 位;Markdown 块列出了各项设置。

把 ID 生成画清楚

  • 按比例画出位布局。 预算一目了然。
  • 标出 worker ID 的来源, 并写上租约 TTL。重复就是从这里开始的。
  • 把时钟画成一个依赖。 生成器的正确性取决于它的时钟。
  • 把故障规则写在画布上: 时钟倒退、租约丢失、重启。系统设计图示例也用同样的方式把数字放在方框旁边。

在 Archboard 中构建

  1. 用 Google 或 GitHub 登录,并创建一张图。
  2. 为 ID 服务添加一个来自基础包的框架,里面放三个来自 C4 Model 包的琥珀色容器节点:id-gen-0 到 id-gen-2,各对应一个 worker ID。
  3. 在左侧为 Payment 服务和 Order 服务添加两个容器节点,并在它们下方添加一个来自数据包的 Postgres 节点。
  4. 在右侧添加一个紫色的容器表示 etcd,一个来自网络包的蓝色服务器表示 NTP,以及一个来自基础包、写有各项设置的 Markdown 块。
  5. 把它们连起来:两个客户端到框架的 NextId(),到 Postgres 的 INSERT,到 etcd 的租约,以及来自 NTP 的一条虚线时间同步边。
  6. 在下方添加四个来自基础包的矩形椭圆节点,尺寸比例为 1 : 41 : 10 : 12,用来表示位布局,再加几条基础包的文本注释。

这个设计的 Mermaid 流程图可以用 Mermaid 转换器变成可编辑的初稿。这个示例共用 14 个图表节点,在 Free 套餐的限制之内。

常见问题

什么是 Snowflake ID? 一种 64 位的 ID,由时间戳、worker ID 和每毫秒的序号组成,由 Twitter 在 2010 年提出。各个生成器无需互相通信即可工作,ID 大致按创建时间排序。

UUIDv7 比 ULID 更好吗? 两者都包含 48 位的毫秒时间戳和随机位。UUIDv7 是 IETF 标准,并且有数据库支持,例如 PostgreSQL 18 中的 uuidv7();ULID 的文本形式更短。对于数据库主键,优先选 UUIDv7。

主键应该用 UUIDv4 还是 UUIDv7? 大多数表用 UUIDv7:新值排在旧值之后,所以插入始终靠近索引的右边缘。如果 ID 不能暴露创建时间,就用 UUIDv4。

Snowflake 生成器每秒能产生多少个 ID? 每个 worker 每毫秒 4,096 个,约每秒 410 万个,1,024 个 worker 合计 42 亿个。生成器外面的网络调用通常才是真正的瓶颈。

时钟倒退时会发生什么? 生成器不能重用它可能已经用过的毫秒。它可以失败直到时钟追上来,可以等待,也可以维护一个跑在真实时间前面的逻辑时钟;Twitter 最初的版本选择了失败。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。