InnoDB 数据和索引的存储结构以及管理

787 阅读9分钟

  MySQL 从 5.6 版本开始,系统变量 innodb_file_per_table 默认值为 1,意思是每个 InnoDB 引擎的数据表都会对应的在 MySQL 的 data 文件夹下生成一个ibd 文件(MySQL 的数据存储路径可以通过配置文件设置,在数据存储路径下,每个数据库都会生成一个相应的文件夹,数据库中的表对应的文件则位于该文件夹下)。

mysql 数据文件目录结构

  在 innodb_file_per_table 值为 1 的前提下,每个数据表对应一个文件,即每个数据表都有一个独立的 tablespace 。每个 tablespace 中包含若干数量的 segment ,每个 segment 中又包含一定数量的 extent ,构成 extent 的元素即为 page

tablespace 结构示意图

  一个 tablespace 中所有的 page 大小相同,通过系统变量 innodb_page_size 设置,默认为 16 KB,可选的值还有 4 KB,8 KB,32 KB,64 KB。

  若干个 page 构成一个 extent ,如果 page 的大小不超过 16 KB,则 extent 的大小为 1 MB;如果 page 的大小为 32 KB,则 extent 的大小为 2 MB;如果 page 的大小为 64 KB,则 extent 的大小为 4 MB。

  tablespace 中的 segment 的数量为数据表中索引数量的 2 倍。每一个索引分配两个 segment ,一个用来存储 B+Tree 的非叶子节点,另一个则用来存储 B+Tree 的叶子节点。由于 B+Tree 中所有的数据都存储在叶子节点,将所有的叶子节点单独存储在一个 segment 中,可以使得数据读取尽量以顺序 I/O 的方式进行。

  segment 的大小是以 extent 为单位增长的,InnoDB 一次最多可以给一个 segment 分配 4 个 extent ,这样做同样是为了尽量保证数据的连续性。

⒈ 行结构

行结构示意图

  page 中存储的是数据表中的行(记录)。按照 InnoDB 的约定,一个 page 中至少应该存储两条记录。记录的存储结构如上图所示。一条记录包括三个部分:

  • Field Start Offsets 是一个 list,里面按照倒序存储每个字段的下一个字段的开始位置。   假设一个数据表中有三个字段,其中,第一个字段长度为 1,第二个字段长度为 2,第三个字段长度为 4,则 Field Start Offsets 中存储的信息为 [07, 03, 01]。

  另外,一条记录中的开始位置默认为上图中 Zero Point 所指向的位置,而不是 Field Start Offsets 的开始位置。[07,03,01] 即时相对于 Zero Point 的位置。

  • Extra Bytes 长度 6 字节,记录了当前行的一些重要信息。其中:

  ☞ deleted_flag 标记当前记录是否已经删除,长度一个 bit。

  ☞ n_fields 标记当前行中的字段数量,长度 10 bits。

  ☞ 1byte_offs_flag 标记 Field Start Offsets 的 list 中每一项的长度,长度 1 bit。如果该标记为的值为 1,则 list 中各项的长度为 1 byte(此时当前记录的总长度不能超过 127 bytes),否则为 2 bytes。

  ☞ next record 为指向下一条记录的指针,长度 16 bits。

  • Field Contents 则存储这各字段的具体值。

⒉ 页结构

  数据行存储在 page 结构当中,而 page 结构除了要存储数据记录之外,还需要记录一些额外的信息。

page 结构示意图

  ☘Fil Header 存储了一些关于当前 page 的信息,其中有一项非常重要的信息是当前 page 的数据校验和(checksum)。而 Fil Trailer 中也存储了该值。这样,在将 page 数据从内存写入磁盘的时候,header 部分的数据会先写入磁盘,最后 Fil Trailer 部分的数据才会写入磁盘。此时,如果发现 Fil Trailer 中的校验和与 Fil Header 中的不相等,说名数据在写入过程中有异常,需要重新进行写入。另外,Fil Header 当中有两个指针分别指向与当前 page 相邻的上一个以及下一个 page

  ☘Page Header 存储的是与当前 page 中数据相关的信息。包括:

  • PAGE_N_DIR_SLOTS Page Directory 部分所包含的 slot 的数量,初始值为 2
  • PAGE_HEAP_TOP 指向第一条记录(手动写入的记录)的指针
  • PAGE_N_HEAP 当前 page 中的记录总数(包括被标记为删除的记录),初始值为 2
  • PAGE_FREE 指向 Free Space 部分的开始位置的指针
  • PAGE_GARBAGE 被标记为删除的记录所占用的空间大小
  • PAGE_LAST_INSERT 指向最近插入的记录(Zero Point 位置)
  • PAGE_DIRECTION 数据插入的方向,可以是 PAGE_LEFTPAGE_RIGHTPAGE_NO_DIRECTION
  • PAGE_N_DIRECTION 一个方向连续插入的记录数量
  • PAGE_N_RECS 当前 page 中有效的记录数量
  • PAGE_MAX_TRX_ID 作用在当前 page 上所包含的记录上的最大事务 ID(仅存在于辅助索引中)
  • PAGE_LEVEL 当前 page 所处的节点在 B+Tree 中的高度
  • PAGE_INDEX_ID 当前 page 所处的索引的 ID
  • PAGE_BTR_SEG_LEAF 位于 B+Tree 中叶子节点的 page 所处的 segmentheader
  • PAGE_BTR_SEG_TOP 位于 B+Tree 中非叶子节点的 page 所处的 segmentheader

  其中,最后两项所存储的信息用于向 segment 分配新的 page

  ☘ InfimumSupremum 分别表示索引的下界和上界。这样,数据库引擎在按照索引检索数据时就不会超出索引的开始和结束位置。索引在创建时,InnoDB 会在索引的根节点设置 InfimumSupremum 两条记录,这两条记录永远不会被删除。

  ☘ User Records 部分记录了用户插入的数据记录。InnoDB 在存储数据记录时并不会严格按照索引顺序存储,因为这样会涉及到大量的顺序变换。为了保证性能,InnoDB 会将数据插入到 Free Space 或被标记为删除的记录的位置。

被标记为删除的记录会形成一个链表

如果这些被标记为删除的记录中存在连续的空间,其大小大于新插入的数据所需要的空间,那么这些被标记为删除的记录所占用的空间就会被重新利用。

Page Header 中会记录 Free Space 的头部指针,当有新数据插入时,Free Space 的头部指针会相应的往下偏移,最后直到 Free Space 没有足够的空间。

  ☘ Page Directory 中存储的是一定数量的 slot ,具体数量记录在 Page Header 中的 PAGE_N_DIR_SLOT 当中,作用类似于书的目录。

  InnoDB 并不提供 slot 与数据记录的一一映射。在 InnoDB 中,page 当中的记录被分成了若干个 slot 存储,每个 slot 中包含 4 ~ 8 条记录,理想情况下每个 slot 中包含 6 条记录。特殊情况,每个 page 当中的第一个 slot 只包含一条记录,即 Infimum ,第二个 slot 可能包含 1 ~ 8 条记录。

新创建的 `page` ,其中只有两条记录,即 `Infimum` 和 `Supremum` 。此时 `Page Directory` 中也只有两个 `slot` ,分别包含 `Infimum` 和 `Supremum` 。当 `page` 当中插入的记录数量少于 8 条时,这些记录都会被分配到第二个 `slot` 。随着数据的继续插入,第二个 `slot` 会分裂,此时新分裂的 `slot` 每个包含 4 条记录。

page 当中的记录是按照索引的顺序向 slot 当中分配的。

  需要指出,每个 slot 指向的是当前 slot 当中的最后一条记录。这条记录的 Extra Bytes 当中的 n_owned 字段还记录了当前 slot 中所包含的记录的数量。

数据行在 slot 中的分配关系

⒊ 页合并

  在 InnoDB 中,行记录按照主键索引顺序存储在主键索引树的叶节点的 page 中。InnoDB 会为每个索引设置一个属性 MERGE_THRESHOLD ,默认值为 50% ,即当一个 page 中的有效空间利用率不到 50% 时,InnoDB 会尝试将其与邻近的 page 进行合并,以优化空间使用。

  通常情况下,在往 InnoDB 数据表中插入记录时,这些记录会按顺序写入 page 当中的 User Records 空间中。当 page 当中的空间不足时会往新的 page 当中继续写入。

数据记录写入 page

  当有记录被删除时,被删除的记录所占用的空间并不会被回收,而是将这些记录中的 Extra Bytes 中的 deleted_flag 标记为 1。当一个 page 中被删除的记录所占用的空间达到一定的值时(MERGE_THRESHOLD 所设置的值),InnoDB 会查看与当前 page 相邻的 page ,看是否有机会进行空间利用率的优化,如果可以则会将 page 进行合并。

page 中记录被删除

  第二个 page 当中有 50% 的记录被删除,此时 InnoDB 会查看相邻的 page 是否有机会进行空间利用率的优化。发现第三个 page 也只有一半的空间被使用,会尝试将第二个和第三个 page 合并。

page 合并

  合并以后,第二个 page 中存储了原来存在于第三个 page 中的数据,而第三个 page 目前没有数据。

  同样的,使用 update 对数据表进行更新操作也可能会引起 page 的合并。information_schema 中的 innodb_metrics 表中的 index_page_merge_successful 会记录 page 合并的次数。

⒋ 页分裂

  在实际应用中,数据表中的每条记录并不能保证长度都相等,所以 page 中的空间也不会正好被 100% 填充满。即使 page 中的空间被 100% 使用,但对其中的记录进行更新也可能导致更新之后的记录比旧记录需要更多的空间来存储数据。

page 空间没有被充分利用

page 空间被充分利用

  针对第一种情况,如果此时向数据表中插入 ID 为 16 的记录,并且这条记录需要的存储空间大于第二个 page 中剩余的空间,那么 InnoDB 此时会新创建一个 page ,然后确定在第二个 page 中进行分裂的位置(MERGE_THRESHOLD),将分裂位置之后的数据记录移动到新创建的 page 当中,最后重新建立 page 之间的关联关系。

  针对第二种情况,如果此时对 ID 为 16 的记录进行更新操作,并且更新之后的记录需要更多的存储空间,InnoDB 同样会进行上述的分裂 page 的操作。

insert 导致的页分裂

update 导致的页分裂

  页分裂往往会导致 page 顺序的错位,甚至会导致新分裂的 page 与其相邻的 page 处在不同的 extent 中。information_schema 中的 innodb_metrics 中的 index_page_splits 会记录页分裂的次数。

  分裂之后的 page 如果想要恢复原状,有两种方法:

  • 将新分裂出来的 page 中的记录 drop 直到其空间利用率低于 MERGE_THRESHOLD
  • 对 table 进行 optimize 操作

  另外,page 的合并和分裂过程中,InnoDB 会对索引加排他锁,这会导致在此过程中数据无法访问。

References

dev.mysql.com/doc/interna… dev.mysql.com/doc/interna… dev.mysql.com/doc/refman/… www.percona.com/blog/2017/0… mp.weixin.qq.com/s?__biz=MzI…