拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

InnoDB索引底层原理:从B+树、聚簇索引到覆盖索引的MySQL优化指南

InnoDB索引底层原理:从B+树、聚簇索引到覆盖索引的MySQL优化指南

如果你手上有一张千万级的订单表,一条SELECT * FROM orders WHERE order_id = 42能在几十毫秒内返回结果,靠的不是 SQL 优化器神通广大,而是 InnoDB 在磁盘上那片 16KB 的页,以及藏在页背后的 B+ 树。聚簇索引和二级索引其实没那么玄,它们解决的是同一个问题:在有限的磁盘 IO 下,怎么用最少的页访问找到目标行。这篇文章我想从数据落盘的最小单位——页出发,把 InnoDB 的聚簇索引、二级索引、回表、覆盖索引这些概念串起来,顺便聊聊页分裂对性能的影响。适合正在啃 MySQL 原理,或者被慢 SQL 折磨过的后端同学当一份参考。

1. 从一行数据说起:InnoDB 为什么非要用“页”来存数据

1.1 磁盘 IO 的最小成本与 16KB 的由来

很多人理解 InnoDB 索引时,一上来就画 B+ 树,忽略了最底层的约束:数据最终是躺在磁盘上的。磁盘这玩意儿有个特点,读取一个扇区是 512 字节,但操作系统和数据库都倾向于以更大的“块”为单位做 IO,因为一次 IO 的耗时不取决于读多少字节,而是取决于寻道、旋转和传输这几个环节,一次随机读的耗时要远大于顺序读。你如果只读一行数据就发起一次磁盘 IO,成本谁也扛不住。

InnoDB 默认把一批行打包成一个 16KB 的页,读写、缓存、建立索引都是以页为单位。为什么是 16KB 而不是 512B 或者 1MB?太大,缓冲区里能放的页数就少,扫描时也会把不需要的数据带进来;太小,一次 IO 能覆盖的数据量有限,树的高度就压不下来。16KB 这个值在实践中被证明是个不错的平衡点。你可以通过innodb_page_size在初始化时调整,但绝大多数场景下默认值就是最优解。

页的存在还带来一个推论:你要修改任意一行,不管改多小的字段,InnoDB 都得先把整页加载到 Buffer Pool,改完再刷回磁盘。这就叫“页是 InnoDB 的最小一致性单位”。所以你会发现,涉及大字段、超宽表的 SQL,即使只查一列,也可能因为页太大、页内行数太少而变慢。

1.2 一个页里面到底放了什么

InnoDB 的页结构可以大致分成几个区域:

  • 文件头(File Header)和文件尾(File Trailer):记录页号、上一页和下一页的指针、校验值。文件尾用于判断页面写入是否完整,这也是 doublewrite 机制存在的原因之一。
  • 页头(Page Header):记录页状态、页目录槽数、记录数之类的元信息。
  • 用户记录区(User Records):真正存放行数据的地方,从页头之后开始向后增长。
  • 页面目录(Page Directory):从页尾方向向前增长,把页内的记录按组索引,每组的第一条记录的偏移量存到一个“槽”里。

页里的记录是用单向链表串起来的,按聚簇键顺序排列。如果从头到尾扫这个链表,最坏情况下要遍历页内所有记录,效率很低。InnoDB 的优化是:把链表按一定数量分组,每组的起始记录地址放到目录槽里,然后对目录槽做二分查找。你可以把页目录理解成书前面的目录,先翻到大概的章节,再顺着组内的小链表找到目标。

还有一个很容易被忽略的细节:页内永远有两条虚拟记录,叫 Infimum 和 Supremum,分别表示“比页内任何记录都小”和“比页内任何记录都大”。插入记录时会调整前后记录的 next 指针,正是因为有这两条虚拟记录,页面的遍历和边界判断就不需要特判首尾了。很多人以为页内就是一片连续的行数组,实际上它是“链表 + 目录槽”的组合结构,真正的行记录在物理上并不要求连续。

1.3 行格式决定页能装多少行

InnoDB 对单行记录也有格式约束,主要是 COMPACT 和 DYNAMIC 两种,MySQL 5.7 之后默认是 DYNAMIC。每一行内部除了业务字段,还有如下组成部分:

  • 变长字段长度列表:记录 NULL 和变长字段的长度信息。
  • NULL 值列表:用位图标记哪些列是 NULL,省空间。
  • 记录头信息:5 字节左右,存 next 指针、记录类型、是否删除标记等。
  • 隐藏列:DB_ROW_ID(无主键时的 6 字节行 ID)、DB_TRX_ID(事务 ID)、DB_ROLL_PTR(回滚指针),是 MVCC 的基础。

DYNAMIC 和 COMPACT 最大的区别在于大字段溢出时的处理。COMPACT 会在叶子页保留大字段的前 768 字节,剩下的放到溢出页;DYNAMIC 则把大字段整体挪到溢出页,原始页里只留一个 20 字节的指针。所以 DYNAMIC 能让一个页容纳更多普通行,TEXT/BLOB 多的表通常选它更合适。

从这里就能推导出一个很重要的结论:行越短,一个 16KB 的页能装的行越多,B+ 树就越矮,查询时访问的页数就越少。这也是为什么我优化表结构时会盯紧“每一行到底占多少字节”,而不是只盯索引。

2. B+ 树是怎么被“逼”出来的:先回答“B+树是红黑树吗”

2.1 “一条查询 = 每一层的页访问”

搜索热词里有个“b+树是红黑树吗”,答案是明确的两个字:不是。红黑树是内存中的平衡二叉查找树,B+ 树是面向磁盘的多路查找树。别看二者都叫“查找树”,设计目标完全不同。

我们把查询建模成一次从根到叶子的路径:每往下走一层,就要访问一个节点,在 InnoDB 里这个节点就是一个 16KB 的页。如果是二叉查找树或者红黑树,1000 万条数据大概需要log2(1000万) ≈ 24层。也就是说,一次主键查找在最坏情况下要访问 24 个页,如果这些页都不在 Buffer Pool 里,就是 24 次随机磁盘 IO。机械盘一次随机读大概 10ms,24 次就是 240ms,这还只是一条等值查询,完全不能接受。

那为什么不选 AVL 或者红黑树?因为它们在内存里性能很好,节点小、旋转快、缓存命中率高。但放到磁盘上,节点访问次数是硬伤。B+ 树的核心思路是:让一个节点尽可能多地“装键”,把树做得又矮又宽。

2.2 节点更大、更高出度:B 树家族的核心

B 树和 B+ 树都是多路平衡树。假设一个内节点记录(键 + 子页指针)占 16~32 字节,一个 16KB 的页可以放下 500~1000 个键。根节点有 1000 个键,就能指向 1001 个子页;两层内节点可以覆盖 100 万个页;如果每个叶子页装 50 行,那三层树就能覆盖 5000 万行。所以千万级表的聚簇索引,实际树高通常只有 3 层。

B 树和 B+ 树的区别是:B 树的每个节点都保存数据,内部节点也能直接返回记录;B+ 树的内部节点只保存键和指针,所有的数据都集中在叶子节点。这样做有几个直接好处:

  • 内节点不存数据,同样的 16KB 页能容纳更多键,出度更大、树更矮。
  • 叶子节点存储统一的整行数据,不会因为上层节点有数据而出现“同一条记录多个副本”的空间浪费。
  • 范围查询时,B+ 树只需要定位到起点,然后沿叶子链往后扫;B 树则需要在中序遍历过程中反复上下跳转。

我见过有人画 B+ 树时把根节点画得很高、键位很稀疏,其实那是概念图,真实场景中根节点一个页里塞满上千个键才是常态。

2.3 叶子页的双向链表:范围查询和排序的底气

B+ 树和 B 树最大的体验差异就是叶子节点之间的双向链表。InnoDB 的每个索引页文件头里都有FIL_PAGE_PREV和FIL_PAGE_NEXT,把同一层的兄弟页串起来。

这个设计对查询的意义很大。业务里大量 SQL 不是等值查询,而是WHERE id BETWEEN ? AND ?、WHERE create_time > ?、ORDER BY id ASC LIMIT ?。在 B+ 树里,范围查询先定位到左边界所在叶子页,然后顺着双向链表往右扫,不需要重新回到上层节点判断。而且因为叶子页本身按键值有序,走索引的排序往往不需要额外的 filesort。

二级索引也一样。举个例子,ORDER BY user_id LIMIT 10如果走了idx_user_id,MySQL 直接在索引链表上从左往右读 10 个索引条目,再回表取数据。这就是为什么很多慢查询加上合适的索引后,连ORDER BY的消耗都一起消失了。

3. 聚簇索引:主键即数据,数据即主键

3.1 搞懂“索引组织表”

InnoDB 表不是“一堆行数据 + 若干索引”的传统堆表,而是索引组织表。整张表就是一棵以主键为 key 的 B+ 树,树的主键就是聚簇索引。聚簇索引的叶子页保存着一行完整的数据,内部节点只保存主键和指向子页的指针。

这意味着什么?你执行SELECT * FROM orders WHERE order_id = 42的时候,InnoDB 做的事情就是:从根页出发,二分查找定位到第 2 层页,再二分定位到叶子页,最后在叶子页内通过页目录槽位找到主键为 42 的记录,然后把整条记录读出来。整个过程没有第二次查找,也不存在“先查索引再查数据”的两段式逻辑。聚簇索引的“聚簇”含义就在这:数据行的物理排列顺序逻辑上和主键顺序一致(页之间通过链表串联,页内记录顺序是逻辑有序)。

用查字典类比,假设有一本按拼音排序的新华字典,每个词条边上就写着完整解释,拼音就是主键。你按拼音找到某个词,翻过去就是解释正文。这个过程从头到尾只查了一次,是聚簇索引。如果是按部首笔画查,查到页码后你还要翻去字典正文那一页,这就是二级索引 + 回表。

3.2 InnoDB 强制要有一个聚簇键

如果一张表没有定义主键,InnoDB 也不会真的就用堆表存。它会先找第一个非空的唯一索引作为聚簇键;如果连这个都没有,就生成一个隐藏的 6 字节DB_ROW_ID,自动递增,作为聚簇索引的 key。但隐藏 rowid 是内部实现细节,你在任何 SQL 里都用不到它,表数据的物理顺序跟业务查询的两者之间毫无关系,这也是很多“无主键表”性能不稳定的原因之一。

另外要注意,主键和普通唯一约束不是一回事。唯一索引只限制值不能重复,底层仍然是一棵二级索引树;而主键决定了整张表的数据如何组织和存放。所以即使你建了一个业务唯一编号order_no,如果没显式主键,InnoDB 可能就把order_no当成聚簇键了。这个行为并不受你控制,可靠做法永远是建表的时候就明确定义主键。

3.3 主键拜托别用随机值

聚簇索引叶子页里按主键排好序,插入新行时的位置就完全由主键决定。这里有个非常现实的性能分水岭:

  • 自增主键:新行的主键继续往上走,插入位置固定在当前最大键的右边,通常就在最新叶子页的尾部,顺序写,代价很低,页满时申请一个新页继续追加即可。
  • UUID、雪花 ID、业务随机字符串做主键:新行的主键随机落在树的中部,InnoDB 必须从对应叶子页中间插入记录,页内要挪动后面的记录,页满了还要触发页分裂。

更麻烦的是,二级索引叶子页里存的是主键值。主键越大,二级索引的每一行就越大,一个页能容纳的二级索引记录就越少,于是二级索引的树变高、扫描页数变多,回表的成本也同步上升。我在实际优化中见过一张订单表,原来主键是 36 字节的 UUID,二级索引idx_user_id一个页只能装几百条;改成 BIGINT 自增主键之后,同样大的二级索引页,能装下的记录数翻了近一倍,整体查询耗时有肉眼可见的下降。

主键类型写入方式页分裂概率二级索引占用
自增整数/长整数尾部追加,顺序写低紧凑
UUID/随机字符串随机位置插入高膨胀明显
业务自然键(如身份证)随机 + 不稳定高视长度而定
复合主键按第一列有序取决于第一列性质所有索引都存完整组合键,通常偏大

所以我的建议是:OLTP 表尽量用自增整数或 BIGINT 做主键,不要为了“看起来有业务含义”而牺牲写入性能和索引体积。这可能是索引设计里投入产出比最高的一个动作。

4. 二级索引:为什么不直接存数据行

4.1 二级索引叶子页里到底放了什么

二级索引(Secondary Index)是独立于聚簇索引之外的另一棵 B+ 树,它的 key 是你建索引的列,但叶子节点存的不是完整数据行,而是“索引列的值 + 聚簇索引的主键值”。

举个例子,如果有索引KEY idx_user_id (user_id),那这棵树的叶子页记录形如(user_id, order_id)。InnoDB 通过索引找到user_id = 10001时,最多能拿到主键order_id,但它还没法返回status、created_at这些列。它必须拿着这个主键,再去聚簇索引里查一次完整行。这个“再查一次”的过程就叫回表。

为什么二级索引不直接复制整行数据?最直接的原因是空间爆炸。一张表建 4~5 个二级索引,就意味着每行数据要在多棵 B+ 树里各存一份。空间膨胀压缩了每个页的容纳量,同时任何 UPDATE 都要同步修改多个索引,写放大得厉害。二级索引存“主键引用”而不是“数据副本”,本质上是在空间、写性能和查询便利之间做权衡。

回表不是免费的,它意味着额外读取聚簇索引的根页、内页、叶子页,每层一次页访问。这也是为什么很多 MySQL 调优文章反复强调“尽可能减少回表”。

4.2 覆盖索引:省掉回表就是省掉整棵树

如果你要查询的列都已经包含在二级索引里,那 InnoDB 就没必要去回表了。比如:

SELECT user_id, order_id FROM orders WHERE user_id = 10001;

这里user_id是索引列,order_id是主键列,二级索引叶子页里正好都有,于是 MySQL 会直接在二级索引页里返回结果。执行计划中对应的Extra会显示Using index,意思是“覆盖索引,不需要回表”。

覆盖索引的价值非常直观:省掉一次聚簇索引树的完整遍历。所以设计索引时,不要把眼光只盯在 WHERE 条件上,SELECT 的列也应该纳入考虑。比如(user_id, created_at, status)这个联合索引,就能覆盖SELECT user_id, created_at, status FROM orders WHERE user_id = ?。不过覆盖索引也不是越多越好,每多一个索引,INSERT/UPDATE/DELETE 的维护成本就高一层,尤其高频写入的表更需要克制。

4.3 索引下推(ICP):存储引擎帮你先过滤

覆盖索引是“干脆不回表”,索引下推是“少回表几行”。这是 MySQL 5.6 引入的优化,叫 Index Condition Pushdown。

看一个典型场景。表里有联合索引KEY idx_user_status_time (user_id, status, created_at),而你的查询是:

SELECT * FROM orders WHERE user_id = 10001 AND created_at > '2024-06-01';

注意这里跳过了中间的status条件,所以 InnoDB 在 B+ 树定位时只能用上user_id = 10001这一段,created_at因为前缀列缺失,无法在定位时参与范围判断。于是引擎会扫出所有user_id = 10001的二级索引记录。

如果没有 ICP,优化器拿到这些索引记录对应的主键就回表,把完整行拉到 Server 层后,再在 Server 层过滤掉created_at <= '2024-06-01'的行。如果有 ICP,InnoDB 在扫描二级索引页时,直接检查每条索引记录里的created_at,把不满足条件的记录过滤掉,只拿满足条件的少量主键去回表。执行计划里对应的 Extra 是Using index condition。

ICP 对减少回表的收益非常可观,因为它把过滤下推到了最靠近数据源的地方。但它只对二级索引有效,而且要求过滤条件能在索引列上求值。如果索引里根本没有created_at列,ICP 就无从谈起。所以建联合索引时,把“可能带来高选择性过滤”的列放进去,是能给优化器更多操作空间的。

4.4 别忘了二级索引的写放大

二级索引越多,写入越慢,这个代价往往被低估。一行数据的 INSERT,除了写聚簇索引,还要往每个二级索引 B+ 树里插入一条索引记录。如果二级索引页不在 Buffer Pool,InnoDB 会先写 Change Buffer(对非唯一二级索引),把随机 IO 延后合并,后台再批量刷入索引页。这也是为什么索引不是越多越好,每增加一个二级索引,DML 的维护路径就多出一条。

5. 页分裂与合并:索引性能损耗的真实来源

5.1 页满之后的“搬家”过程

B+ 树并不是“无痛变高”的,插入操作最贵的一步是页分裂。当某个叶子页已经满员,而新记录又必须排在这个页内时,InnoDB 会执行如下动作:

  1. 申请一个新的 16KB 页。
  2. 把原页中约一半的记录搬到新页,保证两边都能继续插入。
  3. 调整原页和新页的FIL_PAGE_PREV/FIL_PAGE_NEXT,重建双向链表。
  4. 在父节点插入一条指向新页的键值。如果父节点也满了,就继续向上分裂,直到根节点也分裂,树高增加一层。

这个过程的代价是写放大。你本来只想插入一条记录,结果可能要写一个旧页、一个新页、一个父页,运气不好还要再多写几层。更麻烦的是分裂期间相关页面要加锁,并发下可能把这个位置卡成热点。很多 DBA 观察到 MySQL 在某个主键值附近插入特别慢,大概率就是该主键位置频繁发生页分裂。

打个比方,往一本已经装订好的书中间塞进一页纸,你不能硬塞,得把书脊拆开、重新排页、再装订。页分裂就是数据库里的“重新装订”,成本不在纸本身,而在工序。

5.2 随机主键怎么制造灾难

知道了页分裂的机制,就能解释为什么随机主键会让插入性能雪崩。UUID 主键的值完全没有顺序,新记录几乎每次落在某个已有叶子页的中间位置。叶子页为了维持主键有序,必须不断做页内挪动和页分裂。分裂之后,原来的满页和新的半页都只有约 50% 的利用率,而你还在不停地插入,于是很快又分裂。最终结果是表空间膨胀、页碎片多、扫描时访问的页数成倍增长。

自增主键则完全不同:新记录永远排在所有已有行的右侧,等到当前叶子页满了,直接申请一个新的叶子页追加在链表尾部,不涉及中间记录的搬迁,父节点也只需要在末尾追加一条键值。这就是顺序写与随机写在索引层的本质区别。

我在优化一批历史订单表时见过一个极端案例:同样是 500 万行订单,一张表用自增主键,一张表用了雪花 ID 主键。前者主键等值查询的执行计划显示 rows 约等于 1,后者因为碎片和页占用异常,同样的条件需要扫描 2~3 倍的叶子页。把表重建、改成自增主键并做一次OPTIMIZE TABLE之后,整体空间占用直接缩了 40%。

提示:MySQL 8.0 里你依然可以用 UUID 做主键,但更推荐UUID_TO_BIN()把 UUID 转成二进制有序形式,或者直接换自增主键 + 业务唯一键两条路并行。

5.3 页的回收与空间整理

页分裂让空间膨胀,而删除操作则会制造“半空页”。当叶子页里的记录被大量 DELETE 掉,页利用率低到一定程度后,InnoDB 会尝试把相邻的页合并,释放出一个空闲页还给表空间。页的分配和回收由表空间管理模块负责,底层会用类似位图的结构跟踪每个页的状态,比如 XDES 页里记录区(extent)的分配信息。这其实和你写内存分配器时用位图管理空闲块是一个道理:申请、释放、合并碎片。

问题是,页合并也不是免费的。它同样涉及跨页搬记录、更新链表和父节点,如果一张表高频地插入删除、删除插入,页面的分裂和合并会反复发生,造成不必要的 IO。这也是为什么很多系统设计里,删除不让用户直接删,而是先打标记,再在低峰期统一清理。

实操上,如果一张大表经历过大量删除,即使COUNT(*)变小了,SQL 扫描的页数可能依然很多。我的习惯是定期观察information_schema.tables.DATA_FREE,结合SHOW TABLE STATUS里的Data_length判断碎片程度,必要时用ALTER TABLE t ENGINE=InnoDB或OPTIMIZE TABLE重建表。不过这些操作会触发表级锁或在线重建,一定要放在业务低峰期执行。

6. 把索引调优翻译成页访问量:一个查询到底读了多少页

6.1 先建立页访问量估算

把前面所有概念落到实际操作中,我建议你建立一套“页访问量”的估算方法,用来快速判断 SQL 需不需要优化。

一次主键等值查询WHERE id = ?,访问的页数约等于聚簇索引树高。千万级表通常树高为 3,所以理想情况下就是 3 次页访问。

一次二级索引等值查询,访问的页数约等于“二级索引树高(定位索引记录)+ 聚簇索引树高(回表)”。如果同样都是 3 层树,那么最坏是 6 次页访问。覆盖索引能把这 6 次降到 3 次,所以覆盖索引的价值常常被低估。

一次范围查询,比如WHERE created_at BETWEEN ? AND ?,成本由两部分构成:定位到左边界的那几次页访问,加上顺着叶子链表扫描覆盖范围内记录所占的页数。如果范围很大,扫描页数是主要成本,这种场景靠索引只能保证“不扫全表”,但同样可能很慢。

这 3 个模型能解释绝大多数索引问题的本质:所有调优手段最后都在想办法减少页访问次数。页访问如果命中 Buffer Pool 就是内存操作,没命中就是一次磁盘 IO。所谓慢 SQL 优化,很多时候就是让随机 IO 变成顺序 IO,或者直接少做 IO。

6.2 联合索引、最左前缀与排序字段

联合索引比单列索引复杂,是因为它的排序规则是“多键组合有序”。比如(user_id, status, created_at)这个索引,先按user_id排序,相同user_id内再按status排序,相同再按created_at排序。这带来两个直接推论:

  1. 最左前缀是硬门槛。查询条件里如果不包含user_id,比如直接用WHERE status = 2,InnoDB 无法在树里定位区间,只能扫整个索引,退化到全索引扫描。
  2. 范围条件会阻断后续列。如果WHERE user_id = 10001 AND status > 2 AND created_at > '2024-06-01',status上的范围定位之后,created_at就无法继续参与 B+ 树的定位了,只能作为索引内的过滤条件(配合 ICP)。

所以建联合索引时,我一般按这个顺序考虑:

  • 先把 WHERE 里等值条件的列放前面,区分度高的优先。
  • 再把范围条件列放中间或后面,视查询能否继续用后一列而定。
  • 最后看 SELECT 列和 ORDER BY,能不能塞进索引实现覆盖/避免 filesort。
  • 冗余要克制,能复用已有索引就直接复用,不要每个查询都单建索引。

排序也是一个容易被忽略的点。ORDER BY status, created_at如果和联合索引顺序一致,且前缀条件是等值限定,那么 MySQL 可以直接按索引顺序返回,Extra里不会出现Using filesort。反过来,ORDER BY created_at单独用(status, created_at)索引通常就帮不上忙,因为全局来看created_at不是有序的。

6.3 一条真实慢查询的完整改造记录

我之前处理过一个典型的订单查询:

SELECT order_id, status, created_at FROM orders WHERE user_id = 10001 AND created_at BETWEEN '2024-06-01' AND '2024-06-30' ORDER BY created_at;

最初表上只有主键order_id,查询执行计划是type=ALL,rows 估算 180 万。这是一个典型的三步问题:没有合适的二级索引,导致全表扫描;即使走某个索引,也大概率需要回表;ORDER BY created_at还要额外做 filesort。

我的改造步骤是:

  1. 把 WHERE 里等值条件的列user_id放最左。
  2. 把范围条件created_at放第二列,同时兼顾ORDER BY created_at。
  3. 把 SELECT 里的status也加进索引列,构成(user_id, created_at, status),让查询变成覆盖索引。

最终建索引语句是:

ALTER TABLE orders ADD INDEX idx_user_time_status (user_id, created_at, status);

改完后执行计划变成type=ref,rows 从 180 万直接掉到几千,Extra显示Using index(覆盖,不需要回表),filesort 消失。这个查询在测试环境的耗时从 2.5 秒降到 20 毫秒左右,量级上差了百倍。

我自己排查慢 SQL 的经验是:先看EXPLAIN的type、rows和Extra,尤其注意Using filesort和Using temporary这两个信号,它们往往比单纯的type=ALL更能暴露索引设计缺陷。然后再用页访问量的模型估算一遍,确认索引到底省掉了哪些 IO。索引不是越多越好,但每一棵索引都应该能回答一个问题:它替查询省掉了几次回表、几次扫描、几次排序。能把这个问题回答清楚,索引设计基本就不会跑偏。

返回列表