【MySQL进阶系列】一文打穿 InnoDB 索引底层:从 B+ 树推演、.ibd 页空间与记录存储、页分裂到Buffer Pool与Change Buffer的底层全链路剖析!



💪 今日博客励志语录:
太阳不需要向任何人证明它会升起,它只是准时发光。你也一样,与其拼命向外界证明你的价值,不如静下心来,先把自己点亮。
★★★ 本文前置知识:
B+树
LRUCache
思维导图

引入
在此前的学习中,我们主要围绕数据库以及表的增删查改展开。通过这些内容,我们已经知道,对于一张表来说,其本身并不只是一个简单的“数据集合”,而是由两部分共同构成的:一部分是表中实际存储的数据,另一部分则是描述表结构的元数据。
而对于表中存储的数据,在前面的学习阶段中,我们更多是通过一种逻辑模型来理解其组织方式。具体来说,我们可以将一张表抽象地看作一个一维的 struct 结构体数组,其中表结构中定义的每一个列属性,都对应 struct 结构体中的一个成员变量。这样一来,所谓对表进行增删查改,本质上也就可以暂时理解为:对这个 struct 结构体数组进行增删查改,比如插入一个结构体实例、修改某个结构体实例中的字段值,或者删除一个结构体实例。
不过,需要强调的是,这样的理解本质上只是一个逻辑层面的抽象模型。它的意义在于帮助我们在初学阶段先建立起对表操作的基本认知,降低理解数据库操作的门槛,并完成从编程语言中的数据结构到关系型表结构之间的认知过渡。
但逻辑模型终究只是逻辑模型。它能够帮助我们理解“表是什么”,却并不能真正解释:表在存储引擎内部究竟是如何被组织和存放的。
因此,本文接下来的第一个核心主题,就是尝试揭开表底层实现的这层“黑盒”,进一步去认识一张表在数据库内部真实的物理存储结构究竟是什么。
从减少磁盘 I/O 到页区段管理:InnoDB B+ 树的物理落地
从一维数组到 B+ 树:一场以“减少磁盘 I/O”为核心的数据结构推演
根据上文,我们知道,此前对表中数据组织方式的理解,主要建立在“一个一维 struct 结构体数组”这一逻辑模型之上。这样的抽象在入门阶段是有帮助的,因为它能够帮助我们快速理解表的增删查改:表中的每一行记录,可以类比为一个 struct 结构体实例;而表结构中定义的每一个列属性,则可以类比为结构体中的一个成员变量。
但我们也必须意识到,这种组织方式只可能是一种逻辑上的抽象模型,而不可能是表在存储引擎中真实采用的物理存储方式。原因并不复杂:如果真的把表理解成一个一维数组,那么很多操作在外存场景下都会变得极其低效。
例如,假设我们要删除表中的任意一条记录。若按照一维数组的模型来理解,那么这件事本质上就等价于删除数组中某个位置上的一个 struct 结构体实例。可数组是一种顺序存储结构,一旦中间位置的元素被删除,后续元素通常就需要整体前移,这意味着会涉及大量元素移动。若这个数组中的数据规模很小,这个代价尚且可以接受;但数据库表面对的并不是内存中的小规模数据,而往往是存放在外存中的海量记录,其规模动辄就是 GB 级,甚至 TB 级。在这种场景下,元素移动显然不只是“改几个位置”那么简单,而是需要 CPU 参与处理;而 CPU 能够参与运算的前提,又是这些数据必须先被加载到内存中。也就是说,若真按数组模型去实现,那么删除一条记录,往往还会牵扯到元素移动以及额外的磁盘 I/O。仅从这一点出发,我们就已经能够判断:把表理解成一个一维结构体数组,只适合作为帮助理解的逻辑模型,而不可能是真实的底层实现。
不过,恰恰也正因为这个模型暴露出了明显的问题,它反而给了我们一个很好的切入点。既然一维数组并不适合数据库场景,那么接下来我们就可以反过来思考:如果要为表中的海量数据设计一种真正可用的底层数据结构,它究竟应该具备哪些性质?
根据上文我们知道,针对表的操作无非就是增删查改,也就是 CRUD。而 CRUD 的对象是一张数据库表。问题在于,数据库表中的数据量在真实场景下往往非常庞大,记录条数动辄就是百万级起步,对应的数据规模通常会达到 GB 级,甚至 TB 级。另一方面,无论是查询、修改、删除,还是插入后的定位与维护,本质上都离不开 CPU 的参与;而 CPU 参与运算的前提,是待处理的数据必须先被加载到内存中。然而,面对如此庞大的表数据,我们显然不可能把整张表完整加载到内存里,因为内存容量根本无法承载。因此,表中的完整数据只能长期存放在外存设备中,例如磁盘。而这也就意味着:数据库对表的操作,本质上必须在“CPU 需要处理数据,但数据又主要存放在外存”这一约束下进行。
这时,一个关键矛盾就出现了:外存容量虽然很大,但访问速度远慢于内存。CPU 访问内存通常是纳秒级,而访问外存通常是微秒、毫秒甚至更慢的量级,两者之间往往存在数量级上的差距。以传统机械磁盘为例,其访问速度之所以较慢,是因为寻址过程依赖机械运动,例如磁头移动和盘片旋转;而内存则主要依赖电信号完成访问,这两者在物理机制上的差异,直接决定了它们在访问速度上的巨大鸿沟。于是我们就可以得出一个非常核心的结论:在数据库这种外存场景下,真正昂贵的往往不是 CPU 的计算,而是磁盘 I/O。
更进一步说,CPU 的计算速度通常远快于外存将数据加载到内存的速度。这就导致一种典型现象:CPU 很快就能处理完当前已经到手的数据,但随后却不得不等待下一批数据从磁盘进入内存。在这个过程中,真正拖慢整体性能的,不是“算得慢”,而是“等得久”。因此,面向外存设计数据结构时,核心目标并不是单纯让时间复杂度写得更漂亮,而是要尽可能减少磁盘 I/O 的次数。
顺着这个思路继续往下推,我们就能得到一个更明确的结论:既然要为表中的海量记录设计底层数据结构,那么它首先必须满足的关键性质,就是能够显著减少磁盘 I/O。带着这个标准,再回头看我们熟悉的几种经典数据结构,就会发现它们在数据库场景下都存在明显局限。
先看数组。数组的优势在于物理上连续存储,天然支持随机访问;如果数组中的元素还是有序的,那么还可以利用二分查找来提升查询效率。看起来似乎很理想,但这种“高效”更多成立于内存场景中。一旦数组无法整体装入内存,而只能存放在磁盘上,问题就变了。
假设数组中的所有元素都按键值 key 有序排列,每个 key 用来唯一标识一条记录。现在如果要查询目标 key 对应的记录,看上去我们可以使用二分查找。按照二分查找的原理,每次取出中间元素,与目标 key 进行比较,然后排除左半区间或右半区间,再对剩余区间继续二分,逐步缩小查找范围。从算法复杂度上看,查找次数是 (logN ) 级别,似乎很高效。但要注意,这里的每一次比较都需要 CPU 参与,而 CPU 参与的前提,是待比较的元素必须先从磁盘加载到内存中。也就是说,在外存场景下,二分查找中的每一步比较,都可能伴随一次磁盘 I/O。若数据规模达到百万级,一次查询通常就可能需要二十次左右的外存访问。对于数据库而言,查询恰恰又是最频繁、最核心的操作;而修改、删除等操作,在真正执行之前,往往也需要先定位目标记录,本质上同样隐含着查询过程。更不用说,数组在插入和删除时还会面临大量元素移动的问题。由此可见,数组并不适合作为数据库表在外存中的底层组织结构。
接下来再看链表。链表的优势在于插入和删除比较灵活,只需要修改指针即可,从逻辑上看时间复杂度可以做到 O(1)。但链表的问题也非常明显:它不擅长查找。若要定位目标节点,通常只能从头开始线性遍历,时间复杂度为 O(N)。在内存里,这已经不算高效;到了外存场景下,问题会更严重,因为链表节点通常是离散分布的,遍历过程中每访问一个新节点,都可能伴随一次磁盘 I/O。也就是说,链表虽然擅长局部修改,但在数据库最核心的“查找”问题上表现极差,因此同样不适合作为表数据的底层组织方式。
再进一步,我们来看平衡二叉搜索树,例如 AVL 树和红黑树。相比普通二叉搜索树,它们通过平衡机制压缩了树高,从而把查找效率稳定在对数级别。若从纯算法复杂度的角度看,这已经相当优秀了。但数据库面对的是外存,而不是完全驻留在内存中的小规模数据结构。对于 AVL 树或红黑树来说,每个节点通常只保存一个键值、部分数据以及指向左右子节点的指针。也就是说,一次查找往往需要从根节点出发,沿着树的路径逐层向下访问节点。虽然树高已经被压缩,但在百万级数据规模下,其高度通常仍在二十层左右。若这些节点不能整体驻留在内存中,那么从根到叶的这条路径上,每访问一层节点,都可能产生一次磁盘 I/O。这样一来,即便使用平衡二叉树,一次查找依然可能需要十几次甚至二十多次外存访问。从“减少磁盘 I/O”这个最关键的目标来看,这个代价仍然偏高。
因此,数组不合适,链表不合适,传统的平衡二叉搜索树也依然不够理想。既然问题的根源在于:树的每个节点容纳的信息太少,导致树高仍然偏高,从而使一次查找需要经过过多层级,那么改进方向也就很自然了——我们需要一种新的树形结构,让每个节点能够容纳更多关键字和更多分支,从而显著降低整棵树的高度,尽可能用更少的层数完成一次查找。
而在这样的背景下,就引出了真正适合外存场景的数据结构——B+ 树。
外存场景下的数据结构分析与 B+ 树的引入
根据上文,我们已经分析到了红黑树和 AVL 树。它们虽然都能够将查找效率稳定在对数级别,但更适合作为以内存为主的小规模查找结构,却并不适合作为数据库这种面向外存的大规模索查找结构。其第一个根本原因,在于这类平衡二叉树的扇出太小,树高仍然偏高。对于红黑树和 AVL 树来说,每个节点通常只保存一个键值以及少量指针,因此整棵树在数据规模很大时,仍然需要保持相对较高的层数。这样一来,一次查找就需要从根节点一路向下访问多个节点;而在外存场景下,如果这些节点没有提前命中内存缓存,那么沿路径向下访问的每一层节点,都可能伴随一次磁盘 I/O。也就是说,红黑树和 AVL 树虽然在内存场景下已经足够高效,但一旦面对外存中的海量数据,它们的树高就会转化为真实的 I/O 成本。
但问题还不止于此。即便暂时不考虑树高,仅从磁盘 I/O 本身的工作方式来看,红黑树和 AVL 树也并不理想。我们知道,外存访问的代价很高,因此操作系统和存储系统在实际读取数据时,通常不会一次只读取极小的一点内容,而是会按更大的块或页为单位,将一段连续区域整体加载到内存中,这背后依赖的正是空间局部性原理:当前访问了某个位置的数据,接下来很可能还会继续访问它附近的数据。也正因为如此,预读机制能够在很多场景下显著减少后续的磁盘 I/O 次数。
然而,红黑树和 AVL 树的节点结构恰恰不利于利用这种预读机制。因为这类树的节点通常都很小,而且节点之间主要依靠指针连接,在物理布局上往往是离散分布的。这样一来,即便一次 I/O 已经预读了一段连续的数据,真正被访问到的也可能只是其中极小的一部分;而下一步要访问的子节点,很可能根本不在这片连续区域内,而是落在另外一个位置上。换句话说,红黑树和 AVL 树不仅树高偏高,而且单个节点携带的信息太少、物理分布又不连续,从而很难有效利用外存按块预读所带来的收益。这就导致在外存场景下,平衡二叉树常常会出现这样一种情况:一次 I/O 读进来了一块数据,但真正有用的只是一小部分,其余空间实际上都被浪费了。
而 B+ 树恰恰就是针对这些问题设计出来的。B+ 树的节点不再像红黑树那样只容纳一个键值和两个孩子指针,而是会被设计成与磁盘块的大小对齐。对于数据库而言,一个 B+ 树节点通常会尽量对应一个页,这意味着一次磁盘 I/O 就能够完整读取一个节点。更重要的是,由于单个节点足够大,它就能够同时容纳大量键值和大量子节点指针,从而让整棵树具备非常高的扇出。扇出一旦增大,树高就会被显著压低,于是原本需要经过很多层的查找路径,现在只需要极少的几层就能完成。
如果把一次查找理解为“不断缩小候选区间”的过程,那么红黑树每向下一层,本质上只能把当前范围二分一次;而 B+ 树每向下一层,则可以借助一个节点中的大量关键字,把当前范围一次性划分成多个区间,并迅速排除掉绝大多数无关区间。也就是说,红黑树的每一次向下遍历,只能淘汰一半左右的候选范围;而 B+ 树的一次节点访问,却能够淘汰掉更多无关区间。最终带来的直接结果就是:B+ 树只需要少数几次节点访问,往往就能够定位到目标数据,而每减少一次节点访问,在外存场景下就意味着少一次潜在的磁盘 I/O。
因此,从外存数据结构设计的角度来看,B+ 树真正的优势并不只是“它也是一棵树”,而在于它通过页式节点、高扇出、低树高这几个特性,把数据库场景中最昂贵的成本——磁盘 I/O——压到了尽可能低
从逻辑B+树到InnoDB页、区、段的物理空间演进
认识了 B+ 树之后,我们已经知道,它之所以适合外存场景,一个重要原因就在于:B+ 树的节点大小通常会与磁盘块对齐,一次磁盘 I/O 往往就能将一个完整的节点加载到内存中。这使得它天生就比红黑树、AVL 树这类节点较小且离散分布的结构,更适合数据库的外查找场景。
与此同时,B+ 树中的节点并不是完全同质的,而是分为两类:内部节点和叶子节点。之所以要显式区分这两类节点,是因为它们承担的职责不同:内部节点主要负责索引导航,叶子节点才真正负责存放最终的数据记录。
这里也正体现了 B+ 树与 B 树的一个关键区别:
- B+ 树:内部节点只保存键值和子节点指针,不保存完整数据;真正的数据统一存放在叶子节点中
- B 树:内部节点和叶子节点都可能存放键值及其对应的数据
B+ 树这样设计的直接好处在于:内部节点由于不再携带真实数据,可以容纳更多键值和更多子节点分支,从而拥有更高的扇出。扇出越高,整棵树的高度就越低;而在外存场景下,树高越低,意味着一次查找所需经过的节点层数越少,对应的磁盘 I/O 次数也越少。
除此之外,B+ 树的叶子节点还会按照键值顺序通过链指针连接起来,形成一个有序链表。这样一来,当查询需求不再只是定位某一个点,而是要获取某一个范围内的所有数据时,就不需要频繁回到上层节点重新定位,而可以沿着叶子层顺序扫描——这使得 B+ 树在范围查询上也具有天然优势。
也正因如此,MySQL 的 InnoDB 存储引擎选择了 B+ 树作为索引的核心结构。
至此,我们主要解决的是"为什么 B+ 树能够胜任外查找"这一问题。接下来,就可以进一步回到数据库中的表本身,去看一棵 B+ 树究竟是如何组织并存储一张表中所有记录的。
页:InnoDB 的基本存储单位
在 InnoDB 中,页(Page) 是数据组织与管理的基本单位。默认情况下页大小是 16KB(由 innodb_page_size 决定,并非绝对固定)。后文如果没有特殊说明,我们都默认以 16KB 页作为讨论前提。
这里有一个很容易混淆的点需要澄清:InnoDB 的页,并不必然等同于操作系统或底层存储设备所采用的单次 I/O 粒度。 官方文档也明确提到,底层存储设备的 block size 可能是 4KB 或 16KB,与 InnoDB 的页大小只是"尽量接近更有利",而非天然完全相同。更准确的理解是:页是 InnoDB 内部的数据组织单位;至于把一个 16KB 的页搬到内存中时,底层究竟是通过一次 I/O 完成还是拆成多次更小粒度的 I/O,那属于操作系统和存储层的事情。
站在 InnoDB 的视角下,表中的一条条记录并不是直接零散地存放在磁盘上的,而是被组织到一个个页中。页就是承载这些记录的基本空间单元。
也正因如此,当我们回到 B+ 树时,就可以自然得到这样一个结论:在 InnoDB 的语境下,B+ 树的节点本质上就是按页来组织的。无论是内部节点还是叶子节点,都可以先理解为大小为 16KB 的页。至于这些页内部究竟如何存放内容、不同层的页承担什么职责,就是后文要进一步展开的问题了。
当我们创建一张表时,如果使用的是 InnoDB 存储引擎,那么在数据库目录下通常会看到一个对应的 .ibd 文件。这个文件本质上就是该表对应的表空间文件,表中存储的数据最终都落在这里。
根据上文,我们已经知道,InnoDB 在逻辑上是借助 B+ 树来组织表中数据的。但这里要特别注意一点:这并不意味着 .ibd 文件会在物理上直接实现成一个"多叉链式结构",即真的去维护一个根节点,再让上层节点和下层节点通过内存意义上的指针逐层串起来。因为如果完全按照链式节点的方式来做,各个节点在物理布局上往往是离散的,而数据库面对的又是外存场景,我们更希望文件中的数据能够以更紧凑、更规整的方式组织起来。
因此,从物理实现的角度看,.ibd 文件在物理布局上可以看作由大量固定大小页组成的线性页数组。数组中的每个元素都是一个固定大小的页(默认 16KB);表中数据并不是直接零散地散落在文件中,而是先被组织到一个个页里。需要注意的是,这些页并不是完全同质的:有的页用于承载 B+ 树的叶子页内容,有的页用于承载内部页内容,还有一部分页承担表空间管理和空间分配的职责。也就是说,.ibd 文件虽然在物理上表现为线性的页空间,但在这些页之上,InnoDB 又进一步构造出了更高层的逻辑组织结构。
这种思想其实和我们熟悉的堆很像。堆在逻辑上是一棵完全二叉树,但在实现时通常并不真的采用二叉链表,而是用一个数组去模拟这棵树的结构,再通过下标之间的关系建立父子联系。InnoDB 这里的思路也有相似之处:物理上先有一页一页的空间,逻辑上再把其中一部分页组织成 B+ 树中的不同节点。这样一来,节点之间的关联也不再是传统意义上的内存地址指针——因为数组在物理上是连续的,这里的"指针"变成了数组的下标(页号)。只要知道页号,就能通过 页号 × 16KB 算出偏移量,在文件中定位到任何一个节点。所以无论是空间管理还是数据访问,都更适合外存场景。
既然 .ibd 文件可以理解为由大量页组成的线性空间,那么当数据规模不断增长时,单纯只从"页"这个层级去管理显然是不够的。于是,InnoDB 在页之上引入了更高层级的空间组织方式。
第一层:区(Extent)—— 强调连续性
所谓区,本质上就是由多个连续页组成的更大分配单元。对于默认 16KB 页大小的情况,一个区是 1MB,也就是连续的 64 个页。你可以把它理解为:页是最基本的空间单元,而区则是对这些页进行更大粒度管理之后形成的一段连续区间。
第二层:段(Segment)—— 强调用途归属
这里要特别注意,段并不是"区之上的又一个连续物理层级"。更准确地说,区强调的是连续性,而段强调的是用途归属。段并不要求自己在物理上连续,而是把那些"用途相同"的页或区,在逻辑上归并为一个整体来管理。
对于一棵 B+ 树来说,InnoDB 会为它分配两个彼此独立的段——一个用于管理叶子页(叶子段),另一个用于管理非叶子页(非叶子段)。叶子页和非叶子页虽然同属于一棵树,但在空间管理上并不是混在一起的,而是分别由各自的段来记录和管理。
这里有一个非常重要的设计细节:段在增长时,并不是从一开始就按区分配的。
官方文档明确指出,一个段在增长初期,会先逐页分配——前 32 个页一页一页地从碎片区中拆出来。只有当这 32 个零散页用满之后,才开始按整区分配。
这里顺带解释一下碎片区(Fragment Extent):所谓碎片区,就是那些不归属于任何段、由表空间直接管理的区。它们的作用是在段的早期阶段,提供零散的单页空间——避免一个只有几条数据的小表上来就占掉整整 1MB 的空间。
这样设计的目的就在于:在规模较小时避免过早浪费空间,而在数据继续增长之后,再通过更大粒度的分配方式来减少频繁扩容的开销。
这样一来,整个 .ibd 文件的空间组织方式就可以从粗到细地理解为:
表空间文件由大量页组成,页按连续性可以分组为区,而页和区又会按用途归属到不同的段中。
页是最基本的承载单位,区是连续页的管理单元,段则是同一用途页/区的逻辑集合。正因为有了这几层组织方式,InnoDB 才能够在面对海量数据时,既保持逻辑结构上的清晰,又兼顾物理空间管理上的效率。

而根据上文,我们已经对表的存储结构建立了初步认知:对于 InnoDB 来说,表中存储的数据最终都落在对应的 .ibd 文件中,而这个 .ibd 文件在物理布局上,本质上可以看作一个由大量固定大小页组成的线性页空间。默认情况下,一个页通常是 16KB,只不过这些页承担的职责并不相同:有的页用于承载B+树的叶子页内容,有的页用于承载B+树的内部页内容,还有一些页则专门负责表空间与空间分配相关的管理信息。也就是说,.ibd 文件并不是简单地装着一堆同质数据,而是在一个线性的页空间之上,同时承载了数据页与管理页两类内容。
需要注意的是,表空间在初始状态下并不会直接预分配出一大段很长的连续页空间,而是先从较小规模开始,随着数据增长再逐步扩展。并且,这种扩展也不是从一开始就直接按区进行的。更准确地说,某个段在增长初期,会先按单页分配;只有当前期的单页分配阶段用满之后,才开始按区分配空间。对于默认 16KB 页大小的情况,一个区通常是 1MB,也就是连续的 64 个页。这样设计的目的就在于:在规模较小时避免过早浪费空间,而在数据继续增长之后,再通过更大粒度的分配方式减少频繁扩容带来的开销。
与此同时,无论表空间后续如何增长,开头的一些管理页所承担的职责是固定的。其中,page 0 并不是普通数据页,而更像整个表空间的“总账本”。它会记录当前表空间的一些整体信息,并维护与区分配相关的管理链表。例如,FREE 链表记录的是完全空闲、尚未被分配的区;FREE_FRAG 链表记录的是仍然可以继续拆出单页分配的碎片区;而 FULL_FRAG 链表 记录的则是已经没有剩余可分配单页的碎片区。也正因为有了这些管理信息,InnoDB 才能够在一个线性的页空间之上,继续完成对区以及后续页分配过程的统一管理。

而 page 2 则与“段”的管理直接相关。它同样不是普通数据页,而是一种专门用于登记段信息的管理页。对于 InnoDB 来说,段并不是要求物理上连续,而是把那些用途相同的页或区,在逻辑上归并为一个整体来管理。page 2 里维护的,正是这些段的登记信息
这里还需要进一步补充一点:对于一棵 B+ 树来说,InnoDB 并不是只给它维护一个统一的段,而是会为它分配两个彼此独立的段——一个用于管理叶子页,另一个用于管理非叶子页。也就是说,叶子页和非叶子页虽然同属于一棵树,但在空间管理上并不是混在一起的,而是分别由各自的段来记录和管理。
而 page 2 这样的段登记页,正是用来保存这些段的登记信息的。从结构上看,它在除去页头和页尾之后,中间主体部分可以理解为一组连续的“登记项数组”;每一个登记项就对应一个段,其中自然也包括叶子段和非叶子段。对于每一个具体的登记项来说,它内部所记录的核心内容,正是这个段当前掌握的空间资源情况,例如:单页数组、空闲区链表、未满区链表以及已满区链表。
单页数组:记录这个段当前拿到的零散单页,最多 32 个。
空闲区链表:记录已经分给这个段、但还完全没开始用的区。
未满区链表:记录已经分给这个段、但还没用完的区。
已满区链表:记录已经分给这个段、并且页已经全部用完的区。

也正因为如此,当后续页分裂、页扩展或者继续申请新区时,系统并不是只盯着“我要一个新页”这么简单,而是会进一步回到对应段的登记信息上,去看这个段当前还掌握着哪些可用页、哪些未满区,以及是否还需要继续向上申请新的区。
进一步说,在 InnoDB 中,一棵 B+ 树会使用两个段:一个用于管理叶子页,另一个用于管理非叶子页。也正因为如此,后续当我们真正讨论数据插入、页分裂以及空间扩展时,视角就不能只停留在“记录最终落到了哪个页”这一层,而必须继续往上看到“这个页属于哪个段、这个段当前又掌握了哪些可用页和可用区”。只有把页、区、段这几层关系放在一起看,后续很多底层行为才能真正讲清楚。
这里需要说明的是,在 page 0 和 page 2 之间,其实还存在一个 page1。之所以这里暂时不对它展开详细讨论,并不是因为它不重要,而是因为它的职责与后文将要讲到的索引页管理机制联系更紧密。换句话说,page 1 虽然同样属于系统预留的管理页,但它所涉及的内容,放在当前这个阶段单独展开,读者往往还缺少足够的上下文支撑,反而容易把注意力过早带偏。因此,这里先只知道一点即可:page1 不是普通数据页,它同样承担的是页级状态管理相关的职责。至于它为什么要存在、它究竟记录了什么、以及它与后文索引页的更新和管理之间究竟有什么联系,我们放到后文正式引入索引结构之后再统一展开
认识了前面几个管理页之后,接下来就可以真正进入记录插入的过程了。对于 InnoDB 来说,插入一条记录,并不是简单地"在 .ibd 文件里随便找个位置塞进去",而是要先从根页出发,经由内部页逐层向下,最终定位到目标叶子页,再将记录插入其中。
但需要注意的是,这里的插入不能只盯着"叶子页"这一个层级。因为在 InnoDB 中,页并不是孤立管理的:页按连续性组织成区,相同用途的页或区又归属于同一个段。当插入只是在目标叶子页内部完成时,主要影响的是该页本身;可一旦插入导致页被塞满、触发页分裂,就不再只是"改这个页"这么简单了——系统需要为当前这棵树再分配一个新的兄弟页,而这个新页从哪里来,就要回到对应段的登记信息中去看:当前是否还有零散单页可用,是否还有已经分配但尚未用满的区,或者是否需要继续向上申请新的区。
也正因如此,后续在分析插入、页分裂和空间扩展时,视角就不能只停留在"记录落到了哪个页"这一层,而必须同时看到页、区、段这几层空间管理关系的联动。
接下来,我们就结合前文所讲的空间组织结构,来看一个段从诞生到壮大的三个阶段——也就是一条 INSERT 语句背后,空间分配策略是怎么随着数据规模的增长而自动升级的。
阶段1:种子期 —— 初始页阶段
在 .ibd 文件刚刚创建出来时,InnoDB 并不会一上来就把整个区 0 的 64 个页全部分配到磁盘上,而是先让 .ibd 文件以很小的体积起步(通常只有 6~7 个页的物理空间)。
但从空间管理的角度看,区 0(page 0 ~ page 63)在逻辑上从一开始就存在——只不过初始阶段只有前面几个页有物理空间支撑,后面的页还"没出生"。随着数据不断插入、分裂不断发生,InnoDB 会按需扩展 .ibd 文件,让区 0 中更多的页逐步获得物理空间。这些新扩展出来的页,仍然属于区 0——因为它们的页号始终落在 0~63 的范围内。
这些页全部属于 .ibd 文件中的第一个区(区 0)。其中,page 0、page 1、page 2 是固定的管理页,page 3 是 B+ 树的根页,剩余的页(page 4、page 5 等)则暂时空闲,等待后续被分配。
这里的关键在于:区 0 从一开始就是一个碎片区——它不归属于任何特定的段,而是由表空间直接管理。这意味着区 0 里的空闲页,可以被不同的段按需逐页取用。
最开始插入的记录,会落在根页(page 3)中。随着记录不断插入,page 3 终究会被塞满。一旦触发分裂,系统需要分配新页。此时段还处于"逐页分配"的早期阶段,新页会从碎片区中拆出来——而区 0 本身就是碎片区,所以新页(比如 page 4)就来自这个区中尚未使用的空闲页。
只有当区 0 的 64 个页逐渐被各个段瓜分殆尽时,InnoDB 才会再以区为单位申请新的 1MB 空间,创建出新的碎片区——这就进入了我们接下来要讲的"成长期"。
阶段2:成长期 —— 碎片区共享阶段
当初始的那几个空闲页也被用完时,就必须向外扩容了。此时 InnoDB 才会真正以区(1MB,64 个页) 为单位向操作系统申请扩大 .ibd 文件的体积。
但新开辟的这 1MB 空间,不会直接划给某一个特定的段独享,而是作为公共资源。page 0 会把它登记到 FREE_FRAG(碎片区空闲链表) 中,供所有的段按需取页。
碎片区里的 64 个页是高度共享的——因为此时各个段的规模都很小(都没超过 32 页的阈值),谁也用不完一整个区。于是完全可能出现这种"混住"状态:叶子段从里面拿走 Page 68,非叶子段拿走 Page 69,其他索引的段再拿走 Page 70。虽然物理上它们同处一个 1MB 的区,但逻辑上分属于不同的段。
这就是碎片区存在的意义:先共享,再细分,避免在幼年阶段过早造成整区空间的浪费。
阶段3:成熟期 —— 整区独占阶段
这是一个质变的临界点。当一个段通过逐页分配的方式,把自己的单页数组(FRAG ARRAY) 填满——也就是从各个碎片区里攒够了前 32 个单页之后,InnoDB 的空间分配策略就会发生彻底的切换:
策略切换:后续再申请空间,page 0 不再从碎片区里拆页给它,而是直接划拨整块的空闲区。新分到的区会先挂到该段的 FREE 链表;随着区里的页被逐步使用,该区转入 NOT_FULL 链表;等整区的 64 个页全部用完,最终进入 FULL 链表。
独占模式:到了这个阶段,新分到的整区从此归属该段独立管理,其他段不能来拆分使用——和前面碎片区的"混住"形成了鲜明对比。
性能飞跃:进入整区分配阶段后,B+ 树分裂产生的新页通常来自已经分配给该段的区,因此在文件布局上往往更集中,物理局部性也明显好于前期的零散单页阶段。磁盘在读取时大幅减少了随机寻道的成本,同时也更有利于操作系统的预读(Read-Ahead) 机制——因为相邻的页很可能紧接着就会被访问到。
回看全局:空间分配的三次升级
把三个阶段串起来看,InnoDB 的空间分配策略本质上就是一条渐进式的升级路径:
逐页从初始页中取 → 逐页从碎片区中取(共享) → 整区独占分配
每一次升级都发生在"前一阶段的资源被耗尽"的时刻,而每一次升级带来的都是更大的分配粒度和更好的物理局部性。这种"小时候省着用,长大了放开用"的渐进策略,让 InnoDB 在面对从几条记录到几千万条记录的跨度时,都能保持合理的空间利用率和访问性能。
还记得前文讲过的寻址公式吗?无论处于哪个阶段,定位一个页的方式都没有变过——物理偏移量 = 页号 × 16KB。单页数组里存的就是页号,区里的页也是通过页号定位。从始至终,页号(数组下标) 就是 InnoDB 在这个线性页空间里的"万能坐标"。

最后对单页数组做一个补充:单页数组里存的内容就是页号(Page Number)。
而我们在前文反复强调,整个 .ibd 文件在物理底层,就是一个巨大且连续的一维 16KB 页数组。因此,当 InnoDB 需要从单页数组中拿出某个页时,底层发生的事情极其高效:
- 系统从段登记项的单页数组里读出一个页号(比如
5)。 - 系统把这个页号转换成 .ibd 文件中的字节偏移量:
5 × 16KB = 80KB。 - 把从这个偏移量开始的 16KB 数据读入内存。
整个过程没有任何"查找"——拿到页号的瞬间,物理位置就已经被算出来了。
而且这个机制不只用在单页数组上。在整个 InnoDB 的页空间体系里,页号 = 数组下标 = 内存指针这个等式无处不在:
- B+ 树非叶子节点的子节点指针,本质上就是子节点的页号
- 页与页之间的
FIL_PAGE_PREV/FIL_PAGE_NEXT链接,也是页号 - 段登记项里的 FREE / NOT_FULL / FULL 链表,串起来的同样是区号(本质也是首页号)
页号(数组下标) 完美扮演了 C/C++ 中"内存指针"的角色。
通过这种**“物理连续空间 + 偏移量计算”** 的机制,InnoDB 巧妙地避开了传统文件系统中复杂的指针管理——那些在逻辑上散落在文件各处的 B+ 树节点,仅仅依靠几个简单的整数页号,就能被高效地重新缝合在一起。
从.ibd页空间到B+树索引到记录数据块:InnoDB索引寻址、页内组织、页内分配与页分裂机制
从页空间到索引寻址:InnoDB 如何在.ibd中定位数据
根据前文,我们已经建立起 .ibd 文件的物理模型——它本质上是一个由大量 16KB 页组成的线性页空间,其上又通过"区(64 个连续页)"和"段(用途归属)"两层结构进行管理。这个物理模型,是接下来理解索引底层运作的基石。
而我们知道,插入到表中的记录最终都会落在 B+ 树叶子页对应的页中。此前我们的学习主要围绕数据库操作层面展开,其中最核心、最频繁、也最复杂的需求就是查询。既然 InnoDB 对数据的组织采取的是 B+ 树,那么无论是查询还是插入,本质上都离不开在这棵树上进行定位。
由于 B+ 树的内部页并不存放真正的业务数据,真正的记录都位于叶子页中,因此每一次定位,都意味着系统需要从根页出发,沿着内部页逐层向下遍历,经过一次次键值比较,不断缩小范围,最终落到目标叶子页。也正因为如此,B+ 树内部页的核心作用就是充当"路标"完成导航;而导航能够成立的前提,正是内部页中保存着可以参与比较的键值。
把这一点串联回数据库本身,就可以进一步理解:插入到数据库中的记录之所以能够被组织成一棵 B+ 树,前提就在于这些记录必须能够围绕某个键值建立关系。对于 InnoDB 来说:
- 优先使用表的主键(Primary Key) 作为这个键值
- 如果没有定义主键,InnoDB 会寻找表中第一个所有涉及列都是
NOT NULL的 UNIQUE 索引,用它来充当聚簇键 - 只有当连这样的索引都不存在时,InnoDB 才会在后台自动为每一行生成一个 6 字节的隐藏字段
ROW_ID,并以此建立隐藏的聚簇索引
也就是说,无论你在表定义中是否显式给出合适的键值,InnoDB 最终都必须先得到这样一个可比较、可导航的键,才能真正把表中的记录组织成底层那棵 B+ 树。
根据刚才的分析,我们知道在创建表时设定的主键会被 InnoDB 用作底层聚簇 B+ 树的导航键值。这自然引出了数据库中一个极其重要的概念——索引(Index)。
顾名思义,索引的作用就是"导航与定位",带我们迅速找到存放有效数据的叶子节点。既然主键承担了导航任务,那么这是否意味着"索引 = 主键"呢?
这里必须严格区分:主键索引只是索引的一种特殊形式——主键必然对应一个索引(而且是聚簇索引),但反过来,索引绝不仅仅只有主键索引这一种。用集合的概念来描述:主键只是索引的一个子集。 换句话说,主键必然是索引,但能充当索引的,绝不仅仅只有主键。除主键之外的列也可以建立索引,这就是后文要讲的二级索引。
我们通过一个常见业务场景来理解为什么需要"主键之外的索引"。
假设有一张 Student 表,记录了学生的学号(主键)、姓名、年龄等信息。
场景 A:查询学号为 007 的学生姓名
在 SQL 执行过程中,系统可以拿着 007 这个值,沿着聚簇索引对应的 B+ 树,从根页出发,经过内部页逐层向下比较,最终定位到目标叶子页并取得对应记录。在这种场景下,主键值天然就可以作为树中的导航键值。
场景 B:查询姓名为"张三"的学生学号
此时尴尬的情况出现了。聚簇索引 B+ 树的内部节点是以"学号"作为路标构建的,它根本不认识"张三"。面对这个需求,聚簇索引的树状导航瞬间失效。
在姓名列没有建立任何索引的前提下,数据库唯一的选择就是降级为最原始的手段:跑到 B+ 树最底层的叶子节点链表上,从第一条记录开始挨个进行线性比对(即全表扫描),时间复杂度直接退化为 O ( N ) O(N) O(N)。
而这种场景在真实业务中恰恰大量存在。很多时候,查询条件并不是主键,而是姓名、手机号、邮箱、班级编号等非主键字段。如果每一次都只能做全表扫描,代价显然太高。因此,数据库必须允许我们对非主键列额外建立索引——让系统不必再依赖主键那一棵树来完成所有查询,而是可以针对其他常用查询条件,再额外维护对应的导航结构。
二级索引与回表:
面对真实存在的非主键查询需求,我们可以专门为"姓名"这个列单独建立一个索引。这种以非主键列构建的索引,被称为二级索引(Secondary Index) 或辅助索引。
它同样会维护一棵 B+ 树,只不过这棵树的导航键值不再是主键,而是你建立索引的那个列(例如姓名)。但需要特别注意:二级索引对应 B+ 树的叶子页中,存放的并不是整行有效数据,而是"二级索引列值 + 该行对应的主键值"。
真正完整的行数据,仍然存放在那棵以主键组织起来的 B+ 树叶子页中——而这棵以主键为组织核心、叶子页直接存放整行记录的树,就叫做聚簇索引(Clustered Index)。
也就是说,二级索引负责先按非主键条件完成第一次定位,定位到对应主键值之后,再由系统拿着这个主键值回到聚簇索引中找到真正的整行记录。
这就引出了 InnoDB 查询中一个极其精妙、但也极其昂贵的机制——“回表”。
当我们执行"查询张三的完整信息"时,底层实际发生了两次树的遍历:
- 先在二级索引树中,通过路标定位到"张三"所在的叶子节点,拿到他的主键(学号 007)
- 拿着学号 007,回到聚簇索引树中重新进行一次导航,最终拿到张三的完整有效数据
一次查询要遍历两棵 B+ 树——这个代价在数据量大、查询频繁的场景下会被放大得非常明显。后文我们讲索引优化时会看到,如何避免或减少回表(覆盖索引、索引下推等)是 InnoDB 查询调优中最核心的话题之一。
把索引落到物理层:页号 = 数组下标
认识了索引这个概念之后,接下来我们就需要重新回到前文建立起来的那个物理模型中去——也就是 .ibd 文件那个线性页数组。
无论是查询、删除还是修改表中的记录,这些 CRUD 操作本质上都包含一个"定位目标记录"的过程。当查询条件是主键时,系统会从聚簇索引 B+ 树的根页出发,逐层向下遍历:在每个内部页中,把目标主键值与当前页中的键值依次比较,从而判断下一步应当进入哪一个子页,直到最终定位到目标叶子页。
表面上看,这似乎只是一个"树的遍历"过程。但根据前文我们已经建立起来的物理存储认知:所谓的"内部页"和"叶子页",从来不是内存中孤立分配的链式节点,它们全都是那个线性页数组中大小固定的 16KB 物理块。这些页以离散或相对集中的方式,散落在 .ibd 文件的页空间中。
那么"沿着 B+ 树向下遍历",在底层究竟是怎样一个过程?其实就是:在当前页中完成键值比较之后,跳转到下一层对应的那个页——而跳转的依据,就是当前页里记录的"下一层页的页号"。
也正因为如此,我们就可以更清楚地理解 B+ 树内部页的真实结构:
B+ 树内部页中存储的核心内容是"键值 + 子页页号"
如果放在我们之前自己模拟实现 B+ 树的语境中,后者通常会表现为"指针数组";而在 InnoDB 这种以页数组为物理基础的场景下,这里存储的就不再是内存地址意义上的指针,而是下层节点对应的页号。换句话说,B+ 树内部页并不是通过传统的内存指针把树串起来的,而是通过"键值 + 页号"的方式,把线性页空间中的不同页重新组织成一棵逻辑上的 B+ 树。
举个具体例子,当执行如下 SQL 语句时:
SELECT * FROM table WHERE id = 500;
底层动作可以理解为下面这个过程:
- 读取根页:系统首先把当前这棵树的根页加载到内存中
- 比较键值:CPU 在这个页内部,拿着查询条件中的
500,与当前页中各个导航条目的键值进行比较 - 确定目标页号:比较之后,系统发现
500落在某个键值区间之中,对应的子页页号是42 - 跳转到目标页:有了页号,InnoDB 直接进行一次极简的数学运算:
O f f s e t = 42 × 16384 = 688128 字节 Offset = 42 \times 16384 = 688128 \text{ 字节} Offset=42×16384=688128 字节
拿着这个字节偏移量,InnoDB 发起一次文件读取,把 Page 42 加载到 Buffer Pool(InnoDB 的缓存层,后文会专门讨论)中,继续在 Page 42 上重复"比较键值 → 确定下一层页号 → 跳转"的过程,直到落到叶子页,拿到 id = 500 的完整记录。
如果从物理地址计算的角度去理解,那么在默认 16KB 页大小的情况下,只要知道某个页号,就可以推导出该页在文件中的偏移位置。也就是说:
页号在这里所扮演的角色,本质上就相当于"线性页数组中的下标"
正因如此,我们前文所建立的".ibd 文件本质上是一个线性的页数组"这一物理模型,才真正和 B+ 树的导航过程严丝合缝地对应了起来:
- 逻辑层:B+ 树通过"键值 + 子页页号"完成导航
- 物理层:页号 × 16KB = 文件偏移量,直接定位到磁盘位置
两层之间的桥梁,就是"页号"这个简单的整数。InnoDB 用一个最普通的数组下标,把抽象的树结构和具体的字节偏移量缝合在了一起——这就是整个 InnoDB 存储引擎最核心、也最优雅的设计之一。
上一节我们讲清了逻辑层:聚簇索引和辅助索引各自维护独立的 B+ 树。但这两棵 B+ 树在物理层面到底怎么分家——也就是它们在 .ibd 文件里如何被分别存放、各自占用的页和区如何被独立管理——这是这一节要回答的问题。
经过前文的剖析,我们已经清楚,一个表的 .ibd 文件不仅存储真实的行数据,还同时承载着聚簇索引(主键 B+ 树)以及各种辅助索引(二级 B+ 树)的结构。既然 .ibd 文件在物理上是一个线性的页数组,而不同用途的页又以离散或集中的方式分布其中,依靠"段(Segment)"来登记和追踪相同用途的页就显得尤为关键。
但这里必须明确一个核心痛点:一张表往往不止一个索引。如果整个 .ibd 文件仅仅笼统地划分为"一个全局叶子段"和"一个全局非叶子段",那么当你执行全表扫描(预期只扫描聚簇索引的业务数据)时,InnoDB 在连续的物理区里读出来的内容,就会一会儿是真实的业务数据,一会儿又是辅助索引的主键映射数据。这不仅在逻辑上是一团乱麻,物理层面的顺序 I/O 也会被彻底破坏。
因此,为了保证物理存储的纯粹性,InnoDB 定下了一条铁律:段(Segment)的分配粒度绝不是表,而是具体的 B+ 树(索引)。
无论是聚簇索引(主树),还是辅助索引(二级树),只要你创建了一棵树,InnoDB 就会为这棵树专属分配两个段:
- 数据段(专属叶子段):专门存放这棵树底层的叶子节点
- 索引段(专属非叶子段):专门存放这棵树上层的内部目录节点
具体例子:一张两个索引的表 = 4 个段
为了更直观地理解,让我们回到之前举例的 Student 表。假设这张表有一个主键 id,以及一个手动为 name 字段创建的辅助索引。在这个 .ibd 文件的页数组中,实际上同时生长着 2 棵 B+ 树,因此底层会为它们分配 4 个独立的段:
-
树 1:聚簇索引(以 id 为键值)
- 段 A(聚簇叶子段):里面全是 16KB 的页,装满了完整的学生数据行
- 段 B(聚簇非叶子段):里面全是 16KB 的页,装满了
[id + 子页号]的导航目录
-
树 2:辅助索引(以 name 为键值)
- 段 C(辅助叶子段):里面全是 16KB 的页,装满了
[name + 主键 id]的映射条目 - 段 D(辅助非叶子段):里面全是 16KB 的页,装满了
[name + 子页号]的导航目录
- 段 C(辅助叶子段):里面全是 16KB 的页,装满了
这一切是如何被记录的?
还记得我们之前解剖过的表空间总登记簿——Page 2(INODE 页) 吗?那个页里面有 85 个用于登记段资产的槽位(INODE Entry)——单个 INODE Page 装不下时,InnoDB 还可以串接更多 INODE Page,所以这个 85 不是"一张表最多 42 个索引"的硬上限。
在建表初期,只有主键时:
- Slot 0 分配给了 段 A
- Slot 1 分配给了 段 B
当你执行 CREATE INDEX idx_name ON Student(name); 时,系统会立刻在 Page 2 里激活两个新的登记项:
- Slot 2 分配给了 段 C
- Slot 3 分配给了 段 D
(这里只是为了讲解方便,实际的槽位号未必严格从 0 开始连续递增——索引被删除后会留下空槽,可以被新索引复用)
补充:INODE 页的动态扩展:当 85 个槽位不够用时怎么办:
还记得我们之前解剖过的 Page 2(INODE 页) 吗?那个页里面有 85 个用于登记段资产的槽位(INODE Entry)。每创建一个索引就消耗 2 个槽位(叶子段 + 非叶子段),也就是说单个 INODE 页最多支撑 42 个索引。
但这并不是"一张表最多 42 个索引"的硬上限——因为 InnoDB 可以动态分配更多的 INODE 页,并通过链表把它们串起来。下面我们来看这套扩展机制是怎么运转的。
Page 0 的两条 INODE 管理链表
Page 0 作为整个 .ibd 文件的总指挥(FSP_HDR 页),在它的头部数据结构中专门预留了两个链表基础节点(List Base Node,各占 16 字节),用来统帅所有的 INODE 页:
- FSP_SEG_INODES_FREE:空闲 INODE 页链表——只要某个 INODE 页里还有空余的槽位(不足 85 个),它就被挂在这条链表里
- FSP_SEG_INODES_FULL:已满 INODE 页链表——当某个 INODE 页的 85 个槽位全部被登记满,它就从 FREE 链表摘下来,转入这条链表
每个链表基础节点记录了三样信息:链表的首节点页号、尾节点页号、以及链表中的节点总数。
INODE 页之间的串联纽带
知道了是谁在管理链表,那具体的"链条"长什么样?
无论是最初的 Page 2,还是后来因为不够用而新创建的 INODE 页,它们的内部结构都是一样的。在每个 INODE 页的头部区域,有一个 12 字节的 List Node(FLST_NODE) 结构,里面包含两个关键信息:
- Prev Node Page Number:指向上一个 INODE 页的页号
- Next Node Page Number:指向下一个 INODE 页的页号
通过"Page 0 记录头尾 + 各个 INODE 页记录前后"的方式,就形成了一条可以跨越不连续物理页的双向链表。这个设计模式其实和我们前文讲过的 FIL_PAGE_PREV / FIL_PAGE_NEXT(数据页之间的双向链表)是完全一致的——InnoDB 在不同层级上反复复用着同一套"链表基础节点 + 双向指针"的串联范式。
动态扩展的完整流程
结合具体场景,我们推演一下当不断创建索引时,底层发生了什么:
初始状态:建表时,系统初始化 Page 0、Page 1 等管理页,并分配 Page 2 作为第一个 INODE 页。此时 Page 2 几乎是空的,Page 0 将 Page 2 挂到 FSP_SEG_INODES_FREE 链表中。
持续建索引:每创建一个辅助索引,消耗 Page 2 中的 2 个 INODE Entry(叶子段 + 非叶子段)。
临界点(Page 2 满了):当建了约 42 个索引后,Page 2 的 85 个槽位全部用完。InnoDB 将 Page 2 从 FSP_SEG_INODES_FREE 链表中摘下来,放入 FSP_SEG_INODES_FULL 链表。
分配新页:再建下一个索引时,InnoDB 去 FREE 链表里找,发现是空的。于是向表空间申请一个新的 16KB 数据页(比如 Page X),将其格式化为 INODE 页类型,在里面分配新的 INODE Entry,并将 Page X 挂到 FSP_SEG_INODES_FREE 链表中。
持续扩展:Page X 也满了就再分配 Page Y,以此类推。链表可以无限延伸。
用逻辑结构来表示这个串联关系:
[ Page 0 : FSP_HDR ]
|
+-- FSP_SEG_INODES_FULL ====> [ Page 2 ] <---> [ Page X (满) ] <---> [ Page Y (满) ]
|
+-- FSP_SEG_INODES_FREE ====> [ Page Z (正在使用,还没满) ]
无论你创建多少个索引,InnoDB 永远能通过 Page 0 顺藤摸瓜,沿着双向链表找到所有散落在 .ibd 文件各个位置的 INODE 页,进而找到所有的段和数据。
无论表中增加了多少个辅助索引,底层的管理逻辑根本没有变复杂——它只不过是在 INODE 页里多记了几笔账,在 .ibd 的线性页数组里多圈了几块专属空间而已。而当一个 INODE 页的账本记满了,Page 0 就会再分配一个新的 INODE 页接上去——扩展能力没有上限。
寻址的关键:每棵 B+ 树的"大门"在哪里?
此时,我们需要补充一个关键的寻址细节。前文提到,一个 .ibd 文件中可能同时共存着多棵 B+ 树(一棵聚簇索引树和多棵辅助索引树)。访问任何一棵 B+ 树,都必须从它的根节点作为入口开始。但问题在于,辅助索引往往不是在建表时同步创建的——你可能在建表 3 年后,才根据业务需求动态创建它。那么,当系统执行 SELECT * FROM table WHERE name = '张三' 时,它究竟去哪里找 name 这棵辅助索引树的"大门(根页号)"呢?
对于一个独立的 .ibd 文件来说,聚簇索引在这件事上有一个独特的优势:它的根节点页号是在建表时就确定下来的固定值,通常就是 page 3——而且在表的整个生命周期里永远不会改变。
所以无论何时,只要打开这个表空间文件,聚簇索引的入口都稳定地停在 page 3 这个位置上。注意:page 3 的特殊性不是"硬编码免去查字典"——它的根页号同样会被记录在数据字典里(下面就会讲到),只不过它在数据字典里的值永远是 3 而已。
辅助索引:根页号在创建时动态分配
辅助索引的命运则完全不同。当你执行 CREATE INDEX 创建辅助索引时,文件前面的物理页早就被占满了,新树的根节点从何而来呢?
此时就必须结合我们前文提到的段空间分配的生命周期来理解了。
InnoDB 首先会在 Page 2(INODE 页)为这棵新树分配专属的叶子段和非叶子段。由于此时这棵树是全新的,其非叶子段的页数为 0,远低于 32 页的阈值。因此,遵循"能省则省"的原则,系统不会直接划拨一个全新的 1MB 专用区,而是会去请求 Page 0,从表空间的 FREE_FRAG(碎片区链表) 中申请一个离散的空闲单页(假设分配到了 Page 450)。
这个从碎片区里抠出来的 Page 450,就顺理成章地成为了这棵辅助索引树的根节点。紧接着,系统会进行一次"双重登记":
- 物理确权:将 Page 450 登记在 Page 2 对应非叶子段的单页数组(FRAG ARRAY)中
- 逻辑挂牌:将 Page 450 上报给 MySQL 的核心元数据区——数据字典(Data Dictionary)
既然这些辅助索引的根节点散落在文件的不同位置,究竟是谁在记住它们的位置?
答案是:数据字典(Data Dictionary)。
InnoDB 并不会把入口信息暴露在普通表的 .ibd 文件里。你可以把数据字典理解为整个数据库系统的"楼层指引牌"——它是 MySQL 维护的一份系统级元数据,记录了全库所有表、所有索引的核心信息。
在数据字典内部,维护着一张系统级的隐藏表(逻辑上称为 SYS_INDEXES),它记录了全库所有索引的入口信息,结构大致如下:
| 表名 | 索引名 | 索引类型 | 所在的表空间 ID | 根节点页号 (Root Page No) |
|---|---|---|---|---|
| Student | PRIMARY | 聚簇索引 | 24 | 3 |
| Student | idx_name | 二级索引 | 24 | 450 |
| Student | idx_age | 二级索引 | 24 | 880 |
这里需要特别澄清一个容易混淆的点——Page 2 和数据字典都"管理"索引相关的信息,但维度完全不同:
- Page 2(INODE Page) 记录的是段的空间资源:每个段拿到了哪些页、哪些区,单页数组和 FREE / NOT_FULL / FULL 三条链表的状态
- 数据字典(SYS_INDEXES) 记录的是索引的逻辑入口:索引名 → 所属表 → 根节点页号
一个是"空间账本",一个是"逻辑入口指引"——两者各管各的,互不重复。
把上面所有概念串起来,当你敲下 SELECT * FROM Student WHERE name = '张三'; 这条查询语句时,底层的完整寻址过程是这样展开的:
-
优化器选索引:优化器决定使用
idx_name辅助索引 -
查指引牌:系统去内存中的"数据字典缓存(Data Dictionary Cache)"中查找,得到
idx_name的根节点页号是 450 -
定位辅助索引根页:拿到 450 后,InnoDB 进行一次极简的换算:
O f f s e t = 450 × 16 K B Offset = 450 \times 16KB Offset=450×16KB
然后发起一次文件读取,把 Page 450 加载到 Buffer Pool 中 -
辅助树内部导航:在辅助索引树里比较键值(“张三”),一路逐层向下,最终在辅助树的叶子节点拿到"张三"对应的主键(比如 ID: 7)
-
回表(再次寻址):拿着 ID: 7,跳回到永远不变的 Page 3(聚簇索引根节点),重新往下寻址,最终从聚簇索引叶子页里提取出完整的行数据
整个过程横跨两棵 B+ 树,依赖两次根节点定位:第一次定位通过数据字典查到 450,第二次定位则因为聚簇索引根页号永远是 3 而无需查询。这就是为什么我们前文说"回表"是 InnoDB 查询中一个精妙但昂贵的机制——昂贵之处就在于这种跨树的二次寻址。
InnoDB 叶子页全景剖析:七大功能区、记录组织与空间分配
远不止"键值+数据":InnoDB 叶子页的七大功能区
接下来我们将目光下移,转移到真正承载业务数据的叶子节点上。
在此之前,如果你曾自己动手模拟实现过 B+ 树,在那个简化的版本中,叶子节点通常只包含两部分:键值(Key)以及它对应的数据(Data)。虽然 InnoDB 存储引擎确实也是用 B+ 树来实现索引结构的,但在真实的工业级数据库场景下,叶子节点的构造要远比单纯的“键值对”复杂得多。这里我们先以**聚簇索引(主键索引)**的叶子页为例来拆解结构;关于二级索引的叶子页存什么、为什么要"回表",我们后文会专门讨论。
在 InnoDB 中,B+ 树的叶子节点对应着底层的数据页(Data Page,在源码中统称为 Index Page)。在这 16KB 的微观世界里,你真正写入的“键值 + 数据”其实只占据了一部分空间。剩下的空间,全都被各种极其关键的**“元数据(Metadata)”和“内部管理结构”**填满了。
为什么不能简单粗暴地只存数据?因为对于数据库来说,仅仅“把数据存下来”是远远不够的,它还要解决三个极其致命的底层挑战:
- 防损坏: 磁盘断电导致这 16KB 的页只写了一半怎么办?
- 连贯性: 当执行范围查询(如
WHERE id > 10)时,如何快速跨越物理边界跳到下一个页? - 极速查找: 16KB 的空间里能塞下成百上千条记录,难道每次定位数据都要在页内从头到尾进行 O ( N ) O(N) O(N) 的全遍历吗?
为了完美解决这些问题,InnoDB 将这 16KB 的“房间”精细地划分成了七个功能区。让我们直接推开这扇门,一探里面的真实构造:
-
File Header(文件头,38字节):通用签证官
- 元数据: 记录了本页的“页号”、所属表空间以及校验和,以及极其关键的双向链表指针(
FIL_PAGE_PREV和FIL_PAGE_NEXT)。 - 作用: 前文提到 B+ 树的叶子节点是彼此相连的。这种连接并不是树结构本身天然带来的,而是依靠文件头里的这两个指针,把物理上可能散落在不同区(甚至碎片区)的页,在逻辑上串成了一个极其顺滑的双向链表,这是完美支持范围查询的基石。
- 元数据: 记录了本页的“页号”、所属表空间以及校验和,以及极其关键的双向链表指针(
-
Page Header(页头,56字节):本页大管家
- 元数据: 记录了本页目前有多少条记录、还有多少空闲空间、最后一次插入的位置等状态信息。
- 作用: 当有新数据想要塞进这个页时,系统第一眼就是看这里,从而瞬间判断出是否需要向大管家申请新页、触发“页分裂”。
-
Infimum & Supremum(虚拟边界记录,26字节):页内门神
- 元数据: 这是 InnoDB 在每个页里强行安插的两条“假”记录。一条代表比本页任何记录都小的下界(Infimum),一条代表比任何记录都大的上界(Supremum)。
- 作用: 它们充当了页内数据单向链表的绝对起点和绝对终点。
-
User Records(用户真实记录):正文主体
-
这才是你真正关心的
[键值 + 真实数据]存放的地方。 -
核心机制: 页内的记录在物理上并非严格紧挨着,而是通过每条记录头部自带的**“下一条记录指针(next_record)”,在页内串成了一个单向链表**。
-
-
Free Space(空闲空间):待开发地皮
- 这 16KB 里还没被用掉的空白字节。新插入的记录都会从这里动态划走一块空间。
-
Page Directory(页目录):页内高铁站
- 元数据: 一个专门存放“槽位(Slot)”的数组。
- 作用: 刚才提到,页内记录是一个单向链表。如果在上千条记录的链表里挨个比对找数据,时间复杂度是极其低效的 O(N)。为了提速,InnoDB 把这些记录分组,将每组最后一条记录的相对偏移量提取出来,放进这个页目录中。这样一来,CPU 就可以利用这个目录直接进行二分查找(O(log N)),瞬间锁定目标记录所在的组!
-
File Trailer(文件尾,8字节):安全质检员
- 元数据: 校验和(Checksum)和最后修改的日志序列号(LSN)。
- 作用: 专防“页断裂(Torn Page)”。如果页写到一半突然断电,下次读取时,系统会将文件头和文件尾的校验和进行比对。一旦发现对不上,立马就能判定该页已损坏。

接下来我们对 File Trailer 这部分做一个补充。想象一个极端的物理场景:虽然对 InnoDB 来说最小的 I/O 单位是 16KB,但在更底层的操作系统和磁盘硬件看来,写入往往是按更小的块(比如 4KB,甚至 512 字节的扇区)进行的。如果在这个 16KB 刚刚写入了前 8KB 的时候,机房突然断电了——
此时,磁盘上就会发生一个可怕的现象,叫作**“页断裂(Torn Page)”**:
- 这一页的上半部分是刚刚刷进去的新数据
- 这一页的下半部分还是覆盖前的旧数据
如果数据库只在文件头放一个校验和,当系统重启读取这页时,它可能根本发现不了下半部分的数据已经错乱了。
而有了"首尾呼应"的双重 Checksum,当系统重启并把这个 16KB 读取到内存时,质检流程就变得万无一失:
质检员先看一眼 File Header 里的 Checksum(代表新数据算出来的值),再看一眼 File Trailer 里的 Checksum(此时因为断电没写完,它还是旧数据的值)。一对比,发现不一样!系统立刻判定:发生了页断裂,数据页已损坏!
这时候,InnoDB 就会直接丢弃这个损坏的页,先从 Doublewrite Buffer(双写缓冲区) 中恢复出完整的页,再用 Redo Log(重做日志) 重放后续的改动,完成数据恢复(这是后话了,我们后面会专门讨论)。
页内记录的组织艺术:单向链表、页目录与哨兵机制
认识了数据页的七大功能区后,我们必须直面一个核心矛盾:在 16KB 的微观空间里,记录到底该如何组织,才能同时兼顾"极速插入"与"极速查询"?
在此前我们自己动手模拟实现 B+ 树时,叶子节点通常使用两个简单的数组来分别存储键值(Key)和对应的业务数据(Value)。在这种朴素的数组模型下,每次插入一条新数据,只要它的键值不是最大的,就必然牵扯到数组元素的向后挪动。如果仅仅是内存里的小规模数组,这种移动代价尚可承受。
然而,我们面对的是工业级数据库场景。一个 16KB 的叶子页能够承载数百甚至上千条记录。如果依然采用连续数组来存储业务数据,每一次插入或删除都将面临极其惨烈的 O(N)级别数据大挪移。在极为昂贵的底层存储引擎中,这种性能损耗是绝对不可接受的。
既然数组的插入代价太大,InnoDB 果断选择了另一种数据结构:链表。
表中的每一行真实业务数据,就对应着链表中的一个节点。但这里必须澄清一个概念:由于这些记录全都被圈禁在一个连续的 16KB 页空间内,它们之间根本不需要存储传统意义上那种长达 8 字节的内存指针。为了把空间压榨到极致,InnoDB 在每条记录的头部只保留了一个极其轻量的元数据——next_record(下一条记录相对于当前记录的字节偏移量)。
这里一个非常容易踩坑的细节是:next_record 不是"相对于页首地址的偏移量",而是"相对于当前记录本身的偏移量"。换句话说,寻址公式是:
下一条记录地址 = 当前记录地址 +
next_record
并且这个偏移量是有符号的,既可以是正数也可以是负数。这个看似不起眼的设计,让 InnoDB 能在不搬动记录物理位置的前提下,仅通过修改偏移量来完成链表的逻辑重组——比如删除记录时,直接改上一条的 next_record 指过去就行。
更绝的是,为了省空间,InnoDB 甚至省去了"前驱指针",所有记录仅仅通过 next_record 串成了一个单向链表。采用这种设计的优势不言而喻:无论页内有多少数据,新记录的插入与删除都只需要修改局部偏移量,时间复杂度完美降维至 O(1)。
但是,引入单向链表立刻带来了另一个致命缺陷:众所周知,链表的物理布局是离散的(即使在同一个页内,先插入的记录和后插入的记录也未必挨着)。如果要在这个单向链表中寻找某条特定记录,唯一的办法就是从头开始线性遍历。面对成百上千条记录,O(N)的查询代价同样是数据库无法忍受的。
为了加速页内记录的查找,InnoDB 祭出了一个极其优雅的设计:分组与页目录(Page Directory)。
既然单向链表不能跳跃查找,那我们就给它建一个"目录"。InnoDB 会将页内的所有正常记录按照键值大小进行分组(通常 4 到 8 条记录为一组)。每一组的最后一条记录(也就是该组键值最大的记录)会被推选为**“组长”**。
紧接着,InnoDB 会在页的尾部开辟一块特殊区域——Page Directory(槽位数组)。系统会将每一个"组长"的页内偏移量提取出来,按顺序依次存入这个目录的槽位(Slot)中。因为槽位在物理上是严格连续的数组,这也就意味着:无序的单向链表,在这里被强行映射成了一个有序的偏移量数组。
当我们要在这个 16KB 的页内查询某条记录时,堪称艺术的寻址过程开始了:
- 二分查找:CPU 直接在连续的 Page Directory 槽位数组中,对组长的键值执行二分查找(时间复杂度 O(log N))。
- 锁定分组:通过二分查找,瞬间锁定目标数据必然潜伏在某一个具体的分组中。
- 局部遍历:进入目标分组后,沿着单向链表线性扫描。由于每个分组最多只有 8 条记录,这一段是 O ( 1 ) O(1) O(1) 的常数开销。
通过"分组槽位 + 二分查找 + 极短链表遍历"的组合拳,InnoDB 在完全不破坏插入 O ( 1 ) O(1) O(1) 性能的前提下,将页内成百上千条记录的查询时间,严格压缩到了 O(log N) 的极速境界。
在理解了页目录的宏观作用后,我们必须进一步放大显微镜,去看看 CPU 究竟是如何在这个目录里执行二分查找,并精准切入单向链表的。
很多初学者会误以为,槽位(Slot)里存储的是一个类似于 [组长键值, 偏移量] 的二元组。但在真实的 InnoDB 底层,为了将 16KB 的空间压榨到极致,每一个槽位被极其严格地限制为仅仅 2 个字节。这 2 个字节里只存储了组长记录的相对偏移量,根本没有存储键值。
那么,没有键值的槽位,二分查找该怎么做?
当 CPU 执行二分查找时,它会直接读取中间槽位的 2 字节偏移量,瞬间跳转到对应组长记录的内存位置,再从组长记录那里读出真实的键值进行比对。一次"读偏移量 → 跳指针 → 读键值"的轻量跳转,就完成了一次二分比较。用微乎其微的指针跳转代价,换取了极其可观的空间节省——这就是工业级存储引擎对每一个字节的极致计较。
那么,一次完整的页内精确查找,其真实的执行轨迹究竟是怎样的呢?假设我们要查找目标键值 key = 42,整个过程分为极其精妙的三步:
1.二分定位目标分组:
CPU 拿着目标键值
42,在只有偏移量的槽位数组中执行二分查找。它的目标是——定位到目标记录必然所在的分组。举个具体的例子:假设各槽位的组长键值依次是
32, 40, 48, 56, …。别忘了一个关键前提:槽位里存的组长,是每个分组中键值最大的那条记录。也就是说,Slot 3 这一组里所有记录的键值都 ≤ 40,Slot 4 这一组里所有记录的键值都 ≤ 48(且 > 40)。
基于这个前提,目标值
42的归属一目了然:
42 > 40:说明42绝对不可能出现在 Slot 3 及之前的任何分组里(它们的最大值都 ≤ 40)42 ≤ 48:说明42一旦存在,必然落在 Slot 4 这一组的键值范围内(> 40 且 ≤ 48)两条推理叠加,结论就非常干脆:如果目标键值
42在这个页里存在,它就只可能藏在 Slot 4 所管辖的分组里——别的地方一概不用看。于是,二分查找到此结束,我们得到了明确的目标分组:Slot 4。
2.跨组跳跃(寻找分组起点):
这是整个寻址过程中最核心、也最容易被忽略的艺术。
由于页内的真实记录是一个单向链表,Slot 3 只记录了该组最后一条记录(组长 40)的位置。如果我们直接跳到 40,我们根本无法在 Slot 4 的组内去寻找目标值为 42 的记录(因为 Slot 4 分组的第一条记录不等于 40)。
那么,如何进入 Slot 4 分组的起始位置呢?
答案是——通过组长的 next_record 指针。系统会读取 Slot 3 的组长(键值 40)的 next_record 偏移量,跳到下一条记录上。因为全局是单向有序的,Slot 3 组长的 next_record 天然就指向了 Slot 4 分组的第一条记录!
这一步,正是"单向链表 + 分组槽位"两种结构在物理层面最巧妙的衔接点。
3.局部线性扫描(极速出击):
顺着 Slot 3 组长的 next_record,我们成功进入了 Slot 4 分组的起始点。接下来,由于 InnoDB 规定每个分组最多只能包含 8 条记录,CPU 只需要顺着单向链表,在最多 8 次极速的线性对比后,就能精准命中目标记录 42,或者确认记录不存在。

至此,一次 16KB 叶子页内的精确查找,就以O(log N) 的整体复杂度完美收官。
在明确了页内记录是通过 next_record 串联成单向链表之后,我们不得不面对一个经典的算法边界问题:当我们要在这个链表的头部插入一条比所有人都小的新记录,或者在尾部追加一条比所有人都大的新记录时,如何处理那些烦人的"空指针"或"越界"等边界条件?
只要你刷过哪怕几道简单的链表算法题,就一定听过一个大名鼎鼎的技巧——哨兵节点(Dummy Node)。
InnoDB 的设计者同样深谙此道。为了让页内记录的插入、删除、查找等操作变得绝对统一且丝滑,InnoDB 在每一个 16KB 数据页初始化时,都会在物理空间的固定位置,强行写入两条"虚拟记录":Infimum 和 Supremum。
这可不是写在页头里的两个简单指针,而是两条实实在在参与链表运作的记录:
- Infimum(下界哨兵):它的键值被硬性规定为比本页中任何可能出现的用户记录键值都要小。它是整个页内单向链表的绝对起点。
- Supremum(上界哨兵):它的键值被硬性规定为比本页中任何可能出现的用户记录键值都要大。它是整个页内单向链表的绝对终点。
这里有一个非常容易被误解的地方。你看我上面用了"键值被硬性规定为最小 / 最大",读者很自然会好奇——这两条虚拟记录里到底存了一个多小或多大的数字?
答案是:根本没存什么极值数字。
InnoDB 在这两条记录里存的,就是 8 字节的硬编码字符串 "infimum\0" 和 "supremum" 而已,仅此而已。
它们之所以能扮演"最小"和"最大",靠的不是数据层的数字大小,而是 InnoDB 比较逻辑层的特殊判定——源码里直接写死了:“遇到 Infimum 就当它最小,遇到 Supremum 就当它最大”。
换句话说,哨兵的"极值"不是存储出来的属性,而是代码层的一纸约定。这种"用 8 字节字符串换来一整套边界逻辑的简化"的设计,正是工业级存储引擎的典型手法——永远在追问:“这一字节能不能压榨出更多用途?”
有了这两位门神,页内两个最关键的数据结构——User Records 链表和Page Directory 槽位数组——的边界问题,同时被统一了:
第一层镇守:User Records 链表的起止统一
页内真实的单向链表就成了一条**“不会走丢”**的链表——从起点到终点,每一步都有明确的去向,永远不会撞到 NULL。一个完整数据页的遍历路径,其真实的物理流转如下:
- 起点启航:遍历永远从 Infimum 记录出发。
- 切入正文:Infimum 的
next_record指针,精准地指向页内键值最小的第一条真实用户记录。 - 数据游走:顺着真实记录的
next_record,在 User Records 区域内依次向后遍历。 - 终点撞线:当到达页内键值最大的最后一条真实记录时,它的
next_record指针不再指向 NULL,而是稳稳地指向了 Supremum 记录。
第二层镇守:Page Directory 二分查找的边界统一
更绝的是,两位门神的庇护不止于单向链表,还延伸到了 Page Directory 的槽位数组:
- Slot 0(页目录的第一个槽位)固定指向 Infimum
- 最后一个 Slot 固定指向 Supremum

这意味着什么?意味着 CPU 在槽位数组里做二分查找时,根本不用写"如果目标比所有记录都小怎么办"或"如果目标比所有记录都大怎么办"这种边界判断——因为 Slot 0 和最后一个 Slot 永远守在数组的两头,任何目标键值必然落在 [Infimum, Supremum] 这个区间内,二分的左右指针永远有一个合法的归宿。
对于这 16KB(16384 字节)的数据页而言,Infimum 和 Supremum 根本不需要在空闲空间(Free Space)中去抢占地盘——它们是与生俱来的。
当 InnoDB 初始化一个全新的空白数据页时,它的前置物理布局就已经被底层代码死死焊住了:
0 ~ 37 字节:File Header(文件头,38字节)38 ~ 93 字节:Page Header(页头,56字节)94 ~ 119 字节:Infimum & Supremum 专属领地(固定 26 字节)120 字节起:User Records(真实的业务数据从此开始往后排布)
也就是说,无论这个数据页承载的是主键还是辅助索引,也不管里面装了 1 条还是 1000 条业务数据,偏移量 94 到 119 的这 26 个字节,永远是留给这两位“门神”的绝对禁区。
既然它们不存储动态的真实键值,这 26 个字节的肚子里到底装了什么?底层源码的做法极其简单粗暴,它们被严格平分为两个 13 字节的结构:
- Infimum(13 字节) =
5 字节记录头+8 字节硬编码数据 - Supremum(13 字节) =
5 字节记录头+8 字节硬编码数据
那这 8 字节的“数据”究竟是什么?InnoDB 的设计者在这里直接写死了两串英文字符的 ASCII 码:
- Infimum 的 8 字节数据区,硬编码写入了单词:
infimum\0 - Supremum 的 8 字节数据区,硬编码写入了单词:
supremum
这就完美解释了此前的疑问:系统在进行页内查找比对时,底层设定了极其霸道的比较规则——只要读到 infimum\0 这个字符串,就无条件判定它比任何业务键值都小;只要读到 supremum 这个字符串,就无条件判定它比任何业务键值都大。在这里,业务数据类型完全失效,底层的规则就是王道。
你可能会好奇,页尾的槽位数组(Page Directory)是如何与它们联动的?在这 16KB 数据页的末尾,那个用于二分查找的页目录,有着极其森严的等级规矩:
- Slot 0(第 0 号槽位):永远只指向 Infimum。
Infimum 是个“光杆司令”,它自己单独构成一个分组。Slot 0 里存储的偏移量,永远死死指向第 94 字节处的 Infimum 记录。 - 中间槽位: 依次指向你的各个业务数据分组(User Records)的组长。
- Slot N(最后一个槽位):永远只指向 Supremum。
作为压轴出场的门神,Supremum 是最后一个分组的“大组长”。InnoDB 规定,以 Supremum 结尾的这个分组极具弹性,它可以包含 1 到 8 条记录。换句话说,页内最大的那几条业务记录,最终都会通过next_record指针,乖乖归队到 Supremum 的麾下。
刚才提到,页内最大的那几条业务记录,最终都会“乖乖归队到 Supremum 的麾下”。为了彻底搞懂这句话的含金量,我们需要把微观镜头对准 InnoDB 的页内分组(Page Directory Grouping)与单向链表的结合部。
在 InnoDB 的规则中,将单向链表切分为一个个“分组”的核心逻辑是:每个分组内键值最大的那条记录,自动成为该组的“组长”。 而页尾的页目录(Page Directory)里的每一个槽位(Slot),存储的永远是这些“组长”的物理偏移量。
既然如此,我们来看看页内单向链表的末端到底发生了什么。
假设你当前的数据页里,由于不断插入,最大的几条真实业务记录的键值分别是 97、98、99。在底层的物理链表中,由于必须保持单向有序,它们的连接形态必然是这样的:... -> [记录 97] -> [记录 98] -> [记录 99] -> [Supremum]
看出端倪了吗?
因为底层硬编码规定了 Supremum 永远是全页最大的记录,所以不管你插入了多少条真实的业务数据,单向链表的终点永远是指向 Supremum。
既然 Supremum 是整个链表的最后一条记录,那么按照“最大者当组长”的铁律,它自然而然地就成为了全页最后一个分组的“终极组长”。
此时,InnoDB 会把这几条记录划归为同一个组,在这个微观的“黑帮”里:
- 组长: Supremum(永远压轴,占据最后一个槽位 Slot N)。
- 组员: 记录 97、记录 98、记录 99。
- 组员规模(n_owned): 在 Supremum 那 5 字节的记录头中,
n_owned字段的值会被更新为 4(代表本组包含 3 条真实记录 + 1 条 Supremum 自身)。
这种设计的精妙之处在于寻址时的完美闭环:
当 CPU 想要查询键值为 98 的记录时,二分查找会瞬间判定 98 落在最后一个槽位(即 Supremum 管辖的分组)的范围内。于是,CPU 会精准定位到倒数第二个槽位(Slot N-1)的组长,顺着它的 next_record 指针往下走。第一步跨入的就是 记录 97,接着顺藤摸瓜,极速命中 记录 98。
所谓的“麾下”,在底层其实就是极其严谨的数据结构映射:页内键值最大的这几条真实数据,在逻辑上被划归到了以 Supremum 为首的最后一个槽位;而在物理上,它们的单向指针也最终都顺次连向了这条虚拟的门神记录。
InnoDB 页内空间分配机制:从 PAGE_HEAP_TOP 到 PAGE_FREE
至此,我们已经对叶子页的宏观构成建立起了清晰的认知。一个叶子页能够承载成百上千条记录,这些记录本质上就是单向链表中的一个个节点,物理上全都栖身于 User Records 区域。
根据前文的推演,当我们插入一条新数据时,系统会从 B+ 树根节点逐层向下寻址,最终落脚到目标叶子页。接着,借助页目录(Page Directory)的槽位进行二分查找,锁定目标分组,最后在组内进行顺序遍历,敲定新记录的插入位点——也就是找准它的"前驱"和"后继",然后修改前驱节点的 next_record 偏移量,把新节点挂载到单向链表上。
但是,仅仅知道新节点该挂在哪里还不够。链表逻辑上说得通,不代表物理上就有地方放它。打个比方:你去图书馆借了一本书,图书管理员告诉你这本书应该插在 A03 号书架第 5 排、第 12 本和第 13 本之间——这是逻辑位置。但问题是,那个位置已经塞满了书,根本插不进去。你还得真正找出一段空着的架子,把这本书摆上去。
新记录的写入也是同样的道理。next_record 解决的是"这条记录在链表里排第几"的问题,但它最终要被写到 16KB 页里的哪个具体位置上——比如从第 200 个字节开始、占 30 个字节——这才是真正要解决的物理空间分配问题
这里就触及到了数据库底层开发与普通业务开发的一道分水岭。我们平时写应用层代码,要一块内存就直接调用 new 或者malloc申请一段空间,底层不够用了自然会向操作系统再要——有后路。但 InnoDB 在分配页内空间时,根本没有这种奢侈:
它只能在这个被死死圈定的 16KB 页内部解决问题。不能越界到别的页,也不能向操作系统申请更多字节。这个页有多大就是多大,用完了就只能自己想办法腾地方。
这个"没有后路"的约束,逼着 InnoDB 必须把这 16KB 内部每一寸字节的使用情况追踪得清清楚楚:
- 哪些字节已经被占用了?
- 哪些字节还从来没用过?
- 被
DELETE掉的记录留下的洞有多大、在哪里?
全部都要自己记录、自己管理。
为此,InnoDB 的页内空间管理采取了一套双轨制:一条轨道负责从未使用过的连续空间,另一条轨道负责回收已删除记录留下的离散空闲块。两套机制并存、互为补充。它们的核心都藏在我们之前解剖过的 Page Header 里,由两个关键属性掌控。
轨道一:连续空间的开荒 —— PAGE_HEAP_TOP:
当一个 16KB 的数据页刚刚被初始化,或者只插入了很少几条数据时,User Records(用户记录区)和底部的 Page Directory(页目录)之间,夹着一大片从未被使用过的连续空白字节。
InnoDB 管理这片连续空间的方式极其简单:只用一个偏移量指针就够了,完全不需要链表。
在 Page Header 中,有一个叫作 PAGE_HEAP_TOP 的属性,它始终指向这片连续空白区域的当前边界。当你执行 INSERT 插入一条首次写入的新记录时,流程是这样的:
- 系统读取
PAGE_HEAP_TOP的当前位置 - 从这个位置开始,直接往下划出新记录所需的字节数(比如 30 字节)
- 把真实数据写进去
- 将
PAGE_HEAP_TOP指针向下推进 30 字节
这一套下来,就是一次纯粹的"向下开荒"——边界指针单向推进,没有搜索、没有链表遍历,时间复杂度严格 O ( 1 ) O(1) O(1)。
轨道二:已删除空间的回收 —— PAGE_FREE:
接下来我们要讨论的是删除带来的空间回收问题。关于 DELETE的底层机制我们后面会专门展开,这里先给出一个关键事实:当你 DELETE 一条记录时,InnoDB 并不会立刻把这行数据从磁盘上抹掉,而是仅仅把记录头里的delete_flag 标志位置为 1。
换句话说,被"删除"的记录此刻仍然老老实实地躺在 16KB 页里的原位置上,只是多了一个"我已经是死人了"的标记而已。
问题来了:如果只打标记不回收,这 16KB 的空间迟早会被塞满"僵尸记录"。所以 InnoDB 引入了第二条轨道——空闲链表:
- 当一条记录被删除时,它不仅被打上删除标记,还会被从正常的单向链表中摘除,然后通过它自己的
next_record指针,串入一条专门存放被删记录的独立链表中。 - 这条链表的头指针,同样保存在 Page Header 里,叫作
PAGE_FREE。 - 所有被删记录就这样被串成了一条空闲链表(Free List),等待被后续的
INSERT复用。
这里一个非常重要的信息是:同一个 next_record 字段,在不同的语境下承担了两种完全不同的角色——正常记录的 next_record 串起业务链表,被删记录的 next_record 串起空闲链表。InnoDB 通过记录头上的 deleted_flag 来区分这两种语境。这种"一字段多用"的设计,是字节极限压缩的又一处体现。

双轨如何协同:分配的真实流程:
现在关键问题来了——当 INSERT 进来要一块空间时,InnoDB 是先走哪条轨道?
第一步:先检查 PAGE_FREE 链表的头节点
注意,这里有一个非常容易被想当然的误区:InnoDB 并不会遍历整个 PAGE_FREE 链表去找一个"大小最合适"的碎片——它只看链表头的第一个节点。
- 如果头节点的空间 ≥ 新记录所需大小,直接复用这块空间,并把它从链表里摘除。
- 如果头节点的空间不够大,InnoDB 根本不会往后看第二个节点,而是直接放弃整个空闲链表,转头去走轨道一。
这个看似"偷懒"的设计,其实是一次精心的工程取舍:分配操作在数据库里是极其高频的路径,必须保持 O(1) 的复杂度,不能为了省下几十字节去做 O(N) 的链表遍历。那么积累下来的那些"不够大"的碎片怎么办?—— 交给后面要讲的页内碎片整理(Page Compaction) 统一处理。
第二步:去 PAGE_HEAP_TOP 开荒
如果 PAGE_FREE 链表为空,或者头节点的空间不够用,系统才会去请求 PAGE_HEAP_TOP,从那片从未使用过的连续空白区域里,划出新记录需要的字节数。
终极拷问:如果两条轨道都"枯竭"了呢?
假设你在一个页上疯狂地增删改查,导致:
PAGE_FREE链表头节点只有 10 字节,后面节点里藏着几个 12、15 字节的小碎片PAGE_HEAP_TOP上方的连续空白也只剩下 20 字节
此时你想插入一条 30 字节的新记录——表面上看,不管走哪条轨道都不够用了。
但注意:如果把 PAGE_FREE 链表里那些"不够大"的碎片加起来,总和其实还是够的。问题只在于它们是离散的——零零散散地分布在 User Records 区的各处空洞里,没有任何一块单独拎出来能够容纳新记录。
这时 InnoDB 会触发它的保底机制——页内碎片整理(Page Compaction / Reorganization):
- 在内存里开辟一块临时的 16KB 缓冲区
- 把当前页里所有未删除的有效记录,按照链表顺序依次、紧凑地重新排布到 User Records 区的起始位置——被删除的记录这一步全部丢弃,中间不再留下任何空洞
- 把整理好的内容写回这个页原来的物理位置——页号、表空间号、在 B+ 树中的位置全部保持不变
这一整顿带来的物理层面变化是显著的:
- 原本:有效记录夹杂着被删记录的空洞,零散分布在 User Records 区;
PAGE_HEAP_TOP被迫停在一个很靠下的位置,下方只剩 20 字节连续空白。 - 整顿后:所有有效记录紧紧贴在 User Records 区的顶部;
PAGE_HEAP_TOP被大幅抬升;之前那些散布在各处的碎片空洞被一次性收编,在PAGE_HEAP_TOP之下合并成了一大片连续的空闲区域。
换句话说,Compaction 的本质就是把"碎片化的空闲空间"整合为"连续的空闲空间"——对于那条等着被插入的 30 字节新记录而言,Compaction 之后轨道一(PAGE_HEAP_TOP 开荒)瞬间就能满足需求了。
对 B+ 树上层和外部访问者而言,这次整顿几乎是"透明"的——页号没变,指向它的指针也都不用动。
Compaction 的代价其实不高(纯内存操作),但它会短暂锁住这个页、阻塞其他访问者,因此 InnoDB 只会在"确实没地方可以分配"时才触发它。
亲手感受这套机制:
文字讲再多,不如亲手点几下。为了让你亲眼观察到 PAGE_HEAP_TOP 的开荒、PAGE_FREE 的回收、以及 Compaction 的整理过程,我做了一个可交互的底层模拟器:
点一下插入和随机删除,观察 Page Header 里这两个指针如何实时变化——这比任何文字描述都来得直观。
看完上文的内容,这里读者可能会产生一个较真的追问:那链表中间存在"够大"的节点怎么办?
这里我们必须直面一个细心读者一定会想到的边界情况:
如果
PAGE_FREE链表的头节点只有 10 字节(不够新记录),但链表中间某个节点有 40 字节(恰好够用),InnoDB 难道也不管不顾,直接去触发 Compaction 吗?
答案是:是的,一概不管。
InnoDB 看了头节点一眼,发现不够,转身就走——连"后面还有大节点"这件事它都不知道,因为它压根没去遍历。然后依次尝试 PAGE_HEAP_TOP 开荒,如果连续空白也不够,就直接触发 Compaction。
这种行为在你描述的场景下完全可能发生。而且在 InnoDB 看来,这不是什么"意外情况",这就是它预期中的正常流程。
为什么不肯花点时间遍历一下链表呢?
我们算一笔账。假设链表有 20 个节点,头节点不够、中间第 15 个节点恰好够。如果 InnoDB 选择遍历:
- 收益:节省一次 Compaction,新记录直接复用第 15 个节点
- 代价:每一次
INSERT都要承担最多 O(N) 的链表遍历
INSERT 是数据库里频率极高的路径,如果每秒上万次,每次都做 O(N) 遍历,累积下来的 CPU 代价远远超过偶尔触发一次 Compaction 的代价。更何况:
- Compaction 本身并不昂贵——纯内存操作,一次耗时在几十微秒级别
- Compaction 一次消灭所有碎片——不只是那个 40B 的中间节点被"利用",链表里所有的 10B、12B、15B 碎片统统被合并到连续空白中,一次整顿把所有遗留问题全部解决
InnoDB 做的是一个非常典型的分摊(amortized)权衡:
牺牲"极端情况下能精准复用中间节点"的细粒度优化,换取
INSERT分配路径严格的 O(1) 复杂度。
把精细的空间整理工作集中到 Compaction 这一次批处理里去完成,而不是分散到每一次 INSERT 里。
Compact 行格式详解:一条记录的元数据是如何组织的
理清了 16KB 物理页内精妙的空间"圈地运动"(双轨制分配)之后,我们的显微镜需要再次放大倍率,将焦距锁定在那块刚刚被划拨出来的、承载单条记录的数据块上。
很多初学者会下意识地认为,既然是业务数据块,那里面装的肯定就只有我们插入的 id、name、age 等纯粹的表字段。但在严谨的 InnoDB 看来,仅仅存储这些字段值是远远不够的。
这就像一个 16KB 的数据页不能只有 User Records,还必须配备 File Header 和 Page Header 来维持物理秩序一样——哪怕是小到只有几十字节的一条单行记录,也必须拥有专属的"管理信息"来宣告自己的状态。这种"元数据 + 真实内容"的分层结构,在 InnoDB 里是一种跨层级复用的设计哲学:页有页的元数据,记录也有记录的元数据。
因此,在 InnoDB 的底层(以最常用的 Compact 行格式为例),一条物理记录被严格地划分为两大阵营:记录的额外信息(元数据区) 与 记录的真实数据(真实数据区)。
而元数据区本身又由三个子部分组成,按物理存储顺序从左到右依次为:
- 变长字段长度列表 —— 记录每个变长字段(如
VARCHAR、TEXT)的实际长度 - NULL 值列表 —— 用位图标记哪些字段是 NULL
- 5 字节记录头(Record Header) —— 包含
deleted_flag(删除标记位)、n_owned(页目录分组计数)、以及我们前文已经打过照面的老朋友next_record(就藏在记录头最后的 2 字节里,负责把同页内的记录精准串联成单向链表)
紧接在记录头之后,才是我们真正关心的真实数据——那些 id、name、age 的字段值。
完整的物理布局是这样的:
[变长字段长度列表] [NULL 值列表] [5 字节记录头] [真实数据]
└─含 next_record
这里先埋两个钩子,后文会专门展开:
- 钩子一:你可能已经注意到,变长字段长度列表和 NULL 值列表,被放在了记录头之前,而不是真实数据之后。为什么?
- 钩子二:更反直觉的是,这两个列表内部都是以**"逆序"的方式存储的——也就是说,第一个字段的长度信息,反而存在列表的末尾**。
这两个设计看起来拧巴,其实背后藏着 Compact 行格式一个非常精妙的工程考量,我们接下来会一一拆解。

钩子展开:两个看似拧巴的设计,背后的工程哲学
我们先把两个钩子完整摆出来:
- 钩子一:变长字段长度列表和 NULL 值列表,被放在了记录头之前,而不是真实数据之后
- 钩子二:这两个列表内部都是以**“逆序”**存储的——第一个字段的元信息,反而存在列表的末尾
这两个设计单独看都很怪,但一旦把它们组合起来,就会发现 InnoDB 的设计者其实在下一盘非常聪明的棋。
先铺一个关键前提:记录的"基准地址"在哪里?
要理解为什么要这样设计,我们必须先回答一个看似无聊的问题:
当 InnoDB 顺着 next_record 指针跳到下一条记录时,它手里拿到的这个"目的地地址",到底指向记录的哪个位置?
是指向记录的最左端(也就是变长字段列表的开头)?还是指向记录头?还是指向别的什么地方?
答案非常关键——next_record 指针的落脚点,是"真实数据区的起点",也就是记录头和真实数据之间的那个分界线。这个位置就是我们所说的**“基准地址”**。
这个选择不是随便定的。因为在数据库的日常操作中,读取字段值是最高频的行为——而一旦记录的地址直接指向真实数据的起点,CPU 要取第一个字段,就是零偏移直接访问;要取第二个字段,就是固定偏移 + 字段 1 长度——一步到位,不用跨越任何元数据区。
把最高频的访问路径优化到极致,这是"地址指向真实数据起点"的核心原因。
有了这个前提,我们回头再看两个钩子,所有拧巴的设计都会瞬间变得合理。
钩子一的答案:列表放在记录头之前,是为了"向左伸手就能拿到元数据"
既然记录的基准地址指向真实数据区的起点,那记录头自然就被安排在基准地址左边紧邻的 5 字节处——读记录头,就是往左数 5 字节。
那变长字段长度和 NULL 值列表呢?它们逻辑上也是这条记录的元数据,同样被安排在基准地址的左边——紧挨着记录头,继续往左延伸。
用一张图来表达这个布局:

这就解答了钩子一:列表放在记录头之前(也就是基准地址的左边),是为了让元数据的访问方向和真实数据的访问方向形成"两翼分开"的对称格局。向左伸手拿元数据,向右伸手拿字段值,互不干扰。
如果反过来把列表放在真实数据之后(基准地址的右边),每次读字段值都要先"跨过"这些元数据才能到达真实数据——这对最高频的字段读取路径来说就是一次不必要的地址跳转。InnoDB 绝对不会接受这种设计。
钩子二的答案:逆序存储,是为了让"字段 1 的元数据"离基准地址最近
理解了钩子一,钩子二就水到渠成了。
问题:假设一条记录有 3 个字段,变长字段长度列表里需要存 3 个字段的长度。如果按正序存(字段 1 在前、字段 3 在后),那么:
- 字段 1 的长度,位于列表的最左边——也就是离基准地址最远
- 字段 3 的长度,位于列表的最右边——也就是离基准地址最近
但在 SQL 的语义层面,字段的访问顺序通常是按定义顺序来的——表定义是 (id, name, age, email, bio),绝大多数查询都是先访问 id,再访问 name。
也就是说,访问频率最高的是字段 1,最低的是字段 N。可如果按正序存储,访问频率最高的字段 1 的元数据反而离基准地址最远,每次都要"跨过"其他所有字段的元数据才能拿到——这不合理。
解决方案:把列表反过来存。
(正序存储 —— 不合理)
[字段 1 长度][字段 2 长度][字段 3 长度] [记录头] [真实数据]
↑ 离基准地址最远 离基准地址最近 ↑
(逆序存储 —— InnoDB 实际采用)
[字段 3 长度][字段 2 长度][字段 1 长度] [记录头] [真实数据]
↑ 离基准地址最远 离基准地址最近 ↑
也就是访问最频繁的字段 1
逆序之后,字段 1 的长度信息紧挨着记录头——从基准地址往左跨过 5 字节记录头,立刻就能拿到字段 1 的长度。字段 2 的长度在字段 1 之后(往左再走一点),字段 3 再远一点…
访问频率 = 访问路径长度,这就是逆序存储的本质。
NULL 值列表同理:最高位(或者说最靠左的那一位)对应字段 N,最低位对应字段 1。字段 1 是否为 NULL 的那个 bit,距离基准地址最近。
两个钩子合起来看:
现在我们把两个钩子合在一起,Compact 行格式的完整设计逻辑就出来了:
- 基准地址选在真实数据的起点,因为字段读取是最高频操作
- 元数据区放在基准地址左边,让字段访问路径干干净净不被元数据打扰
- 元数据区内部逆序存储,让字段 1(最高频访问)的元信息离基准地址最近,字段 N(最低频访问)的元信息离基准地址最远
三句话连起来,本质上就是同一个工程哲学的三次体现:
把访问路径的长度,严格按照访问频率来反向分配——越高频的东西,离"出发点"越近;越低频的东西,可以被流放到越远的位置。
这种思路不是 InnoDB 独创的,你以后在各种工业级系统里还会反复遇到。举几个例子:
- CPU 缓存体系 —— L1 离 CPU 最近但最小(存最热的数据), L2 稍远稍大, L3 最远最大
- Linux 进程的 task_struct —— 最高频访问的字段(比如
state、flags)放在结构体的起始偏移位置,编译器和 CPU 可以用最短的偏移量访问
“冷热分离 + 按频率反向排列”,是系统工程师在几十年里不断复用的一套底层直觉。InnoDB 的 Compact 行格式,只不过是这套直觉在"行存储"这个具体场景下的一次应用。
讲清楚这两个钩子之后,后文再去拆解"记录头的 5 字节怎么划分"、“NULL 值列表的位图宽度怎么决定”、"变长字段长度到底占 1 字节还是 2 字节"这些细节,就都有了统一的设计基准——读者不会再把这些细节当作零散的事实来死记,而是会理解它们都是同一个工程哲学的衍生产物。
变长字段列表
接着让我们先从最左边的"变长字段长度列表"开始拆解。
为什么需要这个列表?
对于像 INT、BIGINT 这种定长类型,每个值占用的字节数是固定的(INT 永远 4 字节,BIGINT 永远 8 字节),InnoDB 根本不需要额外记录它们的长度——直接从数据字典里查一下类型,就知道每个字段占多少字节了。
但 VARCHAR、TEXT、BLOB 这类变长类型就不一样了。同样一个 VARCHAR(100) 字段,这条记录里可能只存了 3 个字符,下一条记录里可能存了 50 个字符——每条记录里的实际占用字节数是动态变化的。
如果不把这些"实际长度"记下来,InnoDB 后续解析这条记录时,就不知道该从哪里切分字段边界。所以变长字段长度列表的存在,本质上是为了解决"边界不确定"的问题。
逆序存储的"顺序"到底是什么顺序?
上文讲过,变长字段长度列表采用逆序存储——离基准地址越近的长度信息,对应的字段访问频率越高。
但这里有一个非常容易被忽略的细节:这个"逆序",是相对顺序,不是绝对顺序。
什么意思?假设一张表有 5 个字段:
(id INT, name VARCHAR(50), age INT, email VARCHAR(100), bio TEXT)
其中 id、age 是定长字段,name、email、bio 是变长字段。
那么变长字段长度列表里只包含 3 项(name、email、bio 的长度)——定长字段的长度信息根本不存,因为它们的长度是固定的,查一下表定义就知道了。
这 3 项按逆序排列,从右到左(即从离基准地址最近到最远)依次是:
(离基准地址最远) (离基准地址最近)
[bio 长度] [email 长度] [name 长度] → [记录头] [真实数据]
↑ ↑ ↑
字段 5 字段 4 字段 2
(变长 3) (变长 2) (变长 1)
也就是说,"逆序"指的是变长字段之间的相对顺序,而不是"表中所有字段"的顺序。定长字段被完全跳过了。
那映射关系藏在哪里?
细心的读者到这里一定会问:既然定长字段被跳过了,InnoDB 怎么知道列表里的第 1 项对应表定义里的 name,第 2 项对应 email,第 3 项对应 bio 呢?总得有一张映射表吧?
答案很有意思:InnoDB 根本没有在记录里维护这张映射表。
映射关系存在表结构的元数据(data dictionary)里——也就是 CREATE TABLE 时记录下来的那份表定义。InnoDB 访问记录时,会根据表定义知道:
- 这张表有哪些字段
- 每个字段是定长还是变长
- 变长字段之间的相对顺序是什么
有了这份"全表共享"的元信息,每条记录的变长字段长度列表就可以只存数值,不存字段标识——同一张表的所有记录,都按同样的规则排布,InnoDB 根据表定义就能反推回每个长度值对应哪个字段。
这又是一次典型的**"能不存就不存"的字节压榨**:把所有记录都要用到的共性信息(字段结构)抽离到元数据里,让每条记录只存自己特有的内容(实际长度值)。一张表如果有上千万条记录,这种设计能省下的空间非常可观。
NULL值列表
讲完变长字段长度列表,我们把镜头右移一格,来到 NULL 值列表。
当我们在 CREATE TABLE 时允许某些列为 NULL,并且在 INSERT 时没有给这些列赋值,这些列里就会"存"一个 NULL。但这里必须澄清一个概念上的陷阱:
NULL 不是一个具体的值,而是一种"无值"状态。
它既不是数字 0,也不是空字符串 "",而是一种特殊的"未知"或"缺失"语义。如果 InnoDB 真的把 NULL 当作普通值存储——比如给每个 NULL 字段分配一块区域、填上"NULL"字样——那对存储空间来说是巨大的浪费。毕竟 NULL 本身不携带任何信息,我们只需要知道"这个字段是不是 NULL"就够了。
“是不是"是一个二元问题,最适合用1 个 bit 来表达——0 代表"不是 NULL”,1 代表"是 NULL"。这就是 NULL 值列表(NULL 位图) 的设计起点。
位图到底要包含哪些字段?
这里必须回答一个关键问题:NULL 位图到底要为表里的哪些字段预留 bit 位?
一个朴素的思路是:给每个字段都预留一个 bit。但 InnoDB 没有这么做,它做了一个非常聪明的优化——只有在表定义中被声明为"允许 NULL"的列,才会出现在位图里。
为什么?因为如果一个字段被定义为 NOT NULL,那它永远不可能是 NULL,给它留一个 bit 位毫无意义——这个 bit 永远是 0,存了等于没存。既然如此,干脆就不为它预留位置了。
举个例子:
CREATE TABLE user (
id INT NOT NULL, -- NOT NULL,不进位图
name VARCHAR NOT NULL, -- NOT NULL,不进位图
age INT, -- 允许 NULL,进位图
email VARCHAR, -- 允许 NULL,进位图
bio TEXT -- 允许 NULL,进位图
);
这张表的 NULL 位图里只有 3 个 bit(age、email、bio),而不是 5 个 bit。这又是一次"能不存就不存"的字节压榨,和变长字段长度列表"只存变长字段"的策略一脉相承。
这里要和变长字段长度列表做一个关键对比:
同样是"只包含部分字段",NULL 位图和变长字段长度列表的筛选标准却不一样:
- 变长字段长度列表:只包含变长字段,因为定长字段的长度固定,查表定义就知道,不用存
- NULL 值列表:只包含允许 NULL 的字段,因为
NOT NULL字段永远不会是 NULL,不用存
两者的底层思想是一致的:凡是可以通过查表定义反推出来的信息,就不在记录里重复存储。映射关系全部交给数据字典,记录只存"这条记录独有的"内容。
位图的长度:按字节向上对齐:
还有一个容易被忽略的细节:NULL 位图的长度不是按 bit 数算的,而是按字节向上对齐的。
- 允许 NULL 的列有 1~8 个 → 位图占 1 字节
- 允许 NULL 的列有 9~16 个 → 位图占 2 字节
- 允许 NULL 的列有 17~24 个 → 位图占 3 字节
为什么要字节对齐?因为 CPU 访问内存的最小单位就是字节——哪怕只用 3 个 bit,物理上也必须占用 1 个完整字节,剩下的 5 个 bit 补 0。反正这些空闲 bit 并不会增加额外开销,InnoDB 就干脆让位图保持"整齐的字节对齐"。
位图内部也是逆序存储的:
NULL 位图内部的 bit 排布规则是:
- 位图的最低位(bit 0,最右边那一位) → 对应表定义中最靠前的允许 NULL 列
- 沿低位向高位递增,依次对应后续的允许 NULL 列
- 超出"允许 NULL 列数"的那些高位 bit → 全部补 0,只是字节对齐的填充
还是以上面那张 user 表为例,3 个允许 NULL 的列按表定义顺序依次是 age(第 3 列)、email(第 4 列)、bio(第 5 列)。它们在位图里的 bit 分配是:
bit 7 bit 6 bit 5 bit 4 bit 3 bit 2 bit 1 bit 0
0 0 0 0 0 [bio] [email][age]
└───── 补 0 (5 bit) ─────┘ └── 实际使用 (3 bit) ──┘
字节对齐填充 逆序对应字段
(字节的真正最高位 (实际使用部分的最高位 (字节的最低位
bit 7,不对应字段) bit 2,对应 bio) bit 0,对应 age)
所以这里的"逆序",更严格地说是:表定义顺序在位图里是从低位向高位递增的——表里最靠前的字段,它的 NULL 标志位 bit 序号最小(离最低位最近)。
这里的逻辑和之前一样:越高频访问的字段(也就是表定义中越靠前的字段),它的 NULL 标志位越靠近基准地址,读取时 CPU 的访问路径越短。
完整的字段读取流程:
现在我们把变长字段长度列表和 NULL 位图的知识串起来,梳理一次读取一个字段值的完整流程:
假设 InnoDB 现在要读取某条记录的 email 字段(变长 + 允许 NULL):
- 从基准地址向左跨过 5 字节记录头,进入 NULL 位图
- 在 NULL 位图里找到
email对应的 bit 位(根据表定义里email在"允许 NULL 的列"中的相对位置) - 如果这个 bit 是 1 →
email是 NULL,直接返回"这个字段为空",不需要继续任何操作——真实数据区里根本没有为它分配空间 - 如果这个 bit 是 0 →
email不是 NULL,继续往左跨过 NULL 位图,进入变长字段长度列表 - 在变长字段长度列表里找到
email的实际长度(根据表定义里email在"变长列"中的相对位置) - 回到基准地址,向右跨到
email字段的起始位置,按照上一步得到的长度,读取这么多字节
整个流程里,定长字段、NOT NULL 字段、变长字段、NULL 字段各自的处理路径都不一样,但每一种路径都被精心优化到了最短。
记录头
讲完了变长字段长度列表和 NULL 值列表这两位"左翼守卫",我们终于来到了元数据区的最后一块——紧贴基准地址左边的 5 字节记录头(Record Header)。
这 5 字节 = 40 个 bit,被 InnoDB 精确地切割成了 7 个字段。没有一个 bit 是多余的,每一个字段都对接着我们前文已经解剖过的某个核心子系统。
先给出总览:
高位 ←————————————————————————————————————————————————→ 低位
[未使用 2bit] [deleted_flag 1bit] [min_rec_flag 1bit] [n_owned 4bit] [heap_no 13bit] [record_type 3bit] [next_record 16bit]
下面逐个拆开。
① 未使用(2 bit)
这 2 个 bit 是 InnoDB 预留的扩展位,目前固定为 0,没有任何功能。留着是为了将来如果需要增加新的标志位,可以直接启用,不用改动记录头的整体大小——这是工程设计中非常常见的"预留空间"思路。
② deleted_flag(1 bit)—— 对接"双轨制空间分配"
这位老朋友在前文"双轨制空间分配"那一节已经详细登场过了:
- 设为 1 表示"这条记录已被逻辑删除"
- 记录从业务链表中摘除,通过
next_record串入PAGE_FREE空闲链表 - 物理空间不会立刻释放,等待后续
INSERT复用或 Compaction 整理
这里不再展开。
③ min_rec_flag(1 bit)—— 对接"B+ 树非叶子节点"
这个标志位只在 B+ 树的非叶子节点(内部节点)上才有意义,对叶子节点的记录来说永远是 0。
它标记的是:这条记录是否是当前非叶子节点中键值最小的那条目录项记录。
为什么需要这个标记?回忆一下 B+ 树的结构——当 B+ 树发生节点分裂或合并时,InnoDB 需要快速定位到某个非叶子节点里"最小的那条目录项",以便更新上层节点的索引指针。有了这个 1 bit 的标记,就不用遍历整个节点去找最小值了,直接找 min_rec_flag = 1 的那条记录即可——一次 bit 检查代替一次遍历。
对我们当前讨论的叶子页记录来说,这个 bit 永远是 0,可以先跳过。等后面讲 B+ 树的分裂与合并时再回来。
④ n_owned(4 bit)—— 对接"Page Directory 分组机制"
这个字段和我们前文讲的 Page Directory 分组机制直接相关。
还记得吗?InnoDB 会把页内的记录分成若干组,每组 4~8 条记录,每组的最后一条记录(也就是键值最大的那条)被选为"组长"。Page Directory 的槽位指向的就是组长的基准地址。
那么问题来了:InnoDB 在维护分组时,需要知道"这一组里到底有几条记录"——这个数字就存在 n_owned 里。
规则很简单:
- 组长记录的
n_owned= 这一组里的记录总数(包括组长自己),取值范围 1~8 - 非组长记录的
n_owned= 0
举个具体例子:假设 Slot 3 管辖的分组里有 5 条记录(键值分别是 33、35、37、39、40),那么:
- 记录 33、35、37、39 的
n_owned= 0(不是组长) - 记录 40 的
n_owned= 5(组长,管辖 5 条记录)
为什么是 4 bit?因为 4 bit 能表示 0~15,而 InnoDB 规定的分组上限是 8 条,4 bit 绰绰有余。
这个字段的存在价值:当 INSERT 导致某个分组超过了 8 条记录需要拆分时,InnoDB 只需要读一下组长的 n_owned,就能瞬间知道是否需要拆分——不用遍历组内链表去一条一条地数。
⑤ heap_no(13 bit)—— 对接"页内物理分配追踪"
这是整个记录头里最容易被忽略的字段,但它的设计非常有意思。
heap_no 记录的是:这条记录在页内的物理分配顺序编号。
注意这里说的是"物理分配顺序",不是"键值排列顺序"。也就是说:
- 第一条被写入这个页的记录,
heap_no= 2 - 第二条被写入的记录,
heap_no= 3 - 第三条被写入的记录,
heap_no= 4 - …以此类推,单调递增,永不回退
为什么从 2 开始,而不是从 0 开始? 因为 0 和 1 被两位门神预定了:
heap_no= 0 → Infimumheap_no= 1 → Supremum
它们在页初始化时就被写入了,永远占据 0 号和 1 号。用户真实记录的编号从 2 开始。
为什么需要 heap_no? 它的用途主要有两个:
用途一:页内物理分配追踪
heap_no 配合 Page Header 里的 PAGE_N_HEAP 字段,让 InnoDB 能追踪这个页到目前为止一共分配过多少条记录(不管有没有被删除)。PAGE_N_HEAP 的值就是"当前页内 heap_no 的最大值 + 1"——也就是"下一条新记录该分配多少号"。当 INSERT 进来时,系统读一下 PAGE_N_HEAP,立刻就知道新记录的 heap_no 应该是多少,严格 O(1)。
用途二:行锁的记录标识(后文展开)
这个用途现在先记住结论就行:后面学到事务和锁的时候你会发现,InnoDB 的行锁不是直接锁"键值",而是锁"某个页的某个 heap_no"。为什么?因为键值可能很长(比如一个 VARCHAR(255) 的联合主键),用它做锁的标识太浪费内存;而 heap_no 只有 13 bit,用它来标识"页内的哪一条记录"既紧凑又唯一。
为什么是 13 bit? 因为 13 bit 能表示 0~8191。一个 16KB 的页里,一条记录最少也要占几十字节(记录头 + 元数据 + 至少一个字段),所以一个页内不可能超过几千条记录,8191 的上限绰绰有余。
⑥ record_type(3 bit)—— 对接"Infimum / Supremum 身份判定"
这 3 bit 标识了这条记录的身份类型,一共有 4 种取值:
- 0 = 普通记录(用户真实数据,存在于叶子页的 User Records)
- 1 = B+ 树非叶子节点的目录项记录(存在于内部节点)
- 2 = Infimum(每个页固定一条)
- 3 = Supremum(每个页固定一条)
这就是 InnoDB 区分"门神"和"凡人"的依据。前面我们讲过 Infimum 和 Supremum 内部存的是 "infimum\0" / "supremum" 硬编码字符串,但 InnoDB 在代码层判断"这条记录是不是 Infimum"时,根本不会去比较字符串内容,而是直接读这 3 bit 的 record_type——一次 bit 运算就完事,不用做字符串比较。这又是一次"能不做就不做"的效率优化。
⑦ next_record(16 bit = 2 字节)—— 对接"页内单向链表"
从 Infimum/Supremum 到 Page Directory 到基准地址,这位老朋友已经在前文反复登场过了。这里只做一个最精炼的技术总结:
- 它是有符号的 2 字节相对偏移量(可正可负)
- 寻址公式:当前记录的基准地址 +
next_record= 下一条记录的基准地址 - 它的落脚点就是下一条记录的真实数据区起点(基准地址)
回头看这 5 字节记录头的 7 个字段,你会发现一个非常清晰的规律——没有一个字段是孤立存在的,它们各自对接着 InnoDB 页内的某个核心机制:
deleted_flag→ 双轨制空间分配(PAGE_FREE链表)min_rec_flag→ B+ 树的分裂与合并n_owned→ Page Directory 分组计数heap_no→ 页内物理分配追踪 + 行锁标识record_type→ Infimum / Supremum 身份判定next_record→ 页内单向链表
5 字节 = 40 bit,不多也不少。不是因为设计者懒得多加,而是经过精密计算后,这些 bit 刚好覆盖了所有必须在记录级别追踪的状态。开头那 2 bit 的预留位,也说明设计者已经预见到了未来可能的扩展需求。
如果你觉得这 40 bit 的分工很像什么——没错,它很像 Linux 里的 task_struct 把进程的所有核心状态字段打包在一个结构体里,或者像 TCP 报文头把序列号、确认号、标志位全部塞进固定的 20 字节头部。工业级系统在设计核心数据结构时,永远都在追求同一件事:用最少的字节,承载最多的状态,对接最多的子系统。
至此,Compact 行格式的整个元数据区就拆解完了:
- 变长字段长度列表 —— 解决"变长字段边界不确定"的问题
- NULL 值列表 —— 用 1 bit 标记"有没有值",省掉存储"空值"的浪费
- 5 字节记录头 —— 用 40 bit 对接 6 个核心子系统
三者加起来,就是 InnoDB 为每一条记录配备的"管理信息"——正是有了这些元数据,一条记录才不仅仅是一堆字段值的堆砌,而是一个能够被插入、删除、查找、锁定、回收、重组的活的存储单元。
InnoDB 的内部页结构与辅助索引批量构建机制
在彻底解剖了叶子页(Leaf Page)中单条记录的微观构造后,我们需要将视线再次拉高,去看看整棵 B+ 树的"导航中枢"——内部节点(非叶子节点)。
正如前文推演的那样,对于 InnoDB 存储引擎而言,无论是存放底层数据的叶子节点,还是用于上层路由的内部节点,它们在物理载体上完全一致——都是大小为 16KB 的数据页(Index Page)。它们拥有相同的宏观七大分区:通用的文件头(File Header)、管理本页的页头(Page Header)、承载数据的用户记录区(User Records),以及用于二分查找的页目录(Page Directory)等。
真正的差异,隐藏在 User Records 区域里那一条条微观的数据块之中。
对于叶子节点,记录的使命是"承载业务",所以里面塞满了完整的行数据(如 name、age 等)。但内部节点的唯一使命是"路标导航"。如果把几百字节的冗余业务数据也塞进内部节点,那么一个 16KB 的页根本装不下几个路标,这会导致 B+ 树的层级急剧"长高",进而引发严重的磁盘 I/O 性能问题。
因此,在内部节点的 User Records 中,每一条记录都被执行了"瘦身"与"改造":
1. 业务数据被剥离
为了压榨空间,除了用于二分查找比对的索引键值之外,其他所有的普通业务列都被剔除——叶子节点里的 name、age 等字段在这里完全消失。
2. 植入子页号
在原本存放业务数据的位置,被植入了一个 4 字节的子页号(Page Number)——这个页号精准地指向下一层子页的物理位置。
注意,目录项记录的整体大小并不固定——它是 [索引键值 + 4 字节子页号],键值的字节数取决于索引列的数据类型:
INT主键 → 键值 4 字节,目录项约 9 字节BIGINT主键 → 键值 8 字节,目录项约 13 字节- 长字符串主键(如
VARCHAR(64))→ 键值可能几十字节,目录项膨胀严重
这也解释了为什么过长的主键会显著拖累 B+ 树性能——目录项变胖 → 单页扇出降低 → 树高增加 → 查找需要的 I/O 次数变多。后文讲"主键设计建议"时我们会再回到这一点。
3. 身份标识:record_type = 1
还记得我们在"5 字节记录头"那一节解剖过的 record_type(记录类型)字段吗?它在不同位置上的取值是严格对应的:
record_type = 0→ 普通叶子节点用户记录(完整业务数据)record_type = 1→ 内部节点的目录项记录([键值 + 子页号])record_type = 2→ Infimumrecord_type = 3→ Supremum
这个 3 bit 的标志位设计非常有意思:它让 InnoDB 在读到一条记录时,只要看一眼记录头的这 3 个 bit,就能瞬间判断"我现在是在叶子节点还是内部节点"——既不需要查表元数据,也不需要看记录的内容,这就是"靠单字段做身份判定"的极致简洁。
也就是说,内部节点页里的每一条真实记录,剥去记录头后,本质上就是一个精简的 [索引键值 + 子页号] 二元组。正是成千上万个这样被压缩的路标,在 16KB 的空间内组成了庞大的路由表,支撑起了 B+ 树高效的多层导航。

至此,我们看清了内部节点在空间维度上的极致优化——通过剥离业务数据、压缩为 [键值 + 子页号] 的二元组,让一个 16KB 页能容纳成千上万个路标。
但 InnoDB 的工程智慧不止于此。还有另一个维度的省成本,体现在时间维度上——也就是当我们在一个已经塞满数据的大表上新建索引时,InnoDB 是如何快速地把这棵新树搭建出来的?这就是接下来要讨论的 快速索引创建(Fast Index Creation)。
在明确了辅助索引"叶子节点只存 [索引键值 + 主键值],查询需回表"的静态机制后,我们必然会面临一个现实的动态痛点:
如果在建表之初表是空的,那么辅助 B+ 树自然跟着主键 B+ 树一起,随着数据的插入一行一行地同步生长。但是,如果在业务跑了三年、表里已经有了 1000 万条存量数据时,我们突然执行 CREATE INDEX 呢?
此时,InnoDB 必须根据这 1000 万条现有记录,从头构建出一棵完整的辅助 B+ 树。它是怎么做的?
如果按照常规逻辑,系统可能会去遍历这 1000 万条数据,然后模拟 INSERT 操作,把提取出的 [索引键值 + 主键值] 一条一条地插入到新的辅助 B+ 树中。但在底层内核开发者眼中,这种做法的代价非常高:单条插入意味着新树需要从上到下不断寻址,伴随着频繁的页分裂和数据移动,I/O 性能会瞬间崩溃。
为了解决这个问题,现代 InnoDB 引入了 Fast Index Creation(FIC,快速索引创建) 与 Bulk Load(批量加载) 机制。它的真实构建轨迹,是一场"自底向上"的批处理:
第一步:全表扫描(基于聚簇索引)
系统首先会从头到尾扫描那棵庞大的聚簇索引(主树)。
注意,这个扫描过程并不需要锁住整张表——这就是 MySQL 5.6 之后引入的 Online DDL 机制的核心:扫描期间用户的并发 INSERT / UPDATE / DELETE 仍然可以正常执行,产生的修改会被记录到一个叫做 Row Log 的临时缓冲区里,等索引构建完成后,再批量"补放"到新索引上。
在扫描过程中,系统会把每一行真实业务数据中对应的辅助索引列提取出来,并带上它的主键值——这就相当于从大树上摘下成千上万个 [索引键值 + 主键] 的二元组。
第二步:全局排序(Filesort)
摘下来的二元组是乱序的。系统会利用内存的 Sort Buffer(如果内存不够就利用磁盘临时文件),将这 1000 万个二元组按照辅助索引的键值大小进行全局排序。
如果键值相同(比如多人同名),则用主键作为第二排序键。这就是为什么辅助索引的叶子节点天然按 [索引键值, 主键] 的复合顺序排列
第三步:自底向上批量构建(Bottom-Up Build)
这是最颠覆常规认知的一步。有了排序好的海量二元组,InnoDB 根本不需要从根节点向下寻址插入。它直接采取底层平铺的批处理策略:
- 系统申请一个 16KB 的空白叶子页
- 因为数据已经排好序了,它就像往盒子里码放砖头一样,按顺序把二元组塞进叶子页——塞满一页就紧接着申请下一页
- 当底层的叶子页全部"平铺"完毕后,系统再提取每个叶子页的最大键值 + 该页页号,组成
[键值 + 页号]的目录项记录,塞进上一层的内部节点中 - 一层一层往上收拢,直到最后生成唯一的根节点
这种"先排好序,再自底向上批量构建"的做法,避免了建索引过程中的页分裂开销,速度快、且生成的 B+ 树页空间利用率高,几乎没有内部碎片。
页分裂的工业级优化:InnoDB 如何避免“五五开”陷阱
之前我们已经彻底打通了索引寻址与页内空间分配的全流程:从根节点逐层向下寻址,利用槽位二分查找锁定逻辑位置,并在 User Records 中通过"双轨制"开辟物理数据块,最终将其挂载到页内单向链表上。
但这一切静态的完美,都建立在一个前提之下:这个 16KB 的数据页还有空闲空间。
随着业务数据的不断涌入,当 InnoDB 检查页头信息,发现剩余空间不足以容纳哪怕一条新记录时,B+ 树就迎来了它生命周期中最剧烈的动态重构——页分裂(Page Split)。系统必须向磁盘申请一个全新的 16KB 兄弟页,并将原页中的部分数据搬迁过去,最后提炼出新的索引键上报给父节点(目录页)。
教科书的陷阱:无脑"五五开"的代价
如果你曾手写过 B+ 树,或者看过基础的数据结构教材,你一定对页分裂的规则熟记于心:从中间键值一刀切,一半数据留在老页,一半数据去新页——也就是经典的"五五开"。
在完全随机插入的场景下,这种 50/50 的分裂策略是合理的,它为左右两个页都预留了未来的缓冲空间。但在真实业务里,这种策略往往会引发一场隐秘的性能雪崩。
为什么?因为真实业务中最常见的插入模式是:基于主键递增(AUTO_INCREMENT)的顺序插入。
想象一下这个场景:
- 当前页(页 A)已经写满了
[1, 2, 3, 4, 5, 6] - 此时要插入记录
7,触发"五五开"分裂 - 页 A 保留前一半
[1, 2, 3];新分配的页 B 分到后一半和新数据[4, 5, 6, 7]
看出端倪了吗?因为业务是顺序递增的,未来新插入的 8, 9, 10... 将全部追加到新页 B 乃至未来的页 C、页 D 中。而那个被劈走一半数据的老页 A,再也不会有新数据插入了。
这就导致了一个可怕的后果:整棵 B+ 树的所有叶子页,永远只维持在 50% 的空间利用率(Fill Factor)。
空间利用率低下,不仅仅是"浪费了一半磁盘"那么简单,它直接对数据库的核心能力——**范围查询(Range Query)**造成了致命打击。
举个具体的例子:假设 id 从 100 到 200 这 101 条记录,原本紧凑存储时刚好能塞进 1 个 16KB 页。当你执行 SELECT * FROM table WHERE id BETWEEN 100 AND 200 时,理想情况下底层只需加载 1 个 16KB 页就能拿到所有数据。但如果整棵树都处于 50% 的稀疏状态,这些记录就会被迫散落在 2 个页中——为了获取同样的数据量,底层必须多产生一倍的磁盘 I/O 开销。在高并发场景下,这种 I/O 放大会直接压垮数据库。
启发式分裂:观察插入方向,做出针对性决策
为了封杀这种因为顺序插入导致的"半空页"现象,InnoDB 在底层实现了一套启发式分裂算法(Heuristic Split)。
这里需要先澄清一个容易形成的误解——启发式分裂本身就是 InnoDB 的默认策略,并不是说"InnoDB 默认走五五开,特殊场景才切换到启发式"。准确地说,根据观察到的插入方向不同,启发式算法会做出三种决策:
- 插入方向不明确(无规律的随机插入)→ 走中间分裂(这才是教科书里的"五五开")
- 插入方向持续向右(顺序递增)→ 走右分裂
- 插入方向持续向左(顺序递减)→ 走左分裂
也就是说,"五五开"只是启发式算法在"无法判断方向"时的退化形态,而不是默认行为。
那么,InnoDB 是怎么"观察"插入方向的?
在我们解剖 16KB 结构时提到的 **Page Header(页头)**里,藏着三个专门用于追踪插入行为的字段:
PAGE_LAST_INSERT:记录最后一次插入记录在页内的字节偏移量——也就是这条记录的物理位置PAGE_DIRECTION:记录插入方向(向右、向左、或无方向)PAGE_N_DIRECTION:记录沿着当前方向连续插入的次数
简短说明:所谓"向右"和"向左",是指当前插入位置相对于上次插入位置的方向——按键值排序后,新键值更大就是向右,更小就是向左。
当一条新记录到来时,InnoDB 会用它的位置和 PAGE_LAST_INSERT 做对比,更新 PAGE_DIRECTION 和 PAGE_N_DIRECTION。判定阈值非常低——只要 PAGE_N_DIRECTION >= 5,InnoDB 就会果断认定"这是顺序插入场景",启动对应方向的分裂策略。
这种"阈值低、决策快"的设计本身就是工业级系统的一个特点——宁可早误判一两次,也要避免错过优化机会。
右分裂:顺序递增场景的优化
当系统检测到 PAGE_DIRECTION = 向右 且 PAGE_N_DIRECTION >= 5 时,会触发右分裂(也叫插入点分裂):
- 老页 A 不搬迁任何数据:所有原有记录原封不动留在老页,填充率接近 100%
- 新申请的页 B 从"导致页满的那条新记录"开始接收数据——这条记录成为页 B 的第一条记录,后续连续插入的
8, 9, 10...全部流向页 B
这种策略的本质,是把"分裂点"从"页中间"移到了"插入点"——也就是从 50/50 切换到几乎 100/0。
这样一来,未来的顺序插入会平滑地流入新页 B,而历史产生的所有老页,其空间利用率都接近完美的 100%。
左分裂:顺序递减场景的对称优化
了解了顺序递增的右分裂之后,很多读者可能会反问:既然有向右,那如果业务场景是按键值持续递减地插入呢?
比如,业务在跑批导入一批历史归档数据,按时间戳倒序插入,主键依次为:100, 99, 98, 97...
InnoDB 的应对策略是完全对称的——左分裂(Left Split):
假设当前页 A 已经写满了 [97, 98, 99, 100],下一条即将插入的新记录是 96。
- 老页 A 完整保留所有较大的键值
[97, 98, 99, 100],填充率接近 100% - 新申请的页 B 在双向链表上挂到老页 A 的左侧(即页 B 成为页 A 的前驱节点)
- 导致页 A 爆满的那条最小的新记录
96,被放入新页 B 中作为第一条记录
未来继续倒序插入的 95, 94, 93... 将全部流向左侧的新页 B。等到页 B 也被塞满,再向左开辟页 C,依此类推。
对比右分裂与左分裂,我们会发现 InnoDB 这套启发式分裂算法背后,藏着一个统一的设计哲学:
尽最大可能保护"历史老页"的紧凑性,将所有的物理空白留给代表着未来的"活动新页"
无论是向左还是向右,InnoDB 都在极力避免因为页分裂而产生的"半空页"。这种对 16KB 空间的极致压榨,就是支撑数据库高并发范围查询的真正底气——这就是工程上常说的"I/O 抠门"。
一个由此而来的调优经验:为什么强烈推荐自增主键?
到这里,我们其实已经能从底层物理机制出发,回答 MySQL 圈子里一个反复被讨论的经典问题:为什么 MySQL 强烈推荐使用自增 ID 作为主键,而不是 UUID 这种随机字符串?
把启发式分裂的逻辑套进去就一目了然:
| 主键类型 | 插入模式 | 触发的分裂策略 | 填充率 | 碎片情况 |
|---|---|---|---|---|
| 自增 INT/BIGINT | 始终顺序递增 | 右分裂 | 接近 100% | 几乎无碎片 |
| 随机 UUID | 插入位置完全随机 | 中间分裂(五五开) | 长期维持 50% | 大量碎片 |
也就是说,自增主键不仅仅是"数字比字符串短"那么简单——它还能完美对齐 InnoDB 的右分裂优化路径,让所有历史页都保持接近 100% 的填充率。这是一条调优经验在底层物理机制上的具体落地。
亲手感受三种分裂策略
文字讲再多,不如自己动手玩一玩。我做了一个可交互的页分裂策略模拟器——你可以分别尝试顺序递增、顺序递减、随机插入三种模式,亲眼看到 InnoDB 是如何在不同场景下切换分裂策略的:
输入 1, 2, 3, 4, 5, 6, 7... 看右分裂的"100/0"是怎么发生的;输入 100, 99, 98, 97... 看左分裂的对称演化;最后输入一组完全随机的数字,看 InnoDB 在"无方向"时退回到五五开的过程。三种模式对比着玩一遍,启发式分裂就再也忘不掉了。
从 Buffer Pool 到 Change Buffer:InnoDB 如何缓存数据页并化解随机 I/O
至此,我们对 .ibd 文件的底层物理存储细节已经建立起了完整的认知。但无论寻址多么精准,CPU 最终只能操作内存中的数据——它没法直接读磁盘上的 .ibd 文件。所以在真正执行任何 CRUD 操作之前,目标页必须先从磁盘搬到内存中。
这就引出了一个核心问题:搬到内存的哪里?怎么管理这些被搬进来的页?
一个朴素的思路是:向操作系统要一大块内存当"中转站",用哪个页就直接拷进来,挨个往后追加。但如果真这么做,灾难立刻就会出现——假设下一次要访问的目标页已经被加载到这个中转站里了,系统该怎么找到它?如果不对这块缓存加以严密的数据结构管理,系统就只能从头到尾进行线性遍历。对于动辄几 GB 甚至几十 GB 的内存缓存来说,O(N) 的遍历代价是数据库完全无法接受的。
为了解决缓存页的管理与极速寻址问题,InnoDB 在启动时会向操作系统申请一大片连续的内存,这片内存就是数据库的核心缓存层——Buffer Pool(缓冲池)。
Buffer Pool 的底层数据结构:哈希表 + LRU 链表
统治这片缓冲池的底层秩序,是 LRU(Least Recently Used,最近最少使用)缓存淘汰算法。
一个工业级的 LRU 缓存由两个核心组件构成:哈希表(Hash Map)+ 双向链表(Doubly Linked List)。
在 InnoDB 的场景下,这套机制被赋予了具体的业务含义:
哈希表: O ( 1 ) O(1) O(1) 寻址:
哈希表的 Key 是一个精准的物理坐标二元组——[表空间号 (Space ID) + 页号 (Page Number)]:
Space ID:代表这是哪一张表(或者哪一个表空间)Page Number:就是我们非常熟悉的、B+ 树中的页号
哈希表的 Value,指向的是该页在 Buffer Pool 中对应的控制块(buf_block_t)——控制块里包含了指向实际 16KB 数据帧的指针、该页在 LRU 链表中的位置、以及脏页状态等管理信息。
LRU 双向链表:冷热排序
链表负责维护页的"热度"。越靠近链表头部(Head)的节点,代表数据越"热"(被高频访问);越靠近尾部(Tail)的节点,则是长期未被访问的冷数据,随时可能被淘汰。
访问一个页的完整流程
当 CPU 要访问目标页时,完整的底层逻辑如下:
系统拿着 [Space ID + 页号] 去哈希表中探测。
如果命中(Cache Hit):以 O ( 1 ) O(1) O(1) 的时间复杂度拿到控制块指针,直接读取数据帧——不需要任何磁盘 I/O。同时,该页对应的 LRU 链表节点会被移到更靠近头部的位置,延长它在缓存中的驻留时间。
如果未命中(Cache Miss):触发一次磁盘 I/O,将 16KB 的页从 .ibd 文件加载到 Buffer Pool 的空闲帧中。随后为其创建控制块,将其插入 LRU 链表(具体插入位置下文会讲),并在哈希表中注册 [Space ID + 页号] → 控制块的映射。如果缓冲池已满,则淘汰链表尾部的最冷节点,腾出空间。
补充:这里我们只讲了"读路径"——数据从磁盘加载到 Buffer Pool 的过程。但当
UPDATE或DELETE修改了缓存中的页之后,这个页就变成了脏页(Dirty Page)——它和磁盘上的原始版本不再一致。脏页如何被安全地刷回磁盘、以及刷盘与 Redo Log 之间的协作关系,我们会在后文讲事务持久性时专门展开。
标准 LRU 的致命缺陷:缓存污染
如果 InnoDB 采用的是标准的 LRU 算法——新页插入链表头部、尾部淘汰最老的页——在稳态下工作得很好。但有一种场景会把它彻底击穿:全表扫描导致的缓存污染(Cache Pollution)。
假设你的 Buffer Pool 只有 10GB,经过一周的稳定运行,里面已经积累了 B+ 树上层目录页、高频访问的用户状态等核心热数据。此时,一个定时跑批任务突然执行了一条全表扫描:
SELECT * FROM history_log_table;
这张表有 50GB,平时根本无人问津。
如果是标准 LRU,这 50GB 的冷门数据页会从磁盘大量涌入 Buffer Pool,全部插入链表头部。而那 10GB 积攒下来的核心热数据,会在短时间内被挤出链表尾部、淘汰回磁盘。当下一秒真正的核心业务请求到来时,缓存命中率暴跌,海量的磁盘 I/O 请求会直接把数据库压垮。
这就是经典的**“劣币驱逐良币”**——一次性使用的冷数据,反而把长期积累的热数据赶走了。
为了封杀缓存污染问题,InnoDB 对标准 LRU 做了两个关键改造:
改造一:冷热分区(Midpoint Insertion)
InnoDB 在 LRU 链表距离尾部约 3/8 的位置划了一条分界线——Midpoint。这条线将整个链表分成两个区域:
- Young 区(热数据区):占据头部约 5/8 的空间,这里驻扎的是经过时间检验、被真正高频访问的核心热数据
- Old 区(冷数据区):占据尾部约 3/8 的空间,这里是所有初来乍到者和即将被淘汰的数据的观察区
防污染规则一:新页一律插入 Old 区头部(Midpoint 处),而不是链表头部。
当全表扫描的数据页大量涌入时,它们全部被按在 Old 区里——无论扫描的数据量有多大,这些一次性使用的冷数据最多只能在尾部这 3/8 的 Old 区内互相淘汰。而头部 5/8 的 Young 区里的核心热数据完全不受影响。
改造二:时间窗口(innodb_old_blocks_time)
但仅仅有冷热分区还不够——细心的读者会发现一个漏洞:
全表扫描时,引擎为了读取一个数据页里的成百上千条记录,CPU 会在极短的时间内(比如几毫秒内)连续访问该页多次。按照 LRU 的标准规则,“被访问就应该提升到更热的位置”。那这些伪热点岂不是瞬间就能冲进 Young 区?
为了焊死这个漏洞,InnoDB 引入了参数 innodb_old_blocks_time(默认 1000 毫秒 / 1 秒)——这是专门为 Old 区数据设立的考察期。
防污染规则二:Old 区的页在进入后 1 秒之内,无论被访问多少次,都不予晋升。
具体规则:
- 当一个页刚进入 Old 区,1 秒之内被 CPU 连续访问时,InnoDB 会判定:这是全表扫描引发的"伪热点"——不予晋升,位置不动
- 只有当这个页在 Old 区待满 1 秒之后,如果它依然被业务系统再次访问,InnoDB 才认可它的价值,将它提升到 Young 区的头部
这样一来,全表扫描的数据页在几毫秒内就被扫完了,远远不到 1 秒的考察期——它们永远无法通过考核,最终只会在 Old 区里被后续到来的新页挤掉。
一个补充细节:Young 区内部也有优化
在 Young 区内部,InnoDB 同样做了一个性能优化:只有当页的位置落在 Young 区的后 1/4 时,再次访问才会把它移到头部——如果它已经在前 3/4 了,就不动。
为什么?如果 Young 区里有上万个页,每次访问都要做"摘节点 + 插头部"的链表操作,高并发下链表锁的竞争开销会很大。通过"前 3/4 不动"这条规则,InnoDB 大幅减少了 LRU 链表的维护频率,而对热度排序的准确性影响微乎其微——因为已经在前 3/4 的页本来就够热了。
把两条规则合起来看,InnoDB 对缓存污染的防御是双保险:
| 防御层级 | 机制 | 效果 |
|---|---|---|
| 第一道防线 | 新页插入 Old 区头部,不进 Young 区 | 冷数据最多只能在 3/8 的 Old 区内内卷,Young 区核心热数据不受影响 |
| 第二道防线 | Old 区页 1 秒内不予晋升 | 全表扫描的"伪热点"因为几毫秒就扫完,永远无法通过考察期 |
两道防线叠加后,只有真正被业务长期稳定访问的数据才能最终晋升到 Young 区——一次性使用的全表扫描数据,无论量有多大、单页内访问频率有多高,都会被精准地拦在 Old 区里自生自灭。
亲手感受冷热隔离:
文字讲再多,不如自己动手玩一玩。我做了一个可交互的 Buffer Pool LRU 模拟器——你可以模拟"正常业务访问"和"突发全表扫描"两种场景,亲眼看到 Young 区的热数据在全表扫描的冲击下纹丝不动:
👉 InnoDB 改良版冷热隔离 LRU 模拟器(带计时器)
试试先点击"正常访问"积累几十个热页,然后突然点击"全表扫描"灌入大量冷页——观察 Young 区是否被冲垮。再试试把 innodb_old_blocks_time 调成 0(相当于关掉第二道防线),同样的全表扫描打过来,看看 Young 区会发生什么。两次对比,冷热隔离的价值就一目了然了。
二级索引的性能陷阱:随机 I/O
在理清了 Buffer Pool 的冷热隔离之后,我们来面对一个真实存在的性能痛点。
假设你执行一条 INSERT 或 UPDATE,这条记录除了要写入主键所在的聚簇索引树,往往还要同步更新多个二级索引。问题在于:主键通常是递增的,对应的页要么已经在 Buffer Pool 里,要么是顺序 I/O;但二级索引的键值往往是分散的(比如邮箱、姓名、UUID),这些二级索引对应的叶子页,大概率不在 Buffer Pool 里,而是分布在磁盘的各个位置。
如果每修改一个二级索引,InnoDB 都必须立刻把目标页从磁盘读进内存、改完再写回去,那一次简单的插入就会引发大量的随机 I/O,数据库性能会急剧下降。
为了破解这个问题,InnoDB 引入了 Change Buffer(写缓冲)。
它的核心思想是:如果目标页不在内存里,那就不急着把它读进来。先把"我要怎么修改它"这笔账,暂时记在 Change Buffer 这个账本上。等未来某一天,这个目标页因为其他操作被真正读进内存时,再把积攒的账一次性"合并(Merge)"进去。
这样一来,对无数个分散二级索引的随机修改,被转化为了对 Change Buffer 的顺序追加写入——随机 I/O 变成了顺序 I/O,性能提升巨大。
Change Buffer 并不是万能的,InnoDB 为它设定了严格的触发条件:
- 只针对非唯一的二级索引——聚簇索引和带
UNIQUE约束的二级索引都被排除(原因下面展开) - 只在目标页不在 Buffer Pool 时触发——如果页已经在内存中,直接改内存页即可(变成脏页),走 Change Buffer 完全多余
- 目标页必须有足够的空闲空间——通过 Page 1 位图确认,避免合并时触发页分裂
另外需要补充的是,Change Buffer 缓冲的不只是 INSERT——它同样覆盖 DELETE-marking(逻辑删除的标记阶段)、PURGE(物理清除已标记删除的索引条目)和 UPDATE(对二级索引的更新本质上是"删旧 + 插新")。由参数 innodb_change_buffering(默认 all)控制具体缓冲哪些操作类型。
为什么聚簇索引和 UNIQUE 二级索引被排除?
你可能会问:既然 Change Buffer 这么好用,为什么它不能用在聚簇索引上?UNIQUE 二级索引又为什么被排除?
答案其实藏在同一条推理链里。
Change Buffer 的核心使命是什么?是**“坚决不把目标页读进内存”,直接把修改挂账。但聚簇索引的基石是主键,而主键天生自带绝对唯一且非空**的约束。
当你执行一条 INSERT 时,InnoDB 必须保证:你插入的这个主键 ID 在整张表里绝无仅有。怎么保证?它必须触发一次磁盘 I/O,把这个主键本该落入的那个 16KB 数据页从磁盘拉进 Buffer Pool,让 CPU 遍历比对,确认没有重复 ID 后,才允许插入。
矛盾就在这里:为了校验唯一性,目标页已经被读进内存了。既然页都已经在 Buffer Pool 里了,CPU 直接修改这个内存页只需要零点几微秒——这个时候再绕一圈走 Change Buffer 挂账,完全多余。
底层逻辑:带唯一性约束 → 必须读盘校验 → 页必定进入内存 → Change Buffer 失去存在意义
这条推理链对聚簇索引和 UNIQUE 二级索引完全通用——只要索引带唯一约束,Change Buffer 就无法介入。这也是为什么 Change Buffer 的触发边界被限定为**“非唯一的二级索引”**。
物理归宿:这本"账"到底存在哪?
这是一个容易产生误区的地方。很多人以为 Change Buffer 的挂账修改直接存在当前表的 .ibd 文件里。
但如果为了缓冲还要去写各自的 .ibd 文件,那又变成了随机 I/O——这就和 Change Buffer 的初衷完全矛盾了。
真实的存储位置是:
- 在内存中:Change Buffer 占据了 Buffer Pool 的一部分空间(由参数
innodb_change_buffer_max_size控制上限,默认占 Buffer Pool 的 25%) - 在磁盘上:Change Buffer 本身是一棵 B+ 树,被统一存放在**全局的系统表空间(System Tablespace,通常是 ibdata1)**中
这样一来,对无数个分散二级索引的随机修改,被转化为对系统表空间中 Change Buffer B+ 树的顺序追加写入。
当然,Change Buffer 本身的写入也会产生 Redo Log——这是保证断电后挂账数据不丢失的关键。Redo Log 的机制我们后续的文章会专门展开。
现在来解决一个关键问题:Change Buffer 在挂账之前,必须确认目标页还有没有足够的空闲空间——如果页本来就快满了,强行挂账会导致未来合并时触发页分裂,那就失去了延迟更新省 I/O 的意义。
可是目标页在磁盘上啊!不读进内存,怎么知道它满没满?
这时候,我们之前提到过的 Page 1(IBUF_BITMAP 页) 就派上用场了。
Page 1 是 .ibd 文件中的第一个 IBUF_BITMAP 页。它不存具体业务数据,而是专门用来存放一定范围内所有数据页的"状态摘要"。随着文件增长,后续还会有更多的 IBUF_BITMAP 页出现在文件的其他位置,各自追踪各自范围内的页状态。
在 IBUF_BITMAP 页中,InnoDB 对每一个被监控的数据页只分配了 4 个 bit:
- 前 2 个 bit(空间评估):记录该页粗粒度的空闲空间,分为大致 4 个档位(如 0 / 512 / 1024 / 2048 字节)。不精确到字节,但足够判断"大概够不够合并"
- 第 3 个 bit(缓冲标记):如果为
1,说明该页在 Change Buffer 中还有未合并的挂账修改 - 第 4 个 bit(身份标识):标记该页自身是否属于 Change Buffer 机制的内部组成页
用 4 个 bit 追踪一个 16KB 页的状态——这又是 InnoDB "能省则省"的典型做法。
完整闭环:6 步完成延迟更新
有了 Page 1 位图的精确制导,一次可能引发随机 I/O 的二级索引修改,被化解为以下 6 步:
- 发起操作:执行
INSERT,需要修改某个非唯一二级索引的叶子页 - 内存探测:InnoDB 检查 Buffer Pool,发现该目标页不在内存中
- 查阅位图:读取该页对应的 IBUF_BITMAP 页中的 4 个 bit,确认该页剩余空间充足
- 挂账缓冲:判定"可缓冲",将本次修改作为一条记录,追加到系统表空间的 Change Buffer B+ 树中
- 避免 I/O:磁盘上的真实叶子页依然沉睡,一次随机 I/O 被成功避免
- 最终合并(Merge):Merge 会在以下几种时机触发:
- 该页因某次查询被读入 Buffer Pool——InnoDB 检查位图发现有挂账,立刻将历史修改全部合并到内存页中,账本结清
- 后台的 Master Thread 定期主动合并——避免挂账越积越多
- InnoDB 正常关闭时——一次性清算所有未合并的条目
把 Change Buffer 放到我们之前建立的整体认知框架中:
| 层级 | 机制 | 解决的核心问题 |
|---|---|---|
| 页内 | 双轨制空间分配 | 16KB 内部怎么分配空间给新记录 |
| 页间 | 启发式页分裂 | 页满了怎么拆,怎么保持填充率 |
| 内存 | Buffer Pool + 改良 LRU | 磁盘页怎么缓存到内存、怎么防污染 |
| 内存 ↔ 磁盘 | Change Buffer | 二级索引修改怎么避免随机 I/O |
Change Buffer 处在内存与磁盘的交界处——它的本质就是用一棵内存 + 磁盘混合的 B+ 树作为中间缓冲层,把对磁盘上分散二级索引页的随机写入,延迟并归并为未来某个时刻的集中合并。
从底层回到上层:索引的创建
前文我们花了大量篇幅解剖底层——从 16KB 页结构、B+ 树分裂到 Buffer Pool 和 Change Buffer。有了这些底层认知之后,接下来看上层的索引操作会轻松很多,因为每一个操作的背后,都是我们已经拆解过的底层机制在运转。
接下来,就让我们回到日常开发的视角,看看如何创建索引。
聚簇索引:声明主键即自动创建
由于 InnoDB 表是典型的索引组织表(Index Organized Table),我们在定义表结构时只要声明了主键(Primary Key),底层系统就会自动构建出一棵聚簇索引树。这一步不需要任何额外操作——主键声明本身就是聚簇索引的创建指令。
二级索引:拯救全表扫描
但根据前文的推演,我们早已清楚二级索引(辅助索引)存在的必要性。在真实业务中,我们不可能永远只靠主键 id 去查数据。当我们需要通过 user_name、email 或 order_no 这样的非主键属性去定位数据时,如果这些列上没有建立二级索引,执行引擎就别无选择,只能发起一场代价高昂的全表扫描(Full Table Scan)。
因此,根据业务的高频查询场景,精准地为非主键列创建二级索引,是数据库使用中的必修课。
创建二级索引的两种方式
方式一:建表时同步创建
这是最顺理成章的方式——在规划表结构的同时,一并规划好索引:
CREATE TABLE `users` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_name` VARCHAR(50) NOT NULL COMMENT '用户名',
`email` VARCHAR(100) NOT NULL COMMENT '邮箱',
`age` INT NOT NULL COMMENT '年龄',
PRIMARY KEY (`id`), -- 自动创建聚簇索引
INDEX `idx_user_name` (`user_name`), -- 创建普通二级索引
UNIQUE INDEX `uk_email` (`email`) -- 创建唯一二级索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里的命名前缀
idx_(普通索引)和uk_(唯一索引)不是 MySQL 的强制要求,而是业界广泛采用的命名规范——它让你在阅读 SQL 或排查慢查询时,一眼就能识别索引的类型。
这里我们可以通过该SQL语句查询一张表已经建立了哪些索引:
SHOW INDEX FROM 表名;

字段里重点看这几个:
KEY_NAME 索引名:在建表时,如果我们通过表级约束或索引定义显式指定了索引名,那么这个名字通常就会出现在 SHOW INDEX 的 Key_name 字段中
COLUMN_NAME 索引包含的列
SEQ_IN_INDEX 该列在联合索引中的顺序
NON_UNIQUE 是否非唯一,0 表示唯一索引,1 表示普通索引
INDEX_TYPE 索引类型,InnoDB 一般是 BTREE
你博客里可以直接写:
方式二:表已存在时追加创建
随着业务演进,我们往往需要对已有的大表动态追加索引。此时底层实际上会触发我们前文讲过的 Fast Index Creation(扫描主树 → 排序 → 自底向上批量构建)以及 Online DDL(扫描期间并发 DML 仍可执行)等机制。
你可以使用 ALTER TABLE 语法:
-- 为 age 列追加普通二级索引
ALTER TABLE `users` ADD INDEX `idx_age` (`age`);
-- 追加唯一索引
ALTER TABLE `users` ADD UNIQUE INDEX `uk_user_name` (`user_name`);
或者使用 CREATE INDEX 语法(两者的底层效果完全等价):
CREATE INDEX `idx_email` ON `users` (`email`);
唯一索引:多了一层约束的二级索引
认识了普通二级索引之后,我们再来看一种特殊的索引:唯一索引(Unique Index)。
唯一索引本质上仍然是一棵 B+ 树,和普通索引一样可以用于加速查询。但它额外多了一层约束语义:索引列的值不允许重复。
例如,给学生表的手机号字段建立唯一索引:
CREATE UNIQUE INDEX uk_phone ON student(phone);
那么之后表中就不能出现两个相同的手机号:
id = 1, phone = '13800000000'
id = 2, phone = '13800000000' -- ❌ 违反唯一索引约束
即使这两条记录的主键 id 不同,也会被拒绝——因为唯一索引约束的不是"整条记录是否不同",而是被索引字段 phone 的值是否重复。
唯一索引到底"约束"的是什么?
这是一个容易混淆的点,需要精确辨析。
我们知道二级索引叶子页中存放的物理条目是 [索引键值 + 主键值]。以 name 列为例:
普通索引(CREATE INDEX idx_name ON student(name))允许多个相同的索引键值存在:
[name = '张三', id = 1]
[name = '张三', id = 5] -- ✅ 允许,name 重复但 id 不同
[name = '张三', id = 9]
唯一索引(CREATE UNIQUE INDEX uk_name ON student(name))则不允许索引键值重复:
[name = '张三', id = 1]
[name = '张三', id = 2] -- ❌ 违反唯一约束,name 重复
注意看:这两个二元组的整体是不同的(因为主键 id 不同),但唯一索引约束的不是整个二元组,而是索引键值本身。如果只约束整个二元组,那由于主键天生唯一,二元组整体永远不会重复——唯一索引就失去了"限制索引列值不重复"的意义。
所以更精确地说:
普通二级索引允许多个相同的索引键值存在;唯一二级索引要求索引键值本身不能重复。唯一性约束作用在索引键值上,而不是作用在
[索引键值 + 主键值]这个完整物理条目上。
如果是联合唯一索引:
CREATE UNIQUE INDEX uk_name_age ON student(name, age);
那么唯一性约束的对象就是 (name, age) 这个组合——不允许出现两条记录拥有完全相同的 (name, age) 组合值,但单独的 name 或 age 可以重复。
普通索引 vs 唯一索引:性能差异在哪?
既然底层都是 B+ 树,两者在性能上有什么区别呢?
查询性能:几乎没有差异
- 唯一索引找到第一条匹配记录后立刻停止搜索——因为唯一性保证了不可能有第二条
- 普通索引找到第一条匹配记录后还需要继续向后扫描一条,确认没有更多匹配
但由于 InnoDB 的数据是按页加载的,"多扫一条"的代价在绝大多数场景下可以忽略——因为下一条记录大概率还在同一个 16KB 页里,不需要额外的磁盘 I/O。
写入性能:这才是真正的区别
还记得前文讲的 Change Buffer 吗?它的触发边界里有一条关键限制:只针对非唯一的二级索引。
原因我们已经推导过:唯一索引在每次写入时都需要读页校验唯一性——页都读进内存了,直接改就行,走 Change Buffer 延迟更新完全多余。
而普通索引没有唯一性约束,不需要读页校验,因此可以利用 Change Buffer 将随机 I/O 延迟为顺序写入——这个差异在写多读少的场景下非常显著。
| 维度 | 普通索引 | 唯一索引 |
|---|---|---|
| 查询性能 | 找到后多扫一条(可忽略) | 找到后立即停止 |
| 写入性能 | 可用 Change Buffer,随机 I/O → 顺序写 | 必须读页校验,无法用 Change Buffer |
| 约束语义 | 允许索引列值重复 | 索引列值不允许重复 |
所以在实际的索引设计中,如果业务上不需要唯一性约束,优先使用普通索引——它能享受 Change Buffer 带来的写入优化。只有当业务确实需要"保证某列不重复"时,才使用唯一索引。
最后再串联一个前文讲过的知识点。如果一张表没有显式定义主键,InnoDB 会按以下优先级选择聚簇索引的组织键:
- 主键(Primary Key)——如果定义了,直接用
- 第一个所有涉及列都是 NOT NULL 的 UNIQUE 索引——如果没有主键,InnoDB 会找满足这个条件的唯一索引来充当
- 隐藏的 ROW_ID——如果连满足条件的唯一索引都没有,InnoDB 自动生成 6 字节的隐藏字段
所以唯一索引不仅仅是"加速查询 + 约束唯一"——在特定条件下,它还可能被 InnoDB 选作聚簇索引的组织键,承担起整棵主树的骨架角色。
索引不是越多越好
讲完了"怎么建索引",必须补一个重要的警示:索引不是越多越好。
每多一个索引意味着:
- INSERT / UPDATE / DELETE 都会变慢——因为每条记录的增删改都要同步维护所有相关索引的 B+ 树。如果一张表有 5 个索引,一条 INSERT 就要同时写入 5 棵树
- 磁盘空间额外占用——每棵二级索引 B+ 树都是独立的物理存储,占据独立的段空间
- 优化器决策复杂度增加——索引越多,优化器在选择执行计划时需要评估的路径越多,有时甚至可能选错
索引的本质是"用写入时的额外代价,换取查询时的加速"。所以每建一个索引,都应该确认它会被高频查询真正使用到——而不是"以防万一"地撒网式建索引。后文讲索引优化策略时,我们会详细讨论"什么时候该建、什么时候不该建"的权衡法则。
补充:MyISAM 的非聚簇索引结构
前文我们主要围绕 InnoDB 展开分析,因为 InnoDB 是 MySQL 当前最常用的存储引擎。但为了避免把“索引 = 聚簇索引”这个认知绝对化,这里有必要补充一下另一个经典存储引擎 —— MyISAM。
和 InnoDB 不同,MyISAM 采用的是**非聚簇索引(Non-clustered Index)**组织方式。也就是说,在 MyISAM 中,索引文件和数据文件是分离的:索引结构主要存放在 .MYI 文件中,而真实的行数据存放在 .MYD 文件中。
这带来了一个非常关键的区别:MyISAM 的索引 B+ 树叶子节点中,并不直接存放完整的行记录,而是存放数据记录在数据文件中的地址。
换句话说,对于 MyISAM 来说,无论是主键索引,还是普通辅助索引,它们在结构上本质都是非聚簇索引:
索引叶子节点 = [索引键值 + 数据文件地址]
当执行查询时,MyISAM 会先根据索引键值在 B+ 树中定位到叶子节点,然后从叶子节点中取出对应的数据地址,再根据这个地址去 .MYD 数据文件中读取真正的行记录。
这一点和 InnoDB 有明显区别。
在 InnoDB 中,聚簇索引的叶子节点直接存放完整行记录,而二级索引叶子节点存放的是:
[二级索引键值 + 主键值]
因此,如果通过二级索引查询完整行数据,InnoDB 需要先在二级索引树中找到主键值,再拿着主键值回到聚簇索引树中重新定位一次,这个过程就叫做回表。
而 MyISAM 没有聚簇索引这一层设计。它的主键索引和辅助索引叶子节点中存放的都是数据地址,因此通过任意索引查到叶子节点之后,都可以直接根据地址去数据文件中读取记录,不需要像 InnoDB 那样再回到“主键 B+ 树”中做第二次树搜索。
但这里要注意:MyISAM 不是完全没有额外代价。 它虽然避免了 InnoDB 二级索引中的“再次遍历聚簇索引树”这一步,但仍然需要根据叶子节点中的地址去数据文件中读取真实记录。如果该数据页不在缓存中,这依然可能触发一次随机 I/O。
所以更准确地说:
MyISAM 的非聚簇索引让主键索引和辅助索引在结构上保持一致:叶子节点都不存完整数据,而是存数据地址。它避免了 InnoDB 二级索引那种“二级索引树 → 聚簇索引树”的回表过程,但查询完整行数据时,仍然需要根据数据地址再访问数据文件。
这也解释了为什么 InnoDB 和 MyISAM 在索引设计上体现的是两种不同的存储哲学:
- InnoDB:数据本身挂在聚簇索引叶子页上,主键查询非常直接;但二级索引查询完整行时可能需要回表。
- MyISAM:数据和索引分离,所有索引都是非聚簇索引;索引叶子节点统一保存数据地址,再通过地址访问真实记录。
因此,不能简单说哪一种结构绝对更好。InnoDB 的聚簇索引更适合主键访问、范围扫描、事务和崩溃恢复等场景;而 MyISAM 的非聚簇索引结构更简单,主键索引和辅助索引的寻址逻辑也更加统一。本文后续仍然以 InnoDB 为主展开,因为我们真正要分析的是当前 MySQL 默认存储引擎下的索引、页、记录和缓存机制。
MyISAM 表文件结构
这里再顺手补充一下 MyISAM 存储引擎下表文件的组织方式。和 InnoDB 将数据与索引都统一组织到表空间文件中的方式不同,MyISAM 更直接地把一张表拆成多个文件来管理。
在经典 MyISAM 存储结构中,一张表通常会对应以下文件:
在 MySQL 5.7 及更早版本中,MyISAM 表通常对应三个文件:
.frm —— 表结构定义文件
.MYD —— 数据文件
.MYI —— 索引文件
但从 MySQL 8.0 开始,.frm 文件被移除,表结构元数据统一进入数据字典。因此在 MySQL 8.0 之后,MyISAM 表在磁盘上主要对应两个文件:
.MYD —— 存放真实行数据
.MYI —— 存放索引结构
在早期版本中,.frm 文件用来保存表结构定义,也就是这张表有哪些字段、字段类型是什么、约束信息是什么等元数据。换句话说,.frm 记录的是“这张表长什么样”。
.MYD 文件用来保存真实的数据记录。这里的 D 可以理解为 Data。表中插入的每一行业务数据,最终都会落到 .MYD 数据文件中。也就是说,.MYD 才是真正存放行记录的地方。
.MYI 文件用来保存索引结构。这里的 I 可以理解为 Index。MyISAM 的主键索引、普通索引、唯一索引等索引结构,都会存放在 .MYI 文件中。
这也正好解释了 MyISAM 为什么属于非聚簇索引组织方式。因为在 MyISAM 中,索引文件和数据文件是分离的:
.MYI:存索引 B+ 树
.MYD:存真实行数据
因此,MyISAM 的索引叶子节点中不会直接存放完整的业务行记录,而是存放:
[索引键值 + 数据文件地址]
当通过索引查询一条记录时,MyISAM 会先在 .MYI 索引文件中沿着索引树定位到目标叶子节点,拿到这条记录在 .MYD 数据文件中的地址,然后再根据这个地址去 .MYD 文件中读取真正的行数据。
所以 MyISAM 的查询路径可以概括为:
索引文件 .MYI
↓ 定位索引叶子节点,拿到数据地址
数据文件 .MYD
↓ 根据地址读取真实行记录
这和 InnoDB 的聚簇索引结构形成了鲜明对比。InnoDB 的聚簇索引叶子页直接存放完整行记录,而 MyISAM 无论是主键索引还是辅助索引,叶子节点中存放的都是数据地址。也就是说,MyISAM 没有 InnoDB 那种“二级索引叶子节点存主键值,然后再回到聚簇索引树中回表”的过程;但它仍然需要根据索引叶子节点中的数据地址,再去 .MYD 文件中读取真实数据。
因此可以这样总结:
MyISAM 的表结构、数据和索引是分文件管理的:
.MYD保存真实数据,.MYI保存索引结构。正因为数据文件和索引文件分离,所以 MyISAM 的所有索引本质上都是非聚簇索引,索引叶子节点存放的是数据文件地址,而不是完整行记录。
结语
那么这就是本篇文章的全部内容,剖析了innodb的底层,下一期我会更新事务,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!感谢各位大佬对我的支持!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)