我把分布式ID从头到尾捋了一遍
从一道没答上来的面试题说起
前段时间一面,面试官问我:分库分表之后,主键 ID 还能用自增吗?
我当时答了句「不能,会重复」,然后就没有然后了。面试官等了十几秒,看我没下文,换了下一个题。
挂了之后我把这事记在了复习清单上。这两天专门把分布式ID这个主题从头到尾捋了一遍,把当时没说出来的东西补齐。这篇就是捋的过程,也当给自己的复习存档。
先搞清楚自增 ID 到底坏了什么
复习的第一步是把问题本身想清楚,而不是急着背方案。
自增主键好用的前提,是数据都住在一张表里。
MySQL 的 auto_increment 是表级别的计数器,每张表只管自己的水位,互相不知道对方的存在。单表时代这没问题,因为全世界只有一张表在发号。
分库分表把这个前提直接拆了。订单表拆成 100 张,每张都从 1 开始数,表 A 有订单 1,表 B 也有订单 1。查询的时候拿着 ID 根本不知道该去哪张表找。也就是说,坏掉的不是「自增」这个能力,而是「全局只有我在发号」这个隐含假设。
想通这一点之后,分布式ID要做什么就清楚了:找一个所有分表都认的源头来发号,只要发出的号全局不重复,拆多少张表都不怕。至于「最好趋势递增」,是后来看 UUID 的时候才理解到的,往下说。
为什么不直接用 UUID
这是我当时卡壳的第二个地方。
面试官其实追问了一句「那 UUID 行不行」
UUID 确实能保证不重复,本地生成,不用任何中心服务,光看唯一性它是合格的。但项目里主键要进 InnoDB,而 InnoDB 的数据是按主键顺序物理组织的。UUIDv4 是纯随机串,每插一条都可能在 B+ 树中间找位置插入,随机页访问和页分裂的概率都上去。
丁奇的《MySQL 实战 45 讲》里专门讲过随机主键对写入的影响(具体结论建议自己去翻原文,我不转述了),大意就是有序主键对 InnoDB 明显更友好。
另外 UUID 文本形式 32 到 36 个字符,比 bigint 宽好几倍。主键宽,所有二级索引都得跟着宽,空间翻着倍地涨。
不过复习材料里有个点纠正了我之前的认知:不能因为 v4 随机就把 UUID 一棍子打死。UUIDv7 带了毫秒级时间戳,排序友好很多。所以结论应该带着条件说:v4 当 InnoDB 主键不行;不进数据库、只在服务间传个标识的场景,UUID 依然是好选择。面试的时候能把条件说出来,和一句「不行」,是两个水平。
我想要的其实是三个东西的组合:全局唯一、趋势递增、ID 短。
顺着这三条去找,主流方案就两种:号段模式和雪花算法。
号段模式:把自增「批发」出去
号段模式的思路是在数据库里放一行记录,存当前已分配的最大值。节点要号的时候一次批发一段,比如 1000 个:数据库在一个事务里把最大值从 1000 推到 2000,这 1000 个号就归这个节点了。别的节点拿到的是下一段,段与段之间天然不重叠。
我最开始没想通的是这一步为什么值钱。后来算了一下才反应过来:数据库访问次数直接除以 1000。节点拿到段之后在内存里慢慢发,数据库平时闲着。在这个基础上还可以做双号段优化,当前段用到一成左右就异步申请下一段,段用完的那一刻就不用停下来等数据库。
复习的时候我给自己总结了两个必考的坑,都是资料里反复强调的:
一是数据库单点。数据库挂了,本地段用完就得停服,所以号段库本身要做高可用。二是 ID 空洞。节点重启会扔掉没用完的半截段,序列里从此有个洞。洞本身无害,订单号断个号用户感知不到,危险的是有人想「把洞补上」,把丢弃的号段捡回来接着用,一捡就重复。
Redis 的 INCR 我也顺手看了一下,思路和号段有点像(原子计数),但每次发号都有一次网络往返,而且 Redis 持久化是异步的,主从切换时计数器可能回退,之前发出去的号会被再发一遍。原子自增解决的是单点并发竞争,管不了故障场景。作为面试的对比项记住就行。
雪花算法:面试官最爱深挖的地方
雪花算法(Snowflake)是这几年的高频考点,原因我猜是它考点密集,一道题能从位划分问到分布式一致性。
位划分本身不难背:1 位符号位、41 位毫秒时间戳、10 位机器标识、12 位序列号。41 位时间戳是当前毫秒减去一个自定义起始纪元,够用 69 年;10 位机器标识是 1024 个节点;12 位序列号管同一毫秒内的并发,一毫秒一台机器 4096 个,用完了自旋等下一毫秒。发号全程在内存里,不碰外部组件,产物是 8 字节的 long。
真正拉开差距的是往下追问的两层。
第一层是时钟回拨。唯一性依赖三个前提:机器号不重复、同一毫秒内序列不重复、时间不往回走。前两个是代码能保证的,第三个不归你管,NTP 校时、运维手动调表都可能让时间往回跳。设想节点在 10:00:00.100 发过一批 ID,时间被拨回 10:00:00.095,程序如果不设防会把序列号清零重新走,等再走到 .100,一个和过去一模一样的 ID 就出来了。
应对分三档,我把口诀记成了「小的等、大的停、逻辑顶」:回拨几毫秒到几秒,暂停发号等时间追上来;回拨幅度大,报警并拒绝发号,宁可停服不赌运气;或者维护逻辑时钟,内部时间永远不小于上次用过的值,但这个状态必须持久化,进程重启要能恢复,只在内存里存个变量等于没做。
第二层是机器号的分配和回收,这个我看面经里很少有人主动提。容器环境扩缩容是常态,老节点 A 还活着,新节点 B 拿到同一个机器号,两边各自看都正常,重复 ID 源源不断地生产,而且这种故障什么报错都没有。所以看成熟实现的时候要重点看机器号怎么管理,比如美团 Leaf 用 ZooKeeper 或数据库分配机器号,百度 UidGenerator 有自己的分配策略(细节我还没读源码,等读完单独写一篇)。
我自己的学习路径是:位划分记住即可,重点读一个成熟实现的源码,看它机器号分配、回拨兜底、重启恢复这三处怎么写。位运算十分钟就能抄完,这三处才是功力所在。
复盘这道题的答法
现在再回到那道「分库分表后 ID 怎么办」,我觉得完整的答法应该是一条线:
先说自增坏在哪(表级计数器失去全局视角),再说唯一性怎么解决(号段或雪花),然后主动补上趋势递增这个维度(引出 UUIDv4 的反例),最后抛时钟回拨和机器号这两个深水区展示深度。


