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 反推出的时间戳,与生成时刻误差 &lt; 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 的编码方式本身就是信息。

关联