
# InnoDB索引页面解析
#### 背景介绍
在生产环境中，InnoDB表数据被大量删除后，原本连续存储的数据页之间会留下空闲页，导致表文件不再紧凑。然而，这些碎片页是如何产生的？能否被新插入的数据复用？能否被其他表复用？
本章节将深入剖析 InnoDB 物理文件管理机制，从原理层面解答上述疑问。
#### 页面
InnoDB的物理文件体系主要包括系统表空间文件（ibdata\*）、用户表空间文件（\*.ibd）、独立的undo表空间文件（默认为undo_\*）、临时表空间文件（temp_\*.ibt）、redo日志文件（#ib_redo\*）以及Double write文件（默认为#ib_16384_\*.dblwr）等。
在这些文件中，除了redo日志文件外，其余文件都是由InnoDB标准页面（Page）构成。页（Page）是 InnoDB 中管理存储空间的基本单位，通常情况下，一个页面大小为16KB，可以通过设置innodb_page_size参数，在初始化数据库时，将页面大小调整为4KB到64KB之间的任意大小。表中的数据以记录的形式存储于页中。当查询的数据不在缓冲池（Buffer Pool）中时，InnoDB 会将该数据所在的整个页加载至 Buffer Pool 中；同理，将 Buffer Pool 中的脏页刷入磁盘时，同样以页为单位进行刷盘操作。
在InnoDB存储引擎中，存在多种页面类型，各自承担着不同的功能角色。包括：
- 表空间首页（FIL_PAGE_TYPE_FSP_HDR）：用于记录表空间的元信息。
- 用户数据存储页面（FIL_PAGE_INDEX）：用于存放用户表的数据和主键索引。
- 回滚段页面（FIL_PAGE_UNDO_LOG）：用于存储undo日志，支持事务的回滚。
- 段索引节点页面（FIL_PAGE_INODE）：用于管理数据文件中的段信息。
- 区信息页面（FIL_PAGE_TYPE_XDES）：用于维护区的元数据。
- Insert Buffer 位图页面（FIL_PAGE_IBUF_BITMAP）：跟踪各页是否有待处理的Insert Buffer 条目。
上述页面类型各异，但它们都共享相同的页面结构，包括通用页头（Fil Header）和通用页尾（Fil Trailer），其中记录的详细信息如下所示：
表1页面结构 
| 结构                                                                                                                                | 名称                                                                                                             | 含义                                | 字节数 |
|:---|:---|:---|:---|
| 通用页头        | FIL_PAGE_SPACE_OR_CHKSUM                                                                                       | 校验值。                              | 4   |
| 通用页头        | FIL_PAGE_OFFSET                                                                                                | 页面编号。                             | 4   |
| 通用页头        | FIL_PAGE_PREV                                                                                                  | 前页编号。                             | 4   |
| 通用页头        | FIL_PAGE_NEXT                                                                                                  | 后页编号                              | 4   |
| 通用页头        | FIL_PAGE_LSN                                                                                                   | 最后修改的LSN。                         | 8   |
| 通用页头        | FIL_PAGE_TYPE                                                                                                  | 页面类型。                             | 2   |
| 通用页头        | FIL_PAGE_FILE_FLUSH_LSN                                                                                        | 仅在系统表空间的第0页定义，表示文件至少被刷新到了对应的LSN值。 | 8   |
| 通用页头        | FIL_PAGE_SPACE_ID                                                                                              | 表空间编号。                            | 4   |
| 页面内容                                                                                                                              | ...                                                                                                            | ...                               | ... |
| 通用页尾                                             | FIL_PAGE_END_LSN_OLD_CHKSUM  | 页尾校验值 (4)。                        | 4   |
| 通用页尾                                             | FIL_PAGE_END_LSN_OLD_CHKSUM  | LSN的低4字节。                         | 4   |
   
我们将重点介绍与用户数据直接相关的FIL_PAGE_INDEX类型，该类型通常被称为索引页类型。
#### 索引页特点
索引页具备以下结构特点：
- 快速定位数据：页目录保存每组最大记录偏移量，通过二分查找与链表遍历的两步法即可快速命中目标。
- 数据完整性与恢复：页面从磁盘读取的时候会检查一次数据页内容校验，确保崩溃后能按序恢复数据，保障数据不丢失且不被损坏。
- 空间管理优化：空闲空间链表有效重组碎片空间，最大化提升页内空间利用率。
- 顺序访问优化：逻辑相邻的记录被存储在同一页内，这种布局更适合顺序扫描操作，从而显著提高读取效率。
上述结构特点协同优化了 InnoDB 的查询性能，同时确保了数据的可靠性。
#### 索引页格式
如下图所示，索引页包括七个部分：文件头部、页面头部、最大最小记录、用户记录、空闲空间、页目录、文件尾部。
图1索引页格式   
![](https://support.huaweicloud.com/bestpractice-taurusdb/figure/zh-cn_image_0000002630263754.png "点击放大")
表2索引页格式 
| 名称                 | 中文名     | 含义                       |
|:---|:---|:---|
| File Header        | 文件头部    | 页的一些通用信息，例如当前页号。         |
| Page Header        | 页面头部    | 数据页专有的一些信息，例如该页中记录的数量。   |
| Infimum + Supremum | 最小和最大记录 | 两个虚拟的行记录，分别代表页内最小/最大记录。  |
| User Records       | 用户记录    | 实际存储的表数据。                |
| Free Space         | 空闲空间    | 页中尚未使用的空间。               |
| Page Directory     | 页目录     | 页中每个分组内的最大记录的相对页首的地址偏移量。 |
| File Trailer       | 文件尾部    | 校验页是否完整。                 |
   
- 文件头部（File Header） 文件头描述了一些针对各种页都通用的一些信息，如下表所示。
  表3文件头 
  | 名称                               | 描述                                | 占用空间大小 |
  |:---|:---|:---|
  | FIL_PAGE_SPACE_OR_CHKSUM         | 页的校验和（checksum值）。                 | 4字节    |
  | FIL_PAGE_OFFSET                  | 页号。                               | 4字节    |
  | FIL_PAGE_PREV                    | 上一个页的页号。                          | 4字节    |
  | FIL_PAGE_NEXT                    | 下一个页的页号。                          | 4字节    |
  | FIL_PAGE_LSN                     | 页面最新一次修改对应的日志序列号（LSN）。            | 8字节    |
  | FIL_PAGE_TYPE                    | 页面类型。                             | 2字节    |
  | FIL_PAGE_FILE_FLUSH_LSN          | 仅在系统表空间的第0页定义，代表文件至少被刷新到了对应的LSN值。 | 8字节    |
  | FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 页归属的表空间space_id。                  | 4字节    |
     
  以下介绍几个较为重要的部分：
  - FIL_PAGE_OFFSET：该页的页号，表示该页面在数据文件中的逻辑偏移量。
  
  - FIL_PAGE_PREV，FIL_PAGE_NEXT：分别保存前一页、后一页的页号，把物理位置不连续的页串联成B+树的同一层逻辑相邻的页。在表空间第0页上，这两个字段被重载为FIL_PAGE_SRV_VERSION 和 FIL_PAGE_SPACE_VERSION。 一方面，大表难以一次拿到连续空间，只能分片分配；另一方面，频繁的页分裂与回收又会让逻辑相邻的页在磁盘上彼此远离。这两个指针正是用来在逻辑层面保持"索引有序"的链表结构，使得查询可以在物理不连续的页间快速跳转。
    
  
  - FIL_PAGE_LSN：当前数据页最新被修改的LSN，Redo Log幂等性依赖此字段。在进行崩溃恢复时，如果发现Redo Log的LSN小于等于该值，就不再应用该Redo Log。
   
- 页面头部（Page Header）
  表4页面头部 
  | 名称                | 占用空间 | 描述                                                                           |
  |:---|:---|:---|
  | PAGE_N_DIR_SLOTS  | 2字节  | 页面目录中的目录数量。一个新建的空数据页包含2个目录，它们分别指向最大记录和最小记录。其中，第一个目录永远指向最小记录，最后一个目录则永远指向最大记录。 |
  | PAGE_HEAP_TOP     | 2字节  | 还未使用的空间最小地址。大于这个地址的且小于数据目录的空间都是未分配的Free Space，可以被后续使用。                       |
  | PAGE_N_HEAP       | 2字节  | bit 15是紧凑格式标志位，剩余15位（bit 14-0）表示堆中记录数量。（包括最小最大记录以及标记为删除的记录）。                 |
  | PAGE_FREE         | 2字节  | 第一个已经被Purge物理删除的记录地址（各个已删除的记录通过next_record也会组成一个单链表，这个单链表中的记录可以被重新利用）。       |
  | PAGE_GARBAGE      | 2字节  | 已删除记录占用的字节数。                                                                 |
  | PAGE_LAST_INSERT  | 2字节  | 最后插入记录的位置。                                                                   |
  | PAGE_DIRECTION    | 2字节  | 记录插入的方向。                                                                     |
  | PAGE_N_DIRECTION  | 2字节  | 当前方向连续插入的记录数量，方向改变后清零。                                                       |
  | PAGE_N_RECS       | 2字节  | 该页中记录的数量，不包括最大和最小记录。与PAGE_N_HEAP不同，如果记录被标记为delete_marked，这个值就会递减。            |
  | PAGE_MAX_TRX_ID   | 8字节  | 修改当前页的最大事务ID，该值仅在二级索引中定义。                                                    |
  | PAGE_LEVEL        | 2字节  | 当前页在B+树中所处的层级， 0即叶子节点。                                                       |
  | PAGE_INDEX_ID     | 8字节  | 索引ID，表示当前页属于哪个索引。                                                            |
  | PAGE_BTR_SEG_LEAF | 10字节 | B+树叶子段的头部信息，仅在B+树的Root页定义。                                                   |
  | PAGE_BTR_SEG_TOP  | 10字节 | B+树非叶子段的头部信息，仅在B+树的Root页定义。                                                  |
     
  
- 最小最大记录（Infimum and Supremum Records）
  这是两条伪记录，在DYNAMIC行格式下，均由5字节的记录头信息和8字节的固定含义组成。由于这两条记录并非用户数据，因此被单独存放在Infimum+Supremum部分。
  根据TaurusDB规定，最小记录是该数据页逻辑上最小的记录，所有用户记录都大于它；最大记录是数据页中最大的记录，所有用户记录都小于它。这两条记录在数据页被创建时生成，且不能被删除。
  图2最小最大记录示意图   
  ![](https://support.huaweicloud.com/bestpractice-taurusdb/figure/zh-cn_image_0000002630423656.png "点击放大") 
- 用户记录（User Records） 用户的所有记录均以行格式存储在此处。在页面初次生成时，这部分内容为空；每次插入记录时，都会从Free Space中划分相应空间至User Records。默认情况下，记录之间并无间隙，但若重用已删除记录的空间，则可能会产生空间碎片。
  记录在页面上按照插入顺序排序，每个记录均包含一个指向下一条记录的指针（next_record），从而构成单向链表。用户可从数据页的最小记录开始遍历，直至最大记录。在聚簇索引中，记录按主键顺序存储。在二级索引中，记录按索引键+主键排序。
  User Records包含所有正常记录及所有已删除的（标记为delete_marked）记录，其中已被Purge物理删除的记录已被移入PAGE_FREE链表，不再被正常访问。
  图3用户记录示意图   
  ![](https://support.huaweicloud.com/bestpractice-taurusdb/figure/zh-cn_image_0000002660542969.png "点击放大") 
- 空闲空间（Free Space） 从PAGE_HEAP_TOP开始到最后一个数据目录之间的空间即为空闲空间，这些空间会被重置为0。当用户需要插入记录时，首先会尝试在被删除记录的空间中查找可用空间；若未找到合适的空间，则从这里进行分配。空间分配给记录后，需要递增PAGE_N_RECS和PAGE_N_HEAP的值。
  
- 页目录（Page Directory） 为了加速页内的查询效率，避免每次查询都对单链表进行遍历，TaurusDB把页内所有有效记录（含 Infimum/Supremum，不含已删除记录）划分成若干组进行管理。对于被标记删除，但尚未被Purge物理删除的记录，其仍保留在 Page Directory 中，并继续挂在 next_record 逻辑链表上，待后续统一清理。
  - 每个组的最后一条记录（即组内最大记录）作为owner_rec，它的 n_owned 字段保存本组记录数。
  
  - 将owner_rec的地址偏移量按索引顺序收集起来，形成页目录（Page Directory），存放在页尾，从高地址向低地址增长。
   
- 数据页尾部（File Trailer） 这个部分处于数据页最后的位置，只有8个字节。前4个字节代表页的校验和，后4个字节存储FIL_PAGE_LSN的低位四字节。
  
 
