MySQL InnoDB Storage Engine

3.InnoDB space file 布局的基础知识: InnoDB 如何构建 space file 及其包含的 page

InnoDB Tablespaces

InnoDB 的数据存储模型使用 space,在 MySQL 中通常称为 tablespace,在 InnoDB 中有时称为 file space。
在操作系统层面,一个 space 文件(逻辑上的)可能由多个文件(物理上的,如 ibdata1、ibdata2 等)组成。
InnoDB 中每个 space 都有一个整数(32-bit) space ID,许多地方都可以用它来指代 space。
InnoDB 总是有一个 system space,其 ID 始终为 0。system space 用于 InnoDB 所需的各种特殊 bookkeeping。
InnoDB 默认使用 file per table 形式增加 space,即为每个表创建一个 .ibd 文件。show variables like %innodb_file_per_table%' 可以查看每个表是不是使用单独文件。

Page

每个 space 被划分为若干 page,page 是 MySQL 中磁盘与内存交互的基本单位,通常每个 page 为 16(innodb_page_size) KiB(有两个原因会导致有所不同:一是编译时指定 UNIV_PAGE_SIZE,二是使用了 InnoDB Compression)。
space 内的每个 page 都会分配一个整数(32-bit) page number,通常称为 offset,实际上就是 page 距离 space 起始位置的偏移量(对于有多个文件的 space 来说,不一定是文件起始位置)。
因此,第 0 个 page 的 offset 为 0,第 1 个 page 的 offset 为 16384,以此类推。
InnoDB 有 64TiB 的数据限制。这实际上是对每个 space 的限制,主要是因为 page number 是 32-bit 的整数,再乘上 page 默认大小:232 x 16 KiB = 64 TiB。

Page 布局如下:

Basic Page Overview

每个 page 都有一个 38-byte 的 FIL header 和 8-byte 的 FIL trailer(FIL 是 file 的缩写)。

header 包含一个用于指示页类型的字段(FIL_PAGE_TYPE),该字段确定 page 其余部分的结构。 FIL header 和 trailer 的结构:

FIL Header and Trailer

FIL header 和 trailer 包含以下结构:

FIL_PAGE_SPACE_OR_CHKSUM:当前页的校验和。
FIL_PAGE_OFFSET:page 初始化后,page number 将存储在 header 中。换言之,该字段被初始化则表示 page 已被初始化。
FIL_PAGE_PREV 、FIL_PAGE_NEXT:用于 page 之间的(双向链表)链接,做到逻辑上连续。
FIL_PAGE_LSN:保存 page 最后一次修改所产生的 64 位日志序列号(log sequence number)。
FIL_PAGE_TYPE:page type 决定了 page 其余部分数据的解析方式。page 可用于:FIL_PAGE_TYPE_ALLOCATED(未使用的空白页)、FIL_PAGE_UNDO_LOG(undo日志)、FIL_PAGE_TYPE_SYS(系统页)、FIL_PAGE_INDEX(索引页、数据页)、......。
FIL_PAGE_FILE_FLUSH_LSN:该项仅针对系统表空间的第一页(page 0 of space 0)定义:该文件已被刷新到磁盘,至少刷新到此 LSN。对于 FIL_PAGE_COMPRESSED 页,我们在这 8 个字节中存储压缩页控制信息。另外,这个字段非常适合在其它空间中重复使用。
FIL_PAGE_SPACE_ID:space ID 保存在 header 中。
FIL_PAGE_END_LSN_OLD_CHKSUM:前 4 字节:存 checksum,后 4 字节:存 header 中的 FIL_PAGE_LSN 的后 4 字节。整个字段主要用于和 header 中的值比较,看是否一致,以保证 page 在磁盘上的完整性。

Space file

一个 space 文件是许多(最多 232 个) page 的连接。为了提高管理效率,page 被分组为 1 MiB(64 个连续 page,默认 page 大小为 16 KiB),称为 extent。许多结构仅使用 extent 来分配 space 内的 page。

InnoDB 需要做一些 bookkeeping 工作来关注所有 page、extent 和 space 本身,因此 space 文件有一些必要的上层结构:

Space File Overview

空间的第一页(page 0)总是 FSP_HDR(FIL_PAGE_TYPE_FSP_HDR) 页,即 file space header 页。FSP_HDR 页包含(confusingly)一个 FSP header 结构,用于关注 space size、free lists、fragmented、full extents。
一个 FSP_HDR 页的内部空间只够存储 256 个 extent(16384 页,256MB)的 bookkeeping 信息,因此每隔 16384 页就必须以 XDES(FIL_PAGE_TYPE_XDES) 页的形式预留额外空间来存储 bookkeeping 信息。
XDES 页和 FSP_HDR 页的结构完全相同,只是在 XDES 页中 FSP header 结构会被 zeroed-out。这些额外 XDES 页会随着 space 文件的增长而自动分配。
空间的第二页(page 1),也就是在每个 FSP_HDR 页或 XDES 页旁边都有的 IBUF_BITMAP(FIL_PAGE_IBUF_BITMAP) 页,是用于保存 Insert Buffer 相关的 bookkeeping 信息。
空间的第三页(page 2)总是 INODE(FIL_PAGE_INODE) 页,用于存储 file segment(groupings of extents plus an array of singly-allocated fragment pages) INODE 条目列表。每个 INODE 页可存储 85 个 INODE 条目,每个索引需要两个 INODE 条目。

System space

系统空间(space 0)在 InnoDB 中比较特殊,它包含了许多以固定页码分配的页,用于存储对 InnoDB 运行至关重要的各种信息。由于 system space 和其它 space 一样,所以它前三页分配了 FSP_HDR、IBUF_BITMAP 和 INODE。之后就不一样了:

ibdata1 File Overview

分配了以下 page:

Page 3,type SYS:与 Insert Buffer 相关的 Headers 和 bookkeeping 信息。
Page 4,type INDEX:保存 Insert Buffer 的索引结构的根页。
Page 5,type TRX_SYS:与 InnoDB 事务系统运行有关的信息,如 latest transaction ID、Binary Log 信息、doublewrite buffer extent 的位置。
Page 6,type SYS:第一个 rollback segment 页。根据需要分配额外 page(或整个 extent)来存储 rollback segment 数据。
Page 7,type SYS:与 data dictionary 相关的 Header,还包含索引页的根页码。要找任何其它索引(表),都需要这些信息。
Pages 64-127:doublewrite buffer 的第一个 block(64 page,1 extent)。doublewrite buffer 是 InnoDB 页刷新策略的一部分。
Pages 128-191:doublewrite buffer 的第二个 block。

所有其它 page 根据需要分配给 indexes(索引)、rollback segments(回滚段)、undo logs(撤销日志)等。

File-per-table space

InnoDB 提供 file-per-table 模式,它会为创建的每个 MySQL 表创建一个文件(如上所述,实际上是一个 space)。为每个表创建的 .ibd 文件具有典型的 space 文件结构:

IBD File Overview

如果忽略在运行时添加索引(fast index creation),在必要的 3 个初始 page 之后,space 中分配的下一个 page 是表中每个索引的根页,顺序与创建表时定义的顺序一致。page 3 是 clustered 索引的根页,page 4 是第一个 secondary key 的根页。
由于 InnoDB 的大部分 bookkeeping 结构都存储在 system space 中,因此在 file-per-table space 中分配的大部分 page 都是 INDEX 类型,用于存储表数据。

4.InnoDB space file 中的 page 管理:space file 中的 file segment、extent 和 page 管理相关的结构

上面介绍了 space 和 page 的基本结构,下面进一步了解 InnoDB 中 page 管理、extent 管理、free space 管理相关的结构,以及它如何跟踪分配给不同用途的页。

Extent 和 XDES Entry(extent descriptor)

InnoDB 页大小通常为 16 KiB,并以 64 个连续 page 组成 1 MiB 的 block,这就是所谓的 extent。

InnoDB 在 space 内的固定位置分配 FSP_HDR 页和 XDES 页,以记录哪些 extent 正在使用,以及每个 extent 中哪些 page 正在使用。这些页结构比较简单:

FSP_HDR Page Overview

它们包含常见的 FIL header 和 trailer、FSP header 和 256 个 XDES Entry(extent descriptors,或简称 descriptor)。它们还包含大量未使用的空间。

XDES Entry(extent descriptor) 具有以下结构:

XDES Entry

XDES Entry(extent descriptor) 中各字段的作用是:

File Segment ID(XDES_ID):如果 extent 属于 file segment,则是该 extent 所属 file segment 的 ID。
List node for XDES list(FLST_PREVFLST_NEXT):指向 previous extent 和 next extent 的指针。
State(XDES_STATE):该 extent 当前的状态,有四种:FREE、FREE_FRAG 和 FULL_FRAG 表示该 extent 属于 space,FSEG 表示该 extent 属于 file segment,其 ID 存储在 File Segment ID 中。
Page State Bitmap(XDES_BITMAP):该 Bitmap 包含范围内每个 page 的 2 bits(64 x 2 = 128 bits, or 16 bytes)。第 1 bit 表示页是否空闲。第 2 bit 是保留 bit,用于指示页是否干净(没有未刷新的数据),但该 bit 目前未使用,始终为 1。

其它结构在引用某个 extent 时,使用的是该 XDES Entry(extent descriptor) 所在 FSP_HDR 页或 XDES 页的页码以及 descriptor 本身在该页内的字节偏移量的组合。

例如:page 0 offset 150 引用的 extent 是 space 中的第一个 extent,占 pages 0-63。而 page 16384 offset 270 则占 pages 16576-16639。

List base node 和 list node

list(or “free lists” as InnoDB calls them) 是一种相当通用的结构,可以将多个相关结构链接在一起。它由两个互补的结构组成,这两个结构构成了一个功能完善的 on-disk doubly-linked list。list base node 具有以下结构:

List Base Nodes

base node 在某些 high level structure (如 FSP header)中只存储一次。它包含 list 长度,以及指向列表中第一个和最后一个 list node 的指针。

List Nodes

list node 存储的不是列表中第一个和最后一个的指针,而是上一个和下一个的指针。
所有指针都由页码(要求在同一 space 内)和该页内可找到的 list node 的字节偏移量组成。所有指针都指向 list node 的起点(即 N+0),而不一定是链接在一起的结构的起点。
例如:当 XDES Entry(extent descriptor) 链接到 list 中时,由于 list node 位于 XDES Entry(extent descriptor) 的 8 字节处,因此读取 list entry 的代码必须知道 XDES Entry(extent descriptor) 的结构,并在 list node 偏移量之前的 8 字节开始读取。
如果能确保 list node 在任何结构中始终处于首位或许是个好主意,但事实并非如此。

file space header 和 extent list

除了存储 XDES Entry(extent descriptor) 条目本身之外,FSP_HDR 页(始终是 space 中的第 0 页)还存储 FSP header,其中包含大量 list。 FSP header 结构:

FSP Header

FSP header 中与 list 无关的字段(not in order)作用:

Space ID(FSP_SPACE_ID):当前 space 的 space ID。
Highest page number in the space (size)(FSP_HEADER_SIZE):最高有效页码,在文件增长时递增。不过,并非所有这些页都已初始化(有些可能是零填充),因为 extending a space 是 a multi-step 的过程。
Highest page number initialized (free limit)(FSP_FREE_LIMIT):free list 尚未初始化的最小页码。
Flags(FSP_SPACE_FLAGS):存储与 space 相关的标志
Next Unused Segment ID(FSP_SEG_ID):用于分配下一个 file segment 的 ID。基本上是一个自动递增的整数。
Number of pages used in the FREE_FRAG list(FSP_FRAG_N_USED):存储此值是为了进行优化,以便快速计算 FREE_FRAG 列表中的可用 page 数,而无需遍历列表中的所有 extent 并对每个 extent 中的可用 page 数求和。

以下 XDES Entry(extent descriptor) list 的 List base node 也存储在 FSP header 中:

FREE_FRAG(FSP_FREE_FRAG):有可用 page 的 extent 称为 fragment,将单个 page 分配给不同用途,而不是整个 extent 分配给不同用途。例如,具有 FSP_HDR 或 XDES 页的 extent 都会被放入 FREE_FRAG 列表,这样 extent 中若有可用 page 就可以分配给其它用途。
FULL_FRAG(FSP_FULL_FRAG):与 FREE_FRAG 完全相同,但适用于没有剩余可用 page 的 extent。当 extent 已满时,extent 将从 FREE_FRAG 移至 FULL_FRAG。如果有 page 释放,extent 不再满,则移回 FREE_FRAG。
FREE(FSP_FREE):完全未使用的 extent,可整体分配给其它用途。例如,分配给一个 file segment(并置于相应的 INODE 列表中),或移动到 FREE_FRAG 列表中供单个 page 使用。

File segment 和 inode

File segment 和 inode(index node) 术语可能是 InnoDB 文档最模糊的地方。
InnoDB 重载了文件系统中常用的术语 inode,并将其用于 INODE 条目(单个小结构)和 INODE 页(包含多个 INODE 条目)。暂且不论命名上的混乱,InnoDB 中的 INODE 条目只是描述一个 file segment,通常称为 FSEG,下文称 file segment INODE。

INODE 页(FIL_PAGE_INODE)结构如下:

INODE Page Overview

每个 INODE 页包含 85(FSP_SEG_INODES_PER_PAGE) 个 file segment INODE 条目(for a 16 KiB page),每个条目 192 bytes。此外,还包含一个 list node,用于 FSP_HDR 的 FSP header 中的 INODE 页列表:

FSEG_INODE_PAGE_NODE:INODE 页在 FSP header 的某个链表节点,记录前后 INODE 页的位置。该链表为 FSP header 中的 FSP_SEG_INODES_FULL 或 FSP_SEG_INODES_FREE。
FREE_INODES(FSP_SEG_INODES_FREE):至少有一个空闲 file segment INODE 条目的 INODE 页列表。
FULL_INODES(FSP_SEG_INODES_FULL):没有可用 file segment INODE 条目的 INODE 页列表。当使用 file-per-table 空间时,除非表有超过 42 个索引,否则每个表空间中的此列表将为空,因为每个索引正好消耗两个 file segment INODE 条目。

file segment INODE 条目具有以下结构:

INODE Entry

file segment INODE 条目中的非 list 相关的字段用途如下:

File Segment ID(FSEG_ID):该 file segment INODE 条目所描述的文件段(FSEG)的 ID。如果 ID 为 0,则该条目未被使用。
Number of used pages in the NOT_FULL list(FSEG_NOT_FULL_N_USED):与 space 的 FREE_FRAG 列表(在 FSP header 中)完全相同,该字段存储 NOT_FULL 列表中已使用的页数,用于快速计算列表中的空闲页数,无需迭代列表中的所有 extent。
Magic Number(FSEG_MAGIC_N):若值为 97937874,则表示该 file segment INODE 条目已被正确初始化。
Fragment Array(FSEG_FRAG_ARR):由 32 个页码组成的数组,这些页码是从 space 的 FREE_FRAG 或 FULL_FRAG 列表中的 fragment extent 中单独分配的。一旦该数组已满,则只能为 file segment 分配完整的 extent。

随着表数据的增长,它会在每个 file segment 中分配单个 page,直到 fragment array 满了,然后切换到每次分配 1 个 extent,最后切换到每次分配 4 个 extent。

file segment INODE 条目中还包含 extent descriptors 的 List base nodes:

FREE(FSEG_FREE):已分配给该 file segment 且完全未使用的 extent。
NOT_FULL(FSEG_NOT_FULL):已分配给该 file segment 且至少已用一个 page 的 extent。当最后一个空闲 page 用完时,该 extent 会被移至 FULL 列表。
FULL(FSEG_FULL):已分配给该 file segment 且无空闲 page 的 extent。如果有空闲 page,该 extent 会被移至 NOT_FULL 列表。

如果从 NOT_FULL 列表中的某个 extent 释放了一个使用过的 page,则该 extent 会被移至 file segment 的 FREE 列表,但实际上会直接移回 space 的 FREE 列表。

索引如何使用 file segment

虽然 INDEX 页尚未介绍,但现在可以了解一个小方面。

每个索引的 root 页的 FSEG header 中包含了指向 file segment INODE 条目的指针,这些 file segment INODE 条目描述了索引如何使用 file segment。

每个索引的 leaf 页使用一个 file segment,non-leaf(internal) 页使用一个 file segment。这些信息存储在 INDEX 页的 FSEG header 中:

FSEG Header

存在的 Space ID 有点多余(它们总是与当前 space 相同)。页码和偏移量指向 INODE 页中的 file segment INODE 条目。这两个 file segment 将始终存在,即使它们可能完全是空的。

例如,在新建表中,唯一存在的页既是 root 页也是 leaf 页,存在于 internal file segment 中(避免以后移动)。leaf file segment 中的 INODE 列表和 fragment 数组都为空。internal file segment 中的 INODE 列表都为空,fragment 数组中有一个 root 页。

串联起来

下图尝试说明索引的整个多级结构:

Index File Segment Structure

索引根页指向两个 file segment inode,每个 inode 都有一个 fragment  数组(指向 fragment  列表中最多 32 个单独的 page)和多个完整 extent 的列表,它们使用 extent descriptor 中的列表指针链接在一起。extent descriptor 既用于引用 extent,也用于跟踪 extent 内的空闲 page。

6.InnoDB 索引页的物理结构:对 InnoDB 索引页、数据存储位置以及记录放置方式的描述

上面介绍了 space 和 page 的基本结构,下面了解下 INDEX 页的物理结构。这将为从逻辑(更高)层面讨论索引奠定基础。

在 InnoDB 中一切都是索引

在了解物理结构之前,必须明白,在 InnoDB 中一切都是索引,这对物理结构意味着什么?

  1. 每个表都有一个主键。如果 CREATE TABLE 没有指定主键,那么就会使用第一个非空的唯一键,如果没有,一个 48-bit 的隐藏 Row ID 字段就会自动添加到表结构中,并用作主键。一定要自己添加主键。隐藏的主键对你毫无用处,但每行仍需花费 6 bytes。
  2. row data(非 PRIMARY KEY 字段) 存储在 PRIMARY KEY 索引结构中,也称为 clustered key。该索引结构以 PRIMARY KEY 字段为键,row data 是附加到该键的值(以及 MVCC 的一些额外字段)。
  3. Secondary keys 存储在相同的索引结构中,但它们以 KEY 字段为键,把主键值(PKV)附加到该键上。

在讨论 InnoDB 中的索引时(如本篇文章),实际上指的是表和索引。

INDEX page(索引页)结构概述

每个索引页的整体结构如下:

INODE Page Overview

页结构的主要部分如下(not in order):

The FIL header and trailer:这是所有 page 都有的。 INDEX 页的不同是 FIL header 中的 previous page 和 next page 指针指向索引同一级别的上一页和下一页,并根据索引的键按升序排列。这形成了每个级别的所有页的双向链接列表。
The FSEG header:索引根页的 FSEG header 包含指向该索引使用的 file segment 的指针。所有其它索引页的 FSEG header 均未使用且用零填充。
The INDEX header:包含许多与 INDEX 页和 record(记录) 管理相关的字段。
System records:InnoDB 的每个页有两个系统记录,称为 infimum 和 supremum。这些记录存储在页中的固定位置,以便始终可以根据页中的字节偏移量直接找到它们。
User records:实际数据。每条记录都有一个 variable-width header 和实际列数据本身。header 包含一个下一条记录的指针,该指针按升序存储页内下一条记录的偏移量,形成单链表。
The page directory:page directory(页目录) 从 FIL trailer 开始从页的 top(顶部) 向下增长,并包含指向页中某些记录的指针(every 4th to 8th record)。

INDEX header

每个 INDEX 页的 header 都是固定宽度,结构如下:

INDEX Header

该结构中存储的字段如下(not in order):

Index ID(PAGE_INDEX_ID):page 所属的索引 id。创建 page 后不应写入此字段。
Format Flag(PAGE_N_HEAP):该页中记录的格式,存储在 Number of Heap Records 字段的高位(0x8000,bit 15=flag)。有两种可能的值: COMPACT 和 REDUNDANT。
Maximum Transaction ID(PAGE_MAX_TRX_ID):修改当前页的最大事务 ID,该值仅在二级索引中定义。
Number of Heap Records(PAGE_N_HEAP):heap 中记录的总数,包括最小和最大系统记录以及垃圾(已删除的)记录。
Number of Records(PAGE_N_RECS):页中的用户记录数。
Heap Top Position(PAGE_HEAP_TOP):记录堆顶指针。堆顶和页目录末尾之间都是空闲空间。
First Garbage Record Offset(PAGE_FREE):指向 free(已删除) 记录列表中第一个条目的指针。该列表使用每个记录头中的 next record 指针单链在一起。
Garbage Space(PAGE_GARBAGE): free(已删除) 记录列表所消耗的字节总数。
Last Insert Position(PAGE_LAST_INSERT):最后插入记录的位置(字节偏移量)。
Page Direction(PAGE_DIRECTION):最后插入方向(LEFT、RIGHT 和 NO_DIRECTION)。表明该页是否正在经历顺序插入(向左[较低值]或向右[较高值])或随机插入。新插入记录的主键值比上一条记录的主键值大,插入方向就是右,反之则是左。
Number of Inserts in Page Direction(PAGE_N_DIRECTION): 一个方向连续插入的记录数量。一旦设置了页方向,接下来的任何插入都不会重置方向,而是会递增该值。
Number of Directory Slots(PAGE_N_DIR_SLOTS):页目录中 slot 的数量。页目录以 slot 为单位,每个 slot 大小都是 16-bit byte。
Page Level(PAGE_LEVEL):该页在索引中的级别。叶页位于第 0 层,从第 0 层开始,B+tree 的层级依次递增。在一个典型的 3 级 B+tree 中,根页为第 2 级,一些内部非叶页为第 1 级,叶页为第 0 级。
PAGE_BTR_SEG_LEAF:B+tree 叶子段的头部信息,仅在 B+tree 的 Root 页定义
PAGE_BTR_SEG_TOP:B+tree 非叶子段的头部信息,仅在 B+tree 的 Root 页定义

Record format: redundant versus compact

COMPACT 记录格式是 Barracuda 表格式中的新格式,而 REDUNDANT 记录格式则是 Antelope 表格式中的原始格式(在 Barracuda 创建之前,这两种格式都没有正式名称)。

COMPACT 格式主要消除了冗余存储在每条记录中的信息,这些信息可从数据字典中获取,如字段数、哪些字段可为空,以及哪些字段为动态长度。

关于 record pointer

record pointer 用于多个不同的地方:INDEX header 中的 Last Insert Position 字段、页目录中的所有值,以及系统记录和用户记录中的 next record 指针。

所有记录都包含一个 header (which may be variable-width),后面是实际记录数据(which may also be variable-width)。

record pointer 指向记录数据第一个字节的位置,它实际上位于 header 和记录数据之间。这样就可以通过从该位置向后读取 header,并从该位置向前读取记录数据。

由于系统记录和用户记录中的 next record 指针总是从 header 向后读取第一个字段,这意味着可以非常高效的读取页中所有记录,而无需解析 variable-width record data。

System records: infimum and supremum

每个 INDEX 页都包含两条系统记录:infimum 和 supremum,位于页的固定位置(offset 99 and offset 112 respectively),结构如下:

INDEX System Records

两条系统记录的位置前都有一个典型的 record header,infimum(696e66696d756d00) 和 supremum(73757072656d756d) 字符串是它们唯一的数据。关于 record header 字段的说明在下面文章中提供。目前,最重要的是观察第一个字段(如前所述,从记录数据倒推)是 next record 偏移量。

infimum record

infimum record 的值小于页中任何键值。它的 next record 指针指向页中键值最小的用户记录。

supremum record

supremum record 的值大于页中任何键值。它的 next record 指针始终为零(表示 NULL,由于 page headers 原因,对于实际记录来说始终是一个无效位置)。页中键值最大的用户记录的 next record 指针总是指向 supremum。

User records

用户记录在磁盘上的实际格式将在以后的文章中介绍,因为它相当复杂,本身就需要很长的解释。

用户记录按照插入的顺序添加到页正文中(可能占用之前已删除记录的空间),并使用每个记录头中的 next record 指针按键升序单链。单链列表从 infimum 开始,按升序排列所有用户记录,最后以 supremum 结束。利用这个列表,可以很方便地按升序扫描一页中的所有用户记录。

此外,使用 FIL header 中的 next page 指针,可以很容易地按升序从一页到另一页扫描整个索引。这意味着升序表扫描也很容易实现:

  1. 从索引的第一页(键值最低)开始。这一页可以通过 B+tree 遍历找到,这将在以后的文章中介绍。

  2. 读取最小值,并跟随其 next record 指针。

  3. 如果记录是 supremum,则继续执行步骤 5。如果不是,则读取并处理记录内容。

  4. 跟踪 next record 指针并返回步骤 3。

  5. 如果 next page 指针指向 NULL,则退出。如果不是,则跟踪 next page 指针并返回第 2 步。

由于记录是单链而非双链的,因此按降序遍历索引并不那么简单,这将在以后的文章中讨论。

page directory

页目录从 FIL trailer 开始,并从那里 downwards(向下) 增长到用户记录。页目录包含一个指向每 4-8 个记录的指针,此外还始终包含一个 infimum 和 supremum。

INDEX Page Directory

page directory 只是一个动态大小的数组,其中包含指向页记录的 16 位偏移指针。下面专门讨论 page directory 的章节会更全面地介绍它的用途。

Free space

用户记录向上增长和页目录向下增长之间的空间被视为 free space。一旦这两个部分在中间相遇,耗尽(假设没有空间可以通过重新组织来清除垃圾释放)了 free space,page 就会被认为是满的。

7.InnoDB 中的 B+Tree 索引结构:对 InnoDB 的 B+Tree 索引逻辑及其效率的高级探索

上面描述了 InnoDB 的 INDEX 页的物理结构。现在来了解下 InnoDB 如何在逻辑上构建索引。

术语:B+Tree、root、leaf、level

InnoDB 使用 B+Tree 结构来构建索引。当数据无法存入内存而必须从磁盘读取时,B+Tree 就显得尤为高效,因为它可以确保读取数据所需的最大 IO 次数是固定的,而且只取决于树的 depth(深度),这样就可以很好地扩展。

索引树从 root 页开始,其位置是固定的(并永久保存在 InnoDB 的数据字典中),作为访问索引树的起点。索引树可以小到只有一个根页,也可以大到多级索引树中的数百万个页。

页分为 leaf(页子) 页或 non-leaf(非页子) 页(在某些上下文中也称为 internal(内部) 页或 node(节点) 页)。叶页包含实际的行数据。非叶页仅包含指向其它非叶页或叶页的指针。B+Tree 是平衡的,因此 B+Tree 的所有分支都具有相同深度。

InnoDB 为树状结构中的每个页分配一个 level(级别):叶子页的级别为 0,依次递增。根页的级别基于树的深度。如果需要区分,所有既不是叶页也不是根页的页可称为内部页。

Leaf and non-leaf pages

对于叶子页和非叶子页,每条记录(包括 infimum 和 supremum 系统记录)都包含一个 next record 指针,该指针存储了通往下一条记录的偏移量(在页内)。链接列表从 infimum 开始,按主键值由大到小的顺序链接所有记录,最后以 supremum 结束。记录在页内没有物理顺序(它们使用插入时的任何可用空间),唯一的顺序来自它们在链表中的位置。

叶子页包含的非主键值是每条记录中 data 的一部分:

B+Tree Simplified Leaf Page

非叶子页具有相同的结构,但它们的 data 不是非主键值字段,而是子页的页码,并且它们也不是确切的主键值,而是它们所指向的子页上的最小主键值:

B+Tree Simplified Non-Leaf Page

同 level(级) 页

大多数索引都包含不止一个页,因此多个页会按升序和降序链接在一起:

B+Tree Simplified Level

每个页都包含 previous page 和 next page 的指针(在 FIL header 中),对于 INDEX 页,这两个指针用于形成同一级别的页的双向链接列表(例如,第 0 级的叶页形成一个列表,第 1 级的页形成一个单独的列表,等等)。

InnoDB 中的单个索引页 B+Tree 大致结构

B+Tree Detailed Page Structure

innodb_ruby

sudo apt -y install ruby ruby-dev
sudo gem install innodb_ruby --source https://mirrors.tuna.tsinghua.edu.cn/rubygems
View Code

InnoDB 中的多级索引 B+tree 大致结构

B+Tree Structure

关于 root page

由于根页是在第一次创建索引时分配的,且页码存储在数据字典中,因此根页永远不能重新定位或删除。一旦根页被用完,就要将其拆分,形成一棵有根页和两个叶页的小树。

但是,根页实际上不能拆分,因为它不能被重新定位。取而代之的是分配一个新的空页,将根页中的记录移到那里(根页提升一级),然后拆分这个新的页。

索引树的高度和最大(满页)大小

Height Non-leaf pages Leaf pages Rows Size in bytes
1 0 1 468 16.0 KiB
2 1 1203 > 563 thousand 18.8 MiB
3 1204 1447209 > 677 million 22.1 GiB
4 1448413 1740992427 > 814 billion 25.9 TiB

使用过大的 PRIMARY KEY 可能会导致 B+Tree 的效率大大降低,因为主键值必须存储在非叶页中。这会极大地增加非叶页中记录的大小。

8.InnoDB 中 records(记录) 的物理结构:InnoDB 行格式的存储

上面介绍 INDEX 页的物理结构和逻辑结构,现在了解下页中 record 的物理结构。

这里只考虑 COMPACT 行格式(适用于 Barracuda 表格式)。

Record offset

在之前的文章中,record offset 已在许多需要指向 record 的结构中进行了描述。record offset 指向 record 数据本身的开头,它是可变长度的,但每个 record 前面都有一个 record header,它也是可变长度。在这篇文章和其中的插图中,使用 N 表示 record 的开始,其中 record 数据位于 N 处并使用正偏移量(e.g. N+1),record header 使用负偏移量(e.g. N-1)。 InnoDB 通常将 record 数据的开始位置 N 称为 origin。

Record header

record header 位于 record 本身之前,其结构如下:

Record Format - Header

record header 中的字段是(从 N 开始倒序排列):

Next Record Offset(REC_NEXT):指向下一个 record 的指针,按主键值升序排列。
Record Type(REC_NEW_STATUS):记录的类型,叶子节点 record(REC_STATUS_ORDINARY)、中间节点 record(REC_STATUS_NODE_PTR)、infimum record(REC_STATUS_INFIMUM)、supremum record(REC_STATUS_SUPREMUM)。
Order(REC_NEW_HEAP_NO):记录插入 heap 的顺序。Heap record(包括 infimum 和 supremum)从 0 开始编号,infimum record 始终为 0,supremum record 始终为 1。插入的 user record 将从 2 开始编号。heap no 相邻的 record 不一定物理上相邻。
Number of Records Owned(REC_NEW_N_OWNED):当前 record 所在的 page directory 拥有的 record 数量。
Info Flags(REC_N_NEW_EXTRA_BYTES):4-bit 的 bitmap,存储有关此记录的布尔标志。目前定义了两个:min_rec(REC_INFO_MIN_REC_FLAG) 表示该记录是 B+Tree 非叶级中的最小记录;deleted(REC_INFO_DELETED_FLAG) 表示该记录已被标记删除(将来会通过清除操作实际删除)。
Nullable field bitmap (optional):每个可为 NULL 的字段占 1-bit 位,存储(倒序)该字段是否为 NULL,四舍五入为整数字节。如果字段值为 NULL,则其字段值从记录的键或行部分中去除。如果没有可为 NULL 的字段,则该 bitmap 不存在。-
Variable field lengths array (optional):变长字段数组占 8-bit 或 16-bit(取决于字段的最大长度,也因此非 NULL 变长列的最大长度是 65533 bytes),存储(倒序)该记录中变长字段数据的长度。如果没有可变长度字段,则不存在该数组。

record header 最小为 5(REC_N_NEW_EXTRA_BYTES) bytes,但对于有许多可变长度字段的 record,其 record header 可能会大很多。 

Clustered indexes

clustered key(PRIMARY KEY)的记录结构较为复杂:

Record Clustered Leaf

记录数据中包含以下字段:

Cluster Key Fields:把 cluster key fields 连接在一起(字面意思)。 InnoDB 只是将每个列存储的 raw bytes 连接到一个字节流中。
Transaction ID:最后修改此记录的事务的 48-bit 整数事务 ID
Roll Pointer:该 record 的上一个版本的地址(在 rollback segment 中的位置)信息。Roll Pointer 结构中有:1-bit “is insert” flag,7-bit rollback segment ID,4-byte page number 和 2-byte page offset of the undo log location。
Non-Key Fields:所有非主键字段(所有非 PRIMARY KEY 字段的实际行数据)串联成一个字节流。

Internally, InnoDB adds three fields to each row stored in the database: https://dev.mysql.com/doc/refman/8.3/en/innodb-multi-versioning.html

如果所有非主键字段都是可为 NULL 的,nullable field bitmap 就会出现在 clustered key leaf pages 中。

非叶页记录的结构类似,但更简单一些:

Record Clustered Non-Leaf

由于非叶页不是 MVCC,因此没有 Transaction ID 和 Roll Pointer 字段。由于 cluster key 不能为 NULL,因此 Nullable field bitmap 和 Non-Key Fields 也不存在。

由于主键必须不是 NULL,因此 nullable field bitmap 不会出现在 clustered key non-leaf pages 中。

Secondary indexes

InnoDB 中的 Secondary indexes(二级索引) 与 clustered key(PRIMARY KEY) 具有相同的整体结构,但不包含 non-key fields,而是会额外包含 clustered key fields(也称 Primary Key Value(主键值),或 PKV)。

如果二级索引中的 secondary key 和 clustered key 之间有重叠字段,则会从 clustered key 中删除重叠字段。

例如,一个表中有一个主键(a, b, c)和一个二级索引(a, d),那么二级索引中的 secondary key 将如预期的那样是(a, d),但 PKV 将只包含(b, c)。

由于 secondary keys 允许 non-unique 和 nullable 字段,因此如果需要,variable field lengths array 和 nullable field bitmap 都可以存在。否则叶子页结构非常简单:

Record Secondary Leaf

与 clustered key 一样,secondary key fields 被串联成一个字节流。clustered key fields 也以完全相同的方式连接在一起,形成 PKV。

二级索引非叶页看起来也很熟悉:

Record Secondary Non-Leaf

对于二级索引非叶页,有一点值得注意:clustered key fields(PKV) 包含在记录中,并被视为记录键的一部分,而不是记录值的一部分。

二级索引可以是非唯一的,但页中的每条记录都必须有唯一的标识符,因此 PKV 必须包含在记录中以确保唯一性。这意味着 secondary keys 非叶子页中的记录将比叶子页中的记录大 4 bytes。

关于每行的开销

通过观察上面的图示,可以很容易地计算出 InnoDB 所需的每行开销。Clustered Key Leaf Pages 至少需要 5 bytes 的 header、6 bytes 的 Transaction ID 和 7 bytes 的Roll Pointer,每行总计 18 bytes。对于非常小的表(例如 2-3 个 int 列),这一开销可能会相当高。

此外,每页的开销也很大,而且在低效填充页时会浪费大量空间(例如,页可能只填充了一半)。

9.使用 page directory 高效遍历 InnoDB B+Tree:了解 InnoDB 中遍历 B+Tree 的效率

这里只考虑 COMPACT 行格式(Barracuda 表格式)。

page directory 的目的

如上所述,INDEX 页中所有记录都按升序链接在单链表中。然而,遍历一个可能有数百条记录的页是非常 expensive 的。必须比较每个记录的键值,且要在 B+Tree 的每个级别上完成此操作,直到在叶页上找到所需记录。

page directory 极大地优化了这种搜索方式,它提供了一个固定宽度(slot)的数据结构,每 4-8 条记录中就会有 1 条记录的指针放入页目录。因此,它可用于对每页中的记录进行二分查找,从目录的中点开始,逐步排除目录的一半,直到只剩下一个条目,然后从这里开始线性搜索。

由于页目录实际上是一个数组,因此可以按升序或降序遍历,尽管记录只能按升序链接。

page directory 的物理结构

INDEX Page Directory

page directory(页目录) 的结构非常简单。slot(槽)的数量(页目录长度)在 INDEX page header 的第一个字段中指定。

将所有记录按 4-8 个为一组,取其中最大记录的偏移量放入页目录。要注意的是页目录始终包含 infimum 和 supremum 系统记录(因此页目录长度最小为 2),且 infimum 记录单独为一组。

页目录中每个条目(组中的最值记录)的记录头(REC_NEW_N_OWNED)中保存了组中记录的个数。

page directory 的增长

一旦页目录槽所对应组中的记录的个数超过 8 条,页目录就会重新平衡,将 4 条记录分配到新的槽(组)中。

对于最小记录所在组只能有 1 条记录(规定),最大记录所在组有 1-8 条记录,其它组只能是在 4-8 之间。

page directory 的逻辑视图

在逻辑层面上,有 24 条记录(键值从 0 到 23)的页的页目录(和记录)如下所示:

B+Tree Page Directory Structure

注意:

如前所述,记录从 infimum 到 supremum 通过所有 24 个用户记录进行单链。

大约每第 4 条记录就会被输入页目录,在插图中该记录用了粗体标出,并在插图顶部的页目录数组中注明了其在页中的偏移量。

页目录是反向存储在页中的,因此插图中的顺序与磁盘上相反。

 


https://dev.mysql.com/doc/refman/8.3/en/innodb-storage-engine.html

https://dev.mysql.com/blog-archive/innodb-tablespace-space-management

https://blog.jcole.us/innodb & https://github.com/jeremycole/innodb_diagrams

http://mysql.taobao.org/monthly/2016/02/01 & http://mysql.taobao.org/monthly/2019/10/01

https://github.com/alibaba/innodb-java-reader & http://neoremind.com/2020/01/inside_innodb_file

posted @ 2019-05-16 11:10  江湖小小白  阅读(24903)  评论(0编辑  收藏  举报