分布式 ID
分布式 ID是在多个进程或节点共同创建数据时,用来区分业务对象的标识。它首先需要在约定范围内不重复;是否按时间排列、是否连续、能否被猜到,是另外的要求。
以游戏用户 UID 为例,多个注册服务可以同时创建用户,但不能把不同用户写成同一个 UID。选择方案时,除了说明数字怎样生成,还要解释节点宕机、时钟变化或发号状态丢失后,系统为什么不会重复使用已经发出去的值。
1. 面试核心
先记住这几条,再读正文- 设计 ID 先明确唯一范围、顺序要求、容量与故障时的行为。发号服务不可用、用户数据丢失和请求重试,是三个不同的问题,不能用“部署集群”一笔带过。卡 · 综合题
- 数据库自增容易实现集中分配;号段让实例批量领取范围,再在本地发号。关键是号段先被可靠地保留,再向外发放,故障恢复不能把已经领取的范围重新分配。
- UUIDv4 用随机位降低碰撞概率,UUIDv7 在高位加入时间信息。随机 ID 不等于绝不碰撞,时间有序也不等于全局业务顺序。 卡 · UUIDv4 与碰撞卡 · 唯一性与顺序要求
- Snowflake 类方案把时间、节点号和序号组合起来。唯一性依赖字段组合不重复,需要同时处理节点号归属、同毫秒序号、回拨和重启。卡 · 唯一性依赖哪些状态?
- 节点位数由同时独立发号的实例数量决定。100 个不同节点号最少需要 7 位,因为
2^6 < 100 ≤ 2^7;是否增加余量取决于预留值和扩容需求。卡 · 100 个实例需要多少节点位? - ID 在生成后仍需被完整保存和传输。超过接收方精确整数范围的值不能直接当作普通浮点数处理;面对 JavaScript
Number等接收方,可以约定用十进制字符串传递。卡 · 生成之后的保存与传输
2. 唯一性与顺序要求
唯一性必须先确定范围。单张用户表可以使用该表内唯一的主键;多个分库中的用户最终要一起查询、迁移或合并时,就需要保证整个用户域内不重复。不同业务共用发号器,也不代表所有业务必须共享同一段号码空间。
下列要求不能互相替代:
| 要求 | 含义 | 不能据此推出什么 |
|---|---|---|
| 唯一 | 不同对象不会使用相同标识 | 不代表按时间排序,也不代表不可预测 |
| 单实例单调递增 | 一个实例下一次生成的值大于上一次 | 不代表其他实例生成的值也按相同顺序出现 |
| 趋势递增 | 一段时间之后生成的值总体更大 | 不代表每两个请求都严格有序 |
| 全局顺序 | 全部参与者遵守同一个明确的先后规则 | 不会仅靠各机器的物理时钟自动得到 |
| 连续无空洞 | 相邻已使用号码之间没有跳号 | 不能由普通自增或号段直接保证 |
例如事务 A 先拿到 100,事务 B 后拿到 101,但 B 先提交。ID 分配顺序与事务提交顺序就不同。若产品需要按注册完成时间展示用户,应保存相应业务字段或定义专门的顺序机制,不能把 ID 大小直接解释为提交先后。
同样,生成一个新 ID 也不会自动让请求幂等。注册请求成功后响应丢失,客户端重试时又生成一个 ID,仍可能创建两个账户。需要通过请求标识或业务唯一条件识别同一次操作,见分布式流程恢复。
3. 数据库分配与号段
3.1 数据库自增
数据库自增把号码分配集中到数据库。业务插入数据时获得自增值,并通过主键或唯一约束防止同一表中重复存储。这种方案适合发号压力与可用性需求能够由现有数据库承担的场景。
多台应用连接同一个分配方,不会因为应用数量增加就必然得到重复号码。但如果每个分库都从 1 开始独立自增,库与库之间就可能重复。此时需要统一分配、明确不相交的号码空间,或者使用另一个全局 ID,不能只把每个库内的唯一性当作全局唯一性。
MySQL 8.0 的 InnoDB 自增值也不保证连续。事务回滚后,已分配的自增值不会因为这次回滚而重新使用,所以表中可以出现空洞。这是普通发号与“已成功业务必须连续编号”的区别,后者需要单独设计业务流程。
3.2 号段的领取与消费
号段是一次保留给某个发号实例使用的一段 ID 范围。实例 A 领取 [1000, 2000),实例 B 领取 [2000, 3000),两者便可分别在本地递增,不必每发一个号码都访问数据库。
号段方案需要保持两个条件:
- 分配方原子地推进分配位置,并可靠保存这个结果,再确认号段已被领取。两个领取者不能获得重叠范围。
- 实例在自己的范围内并发发号时,也要同步本地游标。不能让两个请求都读取同一个游标后返回相同值。
实例 A 宕机时,如果无法证明剩余范围从未发出,可以放弃剩余号码,重新领取新段。这样会留下空洞,但不会为了填补空洞而重新使用旧值。预先领取下一段可以减少换段时的等待,却不改变唯一性的前提。
数据库暂时不可用时,已经领取号段且状态可靠的实例,可以继续发放本地余量;余量耗尽后就不能再安全地凭空领取新段。恢复旧备份或切换到缺少最新分配记录的副本,也可能把已经分配过的范围再次发出去。因此,高可用设计必须保护分配状态,不能只保证有另一台机器接收请求。复制与切换的边界见MySQL 复制。
4. UUID
UUID 是一种 128 位标识格式,不同版本采用不同的生成方式。讨论它是否有序、是否依赖时间或是否泄露某类信息时,需要先说明版本。
4.1 UUIDv4 与碰撞
UUIDv4 使用随机或伪随机数据,除版本和变体字段外,有 122 个随机位。节点可以本地生成,不必为了每个 ID 联系统一发号器。
在随机位独立、均匀的假设下,生成 n 个 UUIDv4 至少发生一次碰撞的概率,在概率很小时近似为 n(n-1) / (2 × 2^122)。这个空间很大,但“概率很小”不是数学上的零。随机源质量差、多个进程复制了相同随机状态等实现问题,还会破坏这个概率估计的前提。
因此应使用可靠实现和合适的随机源,关键数据写入时保留唯一约束,并明确冲突处理。约束覆盖的范围也要与业务匹配:每个分库各有唯一索引,并不能发现另一个分库中的同名 ID。
4.2 UUIDv7 与时间顺序
UUIDv7 把 Unix 毫秒时间戳放在高 48 位;除固定的版本、变体字段外,其余位置可包含随机数据,也允许按规范加入计数等机制。这使相近时间生成的值在按规定表示排序时更容易靠近。
它并不自动建立跨节点的严格先后顺序。两个节点的时钟可能不同,同一毫秒内也可能生成多个值;是否保证单实例内的单调性,仍与具体实现有关。需要严格排序的业务,不能只看到“时间型 UUID”就省略顺序定义。
UUID 的二进制值占 16 字节,常见带连字符的文本表示占 36 个字符。是否用作数据库主键,要结合存储表示、索引大小和访问方式判断。InnoDB 二级索引包含主键列,较宽的主键会增加二级索引空间;具体机制见索引。不能把“某种表示会增加成本”概括成“UUID 永远不能作主键”。
5. Snowflake 类时间型 ID
Snowflake 类 ID 把时间差、节点号和时间单位内的序号编码到一个整数中。以 Twitter 早期公开实现为例,64 位中的最高位保持为 0,其余字段可以这样理解:
| 字段 | 位数 | 作用 |
|---|---|---|
| 毫秒时间差 | 41 | 记录相对约定起点经过的毫秒数 |
| 数据中心号 | 5 | 区分数据中心 |
| 工作节点号 | 5 | 区分同一数据中心内的发号节点 |
| 同毫秒序号 | 12 | 区分同一节点同一毫秒内的多次发号 |
这些位数是一种具体分配,不是所有时间型 ID 都必须遵守的标准。两个 5 位字段合起来可以表达 1024 个不同的节点组合;12 位序号可以表达 0 到 4095。41 位毫秒时间差提供约 69.7 年的表示窗口,起点与耗尽后的处理需要提前确定。
5.1 唯一性依赖哪些状态?
在字段没有越界、编码没有丢位的前提下,不同的“时间差、节点组合、序号”会得到不同整数。真正需要维护的是这些字段组合不能重复:
- 不同发号实例使用不同节点组合。这里的实例是独立维护发号状态的进程或服务,不一定是一台物理机器;同一机器上的两个独立进程也可能互相冲突。
- 同一实例同一毫秒内序号不重复。多线程需要同步访问序号;用完 4096 个值后,不能在同一个毫秒简单清零继续发放。
- 时间回拨和重启不会重用旧状态。如果时间从
1001回到1000,又把序号设为0,就可能重复之前的字段组合。
Twitter 早期实现对一次进程运行中的发号加同步,遇到回拨直接拒绝生成,序号耗尽时等待下一个毫秒。这些行为可以从源码确认,但该片段本身并没有完成节点号分配、跨重启状态恢复或故障转移的全部工作。
重启后不能仅靠重新初始化内存中的 lastTimestamp 来证明安全。可以持久保留安全时间边界并在重启时检查,或者分配不会与旧实例及其已发号范围冲突的新节点身份;具体方案要与节点复用、时钟和状态存储一起设计。采用租约分配节点号时,还必须让失去资格的旧实例停止有效发号,不能只让管理系统把同一个号交给新实例。
序号位宽给出的是每个时间单位可用的编码数量,不是实测吞吐承诺。锁竞争、时钟读取、等待与服务调用开销都会影响实际速度,不能把 4096 × 1000 直接当作某个实现必然达到的 QPS。
5.2 100 个实例需要多少节点位?
节点位数 b 至少满足 2^b ≥ N,其中 N 是需要同时区分的独立发号实例数。100 个实例只从表示数量看需要 7 位,因为 6 位只有 64 种取值,7 位有 128 种。
如果要保留特殊值、给扩容留空间,或把区域编号也包含进来,可以分配更多位。位数增加后必须从总位宽预算里考虑代价:时间位减少会缩短寿命,序号位减少会降低每时间单位的容量。无论有多少位,同一个节点号被两个有效实例同时占用,仍然可能重复。
5.3 字段编码示例
下面的 Go 示例把两个节点字段合并为 10 位,只演示范围校验、编码与解码。deltaMS 已经是相对起点的非负毫秒差;代码不会读取时钟,也不负责分配节点号和序号,因此不是完整发号器。
package main
import "fmt"
func packID(deltaMS, worker, sequence uint64) (uint64, error) {
if deltaMS >= 1<<41 || worker >= 1<<10 || sequence >= 1<<12 {
return 0, fmt.Errorf("ID field out of range")
}
return deltaMS<<22 | worker<<12 | sequence, nil
}
func unpackID(id uint64) (deltaMS, worker, sequence uint64) {
return id >> 22, (id >> 12) & 1023, id & 4095
}
func main() {
id, err := packID(1000, 7, 5)
if err != nil {
panic(err)
}
deltaMS, worker, sequence := unpackID(id)
fmt.Println(id)
fmt.Println(deltaMS, worker, sequence)
_, err = packID(1000, 7, 4096)
fmt.Println(err != nil)
}
输出依次是 4194332677、1000 7 5 和 true。最后一次调用拒绝了超出 12 位范围的序号,避免把越界值混入相邻字段。unpackID 用于解码本例合法的 63 位结果;接收外部 ID 时还需要先校验约定的范围。
重复调用 packID(1000, 7, 5) 必然得到同一个数。这不是编码函数的缺陷,而是说明唯一性不能由移位操作凭空创造:调用者必须维护字段组合不重复的条件。
6. 生成之后的保存与传输
用户记录仍要保存 UID 以及它对应的业务数据。发号服务故障影响后续取号,不应让已经存储的用户数据跟着消失;数据库故障则需要按数据恢复目标处理。把发号器部署多份,只解决其中一部分可用性问题,还必须证明它们不会领取重叠范围或重复占用节点号。
ID 也需要在不同语言和协议间保持精确。JavaScript Number 使用二进制双精度浮点数,安全整数上限为 2^53 - 1,小于本例 63 位 ID 的最大值。通过 JSON 数字传给默认解析为 Number 的接收方,可能在解析时就丢失精度;之后再转成字符串也无法恢复原值。可以从发送端开始用十进制字符串,并约定接收端按字符串保存或转换为精确整数。
最后,ID 是标识,不是权限凭据。连续号码可能方便枚举、暴露部分业务规模;随机号码可以降低直接猜测的便利程度,但访问用户资料仍必须验证调用者是否有权限。相应边界见 Web 应用安全。
7 · 面试题卡片
本页题目与参考回答
7.1 怎样设计游戏用户 UID?在哪里生成,怎样避免多机重复,故障时怎么办?
题型:综合题
考察重点:围绕业务约束选择分配方案,区分发号状态、用户存储与服务可用性。
相关内容:唯一性与顺序要求、数据库分配与号段、唯一性依赖的状态、生成之后的保存与传输。
参考回答
**先确定 UID 的唯一范围、容量、顺序和可用性要求,再选能够维护这些条件的方案。**注册请求量能由现有数据库承担时,可以集中分配并用唯一约束保存用户记录;需要减少每次取号对数据库的依赖时,可以考虑号段。需要本地生成时,再比较 UUID 或有受控节点身份的时间型方案。
号段要先原子、可靠地保留范围,再在本地同步消费;多个实例的范围不能重叠。时间型方案要保证节点组合、同毫秒序号及回拨和重启处理不会重复。增加负载均衡不能代替这些条件。
发号节点宕机时,其他节点能否接替,要看它们是否具有安全的号码空间或节点身份;分配方暂时不可用时,已领取号段可以支持有限时间的继续发号。用户数据则由存储系统负责持久化和恢复,不能把发号服务与用户存储混为一件事。
7.2 用节点位区分 100 个发号实例,至少需要多少位?
考察重点:位宽与可表示数量的关系,以及节点身份的实际归属。
相关内容:节点位数。
参考回答
**只考虑表示 100 个不同节点号,最少需要 7 位。**6 位能表示 64 种值,7 位能表示 128 种;如果有预留值或扩容需求,再增加余量。
这里区分的是独立发号实例,不能只数物理服务器。位数够用也不等于分配正确,还要避免同一个节点号被两个有效实例同时使用。
7.3 UUIDv4 能保证绝对不重复吗?
考察重点:随机空间、碰撞概率和实现条件,唯一约束的覆盖范围。
相关内容:UUIDv4 与碰撞。
参考回答
**不能把 UUIDv4 的低碰撞概率说成绝对不重复。**它有 122 个随机位,独立均匀生成时碰撞概率很小,但随机源或状态复用错误会破坏这个前提。
应使用可靠实现,按业务后果设置唯一约束与冲突处理;多库场景还要确认检测范围,单个库的约束不会自动覆盖其他库。
7.4 时间型 ID 加上节点号以后,为什么还要处理时钟回拨和进程重启?
考察重点:字段组合唯一性的前提,以及进程内状态与跨重启状态的区别。
参考回答
**节点号只能区分不同节点,不能阻止同一节点再次使用旧的时间与序号组合。**回拨后重置序号,或者重启丢失最后发号时间,都可能重复生成旧 ID。
需要明确回拨时拒绝、等待或使用受控逻辑时间的策略,并保证时间窗口和序号不被重复使用。跨重启还需要可靠的状态边界或安全的新身份分配;只在内存里加锁不够。节点号复用时,也要确保旧实例不能继续用同一身份发号。
7.5 ID 越大,是否就代表业务完成得越晚?
考察重点:分配顺序、实例内顺序、时间趋势与事务提交顺序的区别。
相关内容:唯一性与顺序要求、UUIDv7 与时间顺序。
参考回答
**不能直接这样判断,ID 通常记录的是分配规则,不是业务完成顺序。**事务先获得较小 ID,仍可能比另一个事务更晚提交,甚至回滚。
时间型 ID 还受到节点时钟和同时间单位内排序规则的影响。业务需要严格先后时,应单独定义对应事件与排序机制,而不是默认用 ID 大小代替。
7.6 64 位 ID 通过 JSON 传输时,为什么有时要使用字符串?
考察重点:协议中的数字与接收语言的精度范围,避免解析阶段丢失信息。
相关内容:生成之后的保存与传输。
参考回答
**因为接收方可能用不能精确表示全部 64 位整数的类型解析 JSON 数字。**例如 JavaScript Number 的安全整数上限是 2^53 - 1,超过后相邻整数可能被解析为同一个值。
可以约定发送十进制字符串,让接收方保持字符串或转换为精确整数类型。必须在丢失精度之前这样处理,先解析成不精确的数字再转字符串,无法找回原 ID。
8. 参考资料
- RFC 9562:UUID,第 5.4、5.7、6.2–6.4、6.7–6.13 节
- Twitter Snowflake:早期 IdWorker 实现
- MySQL 8.0:InnoDB 自增处理
- MySQL 8.0:聚簇索引与二级索引
- 美团技术团队:Leaf 分布式 ID 生成系统,号段方案
- RFC 8259:JSON 数字的互操作性,第 6 节
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明