最近在整理以前的草稿箱,翻到了这篇建于 2019 年的关于“唯一 ID 生成器”的笔记。当时似乎正在为一个分布式系统设计主键生成策略。既然翻出来了,就趁着这个机会,结合这几年在分布式领域的经验,把这个话题完善一下。
在单体架构中,我们习惯使用数据库的自增 ID(Auto Increment)作为主键,简单且性能不错。但在分布式系统或微服务架构下,分库分表之后,自增 ID 就捉襟见肘了。我们需要一个能够在分布式环境下生成全局唯一、趋势有序、且高性能的 ID 生成方案。
常见方案对比
在确定最终方案之前,通常会考察以下几种常见的替代方案:
1. UUID (Universally Unique Identifier)
UUID 是最容易想到的方案,本地生成,不需要网络交互。
- 优点:性能极高,无网络消耗,全球唯一。
- 缺点:
- 太长:128 bit(16字节),通常以 36 字符的字符串存储,浪费存储空间。
- 无序:UUID 是无序的,作为 B+ 树索引会导致频繁的页分裂(Page Split),严重影响数据库插入性能。
- 不可读:不具备业务含义。
2. NanoID
NanoID 是近年来非常流行的一个小巧、安全的唯一 ID 生成库,特别适合前端或对 ID 长度敏感的场景。
- 特点:
- 小巧:代码体积非常小(仅约 130 字节)。
- URL 安全:默认字符集包括
A-Za-z0-9_-,在 URL 中不需要转义。 - 更短:相比 UUID 的 36 字符,NanoID 默认只需要 21 个字符就能达到类似的碰撞概率(使用更大的字母表)。
- 优点:比 UUID 更短,更适合作为对外暴露的 ID(如短链接、资源 ID)。
- 缺点:
- 无序:和 UUID 一样,它是无序的,不适合直接作为数据库主键(尤其是 MySQL InnoDB)。
- 非纯数字:包含字母,如果业务要求纯数字 ID 则不适用。
3. 数据库自增 / 步长设置
利用数据库的 auto_increment,通过设置不同的起始值和步长(Step)来区分不同的数据库实例。
- 优点:实现简单,ID 有序。
- 缺点:
- 强依赖数据库,存在单点故障风险。
- 扩展困难,增加数据库节点需要重新调整步长。
- 性能受限于数据库单机瓶颈。
4. Redis 生成
利用 Redis 的 INCR 原子操作。
- 优点:性能比数据库好,有序递增。
- 缺点:需要维护 Redis 集群,增加系统复杂度。Redis 如果宕机或持久化出问题,可能会导致 ID 重复或跳变。
Snowflake 雪花算法
经过对比,Twitter 开源的 Snowflake(雪花算法) 是目前最主流的分布式 ID 生成方案。它是一个 64 bit 的整数(long),结构紧凑,且按时间趋势有序。
标准结构(64 bit):
| 1 bit | 41 bit 时间戳 | 10 bit 机器ID | 12 bit 序列号 |
|-------|--------------|---------------|--------------|
| 符号位 | 毫秒级时间戳 | 工作节点 ID | 毫秒内序列号 |
- 1 bit:符号位,始终为 0(保证 ID 为正数)。
- 41 bit:毫秒级时间戳。可以使用 69 年($2^{41} / (1000 \times 60 \times 60 \times 24 \times 365) \approx 69$)。
- 10 bit:机器 ID。通常分为 5 bit 数据中心 ID 和 5 bit 工作机器 ID,支持 1024 个节点。
- 12 bit:序列号。每毫秒支持生成 4096 个 ID。
理论上 QPS 可达 400w+,对于绝大多数系统来说绰绰有余。
自定义方案:秒级时间戳变体
回到我草稿箱里最初记录的那个方案,当时我设计了一个稍微不同的变体,使用了32 bit 的秒级时间戳。
草稿中的结构:
id = |----32bit(时间戳秒)---|---5bit(机器标志位)---|---26bit(自增序列)------|
让我们来分析一下这个变体的特性:
- 32 bit 时间戳(秒):
- 标准 Unix 时间戳是 32 位的,但在 2038 年会溢出。如果从自定义的 Epoch(比如 2019 年)开始算,32 bit 可以支撑 $2^{32} \approx 136$ 年。
- 对比:相比标准版的 69 年,这个方案的有效期更长。
- 5 bit 机器标志位:
- 只能容纳 $2^5 = 32$ 台机器。
- 缺点:对于大型微服务集群来说,32 个节点可能太少了。如果服务实例扩容超过 32 个,这个方案就会失效,或者需要重新分配 ID 生成服务。
- 26 bit 自增序列:
- $2^{26} \approx 67,108,864$。意味着每秒每台机器可以生成 6700 万个 ID。
- 评价:这有点过度设计了。单机几乎不可能达到 6700 万 QPS 的写入量。把这么多 bit 给序列号,不如分给机器 ID。
改进建议: 如果确实想用秒级时间戳(为了更长的有效期或与现有系统兼容),建议调整 bit 分配,平衡机器数量和并发量。例如:
| 1 bit | 32 bit 秒级时间戳 | 16 bit 机器 ID | 15 bit 序列号 |
- 16 bit 机器 ID:支持 65536 个节点(足够大规模集群使用)。
- 15 bit 序列号:每秒支持 32768 个 ID(对于单机来说也是很高的并发了)。
核心问题与解决
无论使用标准 Snowflake 还是变体,在工程实现上都需要解决几个核心问题:
1. 机器 ID (Worker ID) 的分配
在 Kubernetes 容器化环境下,Pod 重启后 IP 和 hostname 都会变,如何保证 Worker ID 唯一且不冲突?
- 静态配置:在配置文件中写死(不适合动态扩容)。
- Redis/Zookeeper 协调:启动时去注册中心获取一个临时的 ID。
- IP 映射:根据内网 IP 的最后一段来生成 ID(前提是网段规划可控)。
2. 时钟回拨 (Clock Rollback)
Snowflake 强依赖服务器时间。如果机器时钟回退(比如 NTP 同步导致时间回跳),可能会导致 ID 重复。
- 检测:记录上一次生成 ID 的时间戳,如果当前时间 < 上次时间,说明发生了时钟回拨。
- 策略:
- 等待:如果回拨时间很短(如几毫秒),就循环等待直到时间追上来。
- 报错:直接抛出异常,拒绝生成 ID,让上层重试或切换到其他节点。
- 备用位:利用扩展位来区分时钟回拨的场景(较复杂)。
3. 序列号溢出
如果某一毫秒(或秒)内的请求量超过了序列号上限(标准版是 4096),该怎么办?
- 阻塞:自旋等待下一毫秒/秒,重置序列号。
总结
对于大多数业务场景,标准的 Snowflake 算法(毫秒级) 是最佳选择,很多成熟的库(如 Java 的 Hutool,Go 的 sonyflake/snowflake)都已内置。如果需要更短、URL 安全的非主键 ID,NanoID 是一个极佳的现代替代方案。
除非你有极其特殊的需求(例如需要 ID 使用 100 年以上,或者对单机并发要求极高),否则不建议魔改 bit 结构。当年我草稿里的那个“32 bit 秒级 + 5 bit 机器 + 26 bit 序列”的方案,现在看来,机器位数太少是最大的硬伤,属于为了追求极致的时间跨度和序列吞吐,牺牲了扩展性,不太具备通用性。
这也提醒我,做系统设计时,平衡(Trade-off) 永远比极致的参数更重要。