Innodb页面存储结构-2

时间:2023-03-08 16:18:22
Innodb页面存储结构-2

上一篇《Innodb页面存储结构-1》介绍了Innodb页面存储的总体结构,本文会介绍页面的详细内容,主要包括页头、页尾和记录的详细格式。

学习数据结构时都说程序等于数据结构+算法,而在innodb中,其页面结构和记录格式可以说就是其数据结构。并且理解这些结构后,对innodb的页面行为(算法)的理解也有很大帮助。

1. 表空间

介绍页面具体结构之前,先介绍一些Innodb表空间的概念。

表空间,数据文件的集合,在innodb就是idb文件集合。

例如,我们在mysql的配置文件中会指定innodb_data_file_path参数,指定一系列文件列表,如

innodb_data_file_path=/data1/ibdata1:4G;/data2/ibdata2:4G:autoextend

那么,表空间就由ibdata1.ibd和ibdata2.ibd组成。

假设页大小为16k,那么,innodb会对数据文件每16k进行编号(从0开始),作为该页的页号,并且相邻文件的编号是也是连贯的。如下图所示

Innodb页面存储结构-2

图1 表空间文件页面编号

如图1所示,ibd将文件按16k进行划分,每个页面拥有唯一的编号。并且,相邻文件的编号是连续的,ibdata.ibd的最后一个页号是x-1,而ibdata2.ibd的第一个页号是x。

由此可见,innodb表空间有如下特点:

1. 在相同表空间中,页号是唯一标识,不存在页号相同的不同页面。

2. 每个ibd文件的大小必定是16k的倍数。

3. 除表空间的最后一个ibd文件外,其他数据文件的大小不能变化,更不能autoextend。因为,如果中间的文件大小发生改变,就会导致后面页号的改变。

上述的第3点,是由于innodb的具体实现导致的局限,对于修改过MySQL的innodb_data_file_path参数都应该深有体会,因为它是不能随便修改的。

以上介绍的都是innodb的默认表空间,事实上,在innodb中,表空间分为:

  • 共享表空间(默认表空间):上述介绍的表空间,通过配置文件可以指定其文件列表,这个表空间记录了所有表和索引的字典信息、事务信息和部分表的数据等。
  • 独立表空间:启用innodb_file_per_table后,每创建一个表都会创建一个独立表空间,这个表空间的数据只会存储这个表和索引的数据。并且这个表空间只有一个自动增长的数据文件,保存在所在database目录中,名字为表名.ibd。

Innodb使用表空间id来区分不同的表空间,共享表空间的id总是为0,其他独立表空间的id依次递增。那么,某个页面的唯一标识就是

(space_id, page_no)

space_id指表空间id,page_no指在表空间中的页号。根据表空间就可以确定其文件列表,根据页号就能确定文件及文件中的偏移。

2. 页头

上一篇说到页头就是一个页面第一个组成部分,表示该页的控制信息以及页面的使用情况。数据页的页头可分为两个部分:
  • 通用页头:表示所有页面都使用的页头,占38个字节。innodb的页面有很多类型,如簇描述页、事务信息页、数据字典页、ibuf页、数据页等。这些不同类型的页面都有自己的页头,但都会包含这38字节的通用页头,记录一些通用信息。
  • 数据页头:接下来的112个字节是数据页头,表示数据页的头信息。前面说到,innodb的页面有很多类型,上一篇和本文都是针对数据页来介绍的。数据页,顾名思义就是存储真正数据的页面,而数据页头是记录数据页的头信息,其他类型的页面有其他头信息。

也就是说,数据页页头的总大小为38+112=150字节。下面详细介绍页头记录的内容。

2.1 通用页头

通用页头共占38个字节,依次为。

  • 页面checksum值(4字节):页面内容的校验值,用于校验页面内容的合法性和可靠性(如部分写,下文介绍)
  • 页号(4字节):页面在表空间的页号
  • 左兄弟页面的页号(4字节):上一篇第一节介绍了B+树的基础内容,提到相同层次页面是通过一个双向链表连接起来的。而左兄弟页号就是该页在链表中的左兄弟页号。当然,左右兄弟页面都必须属于同一表空间。
  • 右兄弟页面的页号(4字节):同上,右兄弟页号。
  • 页面LSN(8字节):页面最后一次修改的LSN值,用于页面刷盘和恢复。LSN是一个递增的日志序列号。
  • 页面类型(2字节):innodb有若干种页面类型,通过这个可以区分,数据页面页面类型都为FIL_PAGE_INDEX(17855)。
  • 文件刷盘LSN(8字节):只有共享表空间各文件首页使用,记录服务器正常结束的LSN值,一般只用于检查和校验。
  • 表空间ID(4字节):表空间ID

2.2 数据页头

数据页头共占112个字节,成员比较多,也相对复杂些,由低字节到高字节,包括一下成员:

  • PAGE_N_DIR_SLOTS(2字节):指slot区的slot的个数,每个slot两个字节。
  • PAGE_HEAP_TOP(2字节):堆顶指针,未分配空间的首地址
  • PAGE_N_HEAP(2字节):记录堆内记录数,包括已删除记录和最大最小虚记录。因此,初始化为2。第15 bit为1表示row_format=compact
  • PAGE_FREE(2字节):第一个已删除记录偏移。所有已删除记录连接在一起成为*空间链表。
  • PAGE_GARBAGE(2字节):已删除记录占用的总字节数,即记录堆内已删除的总空间大小。
  • PAGE_LAST_INSERT(2字节):最后一次插入记录的偏移
  • PAGE_DIRECTION(2字节):页面最后一次的插入方向,如果本次插入比上次插入的值大就是PAGE_RIGHT,反之就是PAGE_LEFT
  • PAGE_N_DIRECTION(2字节):相同插入方向的连续插入次数
  • PAGE_N_RECS(2字节):页面有效记录数
  • PAGE_MAX_TRX_ID(8字节):最后一次改变页面的事务ID,仅在二级索引中有效,用于二级索引记录MVCC多版本可见性判断。
  • PAGE_LEVEL(2字节):页面在索引中的层次,叶子节点层次为0。
  • PAGE_INDEX_ID(8字节):页面所属索引的ID。
  • PAGE_BTR_SEG_LEAF(10字节):叶子节点段头inode信息,仅在B+树的根页有效
  • PAGE_BTR_SEG_TOP(10字节):内节点段头inode信息,仅在B+树的根页有效

数据页头的成员较多,PAGE_N_DIR_SLOTS、PAGE_N_HEAP、PAGE_N_RECS比较简单,主要用于页面计数,每次页面插入、更新或删除记录,都有可能影响这些值。而其他页头可以通过一些具体操作介绍其关键作用。

3.页面操作

3.1 页面空间分配

涉及页头:PAGE_HEAP_TOP、PAGE_FREE、PAGE_GARBAGE

接口:page_mem_alloc_free和page_mem_alloc_heap

页面由两部分空闲空间组成,包括:

  • *空间链表:记录堆中已删除记录的链表,页头的PAGE_FREE指向了链头,该链表的记录以被删除顺序的逆序为序,即最后被删除的记录在链首,最早被删除在链尾。
  • 未分配空间:未分配的连续空间。

以上两个空闲空间都可以用于新记录的分配,而Innodb插入新记录空间分配的策略是

1. 假设插入新记录的大小是rec_size,未分配空间大小是unalloc_size,*空间链表和未分配空间的总大小是max_size。

2. 如果rec_size + 1K > max_size,页面插入失败。页面总至少预留1K的空间用于数据的更新。

3. 如果rec_size > unalloc_size,页面插入失败。

4. 判断PAGE_FREE指向的第一个已删除记录(称为*链表首记录)的空间是否大于新记录的所需空间,若不满足,进入第5步,否则直接重用该空间,PAGE_FREE指向下一条已删除记录并且PAGE_GARBAGE减去rec_size。(可能考虑性能问题,不会遍历这个*空间链表,并且这个链表也没有按被删除记录的大小排序)

5. 从未分配空间中分配,同时PAGE_HEAP_TOP增大rec_size。这个空间一定足够,由第3步保证。

如果页面插入失败,就会导致页面分裂。

3.2 页面分裂

接口:btr_page_split_and_insert

由于页面空间不足,会导致页面插入失败。那么,就需要页面分裂,一个页面分裂为两个页面来获得更多的空间。

页面分裂是另一种空间分配的方式,并会导致越来越多的数据页。一般页面分裂的主要过程就是将一个页面一半数据拷贝到一个新的页面中,并成为左右兄弟结点,如下图所示。

Innodb页面存储结构-2

图2 一个页面分裂的过程

图2表示一个索引键范围是[1,100]的页面分裂为两个范围分别是[1,50]和[50,100]的页面的过程。两页面成为左右兄弟结点,同时父节点会多了一个指向新页的页面指针。如果此时父节点的空间也不够了,那么就需要进行父节点的页面分裂,那就是一个B树分裂的递归过程了,这里就不再详述。

而页面分裂可以是往左分裂或往右分裂,并且每次分裂并不一定都是页面记录平分,关于这些内容可参考3.5的页面统计优化。

3.3 页面合并

接口:btr_cur_compress_if_useful

页面合并是页面分裂的逆过程,一般发生在删除记录后。当相邻页面的有效使用空间都小于50%时,相邻页面就会合并为一个页面,减少空间浪费。

3.4 页面重组

接口:btr_page_reorganize

我们考虑一个问题,是否存在*空间链表为空(PAGE_FREE值为NULL),但PAGE_GARBAGE值不为0的情况。

从3.1节页面空间分配的第4步知,如果插入记录的空间(rec_size)小于*链表首记录的空间(del_rec_size),可以重用这部分空间,但PAGE_GARBAGE是只减去rec_size,会造成页面del_rec_size-rec_size的空间浪费。如果这样的空间重用太多,浪费可能会越来越多。

另外,上文已经说过,重用*链表空间不会遍历整个链表,而只是用首记录去比较。如果*链表首记录空间非常小,会导致整个*链表空间都无法重用,就会导致更多的空间浪费。

因此,PAGE_GARBAGE就是为了统计这部分浪费的空间,并且如果这部分空间太大,就会导致页面重组。页面重组的步骤大概为:

1. 将页面的所有内容拷贝到一个的16k临时页面,并初始化原页面,此时原页面是一个新页。

2. 按临时页面的记录的先后顺序,依次将记录插入到原页面中。

3. 由于临时页面是完全新的,记录会依次使用未分配空间,空间上紧密相邻的(PAGE_GARBAGE=0),并且物理存储上也是有序。

页面重组主要是发生在Update操作上,即页面无法存储更新后的记录,就尝试页面重组来获得更多空闲空间,再尝试执行更新操作。

3.5 页面统计优化

涉及页头:PAGE_LAST_INSERT、PAGE_DIRECTION、PAGE_N_DIRECTION

接口:page_cur_try_search_shortcut

我们再考虑一种情况,如果连续插入主键值为1、2、3的记录,那么下一条很可能就是插入4。我们知道,每次插入操作前,为了维护原来的页面记录有序性,首先要确定插入位置,即记录4应在记录3后插入。一般情况下,插入位置需要通过二分查找来确定,但是如果页面记录比较多,二分查找也是有性能损耗的(并且上一篇说过不是真正的二分查找)。考虑以上情况,如果插入记录4前,先保存上一次插入记录3的位置,那么插入记录4就不需要二分查找来确定插入位置。

因此,页面统计优化就是针对按索引键顺序连续插入记录的优化,可以快速定位插入位置而避免过多的二分查找。并且,按索引键插入的情况是非常常见的,如以自增列或时间为主键的表。

页面统计优化的相关页头的意义为

  • PAGE_LAST_INSERT:存储上次插入记录的位置
  • PAGE_DIRECTION:存储插入的方向,如果本次插入比上次插入的值大就是PAGE_RIGHT,反之就是PAGE_LEFT。
  • PAGE_N_DIRECTION:相同插入方向的连续插入次数,即递增插入或递减插入的次数。

页面统计优化的逻辑是:

假设PAGE_LAST_INSERT指向的记录是last_insert_rec,比last_insert_rec大的下一条记录是next_rec,比last_insert_rec小的上一条记录是prev_rec,插入的记录是rec。

  1. 1. 如果PAGE_N_DIRECTION > 3并且PAGE_DIRECTION为PAGE_RIGHT,即递增插入的次数多于3次,继续。否则,不能使用统计优化。
  2. 2. 如果last_insert_rec <= rec <= next_rec,则last_insert_rec就是插入位置,不需二分查找,返回。否则,不能使用统计优化。
  3. 3. 如果递减插入,并不使用该项优化。

除了统计优化,以上三个页头也会影响页面分裂的行为,即不是平分为两个页面,而是根据PAGE_LAST_INSERT指向的记录划分。另外,还影响页面分裂是往左分裂还是往右分裂,详见函数btr_page_split_and_insert。

4. 页尾

页尾是页面的最后8个字节,包括两部分,主要用于页面内容的校验,其中包括:

  • OLD_CHKSUM:4字节,页尾的checksum值,与通用页头的页面checksum值使用不同的算法。
  • LSN_LOWER_4BYTES:4字节,记录通用页头中页面LSN的低四字节。

通过页头的页面checksum值、页面LSN和页尾的OLD_CHKSUM、LSN_LOWER_4BYTES可以判断一个页面是否corrupted,判断算法是(详见函数buf_page_is_corrupted):

1. 判断LSN_LOWER_4BYTES是否等于页面LSN的低四字节,若不相等,返回TRUE。

2. 使用相应的checksum生成算法,判断页面checksum值和OLD_CHKSUM是否合法,若不合法,返回TRUE.

一个页面corrupted一般发生在部分写(partial write)的情况下。

当一个在内存中的脏页要写文件前,会将上述LSN和checksum值计算并写入页面合适的位置。当脏页写入文件时发生异常,会导致只有一部分的页面内容写入文件,其他内容就会丢失,也就是所谓的部分写问题。

当部分写产生时,页面显然就是corrupted(不符合上述条件),页尾的主要作用就是为了快速判断一个页面是否corrupted。由于一个页面的大小是16k,而操作系统一次IO一般是512字节,因此,部分写问题是很难避免的(除了特殊的文件系统以及硬件支持),Innodb使用double write来解决部分写问题。

5. 记录格式

前面都是介绍页面的格式,这一节介绍下记录的详细格式。

5.1 记录格式的类型

由于innodb经历了很多年的发展,支持多种记录格式。通过建表指定row_format可以指定不同的记录格式,例如:

create table t1 (

c1 int primary key,

c2 varchar(260) not null,

c3 char(10),

c4 varchar(260),

c5 varbinary(220),

c6 datetime,

c7 blob,

c8 text

) row_format = Compact;

上述语句创建了一个行格式为compact的表t1。

到目前为止,Innodb支持的行格式包括

  • Redundant:MySQL 5.0.3以前的默认记录格式。
  • Compact:Redudant的升级版,MySQL官方称相对于Redundant,平均能节省20%的存储空间,但某些操作可能更耗CPU时间,是MySQL 5.0.3及其以后的默认记录格式。对于行外数据,会存储768字节的行内前缀+20字节行外地址指针。
  • Dynamic:除行外数据处理外, 其他与Compact记录格式是一致的。Dynamic行外数据不会存储行内前缀,只会使用20字节保存行外内容的地址信息。主要为了解决Compact格式无法处理过多行外字段的问题。
  • Compressed:Dynamic格式的压缩版本。

下文主要介绍Compact记录格式,Dynamic是类似的。而Redundant就不介绍了。

5.2 记录格式

Compact记录可分为记录头和记录体两部分,如下图所示。

Innodb页面存储结构-2

图3 记录格式

如上图所示,记录指针是指向记录体的首位置而不是记录头。上一篇提到,页面有效记录是从小到大通过记录指针(下一条记录相对于页面偏移)而连接起来的。那么这个偏移指的就是记录体的偏移,而不是记录头。

这样做的目的是根据记录指针向左和向右就可以分别解析记录头和记录体。

并且,不管聚集索引还是二级索引记录,记录格式都是一致的,仅是记录体的内容会不一样。显然,聚集索引记录体的内容会更多,5.4节会详细介绍。

5.3 记录头

记录头主要存储记录的一些控制信息,包括三部分,如下图所示。

Innodb页面存储结构-2

图4 记录头

由于记录头解析过程是从右往左,我们也从右往左介绍这三部分。

5.3.1 Record Extra Bytes

这部分占5个字节的固定空间,其存储的主要内容为(从低字节到高字节)

Innodb页面存储结构-2

图5 Record Extra Bytes

1. 第0字节包含两部分

a) N_owned:0~3 bit,表示slot支链的高度,仅slot支链链尾记录有效(即Slot指向的记录),其他记录必为0。

b) Info bits:4~7 bit,第4 bit表示是否为最小有效记录,第5 bit表示该记录是否被删除(如执行delete操作,该标记可能会置为1),其他未用。

2. 第1~2字节表示一个short int,包含两部分

a) Status:0~2 bit,共3bit,表示记录的类型,可表示范围是0~7八种可能的记录类型,现支持0~3四种:

  • 0:普通记录(即B+树叶子节点上的记录,包括聚集索引和二级索引记录)
  • 1:索引指针记录(即B+树非叶子节点上的记录,同样包括聚集索引和二级索引非叶子节点记录)
  • 2:最小虚记录
  • 3:最大虚记录

b) Heap No:3~15 bit,表示该节点在记录堆内的标号,该编号每次在记录堆内分配后就会固定不变,不会因为空间重用而改变。

3. 第3~4字节表示记录next指针,共16bit,即两字节,存储下一条记录记录体的页内偏移。另外,*空间链表也是用这个指针连接起来。

5.3.2 Nullable Bitmap

Nullable Bitmap用来表示记录可空字段是否为NULL,每一个可空字段占用1个bit,1表示该字段为NULL。

在Compact格式中,所有类型的字段值为NULL都只会在Nullable Bitmap中标记,不会占用记录体的任何空间。因此,Compact格式有较高的空间利用率。

对于聚集索引记录,假设表的可空字段数为n_nullable。那么,Nullable Bitmap会占用ceiling(n_nullable/8)字节的空间,每一个bit表示依次表示一个可空字段。以5.1节建立的表t1为例,可空字段数为6,那么Nullable Bitmap会占用1个字节。

对于二级索引记录,假设二级索引键的可空字段数为n_sec_nullable,占用空间为ceiling(n_sec_nullable/8)字节。

5.3.3non-NULL Variable-Length Array

non-NULL Variable-Length Array是记录体中非空变长字段的实际长度数组,并标识该字段是否行外存储。由于NULL字段在Compact格式中长度必为0,并且可以在Nullable Bitmap中判断,因此不用存储。变长字段包括varchar、varbinary、text和blob等,如表t1的变长字段包括c2、c4、c5、c7和c8,若这些字段在记录上不是null,那么会使用1~2字节来保存其实际长度。

另外,当记录的总长度超过页面大小的一半(一般为8000字节左右),记录就需要挑选几个最大的变长字段实施行外存储,直到行内记录总长度小于8000字节。值得注意的是,不仅仅是blob或text字段会导致行外存储,varchar和varbinary字段同样会发生。

因此,非null变长字段长度的意义为:

1. 若行外存储

  • 最高位(第15位)总是1,表示使用两字节表示长度
  • 次高位(第14位)为1,表示行外存储
  • 剩余14位(0~13)表示行内存储使用的长度,Compact行格式是768字节前缀+20字节行外指针。而Dynamic行格式仅包含20字节行外指针。行外字段真实长度记录在行外页中。

2. 若行内存储

  • 若字段定义的最大长度小于等于255,用1个字节表示其长度。
  • 若字段定义的最大长度大于255,但实际使用长度小于128,用1个字节表示其实际长度。
  • 若字段定义的最大长度大于255,并且实际使用长度大于等于128或者若字段是大字段(blob、text等),用2字节表示其长度。
    • 最高位(第15位)总是1
    • 次高位(第14位)为0表示行内存储
    • 剩余14位(0~13)表示真实长度。

对于定长字段,即使是char(n),记录是不存储其长度的。事实上,对于定长字段,compact格式的记录都在记录体中使用定义长度的存储空间。例如,t1表的c3列是char(10),即使插入c3列的值为'a',在记录上也会使用10字节的空间,innodb的处理是在末尾填充空格。因此,定长字段不需记录其长度。

例如,对于插入记录

Innodb页面存储结构-2

非空变长字段包括c2、c4、c5、c7和c8,那么变长字段长度使用的字节数分别是1字节(实际长度小于128)、2字节(定义长度大于255实际长度大于等于128)、1字节(定义长度小于255)、2字节(行外字段)、2字节(text字段)。

5.4 记录体

记录体就是存储字段的真正非NULL内容,相比记录头,记录体更简单。下面根据索引类型和是否叶子节点记录来介绍记录体的内容。

5.4.1 聚集索引记录

聚集索引记录的主要结构如图6所示。

Innodb页面存储结构-2

图6 聚集索引记录

如上图所示,聚集索引记录的记录体主要包括:

1.主键:聚集索引记录记录体总是以主键开始的,如果建表不指定主键innodb会用第一个not null的unique索引替代,如果也没有,就会使用6字节的rowid作为主键。rowid是innodb内部维护的递增长整形。然而,使用rowid没有什么好处,并且会影响并发和查询性能,建议任何时候建表都指定主键。

2.事务ID:记录最近影响行(插入、更新、删除操作)的事务ID。

3.Roll Ptr:事务操作(插入、更新、删除)回滚记录的地址,与事务ID一起用于MVCC多版本实现。MVCC、读不上锁、一致读的关键就在于这两个内容。

4.主键外的其他非空字段,并以表定义的顺序为序。

i. null字段是不占记录体的存储空间的。

ii. 定长字段占用定义的最大长度,int占用4字节,char(n)占用n字节。

iii. 变长字段占用长度记录在non-NULL Variable-Length Array

5.4.2 二级索引记录

二级索引记录体包括二级索引键和主键,比较简单,如图7所示。

Innodb页面存储结构-2

图7 二级索引记录

Innodb对二级索引记录做了一些存储优化,如果二级索引键包含主键的一些字段,这些字段只存一份。例如表t2的主键是(c1, c2),二级索引键是(c2, c3),那么,c2不需重复存储,实际二级索引记录的字段依次是(c2, c3, c1)。

另外,由上图知,二级索引记录并不像聚集索引记录一样保存事务id和Roll Ptr等信息。那么,二级索引记录如何保证非锁定读的多版本机制呢?

事实上,如果称聚集索引的事务信息是行级的话(每一行都保存事务和回滚信息),那么,二级索引的事务信息就是页级的。在2.2节中数据页头中的PAGE_MAX_TRX_ID就是这个页级的事务ID,并且只有二级索引页有效。

每当事务修改某条二级索引记录时,都会修改当前页面的PAGE_MAX_TRX_ID的值。当一个读事务某二级索引记录时,Innodb根据一定的算法,使用PAGE_MAX_TRX_ID和当前读事务ID来判断该记录是否不可见。如果不可见,再定位对应聚集索引来判断真实的可见性。

由于事务信息是页级的,就可能会导致一些的无谓的可见性判断,并多了一次聚集索引的记录的查找过程,可能会带来较大的性能损耗。

这里可能说得还不够清楚,涉及到MVCC的机制和可见性判断算法,但innodb的这种设计目的就是用时间换空间,使得二级索引记录不需存储事务信息,提高存储效率。

5.4.3 索引指针记录(非叶子节点记录)

索引指针记录是指B+树中非叶子节点记录,是组织B+树的内节点记录,结构如图8所示。

Innodb页面存储结构-2

图8 索引指针记录

  • 索引键是B+树的排序键,对于聚集索引就是主键,对于二级索引是二级索引键+主键。
  • Child_PageNo:表示不小于索引键值下一层页面页号,该页可以是叶子节点,也可以非叶子节点。也就是说,索引键是Child_PageNo执行页面索引键的下界。

6. 小结

Innodb数据页的存储结构基本上就是上述介绍的,结构还有点复杂,但只有搞清楚这些结构后才能对Innodb的内部处理(如存取效率,MVCC,事务处理,redo和undo等)有更加深刻的理解。

另外,上述讲述很多页头、页尾或记录格式等都是几字节的整形,需要注意的是innodb是使用大端存储的,即高字节在低位。

Innodb的compact记录格式几乎利用了所有字节内容(简直就是寸土寸金),其格式设计也是很有代表性,非常值得借鉴和学习。

 

相关阅读

【MySQL内核解析】Innodb页面存储结构-1