UUID主键导致B+树频繁页分裂,因插入位置完全随机;改用UUID_TO_BIN(uuid, 1)可大幅降低分裂频率,但无法根治;ULID或Snowflake是更优替代方案。

UUID插入位置完全随机,B+树必须频繁分裂页
InnoDB的聚簇索引就是数据本身,B+树叶子节点按主键值物理排序存放行记录。自增ID总追加到最右页末尾,而UUID()生成的值(如'550e8400-e29b-41d4-a716-446655440000')在字节序上是纯随机的——每次INSERT都得二分查找插入点,99%以上概率落在已有页中间而非末尾。
一旦目标页已满(默认16KB),InnoDB立刻触发页分裂:复制约一半键值对到新页、更新父节点指针、写入磁盘。这个过程不是“偶尔慢一点”,而是每千行插入平均引发1.7次分裂(实测数据),且分裂后两页填充率常低于50%,Data_free持续升高。
- 分裂不是孤立事件:父节点可能因子节点增多再次分裂,形成级联
- 分裂期间需加X锁,高并发下容易锁等待甚至死锁
- 分裂后碎片化页无法被Buffer Pool有效预读,
innodb_buffer_pool_reads飙升
BINARY(16)只是省空间,没解决无序性这个根子
很多人把CHAR(36)改成BINARY(16)就以为问题解决了,其实只是把存储从36字节压到16字节,B+树比较逻辑从字符字典序变成字节序,但v4 UUID原始二进制仍是时间戳+随机段混排,高位不单调——插入位置依然不可预测。
例如两个UUID:1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d和6d5c5a5e-5c5a-4e5a-8d5a-5a5e5c5a5e5c,首字节0x1a vs 0x6d就决定它们在B+树里相隔几十层,后续插入毫无局部性。
-
UUID_TO_BIN(uuid, 0)(MySQL 8.0+默认)只去掉连字符,仍是随机序 - 二级索引叶子节点存的是完整主键值,
BINARY(16)让每个索引条目比BIGINT多占8字节,进一步挤占Buffer Pool - 即使压缩了,
ORDER BY id仍大概率退化为全表扫描——物理存储乱序,B+树无法跳跃遍历
真正有效的优化必须让高位具备时间趋势
MySQL 8.0.31+支持UUID_TO_BIN(uuid, 1),第二个参数为1时会把时间戳高位左移到字节序最前,生成“近似递增”的二进制UUID。这能大幅降低分裂频率(实测比普通UUID低90%),但不是零分裂。
原因在于:UUIDv1/v6时间精度是100纳秒,同一毫秒内生成多个ID时,后缀(clock sequence + node ID)仍是随机的。单机高并发写入(比如Web服务每毫秒生成上百ID),这些“同毫秒ID”会集中插入同一数据页,撑满后照样分裂。
- 建表必须用
BINARY(16),插入统一走UUID_TO_BIN('xxx-xxx', 1) - 已有旧数据若用
UUID_TO_BIN(uuid, 0)存的,混用会导致排序错乱 - 更推荐换ULID或Snowflake:ULID是
CHAR(26)字符串但字典序可排序;Snowflake必须用BIGINT UNSIGNED存,禁止前端直传
innodb_fill_factor这类参数只能缓解,不能根治
设innodb_fill_factor = 70确实能在页里预留30%空间,减少分裂频次,但代价是磁盘占用上升、Buffer Pool缓存效率下降——它没改变插入位置的随机性,只是把分裂推迟到更晚发生。
批量插入时用INSERT INTO t VALUES (), (), ()并配合innodb_autoinc_lock_mode = 2(交错模式)能提升吞吐,但对单行高频插入无效。真正要命的是:哪怕只是日志小表,长期用UUID主键,Data_free也会缓慢爬升,某天突然发现查询变慢,才意识到碎片已积累数月。
页分裂是B+树物理结构、缓冲池管理、写入模式三者共同作用的结果,任何单一配置都无法绕过“随机写入必然导致分裂”这个硬约束。


















