它是什么
几乎每个系统都需要一种标识符,能不必去问某一个全局数据库就生成出来:一个短链接码、一个消息 id、一个订单 id。设计上的问题是如何做到不需要协调的唯一性,以及你还需要三个属性中的哪几个:id 能否按时间排序、是否够小(例如一个 64 位整数)、是否可被猜到(这是安全问题)。
Important
核心要点: 任何”先生成、检查是否冲突、再写入”的方案都有 check-then-write 的竞态:两台服务器都可能发现没有冲突、然后都写入。要从构造上消除冲突(计数器,或者时间 + 机器 + 序列号),而不是靠碰运气。
何时需要它
它出现在短链接服务、聊天、订单和信息流里。信号在于:先用具体的并发故障否定较弱的选项,再做选择。
Note
面试信号: 先走一遍 hash 和随机 id,用具体的并发故障逐一否定,然后落在分段分配的计数器或雪花风格的 id 上,这就是对的做法。直接跳到”我会用一个计数器”而不说其他方案为什么不行,就错过了这道题的重点。
各种选项
| 方案 | 做法 | 可按时间排序 | 大小 | 冲突 | 需要协调 |
|---|---|---|---|---|---|
| 自增(数据库序列) | 一个数据库里的一个计数器 | 是 | 64 位 | 无 | 需要:单一瓶颈和单点故障 |
| 内容 hash | MD5 或 SHA,截断并编码 | 否 | 自选 | 有,且存在 check-then-write 竞态 | 不需要 |
| 随机(UUID v4) | 122 位随机数 | 否 | 128 位 | 可忽略 | 不需要 |
| 时间有序的随机(ULID、UUID v7) | 时间戳前缀加随机数 | 是 | 128 位 | 可忽略 | 不需要 |
| 雪花风格 | 时间戳、机器 id、序列号打包成 64 位 | 是 | 64 位 | 无,由构造保证 | 一次性的机器 id 分配 |
| 分段分配 | 中央分配器给每台服务器发一个计数器值的区块 | 大致是 | 64 位 | 无 | 很少:每个区块一次 |
Hash 和随机的短码以同一种方式失败。一个 7 个字符的 base62 码有 62⁷ ≈ 3.5 万亿个取值,所以在已存 10 亿个时,一个随机码每次尝试冲突的概率约 0.03%。听起来可以忽略,但检查冲突(“这个码被占用了吗?“)然后写入是两步,两台服务器同时做时都会通过检查。对长 URL 做 hash 有同样的竞态,更糟的是内容上的冲突,所以你必须探测并重试。两者都把一个唯一性问题变成了并发问题。
分段分配(计数器区块)
每台应用服务器向一个中央分配器(一个用 SELECT ... FOR UPDATE 的数据库行、ZooKeeper,或一个小服务)请求一个包含,比如,1,000 个值的区块,然后在本地发放,不需要任何协调。区块用完时再请求一个。把整数用 base62 编码得到短码。分配器每 1,000 个 id 才被触碰一次,所以一个分配器就能支撑巨大的写入速率,而 id 由构造保证唯一。如果某台服务器死了,它那个区块里没用完的部分直接被跳过。当 id 只需要唯一时,乱序的 id 没有问题。
雪花风格的 id
由三个字段构成的 64 位 id:
| 41 位:自自定义纪元以来的毫秒数 | 10 位:机器 id | 12 位:序列号 |
算例。 41 位毫秒数大约是自定义纪元起 69 年;10 位允许 1,024 台机器;12 位允许每台机器每毫秒 4,096 个 id,大约每台机器每秒 400 万个。因为时间戳在高位,id 按时间排序,这给聊天和信息流提供了免费的排序 key,并让数据库插入大致追加在索引的右边缘(对 B-tree 有利)。
要点出的故障模式:时钟回拨(一次 NTP 调整)可能产生重复或乱序的 id,所以生成器会拒绝发放 id,直到时钟追上,或者沿用上一个时间戳;机器 id 冲突(两台机器配置了同一个 id)会产生重复,所以机器 id 要来自一个注册表;序列号每毫秒重置一次。
时间有序的随机 id(ULID、UUID v7)在高位放一个时间戳,其后是随机数,既可排序,又不需要机器 id 注册表,代价是 128 位。
Warning
生产实践: 可排序的 id 会泄露信息。时间戳前缀暴露了创建时间,顺序的 id 暴露了数量(“订单 100,482”告诉竞争对手你接了多少订单)。如果 id 会对外暴露,就加一个随机成分,或把内部 id 映射成不透明的公开 id。
如何选择
| 情形 | 选择 | 原因 |
|---|---|---|
| 短链接码 | 分段分配的计数器,base62 | 由构造保证唯一,分配器负载极小 |
| 必须在一个会话内排序的消息或事件 id | 雪花风格或 ULID | 可按时间排序且唯一 |
| 单个数据库、规模不大 | 自增 | 最简单 |
| 来自许多独立写者的 id,没有注册表 | UUID v4,如需可排序则用 v7 | 不需要协调;冲突可忽略 |
| 不能被猜到的公开标识符 | 随机,或不透明的映射 | 无法被枚举 |
| 幂等键 | 客户端生成的 UUID | 每个逻辑操作唯一 |
Caution
常见错误: 声称 hash 或随机 id “没有冲突”。它只是冲突不太可能,而 check-then-write 这一步又把竞态带了回来。如果唯一性是一个正确性属性(短链接码、主键),就用唯一约束来强制,或从构造上保证它。
相关
- 演练:设计短链接服务 — 分段分配的典型用法:从计数器区块生成 base62 短码。
- 演练:设计聊天系统 — 可按时间排序的消息 id 在这里给每个会话提供免费的排序 key。
- 参考:幂等与 Outbox 模式 — 客户端生成的 UUID 作为幂等键,并贯穿每一跳。