索引的物理存储结构:数据页、索引页与B+树层高计算

上一章我们搭好了实验环境,相信你已经跑通了基础语句。这一章我想聊聊索引的物理存储——说白了就是索引在磁盘上到底长什么样,怎么算出B+树到底有几层。你可能会觉得层高计算是个理论问题,但我在调优时发现,很多慢SQL的根因就是层高没算好。

数据页:最小的I/O单元

MySQL(InnoDB)的数据页大小默认为16KB。什么意思呢?你读一行数据,InnoDB会把整页数据从磁盘拉到内存里。你写一行,也是写满一页才落盘。这很好理解——机械硬盘最怕随机I/O,一次I/O能多带点数据就多带点。

经验之谈: 我之前帮一个客户看性能问题,他们表很小(几万行),但查询总是慢。一查发现innodb_page_size被改成了4KB,导致每次I/O只能读4KB,频繁I/O次数多了反而更慢。除了特殊场景,不建议随便改page_size。

数据页的结构很清晰,我直接列个表给你看:

区域 大小 作用
File Header 38字节 页类型、上一页/下一页指针、校验和等
Page Header 56字节 槽数、页内记录数、空闲空间起始偏移
Infimum + Supremum 固定 页面内最小和最大虚拟行记录
User Records 动态 实际行数据(按主键顺序排列)
Free Space 动态 剩余空间
Page Directory 动态 槽数组,加速页内二分查找
File Trailer 8字节 校验和,用于崩溃恢复

注意看,User Records 是物理上按主键顺序存储的。你插入一条新记录,InnoDB 不会直接追加到末尾,而是找到该页内合适的位置插入,必要时分裂页。这也是为什么主键顺序插入性能最高。

避坑: 我曾经遇到一个生产事故——表用了UUID作主键,插入急剧随机,导致B+树频繁页分裂,磁盘I/O暴涨,TPS直接掉到个位数。后来改成自增ID,问题就解决了。所以,主键顺序有多重要?可以说是一切性能的基础。

索引页:B+树的非叶子节点

索引页和数据页本质上是一样的——都是16KB的页。区别在于里面存的不是完整行数据,而是索引项。每个索引项包含两部分:

  • 键值:索引列的值
  • 页号:指向下一级页的指针(4字节)

举个例子,假设整型主键(INT,4字节)加上页号(4字节),一个索引项大概8字节。那么每个索引页能存(16KB - 固定开销)≈ 16380字节 / 8 ≈ 约2000个索引项。这个数字很关键,后面算层高时要用。

B+树层高计算:关键公式

现在我们来算核心问题:一个表有1亿行数据,B+树到底有几层?

先明确几个参数:

  • 非叶子节点的出度(扇出):每个索引页能存多少个索引项,记为 N
  • 叶子节点容量:每个数据页能存多少行记录,记为 M
  • 总行数:R

公式很简单,但你要理解它的推导过程:

  1. 第1层(根节点):1个页,最多指向 N 个第2层页
  2. 第2层:最多 N 个页,每个指向 N 个第3层页,总数 N²
  3. 第k层:最多 N^(k-1) 个页
  4. 叶子页总量:第最后层(假设高度为h)有 N^(h-1) 个数据页
  5. 能承载记录数:N^(h-1) × M ≥ R

所以,B+树高度 h 满足:

h = ceil( log_N( R / M ) ) + 1

注意这里加1是因为根节点也算一层。

实战计算示例

我们填几个实际数字。

场景1:整型主键,每行200字节

  • N(非叶子出度):每个索引项 4B键 + 4B页号 = 8B;16384B / 8B ≈ 2048
  • M(每页行数):16384B / 200B ≈ 81 行
  • R = 1亿 = 10^8

计算:R / M ≈ 10^8 / 81 ≈ 1.23×10^6;log₂₀₄₈(1.23×10^6) ≈ log₂(1.23×10^6) / log₂(2048) ≈ 20.23 / 11 ≈ 1.84;向上取整=2;再加1 -> h = 3

是的,你猜对了——一百行的表通常2~3层就够了,三亿行可能也就4层。这也是为什么B+树作为索引结构这么强大:层数极低,I/O次数极少。

场景2:varchar(255) 主键,每行500字节

  • N:假设平均键值30字节+页号4B ≈ 34B;16384/34 ≈ 481
  • M:16384/500 ≈ 32 行
  • R = 1亿

计算:R/M ≈ 3.125×10^6;log₄₈₁(3.125×10^6) = ln(3.125×10^6)/ln(481) ≈ 14.96/6.17 ≈ 2.42;取整=3;加1 -> h = 4

看到没?同样1亿行,因为主键变长、行变长,层数从3变成了4。多一层就是多一次随机I/O。所以,选择短主键非常关键。

重要推论: B+树的层高基本由扇出N和每页行数M决定。N越大、M越大,层数越低。实际调优中,缩短索引列长度、减少行宽是控制层高的有效手段。

我也踩过的坑:索引页分裂导致的层高异常

有一次我排查一个线上查询,走唯一索引,理论上应该3层就能找到,但实际执行计划显示每次查询都需要读4个页。很奇怪,于是我抓了SHOW TABLE STATUS看索引大小,发现数据量明明只有5000万行,索引却占了1.2GB。算一下:如果只有3层,索引页数量应该在2万左右(N=2000,叶子页数≈2.5万),但实际索引页数量超3万——说明扇出远低于理论值。

原因呢?表中大量delete+insert操作导致了索引页分裂后没有合并,形成了很多只存了几十个索引项的半空页。于是扇出从2000降到400,层数自然上去了。

怎么解决?OPTIMIZE TABLE可以重新整理聚簇索引,或者干脆重建索引。从那以后,我养成了定期检查索引碎片率的习惯——SELECT * FROM information_schema.INNODB_SYS_INDEXES配合INNODB_SYS_TABLESPACES可以查出每个索引的实际页数量,跟理论值做对比。

B+树结构图

下面的SVG把完整的B+树结构画出来了。你可以对照着理解层高和图节点的关系。

B+树三层结构示意图(层高h=3) 根节点(索引页) 键值范围:1~10万 索引页1 1~3.3万 索引页2 3.3万~6.6万 索引页3 6.6万~10万 数据页1 1~5000行 数据页2 5001~1万行 数据页3 …… 数据页4 …… 数据页5 …… 说明: 绿色节点为数据页(叶子节点),紫色节点为索引页(内部节点)。 每个索引页指向多个子页,扇出N≈2000(整型主键),层高h=3即可支持约2.6亿行数据(N²×M)。

层高计算的工程意义

知道了怎么算,能干嘛?我列几个直接的应用场景:

  • 预估索引大小:用公式反推索引页数量,就能算出索引占多少磁盘空间。我一般上线前会粗略估算一下,避免索引膨胀导致buffer pool装不下。
  • 判断是否需要分区:如果你算出层高已经接近4或5层(比如1亿行、索引列较长),可以考虑分区或使用子分区来降低层高。
  • 选择索引列顺序:联合索引中把选择性高的列放前面,能减少索引项数量,间接降低层高。
小技巧: 你可以在MySQL里直接查看B+树实际高度:SELECT b.name, a.page_no, a.index_id, a.num_leaf_pages, a.num_internal_pages FROM information_schema.INNODB_SYS_INDEXES a JOIN information_schema.INNODB_SYS_TABLES b ON a.table_id = b.table_id WHERE b.name = '你的库名/表名';。通过叶子页数和内部页数可以反推实际扇出,对比理论值,就知道索引碎片程度了。

内存模型与I/O关注点

最后再提一点:B+树层高直接决定了查询时访问磁盘的次数。一次随机I/O大约10ms(机械盘),3层就是30ms;如果层高变成5层,就是50ms,翻了一倍。但在SSD时代,随机I/O只有0.1ms左右,所以层高的影响没那么夸张。不过Buffer Pool命中率仍然很关键——如果层高导致的I/O次数让BP迅速淘汰热页,反而可能更严重。这个我们后面章节讲缓冲池管理时会展开。

再次提醒: 不要为了降低一层高而盲目缩短主键长度到不合理的程度。比如把varchar(32)的UUID截断成char(16),虽然层数降低了,但冲突概率上升,数据完整性问题更麻烦。平衡才是王道。