Theory · Microservice · Distributed ID

分布式 ID

分库分表之后自增主键失效 —— 全局唯一、趋势递增、高可用,一个 ID 生成器的三重考验

一句话本质

在没有中心数据库自增能力的情况下,给分布式系统的每条数据发一个"身份证"——既要全局不撞,又要对索引友好

主流方案

UUID(无序)· 数据库号段(Leaf-segment)· 雪花算法 Snowflake(时间戳+机器+序列)· Redis/etcd 计数

必考点

雪花算法 64bit 结构与时钟回拨处理;UUID 无序对 B+ 树的伤害;号段模式双 buffer 优化

这份 deck 回答"分布式主键怎么生成":需求清单 → UUID 的无序问题 → 数据库号段与双 buffer → 雪花算法结构与时钟回拨 → 选型对比与开源实现 → QA。核心记忆点:雪花 64bit 切分、时钟回拨四种解法、号段双 buffer 预取。

Why

为什么需要:自增 ID 在分布式下的失效点

先看一个必然发生的事故:订单表拆成 8 个库,每个库的 auto_increment 都从 1 开始。第一分钟,8 个库各自产生了主键为 1、2、3… 的订单——8 条订单共用同一个"身份证号",对账系统当场崩溃。

自增 ID 的四个失效场景

  • 分库分表:8 个库各自从 1 递增 → 主键冲突,唯一性失效
  • 多写节点/多活:两地机房同时写入,auto_increment 无法协调
  • 先写后知的场景:下单前要先生成订单号给用户展示/发短信——写库前就需要 ID
  • 非表主键场景:链路 trace-id、消息 msg-id、幂等键——这些"ID"根本不在数据库里自增
  • 对账与追溯:外部单号/内部主键需要携带可解析的生成时间信息——纯自增无法反查"这条数据什么时候产生的"

分布式 ID 的六条要求(面试标准清单)

  • 全局唯一:底线要求
  • 趋势递增:InnoDB 主键有序写入避免页分裂(见下页 UUID 反例);利于 range 查询与排序
  • 单调递增(更强):部分场景需要(版本比较),多数可放宽为趋势递增
  • 信息安全:连续 ID 暴露业务量(订单号能猜到日出单量)——要不要防爬取决于业务
  • 高可用低延迟:ID 服务挂了 = 全业务停写,生成延迟要低(<1ms 级)
  • 高吞吐:大促峰值 10w+ QPS 生成能力

趋势递增为什么重要(必答点):InnoDB 主键是聚簇索引,无序主键插入 = 随机位置插入 → 频繁页分裂与页缓存失效,写入性能骤降且碎片化。有序主键永远追加到最右页——顺序 IO,这是 MySQL 面试与分布式 ID 题的交叉点。

为什么需要:四个失效场景(分库分表冲突、多活、先写后知、非表 ID)加六条要求清单(唯一/趋势递增/单调/信息安全/可用性/吞吐)。趋势递增与 InnoDB 聚簇索引的关系是交叉必考点:无序主键导致页分裂随机写,有序追加是顺序 IO。

Prerequisites & Glossary

先把词认全:下面每一页都会用到它们

术语一句话理解(先记住这个,细节后面展开)
自增主键数据库自己维护的"每次 +1"计数器。单机好用,多台各数各的就撞号
分库分表一张大表按规则拆到多个库/表上;每个库有自己的自增计数器——冲突的根源
全局唯一整个系统(所有库、所有机房)范围内不会有两个相同的值
趋势递增不要求严格 +1,只要"后生成的大体上比先生成的大"——这是索引友好的底线
聚簇索引 / 页分裂InnoDB 按主键顺序存数据;乱序主键要插到中间,触发页分裂与随机 IO
UUID128 bit 的本地生成标识:零依赖,但太长(36 字符)且(v4)完全无序
号段 Segment一次向 DB 批发 1000 个号,在内存里发完再去领——把 DB 访问频率降 1000 倍
雪花算法64 bit = 时间戳 + 机器号 + 毫秒内序号;本地生成,时间在高位所以趋势递增
workerId雪花里"我是哪台机器"的位段;两台机器取到同一个 workerId 就会生成重复 ID
时钟回拨机器时间被 NTP 调回过去,导致新 ID 的时间戳比旧 ID 还小

前置 deck(本 deck 默认你已经知道)

B+ 树索引 → 为什么"主键有序"这件事值一整页去讲
InnoDB 锁体系 → 自增锁(AUTO-INC lock)在单机下怎么保证不撞
微服务总览 → 数据私有与分库分表是怎么引出本问题的

本 deck 怎么用这些词

后面统一说"发号"。你只要记住一件事:所有方案都在回答同一个问题——"谁来当那个唯一的计数器",以及"当它不是中心数据库时,凭什么相信它不重复"。

一个提前建立的直觉

发号方案的差别,本质是把唯一性押在什么东西上:UUID 押在"随机数大到撞不上",号段押在"DB 只发一次不重发",雪花押在"机器号不重复 + 时钟不倒退"。看一个方案,先问它押的是什么,再问这个前提崩了会怎样——这就是后面每一页的读法。

最小心智模型:发号 = 把"排队领号"从一个中心数据库,改成每台机器自己按规则造号;规则必须自带"我造的号别人造不出来"的信息——要么是随机数(UUID),要么是我的编号(workerId),要么是我领走的一段(号段)。
前置页:十个术语先定义再使用。右侧给三条前置 deck 链接,并给出"唯一性押注对象"这一统一读法,为后面 UUID / 号段 / 雪花三页的对比建立共同框架。

UUID

UUID:零依赖但"太自由"

机制与版本

  • 128 bit 标准格式(8-4-4-4-12 hex),如 550e8400-e29b-41d4-a716-446655440000
  • v1:时间戳+MAC 地址——趋势递增但泄漏 MAC(隐私),且时钟回拨需处理
  • v4:纯随机 122 bit——最常用;碰撞概率极低(生成 10 亿个约 10^-18 级冲突概率)
  • v7(2024 RFC 9562 标准化):Unix 毫秒时间戳前缀+随机——时间有序,为数据库索引设计的新版本,值得关注
  • Go:github.com/google/uuid

优缺点(背表)

优点缺点
本地生成零依赖、无网络开销无序(v4):聚簇索引页分裂、写放大
全局唯一(统计意义上)太长:36 字符,16 字节——索引体积大、比较慢
水平扩展天然支持不可读、无业务含义
传输/调试可见v4 无序且不防信息泄露的"业务量"问题意义不大(反正无序)

适用:trace-id、消息 id、分布式系统关联 ID——不适合做 MySQL 主键(除非 v7 或有序化改造)。

UUID 做主键的性能账(数字记忆)

同样 1 亿行:BIGINT 主键索引 ~2GB 量级,UUID(36char) 主键翻数倍,且二级索引全部携带主键副本(InnoDB 叶子存主键)——放大效应明显。无序插入导致 buffer pool 命中率下降。结论:主键要短且有序。

如果非要 UUID 做主键

缓解手段:① 换 v7/ULID(时间前缀有序);② 存 BINARY(16) 而非字符串(体积减半,比较按字节);③ UUID_TO_BIN(..., swap_flag) 把时间低位换到高位(MySQL 8.0 函数)实现近似有序。面试给这三招 = 知识面加分。

UUID 页:版本演进(v1 隐私问题、v4 随机、v7 RFC 9562 时间有序)、优缺点表(零依赖 vs 长且无序)、性能账(索引体积与页分裂放大)。三招缓解:v7/ULID、BINARY(16)、UUID_TO_BIN swap。结论:关联 ID 适用、MySQL 主键慎用。

Segment Mode

数据库号段模式:Leaf-segment 与双 buffer

号段模式与双 buffer 预取 单号段模式:ID 服务从数据库批量申请一段号如 1 到 1000,内存里顺序发放,用完再申请下一段。双 buffer 优化:当前号段消耗到 10% 时异步预取下一个号段进第二缓冲,当前段耗尽立即切换,消除申请时的数据库等待毛刺。号段表记录业务标记、当前最大值与步长。 DB 号段表(一次事务申请) biz_tag | max_id | step order | 210000 | 1000 coupon | 55000 | 500 UPDATE max_id=max_id+step ID 服务(应用内存) buffer1: 201001+ buffer2: 202001+ 当前段用到 ~90% → 异步预取下一段 批量申请 业务方(本地内存取号) GET /id/order → 201001, 201002… 纯内存发放:微秒级 单号段的问题:号段用尽那一刻要同步等 DB(RT 抖动 → 取号毛刺);DB 重启期间服务不可用 双 buffer(Leaf 优化):消耗到阈值(如 10% 剩余)即异步预取下一段 → 切换零等待;进一步优化:步长动态化(按 QPS 自动调整,保证每段可用 10-60 分钟) 属性:趋势递增 ✓ · 信息安全 ✗(连续可猜)→ Leaf 提供号段+雪花两种;DB 高可用是依赖点(多主/Proxy 兜底) 重启后号段浪费(跳号)是接受的代价——ID 服务保证唯一,不保证连续
号段模式图解:DB 号段表(biz_tag/max_id/step,一次事务 UPDATE 批量申请)→ ID 服务内存发放 → 业务方微秒级取号。核心优化双 buffer:用到 90% 异步预取下一段消除取号毛刺,步长动态化。属性:趋势递增、可读但连续可猜(安全问题)、DB 是依赖点、重启跳号是接受代价。

Snowflake

雪花算法:64bit 的精确切分(必背图)

雪花算法 64 位结构 64 位整数分为四段:最高 1 位符号位恒为 0,41 位毫秒级时间戳可用约 69 年,10 位机器 ID 支持 1024 个节点,12 位序列号表示同毫秒内最多 4096 个序号。理论单机每秒生成约 409.6 万个 ID,ID 随时间趋势递增。 1 bit 符号位 41 bit 时间戳(ms) 2^41 ms ≈ 69.7 年 · 相对自定义纪元 10 bit 机器 ID 1024 节点(5机房+5机器) 12 bit 序列号 同毫秒 0-4095 自旋 合计 64 bit int64/long id = ts<<22 | workerId<<12 | seq 时间在高位 → 全局趋势递增(无需协调) 单机吞吐 = 4096 × 1000 ≈ 409.6 万/s 毫秒耗尽序列 → 自旋等待下一毫秒 优点:本地生成零依赖 · 趋势递增 · 吞吐极高 · ID 含时间可反解生成时间(排查利器) 两个命门:① 时钟回拨(下一页)② workerId 分配与回收——谁保证 1024 个节点不重号? workerId 方案:静态配置 / DB 表分配 / ZK/etcd 顺序节点 / K8s StatefulSet 序号 / IP 后缀哈希——容器环境 IP 漂移是大坑
雪花算法必背:64bit 四段切分(1 符号 + 41 时间戳 ≈69.7 年 + 10 机器 1024 节点 + 12 序列 4096/ms ≈ 409.6 万/s)、拼接公式 id = ts<<22 | workerId<<12 | seq。优点与两个命门:时钟回拨(下页)、workerId 分配回收(容器 IP 漂移是大坑,列五种方案)。时间在高位带来趋势递增是原理核心。

Clock Drift

时钟回拨:雪花算法的头号工程难题

问题定义

  • NTP 校时导致系统时间倒退:当前毫秒 < 上次生成 ID 的毫秒
  • 后果:新 ID 的时间戳小于历史 ID → 趋势递增被破坏 + 序列号可能重用 → ID 重复(最严重)
  • 诱因:NTP 阶跃校正、虚拟机迁移/快照恢复、手动改时间、闰秒处理
  • 本质:雪花把"全局协调"换成了"本地时钟信任"——时钟不可靠时,唯一性的前提就塌了

四种解法(面试背这个梯度)

  • ① 小回拨等待:回拨 ≤ 阈值(如 5ms/100ms)→ 自旋等待时间追上。简单,限小回拨
  • ② 拒绝服务:大回拨直接报错 + 告警人工介入——保守但安全(美团 Leaf 默认)
  • ③ 扩展位方案:预留几 bit 做"回拨计数/备用位"(百度 UidGenerator 思路)——每次回拨翻新位段,牺牲序列容量换不重复
  • ④ 逻辑时钟:不用系统时钟,用"上次最大时间戳与当前取 max"——永不倒退,但长期漂移会让 41bit 更快耗尽

工程加固清单

① 启动时校验:持久化上次时间戳(文件/DB/Redis),启动发现当前时间 < 上次 → 拒绝启动;② NTP 配置成 slew 渐变模式(避免 step 跳变);③ 监控:时钟偏移指标 + ID 服务"等待回拨"计数告警;④ 关键业务在 ID 层加唯一约束兜底(同 workerId 同毫秒同序列的 ID 入库报错可感知)。

workerId 重复问题(同级别考点)

两个节点拿到同一 workerId → 同毫秒同序列 = 必然重复 ID。方案:DB/ZK 分配表(注册回收)、K8s StatefulSet 序号、启动时 etcd 抢注 lease。容器环境禁止"IP 后缀取模"(Pod IP 漂移 + 重启可能撞号)。答"雪花有什么坑"必须两个坑一起答。

时钟回拨页:问题定义(NTP 倒退→趋势破坏+重复)、四种解法梯度(小回拨等待、大回拨拒绝告警、扩展位(UidGenerator)、逻辑时钟取 max)、工程加固四条(持久化上次时间戳、NTP slew 模式、监控告警、唯一约束兜底)。孪生问题 workerId 重复必须一起答——容器环境 IP 漂移场景。

Decision

方案选型对比与开源实现

方案唯一性来源有序性依赖吞吐适用
UUID v4随机 122bit无序极高trace-id、消息 id、非主键关联
UUID v7 / ULID随机尾段趋势(毫秒前缀)极高需要无依赖又有序的场景、新系统主键候选
号段(Leaf-segment)DB 批量分配趋势递增(可读连续)DB(多主)高(内存发放)订单号等业务 ID,需要可读/可控步长
雪花 SnowflakeworkerId+时钟趋势递增(含时间)workerId 协调极高(409万/s/机)高并发主键、分库分表 ID 默认答案
Redis INCR单点原子计数严格单调Redis 高可用中(10w 级)小规模计数、日序列号(order-20260902-000123)
etcd/ZK 顺序节点共识服务严格单调etcd/ZK任务编号、选主序号等低频场景

开源实现速览

美团 Leaf(号段+雪花双模式)· 百度 UidGenerator(雪花变体,RingBuffer+扩展位抗回拨)· 索尼 Sonyflake(位重排:39bit 时间戳 174 年,序列 8bit 更省)· baidu/uid-generator 与 Leaf 面试提名字即可

组合用法(工程常态)

分库分表主键用雪花;对外订单号 = 业务前缀+日期+雪花尾段(可读+不可猜量级);日流水号用 Redis INCR+日期 key(TTL 隔天过期)。不同 ID 需求不同方案,不必一刀切。

选型一句话

默认雪花(吞吐与有序兼备);要可读/风控友好加号段;纯关联 ID 用 UUID v7;弱依赖禁止 Redis/DB 直连取号(单点)。 Always 问一句:这个 ID 用来当主键、当业务单号、还是当关联标识?

选型对比表六行按唯一性来源/有序性/依赖/吞吐/适用五维。开源实现三个名字:美团 Leaf(双模式)、百度 UidGenerator(RingBuffer+扩展位)、Sonyflake(位重排 174 年)。组合用法是工程常态:主键雪花、订单号加前缀、日流水 Redis INCR。选型先问 ID 的用途再定方案。

Cheat Sheet

一页带走:需求、方案、雪花必背、避坑

① 六条需求(先问再选)

全局唯一底线,没有商量余地
趋势递增索引友好: InnoDB 聚簇索引顺序写,避免页分裂与随机 IO
信息安全连续 ID 会泄漏业务量——对外单号要加扰
高可用 / 低延迟ID 服务挂 = 全业务停写;所以尽量本地生成

② 方案速判(按"押注对象"记)

UUID v4随机数;零依赖但无序 + 36 字符——只适合 trace-id / 消息 id,不做主键
UUID v7 / ULID押随机数 + 毫秒时间前缀;无依赖又有序,新系统主键候选
号段 Leaf-segmentDB 不重发;可读连续、步长可控,代价是依赖 DB、重启跳号
雪花 SnowflakeworkerId 不重 + 时钟不倒退;零网络依赖、409 万/s/机,默认答案
Redis INCR / etcd 序号中心计数器;严格单调但吞吐有限,只做日流水号等低频场景

③ 雪花必背(64 bit 切分)

1 + 41 + 10 + 12符号位 0 · 41 bit 毫秒时间戳(≈69.7 年,相对自定义纪元)· 10 bit 机器号(1024 节点)· 12 bit 毫秒内序号(4096/ms)
拼接公式id = ts<<22 | workerId<<12 | seq —— 时间在高位所以全局趋势递增,无需任何协调
吞吐4096 × 1000 ≈ 409.6 万/秒/机;序号用尽自旋等下一毫秒

④ 两个命门与解法梯度

时钟回拨(按幅度分层):小回拨自旋等待 → 大回拨拒绝服务 + 告警(Leaf 默认)→ 扩展位方案(UidGenerator)→ 逻辑时钟取 max 永不倒退。
工程加固:持久化上次时间戳做启动校验 · NTP 配 slew 渐变模式 · 时钟偏移与"等待回拨"计数告警 · 唯一约束兜底。
workerId 防重:DB/ZK 分配表注册回收 · K8s StatefulSet 序号 · etcd 抢 lease;容器环境禁止 IP 后缀取模(Pod IP 漂移会撞号)。
一句话背下来:默认选雪花(吞吐 + 有序 + 零依赖),要可读可控加号段,纯关联 ID 用 UUID v7;看到任何发号方案,先问它把唯一性押在什么上
速查页:四块按使用顺序排。核心带走两点——64 bit 切分与"时间在高位"的原理、时钟回拨四解法梯度与 workerId 孪生问题。

Interview QA

高频 QA

先盖住答案自己答一遍,再往下对照——想不起来比看得顺眼记得牢;答不出的直接翻回上一页速查表。

Q1 UUID 为什么不适合做 MySQL 主键?

页分裂体积

InnoDB 聚簇索引按主键组织数据:v4 无序插入 = 随机位置插入,触发页分裂、写放大、buffer pool 命中率下降;且 36 字符/16 字节体积让主键与所有二级索引(叶子存主键副本)膨胀。缓解:v7/ULID 有序化、BINARY(16)、UUID_TO_BIN swap。

Q2 雪花算法怎么解决时钟回拨?

四解法梯度

按回拨幅度分层:小回拨(几 ms)自旋等待追平;大回拨拒绝服务+告警(Leaf 方式);扩展位方案预留回拨计数位(UidGenerator);逻辑时钟取 max(lastTs, now) 永不倒退。工程加固:启动校验持久化的上次时间戳 + NTP slew 模式 + 监控。同时必须答 workerId 防重(DB/ZK 分配或 K8s 序号)。

Q3 号段模式和雪花怎么选?

可读性 vs 零依赖

号段:ID 可读连续、步长可控,但依赖 DB 高可用、扩容要预建段、重启跳号——适合对外业务单号(订单号可带业务含义)。雪花:零网络依赖、吞吐极高、天然趋势递增,但依赖 workerId 管理与时钟正确——适合内部主键。Leaf 两种都提供,按 ID 用途选。

Q4 订单号要防"猜量",怎么设计?

加扰+前缀

纯雪花/号段连续可猜(竞对可推算日出单量)。做法:对外单号 = 业务前缀 + 日期 + 加扰尾段(雪花值哈希/截断映射、或加随机位);或直接跳号(号段步长随机化)。注意:内部主键(雪花)与对外单号分离设计——内部要有序高效,对外要防猜防枚举,两个 ID 用映射表/字段关联。

Q5 分库分表下,ID 的路由和生成怎么配合?

ID 与路由解耦

两种策略:① ID 不带路由信息,路由键(user_id 哈希)独立——灵活但查详情要带路由键;② ID 内嵌分片位(如低 4 位是分片号)——直接定位库表但扩容分片数困难。主流选 ①:生成与路由解耦,扩容只改路由规则。答出"解耦"思想即可。

Q6 ID 生成服务挂了怎么办?

去中心化答案

架构上避免"中心 ID 服务"单点:雪花/UUID 本地生成无此问题;号段模式则要求 DB 多主 + ID 服务多实例(各持不同号段)+ 客户端缓存一段。兜底降级:极端情况用"雪花近似实现"(时间+实例随机数)保证业务能写,事后对账去重。原则:ID 服务可用性 ≥ 业务可用性,或者干脆本地化生成。

六题:UUID 不适合主键的原因与缓解、时钟回拨四解法梯度(加 workerId 孪生问题)、号段 vs 雪花选型、订单号防猜(内外双 ID)、ID 与分片路由解耦、ID 服务高可用(本地化生成是根本答案)。

Related & References

相关知识点与参考

本领域相关 deck

跨领域相关 deck

参考链接(一手来源)

收尾链接:领域内幂等(键用途)、分布式事务(单号关联)、总览、发现(workerId 抢注);跨领域 MySQL 索引与自增锁、Redis INCR、布隆过滤器。参考:Twitter Snowflake 原始实现、美团 Leaf 官方博客、UidGenerator、RFC 9562(v7)、MySQL 聚簇索引文档。