0%

MySQL 页与行

引言

【页】是 InnoDB 引擎管理内存的基本单元,也是内存与磁盘交互的基本单元。当 MySQL 检索或修改一条记录的时候,InnoDB 会将包含该记录的整页数据加载到内存中(缓存局部性原理)。如果是修改操作,则直接在内存中修改,然后标记当前页为脏页,由其他线程刷盘。

存储结构

从 InnoDB 存储引擎的逻辑来看,所有的数据都存放在一个空间内,称为表空间(tablespace),而表空间由段(segment)区(extent)页(page)组成。如下图所示:

表空间

表空间是一个逻辑容器,它存储的对象是段,一个表空间可以存在一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可划分为系统表空间、用户表空间、撤销表空间、临时表空间等。

表空间被分为共享表空间、独立表空间两种类型。共享表空间意味着多张表共用一个表空间,而独立表空间则意味着每张表有一个独立的表空间,换句话说就是数据和索引信息都会保存在自己的表空间中。独立的表空间可以在不同的数据库之间进行迁移。以下命令可以查看当前系统启用的表空间类型:

1
mysql > SHOW VARIABLES LIKE 'innodb_file_per_table';

可以把表空间看作是 InnoDB 存储引擎逻辑结构的最高层,它是由一个或多个磁盘文件组成的虚拟文件系统,它不仅存储表跟索引,还保留了回滚段、双写缓冲区等。

段是由一个或多个区组成,区在文件系统上是一个连续的空间,而在段中并不要求区与区之间是相邻的。

段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当数据库表、索引被创建时,相应的段也会创建出来。

InnoDB 中,一个区会分配 64 个连续的页。由于 InnoDB 中页的大小默认为 16KB,所以一个区的大小为 64*16KB = 1M。

页是 InnoDB 管理的最小单位,默认大小为 16KB,可以通过 innodb_page_size 设置页的大小(数据库实例初始化之前),一旦设置完成之后,不能再次修改,除非通过 mysqldump 导入和导出来产生新的库。

InnoDB 中常见的页类型为:

  • 数据页(B-tree Node)
  • undo 页(undo Log Page)
  • 系统页(System Page)
  • 事务数据页(Transaction System Page)
  • 插入缓冲位图页(Insert Buffer Bitamp)
  • 插入缓冲空闲列表页(Insert Buffer Free List)
  • 未压缩的二进制大对象页(Uncompressed BLOB Page)
  • 压缩的二进制大对象页(Commpressed BLOB Page)

InnoDB 将数据按行存放,每个页存放的行记录最多为 16KB/2-200 = 7992 行。

页的数据结构:

每部分的含义为:

名称 大小 说明
File Header 38B 文件头,描述页的信息
Page Header 56B 页头,页的状态信息
Infimum + Supremum 26B 最小和最大记录,这是两个虚拟的行记录
User Records 用户记录,存储行记录内容
Free Space 空闲空间,页中还未被使用的空间
Page Directory 页目录,存储用户记录的相对位置
File Trailer 8B 文件尾,校验页是否完整

File Header

File Header 是所有页都有的一个通用结构,占用固定的 38B,它记录页的一些通用状态,如:页号、Checksum、把页串联成双向链表的指针、页的类型等等。

  • FIL_PAGE_SPACE_OR_CHECKSUM

    4B 大小,基于当前页计算出的校验和,校验和不同,两个页数据肯定不同。它的作用是 InnoDB 在脏页刷盘时,有可能会遇到页刷到一半断电的情况,页的头和尾部部分分别记录校验和,只有当两者的校验和一致时,才代表磁盘上的页是完整的,否则就是一个损坏的页。
  • FIL_PAGE_OFFSET

    4B 大小,页号,页的唯一标识,全局递增的数字,InnoDB 通过页号来定位唯一的一个页。由于 4B 大小,所以一个表空间可以有 2^32 个页,如果一个页 16KB,那么一个表空间最多支持 64TB 的数据。
  • FIL_PAGE_PREV & FIL_PAGE_NEXT

    各占 4B,一张表由多个页构成,页与页之间在物理上可以是不连续的,但是逻辑上要连续,而 FIL_PAGE_PREV 跟 FIL_PAGE_NEXT 则分别指向当前页的上一页和下一页的页号,通过这两个指针,将索引页串联成一个双向链表。记录之间虽然是单向的,但是页之间是双向的。
  • FIL_PAGE_LSN

    8B 大小,页最后被修改时所对应的 LSN值,全程为 Log Sequence Number,日志序列号。一个递增的数字,与事务相关。
  • FIL_PAGE_TYPE

    2B 大小,代表当前页类型,其中索引页固定为:0x45BF
  • FIL_PAGE_FILE_FLUSH_LSN

    8B 大小,仅在第 1 页中使用,用来判断数据库是正常关闭还是异常宕机。
  • FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID

    4B 大小,表示当前页属于哪个表空间。

Page Header 是索引页特有的结构,占用固定的 56B 大小,记录索引页中记录相关的状态信息。字段比较多,我们关注一些比较重要的字段即可:

  • PAGE_N_DlR_SLOTS

    一个页可能含有上千条记录,如果挨个遍历,效率太低。为了提高页中记录的检索效率,InnoDB 将页内的记录划分为多个组,组里最大的那条记录相对于页的地址偏移量记录到 Page Directory 中,每个组都对应一个槽,槽的大小固定为 2B。该属性记录的就是页内槽的数量。
  • PAGE_HEAP_TOP

    Free Space 的起始位置,它是 User Records 和 Free Space 的分界点。一个全新页一开始是没有 User Records 部分的,没插入一条记录,都要向 Free Space 申请空间,Free Space 耗尽则代表页满了。
  • PAGE_FREE

    DELETE 命令删除记录时,InnoDB 并不会真的将记录从磁盘中删除,而是在记录的头信息中打个标记,然后将其加入到删除链表中。PAGE_FREE 指向的就是删除链表的头节点。
  • PAGE_DIRECTION & PAGE_N_DIRECTION

    PAGE_DIRECTION 表示最后一条数据插入的方向,比上一条记录值大则记为右边,反之则是左边。PAGE_N_DIRECTION 表示同一方向连续插入的记录数,方向变了该值就会重置。
  • PAGE_LEVEL

    InnoDB 组织数据的形式是 B+ 树,树中的节点就是索引页,PAGE_LEVEL 代表当前页在 B+ 树中所处的层级。InnoDB 规定,叶子节点层级为 0,然后向上递增。

User Records

用户的数据记录存放在 User Records 部分,一个全新的页一开始全是 Free Space。当有数据插入进来的时候需要向 Free Space 申请空间,当 Free Space 耗尽时,则代表当前页已经用完了。如果再有记录需要插入,则需要申请一个新的页了。

记录的格式

MySQL 目前有四种行记录格式:Redundant、Compact、Dynamic 和 Compressed。其中 Redundant 是以前使用的旧格式,为了兼容性还一直保留。自 MySQL 5.1 开始,默认的行记录格式为 Compact,而从 MySQL 5.7 开始,默认的行记录格式为 Dynamic。通过以下命令查看当前表的行格式:

1
mysql> SHOW TABLE STATUS LIKE '<table name>';

Compact 行格式在 MySQL 5.0 引入,设计的目标是高效的存储数据。简单来说就是:一个页中存放的行数据越多,其性能就越高。其结构如图所示:

记录跟记录之间是怎么连接的呢?其答案就在记录头里,我们把记录头信息拿出来看看它的结构:

记录头信息的最后 2B 用来连接下一条记录,将页内所有记录串联成一个单向链表。如下图:

大家不妨想一想,数据是按照什么顺序排序的?

Infimum & Supremum

试想一下,InnoDB 如何知道当前页中是否含有目标数据?需要把整页的数据都遍历一遍吗?那效率太低了。为了解决这个问题,在页中划分出了一块区域 The Infimum and Supremum Records,代表了当前页中的最大和最小记录。

有了 Infimum Record 和 Supremum Record,现在查询不需要将某一页的 User Records 全部遍历完,只需要将这两个记录和待查询的目标记录进行比较即可。比如我们要查询的数 id = 200,那么很明显不在当前页。接下来就可以通过下一页指针跳到下一页进行检索。

Page Directory

通过 Infimum Record 和 Supremum Record 可以判断出目标数据是否在当前页中,避免了页遍历。但是如果目标数据在当前页中,也不可避免需要遍历 User Records,由于 User Records 是单链表,效率并不是很高。那么如何提高这部分效率呢?此时就需要 Page Directory

介绍 PAGE_N_DlR_SLOTS 时提到过,为了提高遍历效率,InnoDB 将记录划分到多个组中,每个组对应一个槽。而 Page Directory 是一个目录,里边有很懂槽位,每个槽位都指向了一条 User Records 中的记录。按照官方设定,在一个完整的页中,每个 6 条数据就会有一个槽。

MySQL 在新增数据的时候就会将对应的槽创建好,有了 Page Directory,就可以对一张页的数据进行粗略的二分查找,确定出大概的位置,找到大概的位置之后,需要回到 User Record 中继续挨个遍历匹配。

File Trailer

File Trailer 是所有页都有的通用结构,占用固定的 8B,它主要作用就是为了校验页的完整性。由于磁盘的速度较慢,InnoDB 不会每次更新数据就直接刷盘。而是将页作为刷盘的基本单位,数据更新时,先对内存中的页进行修改,稍后再将整个页的数据一次性刷到磁盘里。但是刷盘的途中难免会遇到断电等一系列的故障,造成页的数据不完整。InnoDB 再刷盘前根据页数据计算出一个 Checksum,在页头跟页尾都写一份。刷盘的时候,先刷页头再刷页尾,当头尾两个 Checksum 一致时,说明磁盘是完整的,否则,说明出错了。

InnoDB 中最小的存储单元是页,默认每页的大小为 16KB。页中的数据是按行进行存放的,每页中存放的行记录最少为 2 行,最多为 16KB /2 - 200 行,也就是 7992 行。

以 Compact 格式为例,介绍一下行的存储结构:

  • 变长字段长度列表
    MySQL 支持一些变长的数据类型,如: VARCHAR(n)、TEXT、BLOB 等,使用这些数类型的列称为变长字段。由于变长字段存储多少字节的数据是不固定的,因此我们需要在存储真实数据的时候顺便将这些数据占用的字节数页存储起来。在 Compact 行格式中,会把所有的边长字段的真实数据(非 NULL)占用的字节长度都存放在记录开头,从而形成一个长度列表,各个变长字段的长度按照列的顺序逆序存放。至于变长字段长度列表中每个列长度使用多少字节来表示是有一套规则的。

    首先需要说明 W、M、L 的含义:W 为某个字符集中表示一个字符最多需要使用的字节数,这个值可以通过 SHOW charset 命令查看,对应 Maxlen 列的值。比如 UTF8 字符集中 W 就是 3,ASCII 字符集中的 W 就是 1。对于变长类型 VARCHAR(n) 来说,M 代表该类型能够最多存储的字符个数,再结合使用的字符集,可以得出该类型最多占用的字节数为 W * M。而 L 代表的是该类型实际存储的字符串占用的字节数。因此有以下规则:

    1. 如果 W * M <= 255,则使用 1 个字节来表示长度。
    2. 如果 W * M > 255,并且 L <= 127,那么使用 1 个字节来表示长度。
    3. 如果 W * M > 255,并且 L > 127,那么使用 2 个字节来表示长度。

📚 Tips

变长字段的长度最大不会使用超过 2 个字节来表示是因为在 MySQL 中,对于一条记录占用的最大存储空间是有限制的,除了 BLOB、TEXT 类型之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535 个字节。如果表只有一列,那么这也就意味着该列最多占用 65535 个字节,那么此长度使用 2 个字节表示即可。

另外,如果使用定长字符集,如:ASCII 字符集,那么 CHAR(N) 这中类型占用的字节数就是固定的;如果是变长字符集,如:UTF8,那么 CHAR(n) 这种类型所占用的字节数也是不确定的,只不过该列的长度也会被存储到变长字段长度列表中。

  • NULL 标记
    NULL 标志位将每个允许为 NULL 的列都对应一个二进制位,二进制位同样按照列的顺序逆序存放,二进制位为 1 表示该列的值为 NULL;为 0 表示该列的值不为 NULL。该部分一般占用 1 个字节,如果允许为 NULL 的字段超过 8 个,那么需要 2 个字节表示。

行溢出

我们知道行记录占用的空间最多为 65535B(包括隐藏列)。但是一个数据页的大小默认为 16KB,也就是 16384B,并且还有一个限制就是每页最少存储 2 行记录(否则就变成链表了,也就失去了 B+Tree 的意义),因此很有可能出现一页中连一条记录都存不下的情况。

对于这种情况,InnoDB 会将占用的存储空间非常大的列拆开,在当前列中只存储该列的前 768 个字节的数据和一个指向溢出页(Uncompressed BLOB Page 类型)的地址。

如果可以在一页中至少存放两行记录,那么 VARCHAR 等类型(TEXT、BLOB)的列数据就不会放到 BLOB 页中。

最后,我们需要知道如果一条记录占用的字节数过多时,可能发生行溢出。

Dynamic 和 Compressed 行格式

Dynamic 和 Compressed 是 InnoDB 1.0.x 版本开始引入的新格式。新的格式对于存放 BLOB 中的数据采用了完全行溢出的方式,也就是连 768B 的数据也被移到了溢出页中,数据页中只存放了 20B 的溢出页地址。同时Compressed 行记录格式会对行数据进行 zlib 算法压缩,因此在存储 BLOB、TEXT、VARCHAR 这类大长度类型的数据时比较有优势。