【华为云MySQL技术专栏】MySQL InnoDB索引数据页面结构解析
1、背景介绍
页(Page)是 InnoDB 中管理存储空间的基本单位,大小默认是16KB。表中的数据都是存放在页中的,当要查询的数据不在缓冲池(Buffer Pool)中时,InnoDB 会将数据对应记录所在的整个页加载到 Buffer Pool 中;同理,将 Buffer Pool 中的脏页刷入磁盘时,也是以页为单位刷盘的。
本文主要介绍最常见的索引数据页面以及相关的操作,下文中数据页都指索引数据页面。
2、数据页格式
如下图所示,数据页包括七个部分:数据页文件头、数据页头、最大最小记录、用户记录、空闲空间、数据目录、数据页尾部,后文会进行具体介绍。


2.1 数据页文件头(Fil Header)
这个部分描述了一些针对各种页都通用的一些信息,由以下内容组成。
以下详细介绍几个较为重要的部分:
FIL_PAGE_OFFSET:该页的页号,每个表空间从0开始,即这个值乘以页的大小就可以得到页在文件中的起始偏移量。
FIL_PAGE_PREV,FIL_PAGE_NEXT:分别保存前一页、后一页的页号,把分散在物理各处的页按主键顺序(无显式主键时用隐藏 rowid)串成双向链表。一方面,大表难以一次拿到连续空间,只能分片分配;另一方面,频繁的页分裂与回收又会让逻辑相邻的页在磁盘上彼此远离。这两个指针正是用来在逻辑层面保持“主键有序”的链表结构,使得查询可以在物理不连续的页间快速跳转。
FIL_PAGE_LSN:当前数据页最新被修改的LSN,Redo Log幂等性依赖此字段。在进行崩溃恢复时,如果发现Redo Log的LSN小于等于该值,就不再应用该Redo Log。

2.2 数据页头(Page Header)
这部分存储的是数据页相关的元信息。

2.3 最小最大记录(Infimum and Supremum Records)
这是两条伪记录,都是由5字节的记录头信息和8字节的固定含义组成。由于这两条记录并非用户数据,所以单独放在一个Infimum+Supremum部分。InnoDB规定,最小记录是该数据页逻辑上最小的记录,所有用户记录都大于它;最大记录是数据页中最大的记录,所有用户的记录都小于它。他们在数据页被创建的时候创建,而且不能被删除。
2.4 用户记录(User Records)
用户所有的记录都以行格式存储在这里。在页面刚生成时,这部分内容为空;每次插入记录时,都会从Free Space开辟相应的空间划分到User Records。默认情况下记录跟记录之间没有间隙,但是如果重用了已删除记录的空间,就会导致空间碎片。记录按照主键顺序排序,每个记录都有指向下一个记录的指针(next_record),构成一个单向链表。即用户可以从数据页最小记录开始遍历,直到最大的记录。User Records包括了所有正常的记录和所有已删除的(标记为delete_marked)记录,但是不会访问到已删除的记录(这部分记录已移入PAGE_FREE链表)。

2.5 空闲空间(Free Space)
从PAGE_HEAP_TOP开始,到最后一个数据目录,这之间的空间就是空闲空间,都被重置为0,当用户需要插入记录时候,首先在被删除的记录的空间中查找,如果没有找到合适的空间,就从这里分配。空间分配给记录后,需要递增PAGE_N_RECS和PAGE_N_HEAP。
2.6 页目录(Page Directory)
为了加速页内的查询效率,避免每次查询都对单链表进行遍历,InnoDB把页内所有有效记录(含 Infimum/Supremum,不含已删除记录)划分成若干组。
-
每个组的最后一条记录(即组内最大记录)作为owner_rec,它的 n_owned 字段保存本组记录数。
-
将owner_rec的地址偏移量按主键顺序收集起来,形成页目录(Page Directory),存放在页尾,从高地址向低地址增长。

数据页初始化时,就会在页尾(Checksum之前)建好两个槽(Slot),分别指向伪记录 Infimum 与 Supremum。此后每插入一条用户记录,都要同步维护这个“槽目录”:当某组记录数超标时,立即分裂并新增一个槽。
槽目录必须“不疏不密”:
-
过疏——组内记录过多,二分查找效果差;
-
过密——槽数量膨胀,浪费空间。
InnoDB 用三条硬规则保持平衡:
-
Infimum 组只能有 1 条记录(它自己);
-
Supremum 组记录数 1‒8 条;
-
其余组记录数只能是 4‒8 条。
一旦某组在插入记录时超过上限,便会进行组分裂,新增槽并重新均衡记录。由于各槽所代表的主键值单调递增,页内检索可直接用二分法快速定位。
2.7 数据页尾部(File Trailer)
这个部分处于数据页最后的位置,只有8个字节。前4个字节代表页的校验和,后4个字节存储FIL_PAGE_LSN的低位四字节。
3、相关函数
本节详细剖析一下数据页相关的几个核心函数。
3.1 插入记录
核心入口函数在page_cur_insert_rec_low。核心步骤如下:
page_cur_insert_rec_low {
......
// 1. 获取记录的长度
rec_size = rec_offs_size(offsets);
// 2. 从页面内存管理中分配足够的空间
free_rec = page_header_get_ptr(page, PAGE_FREE);
......
// 3. 拷贝记录到分配空间中
insert_rec = rec_copy(insert_buf, rec, offsets);
// 4. 将记录插入页面记录单向链表中,递增PAGE_N_RECS
page_rec_set_next(insert_rec, next_rec);
page_rec_set_next(current_rec, insert_rec);
page_header_set_field(page, nullptr, PAGE_N_RECS, 1 + page_get_n_recs(page));
// 5. 设置heap_no,设置owned值为0
rec_set_n_owned_new(insert_rec, nullptr, 0);
rec_set_heap_no_new(insert_rec, heap_no);
// 6. 更新PAGE_LAST_INSERT,PAGE_DIRECTION,PAGE_N_DIRECTION
page_header_set_field(page, nullptr, PAGE_DIRECTION, ......);
page_header_set_field(page, nullptr, PAGE_N_DIRECTION, ......);
page_header_set_ptr(page, nullptr, PAGE_LAST_INSERT, insert_rec);
// 7. 修改owner_rec的n_owned值
rec_t *owner_rec = page_rec_find_owner_rec(insert_rec);
n_owned = rec_get_n_owned_old(owner_rec);
rec_set_n_owned_old(owner_rec, n_owned + 1);
// 8. 如果owner_rec的n_owned值大于8,则进行SLOT分裂
page_dir_split_slot(page, nullptr, page_dir_find_owner_slot(owner_rec));
// 9. 写redolog日志,持久化操作
page_cur_insert_rec_write_log(insert_rec, rec_size, current_rec, index, mtr);
}
在第二步分配空间时,InnoDB会检查 PAGE_FREE 链表的第一个记录。如果该记录的空间大于需要插入的记录的空间,则复用这块空间,否则就从PAGE_HEAP_TOP分配空间,如果分配成功,需要递增PAGE_N_HEAP。只有当这两处都失败才则返回空。由于只比较链表头,所以算法对空间的利用率不是很高。例如,页面先删除多条大记录,最后删除一条小记录 A,那么此后只有插入比 A 更小的记录才能再次利用空闲链表。这正是 InnoDB 需要定期做空间整理(OPTIMIZE TABLE)的原因之一。
TIPS:
MySQL内只有通过重建表的方式,才可以真正做到重新利用这部分残留空间,例如以下三种方式:
-
OPTIMIZE TABLE;
-
ALTER TABLE … ENGINE=INNODB / FORCE;
-
逻辑导出-再导入。
这三种方式本质上都是通过以下步骤实现的表空间碎片回收:
1. 新建一个文件,按主键顺序调用page_cur_insert_rec_low把行重新插入表中;
2.每当页面写满就进行分裂,调用page_move_rec_list_end / page_move_rec_list_start函数,拷贝记录至新页面,并在原页面上删除对应记录,直至原页面上变为15KB(页面大小的15/16);
3.用新文件替代旧文件,即将旧页丢弃,以新的接近满的页面取代旧页面,实现空间回收。
3.2 删除记录
注意这里的删除操作是指真正的删除物理记录,而不是标记记录为delete_marked。核心入口函数在page_cur_delete_rec。核心步骤如下:
page_cur_delete_rec {
......
// 1. 若待删记录是本页最后一条用户记录,直接调用 page_create_empty() 把整页重置为空页
page_create_empty(page_cur_get_block(cursor), index, mtr)
// 2. 写redolog日志,持久化操作
page_cur_delete_rec_write_log(current_rec, index, mtr);
// 3. 重置PAGE_LAST_INSERT并递增block的modify clock,使所有乐观查询缓存失效
page_header_set_ptr(page, page_zip, PAGE_LAST_INSERT, nullptr);
buf_block_modify_clock_inc(page_cur_get_block(cursor));
// 4. 把前驱的 next 指针改指向后继,逻辑链上剔除该记录
page_rec_set_next(prev_rec, next_rec);
// 5. 调整目录,若某槽指向被删记录,则把该槽指向前驱,同时把槽的n_owned减 1
page_dir_slot_set_rec(cur_dir_slot, prev_rec);
page_dir_slot_set_n_owned(cur_dir_slot, page_zip, cur_n_owned - 1);
// 6. 释放被删记录占用的内存空间
page_mem_free(page, page_zip, current_rec, index, offsets);
// 7. 如果n_owned值小于下限组内记录下限(即4个),则重新均衡目录
page_dir_balance_slot(page, page_zip, cur_slot_no);
}
3.3 查找记录/定位位置
在InnoDB 里,不管“找记录”还是“定位置”都走同一个入口page_cur_search_with_match。
需要的位置有 4 种:PAGE_CUR_G / GE / L / LE → 大于 / 大于等于 / 小于 / 小于等于。
得益于页目录,搜索记录分两步完成:
1.对Slot做二分查找,迅速锁定两个相邻的Slot;
2.对Slot线性扫描几行即可得到最终记录或插入点。
为了提高效率,InnoDB针对按照主键顺序插入的场景做了一个优化。若满足下列 5 条,则跳过搜索,直接把游标钉在PAGE_LAST_INSERT:
-
当前页是叶子节点;
-
查询模式为 PAGE_CUR_LE;
-
同一方向连续插入已超过 3 次(PAGE_N_DIRECTION > 3);
-
PAGE_LAST_INSERT 非空(有上次插入位置);
-
方向为 PAGE_RIGHT(一直往右追加)。
条件全命中即可走捷径,否则仍按常规二分+线性流程。
3.4 其他函数
除增删查外,下列函数同样重要:
-
page_create
新建空页时统一入口,负责把 Page Header、Page Directory、Infimum/Supremum 等元信息一次性初始化到位。
-
page_move_rec_list_end / page_move_rec_list_start
页重组或分裂时,把记录批量搬迁到另一页。
page_move_rec_list_end:从指定记录拷贝到页尾,包括该记录,并从该页面中删除拷贝的记录;page_move_rec_list_start:从首记录拷贝到指定记录为止,不包括该记录,并从该页面中删除拷贝的记录。二者互补,覆盖所有搬迁场景。
-
page_cur_open_on_rnd_user_rec
把记录游标随机落到一条用户记录上。如果没有用户记录,则将游标设置在最小记录上。
4、总结
本文对InnoDB数据页页面结构进行了解析,大致有以下结构特点:
-
快速定位数据:页目录保存每组最大记录偏移,二分+链表两步即可命中目标;
-
数据完整性和恢复:页头校验和与 LSN 实时校验页面、崩溃按序恢复,数据不丢不坏;
-
空间管理优化:空闲空间链表把碎片重新串联,页内利用率最大化;
-
顺序访问优化:逻辑相邻记录存储在同一页,适合顺序扫描,提升了读取效率。
这些结构特点共同优化了InnoDB的查询性能,保障了数据的可靠性。
- 点赞
- 收藏
- 关注作者
评论(0)