设计一个全球聊天产品的消息 ID(参考 Discord / Slack / WhatsApp)。规模假设:3 亿 DAU、峰值 100w 消息/秒、消息按 channel 分片到上百个 Cassandra 节点。每条消息要分配一个 ID,决定它在 DB 中的物理位置、在客户端的排序、在 URL 里的样子。
关键需求:
WHERE id < cursor 翻页要走索引。id_today - id_yesterday 算出日活,是早期 Twitter 的隐私事故。
graph TD
ZK["ZooKeeper / etcd
worker_id 分配"]
NTP["NTP / PTP 时钟同步
检测回拨"]
APP1["App 实例 #1
嵌入 ID lib
worker_id=42"]
APP2["App 实例 #2
嵌入 ID lib
worker_id=43"]
APPN["App 实例 #N
worker_id=...
(最多 2¹⁰=1024)"]
CLIENT["移动客户端
UUIDv7 本地生成
离线消息"]
CASS[("Cassandra
partition by channel
cluster by id DESC")]
ZK -.分配.-> APP1 & APP2 & APPN
NTP -.同步.-> APP1 & APP2 & APPN
APP1 & APP2 & APPN --> CASS
CLIENT -->|带 client_id| APP1
classDef coord fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef app fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef client fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class ZK,NTP coord
class APP1,APP2,APPN app
class CLIENT client
class CASS store
ID 生成是 library 模式,没有独立服务可挂;ZK 只在启动时分配 worker_id,运行时无依赖
组件职责:ZooKeeper 启动时给每个 app 实例分一个 worker_id(10 bit = 1024 个槽),重启时归还。NTP 保证机器时钟不漂移(一般 < 10ms 误差),时钟回拨是 Snowflake 系命门。App 嵌入 ID lib 直接出 ID,无网络调用。客户端离线场景用 UUIDv7 本地生成,服务端原样接受(保证发送幂等)。
原理:UUID 是 128-bit。v4 122 bit 全随机,冲突概率 ~2⁻¹²²,无需协调,但对 B-tree 索引是灾难——每次插入打在随机叶子页,page split + cache miss 暴增。v7(RFC 9562 2024 年定稿)前 48 bit 是毫秒级 Unix 时间戳,后 74 bit 随机/单调,字典序 = 时间序,新插入永远在 B-tree 右侧,page split 几乎为零。
# UUIDv7 极简生成 (Python,RFC 9562)
import os, time
def uuid7() -> bytes:
ts_ms = int(time.time() * 1000) # 48 bit
rand_a = int.from_bytes(os.urandom(2), 'big') & 0x0FFF # 12 bit
rand_b = int.from_bytes(os.urandom(8), 'big') & ((1<<62)-1) # 62 bit
# 组装: ts(48) | ver=7(4) | rand_a(12) | var=10(2) | rand_b(62)
n = (ts_ms << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b
return n.to_bytes(16, 'big')
# Postgres 17+ 原生支持: gen_random_uuid() 仍是 v4, 但社区有 uuidv7() 扩展
ch_/pi_ 前缀 + 16+ 字节随机,是 v4 风格(KV 存储不怕随机插入,且要求强抗猜测)。原理:Twitter 2010 年开源 Snowflake,64 bit = 1 符号位 + 41 bit 毫秒时间戳(自定 epoch,可用 ~69 年)+ 10 bit worker_id(1024 台机器)+ 12 bit 序列号(同毫秒同机器最多 4096 个)。4096 × 1024 = 419 万 ID/ms = 41 亿/秒,远超任何单一系统需求。比 UUID 省一半空间,且天然大致时间序。
| 方案 | 位宽 | 峰值/秒 | 需要协调 | 致命问题 |
|---|---|---|---|---|
| DB auto_increment | BIGINT 64 | 单实例 ~10w | 无 | 单点、泄露业务量 |
| UUIDv4 | 128 | 无上限 | 无 | 索引插入慢 |
| Snowflake | 64 | 41 亿 | worker_id 分配 + 时钟 | 时钟回拨 |
| UUIDv7 | 128 | 无上限 | 无 | 占空间 2 倍 |
| DB segment(号段) | 64 | 取决于段长 | DB 一次拿 1000 个 | 重启浪费段 |
# Snowflake 核心 (Python 伪代码)
class Snowflake:
EPOCH = 1672531200000 # 2023-01-01 自定起点
def __init__(self, worker_id):
assert 0 <= worker_id < 1024
self.worker_id = worker_id
self.seq = 0
self.last_ts = -1
def next_id(self):
ts = int(time.time() * 1000)
if ts < self.last_ts:
raise ClockBackwardError(self.last_ts - ts) # 见下一节
if ts == self.last_ts:
self.seq = (self.seq + 1) & 0xFFF
if self.seq == 0:
ts = self._wait_next_ms(self.last_ts) # 等下一毫秒
else:
self.seq = 0
self.last_ts = ts
return ((ts - self.EPOCH) << 22) | (self.worker_id << 12) | self.seq
原理:Snowflake 的唯一性完全依赖时间戳单调递增。NTP 一次回拨 100ms,机器在这 100ms 内生成的 ID 就可能和过去重复。leap second(闰秒)、虚拟机暂停恢复、运维误操作改时间,都触发过生产事故。
if ts < last_ts: sleep(last_ts - ts)。代价:回拨大时阻塞 ID 生成 = 阻塞写入,超过 5ms 就直接抛错让上层降级。Twitter 原版做法。logical_ts = max(system_ts, last_ts + 1),永不回退。代价:与真实时间逐渐漂移;适合短暂回拨。-x 参数让时间『慢慢追』而不是『跳』,物理上消除回拨。生产标配,但仍需代码兜底。# 处理回拨的鲁棒版本
def next_id(self):
ts = now_ms()
delta = self.last_ts - ts
if delta > 0:
if delta <= 5: # 小回拨,等
time.sleep(delta / 1000)
ts = now_ms()
elif delta <= 2000: # 中回拨,用逻辑时钟
ts = self.last_ts + 1
else: # 大回拨,拒绝服务
raise ClockBackwardError(delta)
# ... 正常分配 seq
原理:三者都想解决『字典序 = 时间序 + 全分布式 + 无中心』。差别在 bit 数、编码、是否单调严格。
| UUIDv7 | ULID | KSUID | |
|---|---|---|---|
| 位宽 | 128 | 128 | 160 |
| 时间精度 | 毫秒 | 毫秒 | 秒 |
| 随机位 | 74 bit | 80 bit | 128 bit |
| 文本编码 | hex+横线 (36 字符) | Crockford Base32 (26 字符) | Base62 (27 字符) |
| 单调保证 | 同毫秒内可选 | 同毫秒严格单调(spec 要求) | 无(秒级) |
| 标准化 | IETF RFC 9562 | 社区 spec(GitHub) | Segment 开源 |
| 典型场景 | 替换数据库 PK | 面向用户的短 ID | 事件流、日志 |
# 三种格式的字符串样例
UUIDv4: 6ba7b810-9dad-11d1-80b4-00c04fd430c8 ← 36 字符
UUIDv7: 018f1c2e-a31b-7c12-9a4b-3e1c2d4f5a6b ← 36 字符, 前 48 bit 是时间
ULID: 01HXY8K5T4ZNQ9P2M3RVW6S0BC ← 26 字符
KSUID: 2QqRwSdEf8VnT1bAaCpYlGmHkXjZ ← 27 字符
Snowflake: 1773294523890106368 ← 19 位十进制数字
cus_, ch_),不属任何标准,但展示了『可读性 + 类型安全 + 抗猜测』的产品级 ID 设计。region(3) + dc(3) + worker(4),让 ID 也能定位物理位置。Twitter 内部就是这样。面试可能追问:
不是 CPU 慢、不是磁盘带宽不够,是 buffer pool 命中率崩。InnoDB 是聚簇索引——主键决定数据在磁盘上的物理位置。
UUIDv7 怎么解:前 48 bit 是时间戳,近期生成的 ID 字典序都很接近,全部插入到 B-tree 右侧少数几个叶子页——这些页一直热,buffer pool 几乎永远命中。Percona / EnterpriseDB 的 benchmark 数据显示,亿级行的表 v7 比 v4 插入快 5-10 倍,写放大降一个数量级。
含义:用 v4 + 单独的 BIGINT 自增 PK + UUID 是 unique key,是历史上的折中方案;现在直接 v7 更干净。
瓶颈不在 4096 上限,在『等下一毫秒』的实现。同毫秒内序列号用完(4096 个),代码要 sleep until next ms。但:
last_ts + seq 要加锁 / CAS。30w/s 下锁争抢成瓶颈,远在 410w/s 之前。gettimeofday() 在 Linux 上虽快(vDSO),但每次 next_id 都调用,30w/s = 3μs/次,CPU 已经吃满。工程解法:
Discord、Baidu UidGenerator、美团 Leaf 都在这个量级做过专门优化。
这是『数据库主键演进』的经典难题。直接换主键 = 改表锁 + 改索引 + 改所有外键,停机几小时。无停机方案:
snowflake_id BIGINT UNIQUE 列,新写入两个 ID 都生成;老数据后台批量回填(按 PK 范围分批)。parent_snowflake_id 列、回填、切读、删老外键。这一步最痛苦,影响所有 JOIN。pt-online-schema-change / gh-ost 影子表方式做,避免锁表。关键陷阱:
GET /v1/legacy_id/123 → snowflake)。整个过程通常 3-6 个月,关键是不要急于删老 ID——保留双写 1-2 个月观察。
核心问题:客户端本地生成的 UUIDv7 时间戳是一周前,到服务端时,按 ID 排序会把这条消息插入到一周前的位置,channel 历史里『过去突然冒出新消息』,UI 看不到。
解法分层:
client_id(客户端生成、用于幂等去重)+ server_ts(服务端收到的时间,决定排序)。排序按 server_ts,去重按 client_id。(channel_id, client_id) 做 upsert,客户端发 3 次同一条只成功一次。更深的设计哲学:永远不要让一个字段同时承担『唯一标识』『时间排序』『因果排序』三件事——三者的时间语义不同(生成时间 / 接收时间 / 事件发生时间)。Slack 的 ts 字段是 server_ts、client_msg_id 是 UUID,分得很清楚。
用户可见 ID 的需求和后端主键完全不同,关键看 5 维:
order/123 可被遍历查别人订单。用户可见 ID 必须有足够随机位(>= 64 bit)。UUIDv7 后 74 bit 随机够用,但前 48 bit 时间戳暴露下单时间——金融场景不可接受。cus_ + 14 随机字符是最佳折中。ord_, inv_)让支持人员一眼看出 ID 类型,避免把 invoice ID 当 order ID 查。这是Stripe 最被低估的工程决策。推荐:Stripe 模式——{type_prefix}_{base62(随机)},22-30 字符。后端主键用 UUIDv7 / Snowflake,对外永远不暴露后端 ID,加一层映射。代价是要存映射表(或者把 prefix + random 直接作为主键),但获得了 API 演进自由度。
反例:早期 Twitter URL 里的 tweet ID 是自增 + 后改 Snowflake,暴露了发推量;现在虽然换了 Snowflake 但仍能反推时间——产品发布时的时间戳设计要慎重。