别再只会“加索引”:从磁盘页到 B+Tree,彻底理解 MySQL 索引的设计与运行

很多人学习 MySQL 索引,是从"最左前缀""回表""覆盖索引""页分裂"这些结论开始的。结论记得不少,真正遇到一条新 SQL 时却仍然只能猜:它会从哪里开始扫描,为什么要回表,范围条件之后的列究竟还能不能使用,插入一行为什么可能修改好几张页面?

问题不在于口诀记得不够,而在于没有先建立索引的整体模型。

索引不是贴在表旁边的"加速标签",也不是一份只保存主键位置的目录。对 InnoDB 来说,每一棵普通索引都是一套独立的、有序的、由磁盘页组成的 B+Tree。查询沿着这棵树找到记录,写入则修改这棵树,同时保证下一次查询仍能按相同规则找到记录。

本文以 MySQL 5.7 为基准,依次回答四个问题:

latex 复制代码
为什么索引必须围绕磁盘页设计?
一棵 B+Tree 在页中究竟保存什么?
点查、范围扫描、回表和联合索引如何发生?
插入、删除和页分裂怎样维持树始终可查?

先看全文总图。现在不必记住其中的函数或字段,只要先抓住两条主线:读取是在有序空间中找到一段路,写入是在改变页面之后继续维持这段路。

为了让后面的具体记录有统一含义,使用下面这张订单表作为例子;不同小节会选用不同 SQL,但底层都是同一套索引结构。

sql 复制代码
CREATE TABLE shop.orders (
    id          BIGINT UNSIGNED NOT NULL,
    customer_id BIGINT UNSIGNED NOT NULL,
    created_at  DATETIME NOT NULL,
    status      VARCHAR(16) NOT NULL,
    amount      DECIMAL(12,2) NOT NULL,
    note        VARCHAR(200) NULL,
    PRIMARY KEY (id),
    KEY idx_customer_time_status(
        customer_id,
        created_at,
        status
    )
) ENGINE=InnoDB
  ROW_FORMAT=COMPACT;

这张表至少包含两棵树:

latex 复制代码
PRIMARY(id)
idx_customer_time_status(customer_id, created_at, status)

理解 MySQL 索引的第一步,就是不再把"表"和"索引"想成一份行文件外加几个旁路目录,而是把它们看成多棵各自有序的 B+Tree。

索引首先是一个磁盘问题:真正昂贵的不是比较,而是读取页面

假设有一亿行订单,要寻找 id=4201

如果全部数据已经在内存中,比较几十次通常不是主要成本;但数据库的设计不能假设所有数据永远驻留内存。InnoDB 按页管理数据,常见默认页大小是 16KiB。页面可能位于 .ibd 文件,也可能已经缓存到 Buffer Pool。即使一次查询最终只需要一行,存储引擎仍然要先得到包含该行的页面,再解析页内记录。

因此索引的核心目标不是单纯减少 CPU 比较次数,而是同时做到:

latex 复制代码
用尽量少的页面访问找到起点
找到起点后可以连续读取范围
页面空间不足时仍能继续插入
结构变化后原有查询规则不失效

把几种常见结构放在磁盘环境下比较,就能理解 B+Tree 为什么出现。

哈希表适合精确等值定位,但哈希值不保留原始键值顺序。查询 created_at BETWEEN ...ORDER BY created_at 或"从某个键开始向后读取 20 条"时,哈希表无法自然提供连续顺序。

普通二叉搜索树保留顺序,却只有很小的分支数。树中每个节点如果都对应一次磁盘页面访问,较深的路径意味着更多随机 I/O。B+Tree 让一张非叶子页保存大量分隔键和子页号,一层可以排除很大的键值范围,因此树可以保持很矮。

只连接叶子的有序链能够连续扫描,但缺少上层导航结构。寻找范围起点时可能要从头走很远。

B+Tree 把两种能力组合起来:

latex 复制代码
上层页面:快速缩小目标范围
叶子页面:保存有序记录并相互连接

所以它既适合点查,也适合范围扫描。MySQL 文档和源码中经常使用 B-tree 这一名称;本文讨论的是 InnoDB 普通索引呈现出的 B+Tree 组织特征:真实索引记录位于叶子层,同层页面有前后链接。

这里还可以得到一个重要结论:

索引树的节点不是抽象圆圈,而是一张可以从表空间读取到 Buffer Pool 的数据库页。

树高决定"找到目标叶子页通常要经过多少层",每一层页面能保存多少分隔键则决定扇出。索引键越宽,一页可容纳的入口通常越少;同样的数据量可能需要更多页面,甚至更高的树。这也是"索引列宽度"会影响读取和存储成本的底层原因。

一棵索引就是一组有入口、有层级、有顺序的页面

CREATE INDEX 建立的不是一个孤立文件。对于独立表空间,聚簇索引页和二级索引页通常都位于同一张表的 .ibd 中,但每棵索引拥有自己的索引 ID、字段定义、根页号和 B+Tree 页面集合。

InnoDB 在内存数据字典中使用 dict_index_t 描述一棵索引。下面只摘取与理解本章有关的字段:

cpp 复制代码
struct dict_index_t {
    index_id_t id;
    // 这棵索引的内部 ID

    const char* table_name;
    dict_table_t* table;
    // 索引属于哪张表

    unsigned space : 32;
    // 索引页面位于哪个表空间

    unsigned page : 32;
    // B+Tree 根页号;搜索从这里进入

    unsigned type;
    // 是否为聚簇索引、唯一索引等

    unsigned n_user_defined_cols : 10;
    unsigned n_fields : 10;
    // 用户定义字段数,以及内部补充字段后的总字段数

    dict_field_t* fields;
    // 按索引顺序排列的字段描述
};

源码位置:

  • mysql-server-5.7/storage/innobase/include/dict0mem.h

这段结构表达了一个很重要的关系:

latex 复制代码
dict_index_t
    给出 space_id + root_page_no
        ↓
从根页进入对应 B+Tree
        ↓
页面里的 node pointer 再指向下一层页面

根页只是搜索入口,不保存整棵树的所有记录。树增长时会分配更多页面,但搜索仍然从索引元数据记录的根页开始。

非叶子页负责缩小范围,叶子页负责保存索引记录

一棵稍大的索引可以有三层:根页、非叶子页、叶子页。

非叶子页中的记录可以概括为:

latex 复制代码
分隔键 K + child page_no

它表达的不是一行业务数据,而是:从某个键值范围开始,应当进入哪个子页继续搜索。

例如搜索元组 (7, 08-18, ...),根页先判断它属于哪个大区间,进入对应的 level 1 页面;level 1 页面再把范围缩小到某张叶子页。每下降一层,目标都从"整棵树"收窄成"一个子页面范围"。

叶子页中的记录才是这棵索引真正保存的 index entry。它们按照完整索引键顺序排列。叶子页还通过 FIL_PAGE_PREVFIL_PAGE_NEXT 形成同层双向链,因此找到范围起点之后,不必每读取下一页都重新回到根页;游标可以直接沿叶子页链继续向右。

同层非叶子页也具有前后页链接,但普通范围查询下降到叶子层后,连续读取依赖的是叶子页链。不要把"所有同层页面存在链接"和"查询会沿所有层级横向扫描"混为一谈。

还要特别注意:

latex 复制代码
page_no 只是页面地址,不代表键值大小

分裂后新页的页号可能大于原页,却被插入原页与下一页之间。真正表示逻辑先后的是页面中的分隔键以及 FIL_PAGE_PREV/NEXT,不是页号数值。

进入一张页面后,查找从"树下降"变成"页内定位"

B+Tree 把目标缩小到一张叶子页,并不等于已经找到记录。一张 16KiB 页面中可能保存几十、几百条变长记录,InnoDB 还需要在页内找到第一条满足比较模式的记录。

页内搜索不是简单地从第一条用户记录扫描到最后一条,也不是对所有记录指针进行一次完整二分。InnoDB 组合使用 Page Directory 和有序记录链:

页面中有两条系统记录:

latex 复制代码
Infimum   表示比页内所有用户记录都小的逻辑起点
Supremum  表示比页内所有用户记录都大的逻辑终点

用户记录通过记录头中的 next 偏移保持索引键有序:

latex 复制代码
Infimum
   → record A
   → record B
   → record C
   → Supremum

记录的物理字节位置不必按键值连续排列。删除、更新和插入可能让页面内部产生碎片,但只要记录链仍按索引键有序,查询就能按逻辑顺序遍历。

Page Directory 位于页尾,由少量 Slot 管理若干记录。页内定位可以理解为两步:

latex 复制代码
先在 Slot 之间二分,找到目标所属的小记录组
再沿组内记录链逐条比较,找到精确位置

源码中的页游标搜索函数直接体现了这种设计:

cpp 复制代码
void page_cur_search_with_match(
    const buf_block_t* block,
    // 当前 Buffer Pool 页面

    const dict_index_t* index,
    // 用什么索引字段和类型解释页内记录

    const dtuple_t* tuple,
    // 要搜索的索引元组

    page_cur_mode_t mode,
    // 寻找 <、<=、> 或 >= tuple 的记录

    ...,
    page_cur_t* cursor,
    // 输出:页内游标停到目标位置

    ...)
{
    ulint up;
    ulint low;
    ulint mid;
    // Page Directory 二分所需的上下界和中点

    const page_t* page;
    page = buf_block_get_frame(block);
    // 从 Buffer Block 取得真正的页面字节

    ...
}

源码位置:

  • mysql-server-5.7/storage/innobase/page/page0cur.cc

到这里,索引搜索已经形成了三层逐步收窄:

latex 复制代码
父页分隔键:从整棵树缩小到一个子页
Page Directory:从整张页缩小到一个记录组
页内记录链:从记录组定位到具体记录

这比"B+Tree 是二分查找"更准确。树层、页目录和记录链使用的是不同粒度的导航结构。

叶子记录保存什么,决定了聚簇索引、二级索引与回表

前面的树结构对聚簇索引和二级索引都成立。二者真正的区别,主要出现在叶子记录保存的内容。

聚簇索引的叶子就是完整业务行

对于 PRIMARY(id)

latex 复制代码
非叶子记录
= 主键分隔值 + 子页号

叶子记录
= 主键 id
+ 其余业务列
+ DB_TRX_ID
+ DB_ROLL_PTR

因此使用主键定位到聚簇叶子记录时,完整业务行已经在那里,不需要再根据"行地址"去另一份行文件读取数据。

这就是"表数据按聚簇索引组织"的真实含义。它不是说数据行旁边额外存在一棵主键树,而是:聚簇索引叶子记录本身就是行记录。

一张 InnoDB 表只能有一棵聚簇索引,因为一行业务数据只能以一种键值顺序作为主要物理组织方式。显式主键通常作为聚簇键;没有主键时,InnoDB 会寻找合适的唯一非空索引,仍然没有时会生成内部行 ID 作为聚簇键。

二级索引的叶子保存二级键和聚簇键

对于:

latex 复制代码
idx_customer_time_status(customer_id, created_at, status)

二级叶子记录可以概括为:

latex 复制代码
customer_id
+ created_at
+ status
+ 聚簇索引键 id

二级索引不复制完整业务行。它携带主键,是为了在命中二级记录后能够定位对应的聚簇记录,同时让相同二级键的多行保持可区分。

于是三种读取路线自然出现。

主键点查:

latex 复制代码
PRIMARY 根页
  → PRIMARY 叶子页
  → 完整行

二级索引点查,并且查询列都在二级记录中:

latex 复制代码
二级索引根页
  → 二级索引叶子页
  → 直接返回二级记录中的字段

这就是覆盖索引。它不是一种特殊索引类型,而是"本次查询需要的列,当前索引记录已经全部提供"。

二级索引点查,但查询还需要 amount

latex 复制代码
二级索引根页
  → 二级索引叶子页
  → 取出主键 id
  → PRIMARY 根页
  → PRIMARY 叶子页
  → 取得 amount 和完整行

这就是回表。回表不是从二级记录跳到某个固定文件偏移,而是拿二级记录中的聚簇键,对另一棵 B+Tree 再执行一次搜索。

因此一个范围查询如果命中一万条二级记录,并且每条都要读取聚簇记录,成本不只是"一次范围扫描",还可能包含大量聚簇索引点查。

一次查询的本质:先找到第一条候选记录,再从那里向后读

现在把树结构、页内定位和两类叶子记录组合起来。

查询并不是把完整 WHERE 文本直接交给某张叶子页。Server 层先选择访问路径,并把能够用于定位的条件整理成索引边界;InnoDB 再根据索引元数据和比较模式,把边界变成 B+Tree 中的起点。

Server 交给 InnoDB 的是一张查找指令

以这个范围为例:

sql 复制代码
WHERE customer_id = 7
  AND created_at >= '2026-08-01'
  AND created_at <  '2026-09-01'

下界可以理解为:

latex 复制代码
Key Buffer  : [ customer_id = 7 ][ created_at = 2026-08-01 ]
keypart_map : 110
flag        : HA_READ_KEY_OR_NEXT

三者分别回答不同问题:

内容 回答的问题 本例含义
Key Buffer 边界值是什么 72026-08-01
keypart_map 索引定义中的哪些字段参与边界 第 1、2 列
flag 与边界相等时停在哪一侧 找第一条 >= 下界的记录

Key Buffer 不是缓存,也不是一条完整索引记录。它只是 Server 层按照索引字段格式编码出的搜索前缀。

latex 复制代码
搜索前缀:  (7, 2026-08-01)
真实二级记录:(7, 2026-08-01, PAID, 4101)

keypart_map=110 只是为了直观表示:联合索引定义中的前两列参与当前边界,status 不属于这次定位键。主键 id 虽然存在于 InnoDB 二级记录中,却不是 Server 这条索引定义中的显式 keypart。

上界同样需要值和开闭语义:扫描遇到第一条 >= (7, 2026-09-01) 的记录时停止,该记录本身不属于结果。

InnoDB 把搜索前缀变成可以与索引记录比较的元组

InnoDB 根据 dict_index_t 中的字段顺序、类型和排序规则,把 Key Buffer 转换为内部搜索元组 dtuple_t。搜索函数接收的核心信息可以压缩成:

latex 复制代码
dict_index_t + dtuple_t + page_cur_mode_t
= 搜索哪棵树 + 比较什么元组 + 找边界哪一侧

源码接口直接反映了这三个输入:

cpp 复制代码
void btr_cur_search_to_nth_level(
    dict_index_t* index,
    // 搜索哪棵索引树;其中包含 space_id 和 root page

    ulint level,
    // 希望停在哪一层;普通记录查询最终到 level 0 叶子层

    const dtuple_t* tuple,
    // 已按索引字段顺序构造的搜索元组

    page_cur_mode_t mode,
    // PAGE_CUR_L / LE / G / GE 等比较方式

    ...,
    btr_cur_t* cursor,
    // 输出:树游标停到目标页和目标记录附近

    ...,
    mtr_t* mtr);

源码位置:

  • mysql-server-5.7/storage/innobase/include/btr0cur.h
  • mysql-server-5.7/storage/innobase/btr/btr0cur.cc

搜索从 index->page 指向的根页开始。每到一张非叶子页,页内比较选择一个 node pointer,再取得对应子页;直到进入叶子页,页游标找到第一条满足比较模式的候选记录。

这里的"游标"可以理解为存储引擎保存的当前索引位置:它知道当前位于哪棵索引、哪张页以及哪条记录附近。范围查询不需要为每一条记录重新从根页开始搜索,因为游标可以从当前位置沿记录链继续移动。

范围扫描有两个独立动作:下界定位和上界停止

范围查询不是在 B+Tree 中一次性取出一个数组,而是:

latex 复制代码
使用下界搜索第一条候选记录
    ↓
逐条读取当前页中的后继记录
    ↓
当前页结束后沿 FIL_PAGE_NEXT 进入下一叶子页
    ↓
每取得一条记录都检查是否越过上界

如果查询还有 LIMIT 20,还要区分"扫描了多少条候选记录"和"最终产生了多少条结果"。只有记录经过索引内过滤、必要的回表和剩余 WHERE 判断后,才算一条最终结果。第 20 条合格结果出现时才能早停。

所以:

latex 复制代码
LIMIT 20
不等于最多读取 20 条索引记录

如果候选记录中只有十分之一满足剩余条件,得到 20 条结果可能已经扫描了约 200 条索引记录,并可能执行多次回表。

联合索引不是一组口诀,而是一条按完整元组排列的数轴

理解联合索引之前,必须先写出这棵树真正保存的完整索引元组。

对:

latex 复制代码
idx_customer_time_status(customer_id, created_at, status)

普通二级索引叶子还携带主键,因此可以把记录顺序理解为:

latex 复制代码
(customer_id, created_at, status, id)

比较采用字典序:先比较 customer_id;相等时再比较 created_at;仍然相等时比较 status;最后使用 id 区分记录。

它不是三列分别保持全局有序,而是完整元组整体有序。

因此:

latex 复制代码
customer_id = 6 的记录形成一段
customer_id = 7 的记录形成一段
customer_id = 8 的记录形成一段

customer_id=7 的区段内部,记录才继续按照 created_at、status、id 排列。脱离相同的 customer_id 前缀,所有客户的某一天并不会集中在一段。

所谓最左前缀,本质上是在回答:SQL 条件能否把目标记录压缩成这条元组数轴上的一个连续区间,或者少量几个连续区间。

为什么范围列之后的字段通常不能继续缩小同一个连续边界

考虑:

sql 复制代码
WHERE customer_id = 7
  AND created_at >= '2026-08-01'
  AND created_at <  '2026-09-01'
  AND status = 'PAID'

customer_idcreated_at 可以形成一个连续日期区间:

latex 复制代码
[(7, 2026-08-01), (7, 2026-09-01))

但是在这段范围中,每一天内部又按 status 排列:

latex 复制代码
(7, 08-01, CANCELLED, ...)
(7, 08-01, PAID,      ...)
(7, 08-01, REFUND,    ...)
(7, 08-02, CANCELLED, ...)
(7, 08-02, PAID,      ...)

所有 PAID 并没有在整个八月范围中聚集成一段。若只读取 PAID,就要在每一天的局部状态区间之间反复跳跃。因此 status='PAID' 通常不能继续把当前日期范围压缩为一个更小的连续起止边界。

但这不表示 status 完全失效。因为它仍然保存在二级索引记录中,可以:

latex 复制代码
参与 ICP,在回表前过滤
参与覆盖索引输出
在满足前置顺序时参与 ORDER BY

所以"范围列之后全部失效"是不准确的。更准确的说法是:

后续字段通常无法继续缩小当前这一段连续扫描边界,但仍可能在扫描、输出和排序阶段发挥作用。

常见条件只是不同的区间生成方式

不必为每一种 SQL 单独背规则,只要观察它在完整元组数轴上形成什么区域。

条件 在有序元组中的含义
a = 7 一个等值前缀区段
a IN (6,8) 两个互不相邻的等值区段
a BETWEEN 6 AND 8 一个连续闭区间
name LIKE 'abc%' 在匹配排序规则下形成字符串前缀范围
只有第二列条件 目标可能散布在每一个第一列区段中

IN 并不是"完全等价于一个等值条件"。它通常产生多个范围,每个范围都可以定位,但多个范围之间需要分别处理。

LIKE 'abc%' 可以利用从 abc... 开始的一段有序字符串范围;LIKE '%abc' 缺少确定的起始前缀,无法直接跳到一段连续区域的起点。

隐式类型转换的问题也可以从这里理解:若列值必须先经过函数或类型转换才能参与比较,转换后的比较顺序不一定还能直接映射到原索引字节顺序,存储引擎就难以构造可靠的起止边界。

联合索引的"左前缀"和字符串列的"前缀索引"也不是同一个概念:前者描述多列元组从左到右的排序依赖;后者是建索引时只保存字符串前 N 个字符,主动缩短索引记录,但可能失去完整值的区分能力。

ORDER BY 和 LIMIT 利用的是同一份索引顺序

如果结果顺序与选定索引从扫描起点向后的记录顺序一致,MySQL 可以边扫描边输出,不必把全部结果交给额外排序过程。

例如固定 customer_id=7 后,索引剩余顺序是:

latex 复制代码
created_at → status → id

那么:

sql 复制代码
ORDER BY created_at, status, id

与叶子记录向右扫描的顺序一致。若最终条件也能较早满足,LIMIT 20 可以在产生第 20 条结果后停止。

如果排序列顺序、方向或前置约束不能与索引顺序对应,索引即使帮助了 WHERE 定位,也不一定能同时消除 Filesort。索引不是分别为 WHERE 和 ORDER BY 维护两套顺序;它始终只有一份由完整索引元组定义的顺序。

覆盖索引、ICP、回表和 Using where,只是条件在不同位置执行

一条使用二级索引的查询,可以拆成一条清晰的数据缩减流水线:

latex 复制代码
边界定位
  → 扫描候选二级记录
  → 使用二级记录中的字段做 ICP
  → 对剩余候选回表
  → 使用完整行做 Server 层过滤
  → 按索引顺序输出或额外排序
  → LIMIT 判断是否停止

这些术语并不是索引能力从低到高的三个等级,而是在说明不同工作发生在哪里。

覆盖索引省掉整次回表

若查询只需要:

sql 复制代码
SELECT customer_id, created_at, status, id

这些值都存在于二级索引记录中,读取二级叶子后已经可以构造结果,不需要进入聚簇索引。这通常会在执行计划中出现 Using index

ICP 在回表之前淘汰不合格二级记录

status='PAID' 不能继续缩小日期范围,但 status 存在于二级记录中,InnoDB 可以扫描每条候选二级记录时先检查状态,只为满足状态的记录回表。

假设日期范围内有 1000 条二级记录,其中只有 120 条是 PAID

latex 复制代码
没有 ICP:可能先回表约 1000 次,再判断 status
使用 ICP:二级索引内过滤后,只回表约 120 次

ICP 没有减少这个日期范围内必须扫描的二级记录数量;它减少的是昂贵的后续回表次数。这通常对应 Using index condition

Using where 表示仍有条件需要在取得当前行后判断

如果条件依赖 amount,而 amount 不在二级索引中,就必须回表取得聚簇记录,再判断完整 WHERE。执行计划中的 Using where 说明仍存在这样的过滤工作,并不表示索引完全没有作用。

同一条执行计划可能同时出现多个提示,因为它们描述的阶段不同:

latex 复制代码
Using index            当前索引已经覆盖查询所需列
Using index condition  InnoDB 在索引记录阶段执行条件下推
Using where            取得当前行后仍需检查剩余条件

深分页慢,是因为 B+Tree 按键查找,不按"结果名次"跳转

LIMIT 100000,20 不能直接让游标跳到"第 100001 条最终结果"。B+Tree 能根据索引键定位,却没有为当前 WHERE 结果动态维护名次。存储引擎通常仍需从范围起点开始扫描、过滤并丢弃前 100000 条结果。

如果业务能携带上一页最后一条记录的完整排序键,例如:

latex 复制代码
(created_at, status, id)

就可以把"按名次跳转"改写为"按索引键寻找新起点",这也是游标式分页通常更稳定的底层原因。

当二级范围扫描产生大量主键,并导致大量随机聚簇索引点查时,MRR 可以先收集一批主键,再按更有利于聚簇读取的顺序回表。它优化的是批量回表的局部性,不会改变联合索引如何形成范围,也不会消除回表本身。

写入不是查询之外的另一套逻辑,而是在维护同一套有序结构

读到这里,查询依赖的规则已经明确:

latex 复制代码
父页分隔键能把搜索带到正确子页
页内记录链保持完整索引键有序
叶子页链可以连续遍历
二级记录中的主键能找到正确聚簇记录

INSERT、UPDATE 和 DELETE 的任务,就是在数据变化之后继续保持这些规则。

插入一行业务数据,需要给每棵索引各写一条记录

执行:

sql 复制代码
INSERT INTO shop.orders
    (id, customer_id, created_at, status, amount, note)
VALUES
    (4905, 7, '2026-08-25', 'PAID', 128.00, '...');

InnoDB 不会先写一份独立"行文件",再让索引只保存文件地址。它会根据每棵索引的字段顺序构造不同 index entry:

latex 复制代码
PRIMARY entry
= id 4905 + 完整业务列 + 事务隐藏字段

二级 entry
= customer_id 7
+ created_at 08-25
+ status PAID
+ id 4905

表中如果有五棵索引,一次 INSERT 通常就要为五棵树分别构造并插入记录。UPDATE 修改索引列时,也可能表现为删除旧索引 entry,再向新键值位置插入新 entry。

这解释了索引最直接的写入成本:索引越多,一次业务写入需要维护的有序结构越多。

插入位置仍然使用 B+Tree 搜索

新 entry 不是追加到文件末尾,而是按索引键找到它应该位于的叶子页和前驱记录。

latex 复制代码
构造 entry
  → 从根页下降
  → 页目录定位记录组
  → 记录链找到插入位置

这与查询起点定位使用的是同一套有序结构。区别在于查询通常取得共享访问,写入需要修改页面并维护记录链、目录和空间信息。

页面放得下,就不必改变树结构

叶子页存在足够连续空间时,InnoDB 可以直接把新记录写入页面,修改相邻记录的 next 关系,并在需要时调整 Page Directory。

如果总空闲空间足够,但被删除记录和历史变化切成了多个碎片,页面可以先重组:按逻辑记录顺序重新整理物理字节,合并出较大的连续空间,再尝试插入。

页面重组不会改变 B+Tree 层级,也不会改变记录的逻辑顺序。它只是重新安排同一页中的物理字节。

InnoDB 源码把"不预期改变树结构"和"允许修改树结构"分成两条插入路径:

cpp 复制代码
err = btr_cur_optimistic_insert(
    flags,
    cursor,
    &offsets,
    &offsets_heap,
    entry,
    &insert_rec,
    &big_rec,
    n_ext,
    thr,
    &mtr);
// 先假设只修改当前叶子页就能完成

if (err == DB_FAIL) {
    err = btr_cur_pessimistic_insert(
        flags,
        cursor,
        &offsets,
        &offsets_heap,
        entry,
        &insert_rec,
        &big_rec,
        n_ext,
        thr,
        &mtr);
    // 当前页无法容纳时,进入允许分裂页面、修改父页的路径
}

源码位置:

  • mysql-server-5.7/storage/innobase/row/row0ins.cc
  • mysql-server-5.7/storage/innobase/btr/btr0cur.cc

这里的 optimistic/pessimistic 描述的是"是否预期需要修改树结构",不是事务锁中的乐观锁与悲观锁。

页面重组后仍放不下,才需要页分裂

页分裂不是把原页从物理中间随便切成两半。InnoDB 要根据索引键选择分裂位置,分配新页,把部分记录移动过去,再把新 entry 插入正确半页。

同时必须修改两类导航信息:

latex 复制代码
同层页链:把新页插入原页与下一页之间
父页入口:增加指向新页的 node pointer

如果只移动记录而不更新父页,下一次从根页下降时找不到新页;如果只更新父页而不修复叶子页链,范围扫描跨页时会跳过新页。页分裂之所以比"申请一张新页"复杂,是因为树形定位和横向扫描必须同时保持正确。

父页也可能没有空间容纳新的分隔键。此时分裂会继续向上传播,直到某一层能够保存新入口。

根页满时,MySQL 5.7 保持原根页号并让树升高

如果当前根页也需要分裂,MySQL 5.7 不必把索引元数据切换到一个全新根页号。它会把原根记录移到新分配的子页,清空原根页并把原根提升一层,再在原根中写入子页入口。

源码注释直接说明了这个过程:

cpp 复制代码
/* Allocate a new page to the tree.
Root splitting is done by first
moving the root records to the new page,
emptying the root,
putting a node pointer to the new page,
and then splitting the new page. */

level = btr_page_get_level(root, mtr);

new_block = btr_page_alloc(
    index,
    0,
    FSP_NO_DIR,
    level,
    mtr,
    mtr);
// 分配与原根同层的新页面

源码位置:

  • mysql-server-5.7/storage/innobase/btr/btr0btr.cc

最终树高增加,但搜索入口仍然是 dict_index_t.page 记录的原根页号。原根页从"保存叶子记录"变成"保存子页入口"。

一次分裂可能同时修改原页、新页、相邻页、父页,甚至更高层页面。InnoDB 使用 mini-transaction,也就是 mtr,组织这种短小的物理修改:在非常短的窗口内持有相关页面 Latch,记录对应 Redo,并在结构修改完成后释放保护。

mtr 不是用户执行的 BEGIN/COMMIT。用户事务可能持续数秒甚至更久,一次页分裂的 mtr 只负责一小组底层页面修改。

DELETE 先逻辑标记,再等待安全的物理清理

删除一行时,InnoDB 不能立即把聚簇记录和所有二级记录的字节擦除。当前事务可能回滚,已经存在的历史读取也可能仍然需要旧版本。

因此删除通常先给相关索引记录设置 delete-mark。记录仍留在页面和记录链中,但逻辑状态已经改变。等到没有事务再需要相应历史版本,Purge 才执行物理清理,使页内空间重新可用。

要区分四件事:

latex 复制代码
SQL 逻辑删除完成
索引记录带 delete-mark
Purge 物理移除索引记录
表空间文件物理缩小

它们不是同一时刻,也不是同一个动作。Purge 释放的空间通常先供表空间内部后续写入复用,不表示 .ibd 文件立刻变小。

B+Tree 可以持续变化,但四个查询不变量不能被破坏

把查询与写入放在一起,MySQL 索引设计可以收束成四条不变量。

第一,父页分隔键必须把搜索带到正确子页。否则根页下降会进入错误范围。

第二,页内记录链必须保持完整索引键有序。物理字节可以移动,逻辑 next 顺序不能错误。

第三,同层页面前后链接必须保持连通。否则范围游标无法跨页连续扫描。

第四,同一行业务数据的所有索引记录必须保持一致。二级记录中的主键必须能够回到对应聚簇记录,事务失败时也不能留下半套索引状态。

这些不变量解释了为什么查询和写入必须学习在一起:

latex 复制代码
查询依靠不变量寻找记录
写入负责在变化后恢复并维持不变量

索引减少了什么成本,又增加了什么成本

理解结构之后,可以不用"索引越多越好"或"索引会拖慢写入"这样的模糊结论,而是具体计算工作发生在哪里。

一次主键点查的主要结构成本可以概括为:

latex 复制代码
从根页下降到聚簇叶子页
+ 在叶子页中定位记录

一次二级索引点查可能是:

latex 复制代码
从二级根页下降到二级叶子页
+ 从聚簇根页下降到聚簇叶子页

一次范围查询可能是:

latex 复制代码
一次起点定位
+ 扫描若干二级叶子页
+ 对部分或全部候选记录回表
+ 可能的额外排序

一次 INSERT 则是:

latex 复制代码
为每棵索引构造 entry
+ 为每棵索引搜索插入位置
+ 修改对应叶子页
+ 必要时重组、分裂并更新父页

因此索引的成本来自几个明确来源。

索引数量增加,会增加磁盘空间、Buffer Pool 工作集以及每次写入需要维护的 B+Tree 数量。

索引键变宽,会让单条记录占用更多页内空间。一页能够容纳的叶子记录和非叶子入口都可能减少,树占用更多页面,缓存密度下降。

二级索引选择性低或者范围很大时,即使能够定位起点,也可能扫描大量二级记录,并执行大量随机回表。此时全表扫描或聚簇扫描有时反而更便宜;是否选择索引是优化器的成本决策,不是"只要存在索引就必须使用"。

覆盖索引能够省掉回表,但为了覆盖而把大量列塞入索引,也会让索引记录变宽。收益是减少读取路径,代价是增加存储、缓存和写入维护成本,两者来自同一个底层结构。

所以设计索引时,真正应该问的不是"这列要不要加索引",而是:

latex 复制代码
这棵树按什么完整元组排序?
目标查询在这条顺序上形成多大的连续范围?
叶子记录能否提供查询需要的数据?
预计扫描多少条记录、多少张页、发生多少次回表?
每次业务写入还要维护多少棵树?

最后把整套索引模型重新连起来

现在可以用一条读取链和一条写入链概括 MySQL 索引。

读取链:

latex 复制代码
SQL 条件
  → Server 选择索引并构造搜索边界
  → InnoDB 根据 dict_index_t 找到根页
  → 搜索元组沿非叶子页逐层下降
  → Page Directory 缩小页内范围
  → 记录链找到第一条候选记录
  → 范围查询沿叶子页链继续扫描
  → ICP 尽量在回表前过滤
  → 二级记录携带的主键用于聚簇索引点查
  → 覆盖、排序顺序和 LIMIT 决定后续工作

写入链:

latex 复制代码
一行业务数据
  → 为每棵索引构造不同 entry
  → 使用同一套 B+Tree 搜索找到插入位置
  → 页面有空间时直接插入
  → 有碎片时先重组
  → 仍放不下时分裂叶子页
  → 修复同层页链并向父页增加入口
  → 必要时继续向上分裂或提升根页
  → 删除则先标记,再由 Purge 安全清理

"最左前缀、覆盖索引、回表、ICP、页分裂"并不是互不相干的规则,而是同一套页级有序结构在查询和写入路径上的不同表现:

InnoDB 把索引键组织成全局有序的 B+Tree 页面;父页负责快速定位,页目录和记录链负责页内定位,叶子页链负责连续扫描,聚簇叶子承载完整行,写入则通过页内插入、分裂和父页更新持续维护这些查询规则。

这套结构把三种看似冲突的需求统一起来:父页和页目录缩短定位路径,叶子页的有序链接支持连续范围读取,页面分裂和父页更新又让有序结构可以持续增长。

查询成本最终落在定位层数、扫描页面数、候选记录数和回表次数上;写入成本则落在索引数量、entry 宽度、页面修改和结构分裂上。所谓索引设计,本质上是在读取路径与写入维护之间,选择一套合适的页面组织方式。

索引的价值不只是"查询可以走 B+Tree",而是用可维护的页级有序性,换取可预测的定位与扫描成本。

相关推荐
步行cgn1 小时前
MyBatis 查询结果字段为 null?一文讲透下划线命名与驼峰映射问题
后端
生锈的键盘1 小时前
gRPC 多路复用全链路拆解:从客户端到服务端,中间隔着 ELB 和 Nginx 到底是怎么玩的?
后端
laity171 小时前
python发光表白爱心(从零到一实现)
前端·后端
用户921080262861 小时前
2. Java 基础该怎么学,先抓业务开发真正用得上的部分
后端
AI老猿博士1 小时前
SpringBoot自动配置揭秘
后端
程序员爱钓鱼1 小时前
Go for 循环详解
后端·面试·go
程序员爱钓鱼1 小时前
Rust impl详解:为Struct定义方法与关联函数
前端·后端·rust
weixin_431600441 小时前
NestJS 入门(9):连上数据库,SQL 写在哪?
数据库·后端·sql·学习·nest.js
qq_22589174662 小时前
基于Flask的城市地铁客流量数据预测系统设计与实现
后端·python·flask