DBA 拦下的那一步
老王给新业务建表,顺手把主键定成 UUID,第 2 篇的教训他还记得一半——不撞号就行。DBA 看了一眼建表语句:主键换掉,否则线上别想扩容平滑。老王不服:UUID 不重复又不依赖中心,凭什么被嫌弃?DBA 甩给他一句:主键不是发给你看的,是给 B+ 树住的。这一篇就讲清楚,B+ 树到底偏爱什么样的主键。
B+ 树视角:顺序是免费的,随机是最贵的
InnoDB 的主键索引是聚簇的:数据行按主键顺序物理排列在 B+ 树的叶子页里。主键的插入模式直接决定这棵树的生态:
递增主键:新行永远插到最右侧叶子页。页满了就开新页,老页原封不动——顺序追加,每页塞得满满当当,热数据集中在树的最右端,Buffer Pool 命中率极高。
随机主键(UUID v4):新行插到树的任意位置。目标页满了,InnoDB 被迫做页分裂——把原页一分为二,腾出位置,同时上层节点跟着分裂维护。代价三连:写放大(一次插入变成多页读写)、碎片率攀升(分裂后两个页各剩半空,永远填不满)、缓存命中率下降(热点分散在全树,Buffer Pool 装不下)。第 2 篇说 UUID「让索引很难过」,病根就在这。
结论先行:主键越趋近写入顺序,B+ 树越省。这就是为什么发号器系列绕来绕去,都在追求「趋势递增」四个字。
趋势递增的精确定义
工程上说递增,要分两档:
严格单调:任何时刻任何节点发出的大于之前所有号。只有中心化发号做得到——DB 自增、单点号段。代价是单点,天生和分布式拧着。
趋势递增(k-单调):不保证全局有序,但大致随时间往上走。允许同一毫秒两台机器发出的号互有先后,允许号段模式下 B 应用发出的号比 A 应用小一截。雪花、双 buffer 号段、Leaf、UidGenerator 全在这档。对 B+ 树而言,趋势递增已经够用——页分裂从「每次插入都可能发生」降级为「偶尔出现一段回插」,碎片率可控。
| 方案 | 有序程度 | B+ 树表现 | 单点风险 |
|---|---|---|---|
| DB 自增 | 严格单调 | 最优,纯顺序追加 | DB 单点 |
| UUID v4 | 完全随机 | 最差,页分裂频发 | 无 |
| 号段(双 buffer) | 段内严格,段间趋势 | 好,偶发段回插 | 无(DB 挂可撑一阵) |
| 雪花 | 单机单调,全局趋势 | 好,毫秒内乱序 | 无 |
雪花的乱序细节
雪花号称趋势递增,但乱序藏在一毫秒里。位分配是时间在高位、机器号居中、序列在低位——时间位保证了跨毫秒一定递增,但同一毫秒内,序列位归零自增,多机之间没有约定:机器 1 的 5 号和机器 2 的 900 号谁先插进 B+ 树,全看网络时序。对 InnoDB 无伤大雅;但有两个业务场景要绕开:一是强依赖全序的账务流水,得回到号段或 DB;二是把雪花 ID 当时间戳用排序取「第一条」,同毫秒可能取错,要按 ID + 时间字段双键排序。
号段模式同理会回插:A 应用手里攥着 1-1000 的号段没用完,B 应用已经领了 1001-2000 的号入库,A 慢悠悠插进来一个 500 号——B+ 树往回找页。但回插的幅度被号段长度封了顶,比 UUID 的全随机好一个量级。
递增的额外红利
趋势递增的收益不止 InnoDB 一家:
LSM 存储友好:RocksDB、HBase、TiKV 这类 LSM 架构,写入先进 memtable 再刷 SST。递增 key 写入集中在 memtable 尾部,压缩效率高;随机 key 会把每个 SST 的 key 区间摊得很开, Bloom 过滤器与 compaction 的成本都上涨。
范围查询免参数:ID 天然带时间语义,「拉出今天上午的订单」可以直接按 ID 范围扫,不用建时间索引——前提是时间在高位,这正是雪花和号段的位设计。
分页稳定:以 ID 为游标的分页(keyset pagination),数据天然稳定递增,翻页不会跳行漏行,比 offset 分页快且稳。
有序是蜜糖也是砒霜
先泼冷水:递增是索引的蜜糖,也是安全的砒霜。ID 越有序越可预测——外部用户拿到订单号 20260914001,下一个订单大概率是 20260914002,爬虫顺着号就能遍历你的数据。趋势递增把「业务规模、增长速度、单量排名」全写在了号上。怎么在有序和泄露之间找平衡,是下一篇的主题。
小结
本篇一句话:主键的有序度直接换算成 B+ 树的成本,趋势递增是分布式的最优解;雪花同毫秒乱序、号段段间回插,都要心里有数;递增红利多,但可预测性的账下一篇算。老王把新表主键从 UUID 换成号段 ID,DBA 点了头——下一个坑,已经埋在安全里了。
评论 (0)