去年帮一个团队看慢查询,打开数据库我就愣住了:一张 1200 万行的订单表,ibd 文件 22 GB。
行宽不到 400 字节,按理说 6 GB 就够了。
翻建表语句,主键是 char(36) 的 UUID。再看数据,这张表每天新增三十万行,跑了两年,从来没重建过。
把它导出来、按自增 id 重新灌一遍,文件变成 9 GB,写入 TPS 从 800 涨到 2100,慢查询少了三分之二。
问题不在 SQL,也不在索引数量,在主键的顺序。
而这件事的根,埋在 InnoDB 的一个设计选择里:它把整行数据存在主键索引的叶子上。

一、先把这个关系倒过来看
学 MySQL 的时候,我们习惯把「表」和「索引」当成两样东西:表存数据,索引指向数据。
MyISAM 就是这么干的。它的 .MYD 文件存数据行,.MYI 文件存索引,索引叶子上挂的是「行号」。所以按索引查一行,要先读索引页拿到行号,再回表文件读那一行------两次寻址。
InnoDB 把这两步合成了一步。
它的做法是:主键索引的叶子节点不存行号,直接存整行数据。
这个改动听起来只是省了一次跳转,实际上改变了「表」的定义:
在 InnoDB 里,表没有独立的存储结构。所谓「这张表」,指的就是这棵以主键排序的 B+ 树。
学名叫索引组织表 (Index Organized Table,IOT)。行业里叫聚簇索引(clustered index)------"clustered" 不是"集群"的意思,是"聚簇":数据行和主键键值聚在一起,物理上挨着存。

这张图从上往下看是拆解,从下往上看才是重点:
- 一行记录(主键 + 事务 ID + 回滚指针 + 各列)
- 装进一个 16 KB 的页
- 64 个连续的页组成一个区(1 MB)
- 叶子段、非叶子段各自向区要空间
- 全部落在
orders.ibd这一个文件里
没有"数据区"和"索引区"的划分。 数据就是索引的叶子,索引的内部节点只是目录。
这一条想通了,后面所有现象都顺了。
顺带回答:为什么二级索引存主键,不存行地址
这是面试里最爱追问的一层。
MyISAM 的索引叶子存的是行地址(文件内的偏移量)。查一行:读索引页 → 拿到偏移 → 去数据文件读那一行。两次寻址,但第二次是"直达",不用再比三次大小。
InnoDB 偏偏选了更"麻烦"的方案:存主键,让查询再走一次三层树。
原因只有一个:行地址会变,主键不会。
想象一下 orders 表有 5 个二级索引。某天中间插入一批数据,触发页分裂,几千行的物理位置全变了------如果二级索引存的是行地址,这 5 个索引里的每一个指针都要跟着改。一次插入要写 5 个索引 × 几千个指针,这在并发写入下是不可接受的。
存主键就没有这个问题:页怎么挪、行怎么搬,主键始终是那个主键。二级索引完全不用管。
用一次额外的查找,换掉了"数据移动必须同步改所有索引"这个灾难。 这是 InnoDB 最划算的一笔交易。
代价也很明确:任何不走覆盖索引的二级索引查询,都要付回表的钱。这篇文章的第四节和第三篇,讲的都是这笔钱怎么算。
二、叶子页里到底有什么
先看一张叶子页的内部构造。

一页 16 KB,切分成几段:
| 区段 | 大小 | 装什么 |
|---|---|---|
| 页头 | 约 120 B | 页号、上一层是谁、页内记录数、页类型 |
| User Records | 约 15.8 KB | 一条条完整的行,按主键升序排列 |
| 空闲空间 | 变长 | 还没用掉的部分 |
| 页目录 | 变长 | 稀疏槽位,每 4~8 行一个,供二分查找 |
| 页尾 | 8 B | 校验和,防止刷盘写坏 |
重点在 User Records。把其中一行拆开,从左到右是:
[变长长度列表][NULL 位图][记录头][主键 id][事务 ID][回滚指针][name][age][...]
三个细节值得停一下:
第一,主键在行里只存一份。 没有额外的「id → 行地址」映射表。这就是省掉那次 IO 的全部秘密,也是它能被叫做"索引即数据"的原因。
第二,事务 ID 和回滚指针就在行里。 这是 MVCC 的家底:每行记录着自己被哪个事务改过、上一个版本在哪。它们存在聚簇索引里,不在二级索引里------所以二级索引无法独立回答"这行的最新版本是什么",它只能给你一个主键。
第三,行在页内是一条单向链表。 记录头里存着"下一条记录的相对偏移"。插入一行时,只需要改前后两条记录的指针,不用把后面的行整体搬家。
如果建表时没写主键呢?
InnoDB 会退而求其次:找第一个非空唯一索引 当聚簇键;如果一个都没有,就悄悄生成一个 6 字节的隐藏列 DB_ROW_ID。所以"我的表没主键,所以没有聚簇索引"这句话是错的------你只是不知道聚簇键是哪个。
验证一下:
sql
-- 页大小(默认 16384)
SHOW VARIABLES LIKE 'innodb_page_size';
-- 每张表、每个索引占多少页、多少 MB
SELECT
database_name,
table_name,
index_name,
stat_name,
stat_value
FROM mysql.innodb_index_stats
WHERE database_name = 'shop'
AND table_name = 'orders'
AND stat_name IN ('size', 'n_leaf_pages', 'n_leaf_pages_marked')
ORDER BY index_name, stat_name;
-- size 的单位是页,乘上 16 KB 才是字节
SELECT table_name,
ROUND(data_length / 1024 / 1024, 1) AS data_mb,
ROUND(index_length / 1024 / 1024, 1) AS index_mb
FROM information_schema.tables
WHERE table_schema = 'shop' AND table_name = 'orders';
n_leaf_pages 这个数字很关键:它告诉你叶子层有多少页。行数 ÷ 叶子页数 = 平均每页塞了几行,除以 16 就是填充率。后面讲页分裂,全靠这个指标判断碎片程度。
三、逻辑有序,物理不连续
这是聚簇索引最容易被误解的一点。
打开客户端 select * from orders order by id limit 100,结果按 id 从小到大排得整整齐齐。于是很多人以为磁盘上的数据也是这么排的。
不是。
排序是链表给的,不是磁盘给的。

叶子页之间用双向链表串起来,页内记录用单向链表串起来。所以"按主键升序"只是一个指针遍历顺序的承诺,和物理位置无关。
为什么物理上会不连续?三个原因:
① 分配以区为单位,谁先要谁先得。 一区 64 页 = 1 MB,从表空间的空闲区里分配。当表和它的几个二级索引同时增长时,页会交错落位。你以为主键 1~100 在一段连续的 1.6 MB 里,实际可能散在三四个区。
② 删除会留下空洞,后来的插入去填空。 一条 100 字节的空洞,塞不下 400 字节的行,就继续往别处找。空洞越碎,物理顺序越乱。
③ 并发插入加剧了无序。 两个事务同时往不同位置插,各自的页从不同的区申请,交错落盘。
那为什么全表扫描还不算太慢?
因为 InnoDB 有预读:顺序扫的时候,它发现你在按顺序读页,就会一次把相邻的 64 页(整个区)读进来。物理不连续造成的损失,被预读吃掉了一大半。
代价出现在点查 和范围小扫上:这类查询没有顺序可循,预读帮不上忙,只能一次一次随机跳。
这就是为什么"聚簇索引快"和"全表扫描慢"这两个说法都对,但它们快慢的对象完全不同:聚簇索引快在点查和范围,全表扫描慢在需要跳着读的场景。
顺手验证一下范围扫描是顺着链表走的:
sql
FLUSH STATUS;
SELECT id, amount FROM orders WHERE id BETWEEN 10000 AND 10100;
SHOW SESSION STATUS LIKE 'Handler_read%';
会看到 Handler_read_next 涨了 101 左右------这个计数器统计的是"按索引顺序读下一条",涨多少就说明沿着链表走了多少条。而 Handler_read_rnd_next 应该几乎不动,它是全表顺序扫的计数。
范围查询为什么反而快
物理不连续,听上去很糟。但有意思的是,范围查询在聚簇索引上依然很快,甚至比单点查更划算。
原因在于它的成本结构:
- 找到起点:3 次页访问(和点查一样)
- 往后扫 100 条:顺着页内链表走,页内跨越不需要额外的树遍历
- 跨到下一页:读下一个叶子页,这个位置在
next指针里直接写着,不用从根重新找
也就是说,扫 100 条记录的总成本 ≈ 3 次页访问 + 若干次相邻页读取。平均到每一行,成本几乎是 0。
这跟"每行一次回表"的二级索引形成鲜明对比:后者是 100 行 → 100 次独立的三层遍历。
所以有一条经验值得记住:能在聚簇索引上做的范围查询,就不要拆成多次点查。 常见反例是代码里 for id in ids: select ... where id = ? 的循环查询------100 次点查 = 300 次页访问,而 where id in (...) 只有 3 次加上少量的页内跳转。
四、为什么主键顺序决定插入性能
现在到了正题。
插入一行,引擎要做四件事:
- 从根往下比较主键,找到"这一行该待的叶子页"
- 页内二分定位插入位置
- 空间够,就插进去,改两个指针
- 空间不够,就分裂:申请一个新页,把记录搬一半过去,重接链表,再到父页登记新页
第 4 步就是页分裂。它的开销我们放到下一篇细拆,这里只看一个指标:它把一页的空间切成两半用。
于是顺序的差异出现了:
自增主键 :每次插入的主键都比现有的大,位置永远在最右页的末尾 。最右页满了才分裂,而且分裂时原页保留绝大部分记录(新页只接走一点点),新页从空开始继续往右长。结果:页填充率约 15/16 ≈ 93.75%。
随机主键(UUID) :插入位置均匀分布在整棵树里。命中一个满页的概率很高,一旦满了就得对半分 ------两页各 50%。结果:页填充率长期在 50%~70% 之间徘徊。
同样 1000 万行、行宽 1 KB:
| 指标 | 自增主键 | UUID 主键 | 差距 |
|---|---|---|---|
| 每页平均行数 | 15 | 8.8 | -41% |
| 叶子页数 | 66.7 万 | 113 万 | +70% |
| 索引文件 | 10.7 GB | 18.2 GB | +70% |
| 三层能装的行数 | 约 2000 万 | 约 1100 万 | -45% |
三层从"能装两千万"掉到"只能装一千万",意味着同样的数据量,你的树可能要多一层。多一层就是多一次页访问,多一次潜在的随机读。
这还只是空间。时间上的账更明显:
① 定位目标页时要读盘。 自增插入的目标页永远是同一个(最右页),它在 Buffer Pool 里几乎必然命中,等于 0 次 IO。UUID 插入的目标页遍布全树,20 GB 的表、假设 Buffer Pool 只有 4 GB,命中率可能不到 30%------剩下 70% 都要随机读一次盘。
② 分裂时要拿锁。 页分裂需要持有该页的 index latch,还要改父页。这个过程中,其它想插同一页的事务得等。在随机主键下,分裂发生在树的各处,锁冲突分散但也更频繁;在自增主键下,分裂只在最右页发生,压力集中但次数少得多。
③ 脏页刷盘变成随机写。 被修改的页是"脏页",最终要刷回磁盘。顺序插入产生的脏页集中在文件末尾,刷盘接近顺序写;随机插入产生的脏页散落各处,刷盘就是随机写。SSD 上随机写还有写放大,机械盘上直接是灾难。
三段叠加,实测差距通常是 2~3 倍。前面那个 800 → 2100 TPS 的案例,就是这个量级。
一个真实的重建前后对比
还是开头那张 1200 万行的订单表。整个过程记录在这里:
| 指标 | 重建前(char(36) UUID) | 重建后(bigint 自增) |
|---|---|---|
| 主键类型 | char(36) |
bigint unsigned |
| 平均每页行数 | 8.4 | 14.6 |
| 叶子页数 | 143 万 | 82 万 |
| ibd 文件大小 | 22.1 GB | 9.2 GB |
| 主键点查 P99 | 1.8 ms | 0.4 ms |
| 写入 TPS(32 并发) | 800 | 2100 |
| 二级索引总大小 | 11.4 GB | 4.7 GB |
注意最后一行。二级索引从 11.4 GB 掉到 4.7 GB,降幅比主键索引还大------因为每个二级索引的叶子都存一份主键,主键从 36 字节变成 8 字节,5 个二级索引就省下 28 字节 × 5 = 140 字节/行,1200 万行就是 1.7 GB 的纯浪费。
这个细节提醒我们:主键宽度的影响,是按"索引个数"放大的。
顺便算一下:主键宽度怎么放大到所有索引上
假设一张表有 1 个主键 + 5 个二级索引,1000 万行。
| 主键类型 | 主键宽度 | 每个二级索引多存 | 5 个索引合计 |
|---|---|---|---|
bigint |
8 B | 80 MB | 400 MB |
char(36) UUID |
36 B | 360 MB | 1.8 GB |
varchar(64) 业务号 |
约 64 B | 640 MB | 3.2 GB |
这还只是"存主键"本身。更麻烦的是每个索引页能装的条目变少了:一页 16 KB,主键 8 字节时能装 1170 个目录项,主键 36 字节时只剩约 380 个。二级索引的树也跟着变高。
所以"主键要窄"不是洁癖,它直接换算成内存占用、页数、树高和 IO 次数。
想自己验证,跑一段 sysbench 就够了:
bash
# 自增主键
sysbench oltp_write_only \
--mysql-host=127.0.0.1 --mysql-db=bench \
--tables=4 --table-size=2000000 \
--rand-type=uniform --threads=32 --time=120 run
# 换成 UUID 主键:改表结构后重跑同一组参数
# ALTER TABLE sbtest1 MODIFY id char(36) NOT NULL;
看 transactions per second 和 iostat 里的随机写比例,两个数字都会变。
五、三次页访问,只有一次可能真的读盘
有人会问:既然聚簇索引的叶子存整行,为什么查一行不是"一次 IO"?
因为要先找到那片叶子。

三层 B+ 树,一次点查要走三次页访问:
- 根页:1 页,常驻内存,命中率 100%
- 第二层:约 1170 页 ≈ 18 MB,绝大多数情况下也常驻,命中率 99% 以上
- 叶子层:2000 万行约 125 万页 ≈ 20 GB,能不能命中,取决于 Buffer Pool 大小和访问分布
所以真实成本是:3 次页访问,2 次内存命中,0~1 次磁盘随机读,总耗时在 0.1~0.3 毫秒之间。
这个结构和哈希表对比很有意思:
哈希表等值查询是 O(1),一次定位。但它定位到的是"行地址",还得再读一次行------而且这次读是纯随机 IO,没有任何缓存层帮你挡。
B+ 树的 3 次访问里,有 2 次几乎必然在内存里,剩下 1 次才可能读盘。**这就是为什么"哈希 O(1) 比 B+ 树快"这句话在数据库语境下是错的。
为什么第二层几乎必然常驻
上面说第二层约 1170 页 ≈ 18 MB。这个数字值得单独算一遍,因为它解释了一件很实用的事。
三层树的结构是:1 个根页 → 1170 个中间页 → 1170 × 1170 个叶子页。
中间页一共 1170 个,每页 16 KB:
1170 × 16 KB = 18.3 MB
也就是说,只要 Buffer Pool 大于 18 MB,整棵树的"前两层"就永远在内存里。
MySQL 默认 innodb_buffer_pool_size 是 128 MB,所以对任何表来说,前两层基本不会缺。你的一次点查,本质上就是"2 次内存查找 + 1 次可能读盘的叶子访问"。
这条推论有两个实际用途:
第一,判断一张表"能不能全放进内存"。 一张 2000 万行、行宽 1 KB 的表,叶子层约 20 GB。Buffer Pool 有 32 GB,理论上能全装下,但还要留给别的表和二级索引。所以现实里"热数据常驻"比"全表常驻"更靠谱。
第二,解释为什么"根页"从不被淘汰。 它被访问得太频繁,LRU 里永远在最热的那一端。Buffer Pool 再小,根页也会被保住------否则每次查询都要读盘,性能会崩到无法接受。**
六、主键怎么选
把上面的结论收敛成四条硬约束:
- 要窄:每个二级索引的叶子都存一份主键,主键宽 36 字节,5 个二级索引就多出 180 字节/行
- 要单调:插入位置固定在右端,不分裂中间页
- 不要改:改主键等于删一行 + 插一行,所有二级索引跟着重建
- 要唯一且非空:否则 InnoDB 会替你选,而它选的那个你往往不知道

按场景选:
| 场景 | 方案 | 备注 |
|---|---|---|
| 单库单表 | BIGINT AUTO_INCREMENT |
最简单,不要想太多 |
| 分库分表 | 雪花 ID / 号段模式 | 趋势递增,保留局部性 |
| 对外不可猜测 | UUIDv7 存成 BINARY(16) |
前 48 位是时间戳,仍然递增 |
| 已经在用 UUIDv4 | 加一列自增 id 当聚簇键 | UUID 只做业务唯一键 |
第三条需要解释一下。UUIDv7 和 UUIDv4 的区别只有一处:v7 把时间戳放在最高位,所以生成的 ID 按时间单调递增 。虽然它的字节序在字符串形式下不直观,但转成 BINARY(16) 存,插入位置就重新回到了右端。
sql
-- 不要这样:36 字节字符串,随机分布
CREATE TABLE t_bad (
id char(36) NOT NULL PRIMARY KEY,
...
);
-- 这样:16 字节二进制,时间有序
CREATE TABLE t_good (
id binary(16) NOT NULL PRIMARY KEY, -- UUIDv7
...
);
如果表已经跑起来了,最省事的补救是加一列自增 id 当聚簇键:
sql
ALTER TABLE orders
ADD COLUMN seq BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
ADD UNIQUE KEY uk_seq (seq),
DROP PRIMARY KEY,
ADD PRIMARY KEY (seq),
ADD UNIQUE KEY uk_uuid (id);
-- 这一步才会真正重排数据,务必低峰期做,并预留 1.2 倍磁盘
ALTER TABLE orders FORCE;
分布式 ID 的两个现实问题
问题一:时钟回拨。
雪花 ID 的结构是「时间戳 41 位 + 机器号 10 位 + 序列号 12 位」。时间戳在前,所以单调递增。但机器时钟一旦回拨(NTP 校时、虚拟机迁移),生成的时间戳会变小,就可能撞上已经发出去的 ID。
三种处理方式:直接抛异常等时钟追上(简单但会短暂不可用)、用历史最大时间戳继续走(可能透支未来的号段)、切换机器号(需要额外协调)。生产上最常见的是第二种加告警。
问题二:号段模式的取号抖动。
号段模式(如美团 Leaf)的做法是:应用启动时向 DB 申请一段号,比如 [1, 10000],在内存里自增发放;用掉 20% 时异步申请下一段。这样 DB 只在号段边界被访问一次,压力从"每行一次"降到"每万行一次"。
坑在内存里:如果应用重启,没用完的号段就浪费了;如果 DB 短暂不可用,号段耗尽的实例会拿不到新号,写入直接失败。所以号段长度要按"峰值 QPS × 可容忍的不可用时长"估算,而不是随手写个 10000。
这两种方案都比 UUIDv4 好,因为它们保住了同一个性质:新 ID 永远比旧 ID 大。
七、几个常见误解
写到这儿,顺手清几个高频错误认知:
✗「聚簇索引会对数据做物理排序」
不会。它只保证链表顺序 等于主键顺序。物理重排只有一种情况:重建表(OPTIMIZE TABLE / ALTER TABLE ... FORCE)。
✗「主键查询一定是一次 IO」
Buffer Pool 命中就是 0 次。真正的 IO 数取决于那一片叶子在不在内存。
✗「删了数据,文件会变小」
不会。删除只是把记录标记为空、把页标记为可复用。文件只增不减。(下一篇细讲碎片。)
✗「没有主键就没有聚簇索引」
有,用隐藏的 6 字节 DB_ROW_ID。你查不到它,但它确实在排序。
✗「二级索引查询慢,说明索引建得不对」
多数时候是回表次数太多,不是索引本身的问题。这是下一篇的主题。
✗「聚簇索引能省掉所有 IO」
它省掉的是"索引 → 行"的那一次跳转。找叶子页的那 3 次页访问,一次都省不掉。
✗「主键用 UUID 是为了分布式唯一,所以没法改」
唯一性和顺序性不冲突。UUIDv7、雪花 ID、号段模式都同时满足这两点,只有 UUIDv4 不满足。选型的时候把这两个需求分开看,答案就清楚了。
八、能带走的三件事
第一,InnoDB 的表就是一棵 B+ 树。 主键索引的叶子存整行,这不是优化技巧,是存储结构本身。理解这一点,"为什么主键这么重要"就不用背了。
第二,逻辑有序 ≠ 物理连续。 顺序由链表保证,物理位置由区分配决定。这解释了两件事:范围扫描为什么快(顺着链表),点查为什么可能慢(要跳到随机位置)。
第三,主键顺序直接换算成 IO 次数和磁盘空间。 自增主键让插入固定在右端,填充率 93.75%;随机主键让分裂散布全树,填充率掉到 50%~70%。同样的数据,文件大 70%,树可能多一层。
下一篇把镜头推到最细:一行数据插进去,页里到底发生了什么,页分裂为什么会"传染",以及一个 22 GB 的表怎么安全地缩回 9 GB。