MySQL 从 5.6 版本开始,系统变量 innodb_file_per_table
默认值为 1,意思是每个 InnoDB 引擎的数据表都会对应的在 MySQL 的 data 文件夹下生成一个ibd
文件(MySQL 的数据存储路径可以通过配置文件设置,在数据存储路径下,每个数据库都会生成一个相应的文件夹,数据库中的表对应的文件则位于该文件夹下)。
在 innodb_file_per_table
值为 1 的前提下,每个数据表对应一个文件,即每个数据表都有一个独立的 tablespace
。每个 tablespace
中包含若干数量的 segment
,每个 segment
中又包含一定数量的 extent
,构成 extent
的元素即为 page
。
一个 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
结构除了要存储数据记录之外,还需要记录一些额外的信息。
☘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_LEFT
、PAGE_RIGHT
、PAGE_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
所处的segment
的header
-
PAGE_BTR_SEG_TOP
位于 B+Tree 中非叶子节点的page
所处的segment
的header
其中,最后两项所存储的信息用于向 segment
分配新的 page
。
☘ Infimum
和 Supremum
分别表示索引的下界和上界。这样,数据库引擎在按照索引检索数据时就不会超出索引的开始和结束位置。索引在创建时,InnoDB 会在索引的根节点设置 Infimum
和 Supremum
两条记录,这两条记录永远不会被删除。
☘ 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
当中的记录是按照索引的顺序向slot
当中分配的。
需要指出,每个 slot
指向的是当前 slot
当中的最后一条记录。这条记录的 Extra Bytes
当中的 n_owned
字段还记录了当前 slot
中所包含的记录的数量。
⒊ 页合并
在 InnoDB 中,行记录按照主键索引顺序存储在主键索引树的叶节点的 page
中。InnoDB 会为每个索引设置一个属性 MERGE_THRESHOLD
,默认值为 50%
,即当一个 page
中的有效空间利用率不到 50% 时,InnoDB 会尝试将其与邻近的 page
进行合并,以优化空间使用。
通常情况下,在往 InnoDB 数据表中插入记录时,这些记录会按顺序写入 page
当中的 User Records
空间中。当 page
当中的空间不足时会往新的 page
当中继续写入。
当有记录被删除时,被删除的记录所占用的空间并不会被回收,而是将这些记录中的 Extra Bytes
中的 deleted_flag
标记为 1。当一个 page
中被删除的记录所占用的空间达到一定的值时(MERGE_THRESHOLD
所设置的值),InnoDB 会查看与当前 page
相邻的 page
,看是否有机会进行空间利用率的优化,如果可以则会将 page
进行合并。
第二个 page
当中有 50%
的记录被删除,此时 InnoDB 会查看相邻的 page
是否有机会进行空间利用率的优化。发现第三个 page
也只有一半的空间被使用,会尝试将第二个和第三个 page
合并。
合并以后,第二个 page
中存储了原来存在于第三个 page
中的数据,而第三个 page
目前没有数据。
同样的,使用 update
对数据表进行更新操作也可能会引起 page
的合并。information_schema
中的 innodb_metrics
表中的 index_page_merge_successful
会记录 page
合并的次数。
⒋ 页分裂
在实际应用中,数据表中的每条记录并不能保证长度都相等,所以 page
中的空间也不会正好被 100% 填充满。即使 page
中的空间被 100%
使用,但对其中的记录进行更新也可能导致更新之后的记录比旧记录需要更多的空间来存储数据。
针对第一种情况,如果此时向数据表中插入 ID 为 16 的记录,并且这条记录需要的存储空间大于第二个 page
中剩余的空间,那么 InnoDB 此时会新创建一个 page
,然后确定在第二个 page
中进行分裂的位置(MERGE_THRESHOLD
),将分裂位置之后的数据记录移动到新创建的 page
当中,最后重新建立 page
之间的关联关系。
针对第二种情况,如果此时对 ID 为 16 的记录进行更新操作,并且更新之后的记录需要更多的存储空间,InnoDB 同样会进行上述的分裂 page
的操作。
页分裂往往会导致 page
顺序的错位,甚至会导致新分裂的 page
与其相邻的 page
处在不同的 extent
中。information_schema
中的 innodb_metrics
中的 index_page_splits
会记录页分裂的次数。
分裂之后的 page
如果想要恢复原状,有两种方法:
- 将新分裂出来的
page
中的记录 drop 直到其空间利用率低于MERGE_THRESHOLD
- 对 table 进行
optimize
操作
另外,page
的合并和分裂过程中,InnoDB 会对索引加排他锁,这会导致在此过程中数据无法访问。