44.24 发号器与全局 ID
一句话
分库分表之后,自增主键失效,全局 ID 必须同时满足三件事:全局唯一、大致有序(对 B+ 树友好)、能反解出时间/节点(便于路由与排查)——Snowflake 用
41 位毫秒 + 10 位机器 + 12 位序列把这三件事编码进一个 long,代价是每毫秒 4096 个的上限与对时钟的强依赖。
五种方案,先看约束
分片本身见 44.14 分库分表;这一节只回答 “分片之后 ID 从哪来”。
| 方案 | 唯一性 | 趋势递增 | 单点依赖 | 单机上限 | 适合 |
|---|---|---|---|---|---|
| 数据库自增 / 步长 | 靠 DB | 是 | DB 是单点 | 受 DB 写入限制 | 小规模、单库 |
| 号段模式(双 buffer) | 靠 DB 分配区间 | 是 | DB(可多库分步长) | 号段大小 ÷ 取号耗时 | 主流业务 ID |
Redis INCR | 靠 Redis | 是 | Redis | 单实例约 10 万/s 量级 | 中等规模、可容忍抖动 |
| Snowflake / 类 Snowflake | 时间 + 机器 + 序列 | 是 | 无(本地生成) | 4096/毫秒 ≈ 409.6 万/s | 高并发、可接受时钟依赖 |
| UUID v4 / ULID | 概率唯一 | v4 否 / ULID 是 | 无 | 无上限 | 请求 ID、非主键 |
🔑 选型的第一问题不是 “哪个快”,而是 “ID 会不会当主键/分片键”:当主键要求趋势递增(否则 B+ 树随机插入),当分片键要求分布均匀(否则热点)。这两个要求方向相反,是后面所有坑的根源。
Snowflake:位分配就是容量规划
64 位拆成三段,怎么切决定了你的上限:
| 段 | 位数 | 数值范围 | 含义 |
|---|---|---|---|
| 时间戳(毫秒) | 41 | 2^41 ms ≈ 69.7 年 | 从自定义纪元起算 |
| 机器 / 机房 | 10 | 1024 个节点(可拆 5 位机房 + 5 位机器 = 32 × 32) | 部署时分配 |
| 序列号 | 12 | 4096 个/毫秒 | 同毫秒内自增 |
算一下上限:
单节点:4096 个/ms × 1000 = 4,096,000 个/秒
时间跨度:2^41 / 1000 / 86400 / 365 ≈ 69.7 年
节点数:2^10 = 1024
所以 “Snowflake 够不够用” 是一道算术题:峰值写 QPS ÷ 4096 = 至少需要几个节点,且节点 ID 必须全局唯一(部署时分配,或启动时向 ZooKeeper/etcd 注册)。
如果把机器位从 10 位砍到 6 位、把序列位扩到 16 位,单节点就能到 65536/ms——代价是节点数降到 64 个。位宽是零和博弈,先算出 “节点数上限” 和 “每毫秒峰值”,再切。
UUID 为什么不能当主键
| 问题 | 具体后果 |
|---|---|
| 无序 | InnoDB 聚簇索引按主键排序,随机 UUID 插入要不断定位、页分裂、机械盘寻道 |
| 空间 | 32 个十六进制字符,比 8 字节 bigint 大数倍;二级索引里都要带一份主键 |
| 不可读 | 出问题无法从 ID 看出时间与来源 |
| 只能做请求 ID | 链路追踪、幂等键非常适合它(无序无所谓,只要唯一) |
顺带一个对照:身份证前 6 位是地区、7–14 位是生日、手机号前 3 位是运营商——这类 “可反解” 的编号体系说明 ID 的编码方式本身就是信息。Snowflake 的 41 位时间戳同理:拿到 ID 就能反推生成时刻,排查 “这条数据是什么时候写的” 不用查库。
时钟:唯一的硬依赖
Snowflake 的正确性建立在 “时间单调向前” 上。NTP 校时、虚拟机迁移、闰秒都可能让时钟回拨。回拨 1ms 会发生什么,本机实测(Python 3.14 / macOS arm64):
EPOCH_MS, MAX_SEQ = 1_600_000_000_000, (1 << 12) - 1
def mkid(ts, node, seq): return (ts << 22) | (node << 12) | seq
def naive(ms): # 只按 ts 变化重置 seq,不检查回拨
st = {"last": -1, "seq": 0}
def nxt():
ts = ms() - EPOCH_MS
st["seq"] = st["seq"] + 1 if ts == st["last"] else 0
st["last"] = ts
return mkid(ts, 1, st["seq"])
return nxt
def safe(ms): # 回拨时复用 last_ts,继续递增 seq
st = {"last": -1, "seq": 0, "back": 0}
def nxt():
ts = ms() - EPOCH_MS
if ts < st["last"]:
st["back"] += 1; ts = st["last"]
st["seq"] = st["seq"] + 1 if ts == st["last"] else 0
st["last"] = ts
return mkid(ts, 1, st["seq"])
return nxt, st
now = [1_700_000_000_000]
n = naive(lambda: now[0])
a = n(); now[0] += 1; b = n(); now[0] -= 1; c = n()
print(f"天真实现 a={a} b={b} c={c} a==c ? {a==c}")
now = [1_700_000_000_000]
n2, st = safe(lambda: now[0])
a = n2(); now[0] += 1; b = n2(); now[0] -= 1; c = n2()
print(f"防御实现 a={a} b={b} c={c} a==c ? {a==c} 回拨处理 {st['back']} 次")
天真实现 a=419430400000004096 b=419430400004198400 c=419430400000004096 a==c ? True
防御实现 a=419430400000004096 b=419430400004198400 c=419430400004198401 a==c ? False 回拨处理 1 次
第一行就是线上事故的样子:回拨 1ms,直接发出两个相同的 ID,而两条 ID 都已经写进了业务表。
| 处理方式 | 行为 | 代价 |
|---|---|---|
| 自旋等时钟追上 | 短暂阻塞后继续 | 回拨大时阻塞时间长 |
| 直接拒发 | 返回错误 | 业务中断(等价于发号器挂了) |
| 复用 last_ts 继续加 seq | 本机实测可行 | 序列提前耗尽,最多撑 4096 个 |
换单调时钟(CLOCK_MONOTONIC) | 不受 NTP 影响 | 跨重启不连续,纪元要重置 |
⚠️ 生产上要显式选一种并写进预案。“时钟回拨” 一定会在某台机器上发生,区别只是你有没有提前处理。
号段模式:更适合业务 ID
Snowflake 的 ID 里带时间,天然有序且不依赖中心;但 “每毫秒 4096” 与 “机器位必须唯一” 对中小团队是负担。号段模式更简单:
① 应用启动 / 号段用尽时,向 DB 申请一段:UPDATE seq SET max_id = max_id + 1000 WHERE biz = 'order'
② 拿到 [1001, 2000] 这段,之后全程本地自增,不再访问 DB
③ 双 buffer:当前段用到 20% 时,异步预取下一段,避免用尽时阻塞
| 参数 | 取值思路 | 影响 |
|---|---|---|
| 号段大小 | 1000–10000 | 太小:DB 压力大;太大:重启浪费 |
| 预取阈值 | 20% | 太低:可能来不及取;太高:浪费段 |
| 多库步长 | 库数 × 步长 | 加库必须同步改步长,否则重号 |
号段模式的单机上限 = 号段大小 ÷ 取号耗时,只要号段够大,本地自增能到百万级/秒;它的风险集中在 DB:取号抖动会直接卡住发号。
低 QPS 的隐藏坑:序列号永远从 0 开始
如果业务 QPS 很低,每毫秒最多一个 ID,序列号就会恒为 0。把 ID 当分片键时,hash(id) % 分片数 会因为低位几乎不变而分布倾斜,热点全落在一个分片。
解法是让低位有变化:
| 做法 | 效果 |
|---|---|
| 时间戳按秒存,序列号按毫秒自增 | 低位变化幅度变大 |
| 序列号起点随机(这秒从 21 起、下秒从 30 起) | 低位随机化,分布均匀 |
| 用号段模式替代 | 号段本身就是均匀递增的区间 |
生产落地:指名 → 可抄 → 可验 → 可退
指名:要现成的用 美团 Leaf(号段 + Snowflake 双模式)、滴滴 TinyID(号段)、百度 UidGenerator(改进 Snowflake,借用未来时间)、Sonyflake(39 位时间 + 8 位序列 + 16 位机器,适合长周期);自研就用 Snowflake 变体。
可抄(位分配配置表,先算再切):
| 业务峰值 | 需要的节点数 | 建议位宽(时间/机器/序列) | 单节点上限 |
|---|---|---|---|
| ≤ 4000/s | 1 | 41 / 10 / 12 | 409.6 万/s |
| ≤ 50 万/s | ≥ 1(有机器位即可) | 41 / 10 / 12 | 409.6 万/s |
| 单节点要更高 | 机器少、序列多 | 41 / 6 / 16 | 6553.6 万/s |
可验:三条断言,写完就跑。
① 唯一性:并发生成 100 万个,去重后数量必须还是 100 万
② 单调性:按生成顺序,ID 必须严格递增(同一节点、无回拨)
③ 可反解:从 ID 反推出的时间戳,与生成时刻误差 < 1 秒
上面 “实测” 里那段脚本跑的就是 ① 与 ② 的最小版本;第三条用 (id >> 22) + EPOCH 反解即可。
可退:
| 退路 | 做法 |
|---|---|
| 发号器不可用 | 本地缓存一段号段(预取),撑住故障窗口 |
| 时钟回拨 | 切换为 “复用 last_ts + 序列递增”,并在监控上告警 |
| 重号风险 | 业务侧兜底:唯一索引 + 冲突重试(幂等键) |
| 迁移换方案 | ID 里预留 1–2 位 “版本/来源”,新旧方案可共存 |
反模式
| 反模式 | 后果 |
|---|---|
| 用 UUID 做 InnoDB 主键 | 随机插入导致页分裂、索引膨胀,写入性能断崖 |
| 机器位写死且不注册 | 扩容时两台机器拿到同一个 ID → 重号 |
| 不处理时钟回拨 | 回拨 1ms 就重号,且已经写进业务表(本机已复现) |
| 号段太小或预取太晚 | 取号请求成为新瓶颈,抖动直接卡业务 |
| 多库步长忘记同步修改 | 加库后 ID 重复,事后靠唯一索引才发现 |
| 低 QPS 下序列恒 0 还当分片键 | 全部分片流量压到一个节点(热点) |
| 发号器与业务同库同事务 | 发号变慢 = 业务变慢,故障域被放大 |
| 没有 “反解时间” 的约定 | 出问题无法从 ID 判断写入时刻,排查成本翻倍 |
三条铁律
🔑 先算峰值再切位宽:
峰值 QPS ÷ 每毫秒序列数 = 需要的节点数;位宽之和必须等于 63(留 1 位符号)。
🔑 机器 ID 必须全局唯一:部署分配或启动注册,二选一,不能 “默认从 1 开始”。
🔑 时钟回拨是必然事件:要么等、要么拒、要么复用时间戳,必须显式选一种并演练。
自检
Q1 为什么 UUID 能做请求 ID,却不能做 MySQL 主键?
▸ 答案:请求 ID 只要求唯一,无序无所谓;主键在 InnoDB 里决定聚簇索引的物理顺序,随机 UUID 会导致插入时不断定位、页分裂、索引膨胀,写入性能断崖式下降。另外 UUID 占空间更大,二级索引里还要各存一份主键。
Q2 单节点 Snowflake 每秒最多发多少个 ID?这个数怎么来的?
▸ 答案:2^12 × 1000 = 4,096,000。12 位序列给同一毫秒内 4096 个名额,乘 1000 毫秒即每秒上限。所以 “够不够用” 取决于峰值毫秒内的写入量,而不是平均 QPS——突发流量按毫秒算。
Q3 时钟回拨 1ms,为什么会产生重复 ID?
▸ 答案:因为 ID 的三段是 (时间戳, 机器, 序列),回拨后时间戳回到了一个已经用过的值,而序列号又被重置为 0,于是拼出与之前完全相同的 ID(本机实测 a == c 为 True)。防护要么不重置(复用 last_ts 继续加序列),要么等时钟追上,要么拒发。
Q4 为什么低 QPS 业务用 Snowflake 反而可能出问题?
▸ 答案:序列号恒为 0 意味着 ID 的低位几乎不变。若把 ID 当分片键,hash 后的分布会随高位缓慢变化而严重倾斜,形成热点。解法是让低位有随机性(序列号随机起点、时间戳按秒存),或改用号段模式。
Q5 号段模式的单机上限由什么决定?风险在哪?
▸ 答案:上限 ≈ 号段大小 ÷ 取号耗时(本地自增本身几乎免费)。风险集中在 “取号” 这一步:DB 抖动会让发号阻塞;多个库表场景还要维护 “步长 = 库数”,加库不同步就重号。
Q6 机器位 10 位够用吗?什么时候该扩?
▸ 答案:10 位 = 1024 个节点。按 “每个 Pod 一个节点 ID” 部署时,副本数 × 服务数很容易超过 1024;这时要么扩机器位(压缩序列位,牺牲单机上限),要么改成 “机房 ID + 机器 ID” 分层并复用,要么改用号段模式(不需要机器位)。
Q7 为什么发号器要能 “反解时间”,这有什么工程价值?
▸ 答案:从 ID 直接算出生成时刻,能在不影响业务表的前提下回答 “这条数据什么时候写的、是不是同一批”,用于排查重复写入、定位慢查询的时间分布、验证迁移是否漏数。身份证/手机号的编码思路同理——ID 的编码方式本身就是信息。
关联
- 前置:44.14 分库分表、44.8 索引原理(为什么主键要趋势递增)
- 并列:44.22 计数系统、18.7 复制拓扑(主从切换也会让时钟与 VIP 抖动)
- 下游:25.4 幂等设计与去重、23.2 NTP 与 TrueTime