索引组织表
在 InnoDB 存储引擎中,表都是根据主键顺序组织存放的,这种存储方式的表称为索引组织表(index organized table)。在 InnoDB 存储引擎表中,每张表都有一个主键(Primary Key),如果在创建表时没有显式地定义主键,则 InnoDB 存储引擎会按如下方式选择或创建主键:
- 首先判断表中是否有非空的唯一索引(Unique NOT NULL),如果有,则该列即为主键。
- 如果不符合上述条件,InnoDB 存储引擎会自动创建一个 6 字节大小的指针。
当表中有多个非空唯一索引时,InnoDB 存储引擎将选择建表时第一个定义的非空唯一索引为主键。这里需要非常注意的是,主键的选择依据的是定义索引的顺序,而不是建表时列的顺序。
以下是一个示例:
1 | mysql> CREATE TABLE z ( |
虽然 c、d 列都是非空唯一索引, 都可以作为主键的候选,但是在定义的过程中,由于 d 列首先定义为唯一索引,故 InnoDB 存储引擎将其视为主键。
逻辑存储结构
从 InnoDB 存储引擎的逻辑存储结构看,所有数据都被逻辑地存放在一个空间中,称之为表空间(tablespace)。表空间又由段(segment)、区(extent)、页(page)组成。页在一些文档中有时也称为块(block),InnoDB 存储引擎的逻辑存储结构大致如下:
表空间
表空间可以看做是 InnoDB 存储引擎逻辑结构的最⾼层,所有的数据都存放在表空间中。在默认情况下 InnoDB 存储引擎有一个共享表空间 ibdata1,即所有数据都存放在这个表空间内。如果用户启用了参数 innodb_file_per_table
,则每张表内的数据可以单独放到一个表空间内。
如果启用了 innodb_file_per_table
的参数,需要注意的是每张表的表空间内存放的只是数据、索引和插入缓冲 Bitmap 页,其他类的数据,如回滚信息、插入缓冲索引页、系统事务信息、双写缓冲等还是存放在原来的共享表空间内。这同时也说明了另一个问题:即使在启用了参数 innodb_file_per_table
之后,共享表空间还是会不断地增加其大小。
以 Undo 日志为例:在事务提交之前,共享表空间的空间占用会不断增长。而且 InnoDB 存储引擎不会在执行 rollback 时去收缩这个表空间。虽然 InnoDB 不会回收这些空间,但是会自动判断这些 Undo 信息是否还需要,如果不需要,则会将这些空间标记为可用空间,供下次 Undo 使用。
段
表空间是由多个段组成的,常见的段有数据段、索引段、回滚段等。因为前面已经介绍过了 InnoDB 存储引擎表是索引组织的,因此数据即索引,索引即数据。那么数据段即为 B+ 树的叶子节点**,索引段即为 B+ 树的非索引节点。回滚段包含了事务执行过程中用于数据回滚的旧数据。
区
段由一个或多个区组成,区通常为 64 个连续的页,也就是总共 1MB 的数据。为了保证页的连续性,InnoDB 存储引擎会一次从磁盘申请 4 ~ 5 个区。连续的256个数据区为一个数据区组。使用区而非单独的页进行数据分配可优化磁盘操作,减少索引时磁盘寻道时间,特别是在大量数据进行读写时。
但是,这里还有这样一个问题:在用户启用了参数 innodb_file_per_table
后,创建的表默认大小是 96KB。区中是 64 个连续的页,创建的表的大小至少是 1MB 才对啊?
其实是因为有时我们可能只是创建一个很小的表,只插入一条或几条数据,此时 直接分配 1MB 区块是很浪费的,所以在每个段开始时,先最多用 32 个页大小的碎片页(fragment page) 来存放数据(也就是数据页);在使用完这些页之后才是 64 个连续页的申请。
页
页是 InnoDB 存储数据的基本单元,标准大小为 16 KB,索引树上的一个节点就是一个页,也就意味着数据库每次读写都是以 16 KB 为单位的,即一次最少从磁盘中读取 16KB 的数据到内存,一次最少写入 16KB 的数据到磁盘。
在 InnoDB 存储引擎中,常见的页类型有:
- 数据页(B-tree Node)
- undo 页(Undo Log Page)
- 系统页(System Page)
- 事务数据页(Transaction System Page)
- 插入缓冲位图页(Insert Buffer Bitmap)
- 插入缓冲空闲列表页(Insert Buffer Free List)
- 未压缩的二进制大对象页(Uncompressed BLOB Page)
- 压缩的二进制大对象页(Compressed BLOB Page)
行
InnoDB 存储引擎是面向行(row-oriented)的,也就是说数据是按行进行存放的。每个页存放的行记录也是有硬性定义的,最多允许存放 16KB / 2 ~ 200 行的记录,即 7992 行记录。
这里提到了 row-oriented 的数据库,也就是说,存在有 column-oriented 的数据库。
InnoDB 行记录格式
行数据拥有多中记录格式,如 COMPACT、REDUNDANT、DYNAMIC 等。
特性 | COMPACT 行格式 | DYNAMIC 行格式 |
---|---|---|
共同点 | ||
存储引擎 | 两者均为 InnoDB 存储引擎支持的行格式。 | 两者均为 InnoDB 存储引擎支持的行格式。 |
事务支持 | 都支持事务和外键。 | 都支持事务和外键。 |
索引支持 | 支持主键和二级索引。 | 支持主键和二级索引。 |
特性 | COMPACT 行格式 | DYNAMIC 行格式 |
---|---|---|
差异 | ||
变长长度列处理 | 存储变长列的前缀数据在行内(最多 768 字节),超出部分存储在溢出页。 | 直接将大列数据存储在溢出页,仅在行内存储指向溢出页的 20 字节指针。 |
存储效率 | 对于小的变长数据相对高效,但对于非常大的列可能造成行内存存储不必要的数据。 | 对于大型 TEXT 和 BLOB 数据更加高效,因为它避免了不必要的前缀存储。 |
行存储开销 | 因为行内存储前缀,可能会导致一些存储开销。 | 使用指针减少了行内存储的开销,允许更大的行灵活性。 |
性能 | 对于小型数据表现良好,但当数据需要多次读取溢出页时,性能可能降低。 | 对于大数据读取性能更好,因为减少了对行内存储的负担。 |
行碎片化 | 大量更新可能导致行碎片化,因为行内和溢出页的数据可能分离。 | 行碎片化问题较少,因为数据更多地存储在溢出页。 |
适用场景 | 适用于包含许多中小型变长列的表,以及需要向后兼容的场合。 | 适合包含大文本或二进制数据的表,尤其是那些需要高效处理大数据的应用场景。 |
COMPACT
Compact 行记录是在 MySQL 5.0 中引入的,其设计目标是高效地存储数据。一个页中存放的行数据越多,其性能就越高。下图是 Compact 行记录的格式:
Compact 行记录格式的前部是一个非 NULL 变长字段长度列表,并且其是按照列的顺序逆序放置的。其长度为:
- 若列的长度小于 255 字节,用 1 字节表示;
- 若大于 255 字节,用 2 字节表示。
变长字段的长度最大不可以超过 2 字节,这是因在 MySQL 数据库中 VARCHAR 类型的最大长度限制为 65535。变长字段之后的第二个部分是 NULL 标志位,该位指示了该字段中是否有 NULL 值,有则用 1 表示。该部分所占的字节数应为 1 字节。接下来的部分是记录头信息(record header),固定占用 5 字节(40 位),每位的含义如下:
名称 | 大小 (bit) | 描述 |
---|---|---|
() | 1 | 未知 |
() | 1 | 未知 |
deleted_flag |
1 | 标记该行是否已被删除;被删除的记录会进入垃圾链表,等待新记录覆盖。 |
min_rec_flag |
1 | 若为 1,表示该记录是 B+ 树节点(非叶子层)的最小目录项或 Infimum 记录。 |
n_owned |
4 | 该记录拥有的后续记录数:在稀疏页目录中,一个槽组内最后一条记录的 n_owned 为组内记录数,其它记录为 0。 |
heap_no |
13 | 索引堆中该条记录的序号(相对于同页其它记录的排序位置)。 |
record_type |
3 | 记录类型,000=普通行,001=B+ 树非叶节点目录项,010=Infimum,011=Supremum,1xx=保留。 |
next_record |
16 | 指向本页中下一条记录头的相对偏移(按主键顺序)。 |
Total | 40 |
最后的部分就是实际存储每个列的数据。需要特别注意的是,NULL 不占该部分任何空间,即 NULL 除了占有 NULL 标志位,实际存储不占有任何空间。
另外有一点需要注意的是,每行数据除了用户定义的列外,还有两个隐藏列,分别是:
- 事务 ID 列(6 字节)
- 回滚指针列(7 字节)
共计额外占用 13 字节。
若 InnoDB 表没有定义主键,每行还会增加一个 6 字节的 rowid 列。
接下来用一个具体示例来分析 Compact 行记录 的内部结构:
1 | mysql> CREATE TABLE mytest ( |
表 mytest 有 4 个字段:
- t1,t2,t4 是 VARCHAR(变长)
- t3 是 CHAR(定长)
插入三条记录后,查看表空间文件 mytest.ibd
。
找到某记录存储起点 0x0000c078
,解释如下:
1 | 03 02 01 -- 变长字段长度列表,逆序 |
接下来是列数据(按顺序):
1 | 61 -- 'a' |
现在第一行数据就展现在用户眼前了。需要注意的是,变长字段长度列表是逆序存放的,因此变长字段长度列表为 03 02 01
,而不是 01 02 03
。此外还需要注意 InnoDB 每行有隐藏列 TransactionID 和 Roll Pointer。同时可以发现,固定长度 CHAR 字段在未能完全占用其长度空间时,会用 0x20
来进行填充。
接着再来分析下 Record Header 的最后两个字节,这两个字节代表 next_recorder
,0x2c
代表下一个记录的偏移量,即当前记录的位置加上偏移量 0x2c
就是下条记录的起始位置。所以 InnoDB 存储引擎在页内部是通过一种链表的结构来串连各个行记录的。
第二行将不做整理,除了 RowID 不同外,它和第一行大同小异。现在来关注有 NULL 值的第三行:
1 | 03 01/* 变长字段长度列表,逆序 */ |
第三行有 NULL 值,因此 NULL 标志位不再是 00 而是 06,转换成二进制为 00000110
,为 1 的值代表第 2 列和第 3 列的数据为 NULL。在其后存储列数据的部分,用户会发现没有存储 NULL 列,而只存储了第 1 列和第 4 列非 NULL 的值。因此这个例子很好地说明了:不管是 CHAR 类型还是 VARCHAR 类型,在 Compact 格式下 NULL 值都不占用任何存储空间。
行溢出数据
我们将过大的字段数据存放到溢出页中,目的是让每个数据页能够存储更多的数据行,也就是减少数据页的膨胀和磁盘 I/O。
Compact 和 Redundant
InnoDB 存储引擎可以将一条记录中的某些数据存储在页外的数据页而非页内。一般认为 BLOB,TEXT 这类的大对象列类型的存储会把数据存放在数据页页外。但是,这个理解有点偏差,BLOB 可以不将数据放在溢出页面,而且即便是 VARCHAR 列数据类型,依然有可能被存放为行溢出数据。
首先对 VARCHAR 数据类型进行研究。MySQL 数据库的 VARCHAR 类型可以存放 65535 字节。但是,是真的吗?真的可以存放 65535 字节吗?
如果创建 VARCHAR 长度为 65535 的表,用户会得到下面的错误信息:
1 | mysql> CREATE TABLE test ( |
从错误消息可以看到 InnoDB 存储引擎并不支持 65535 长度的 VARCHAR。这是因为还有别的开销,通过实际测试发现,能存储 VARCHAR 类型的最大长度为 65532。以上长度的单位都是字节⚠️。
此外需要注意的是,MySQL 官方手册中定义的 65535 长度是指所有 VARCHAR 列的长度总和。如果列的长度总和超过这个长度,依然无法创建。
但是有没有想过,InnoDB 存储引擎的页为 16KB,即 16384 字节,怎么能存放 65532 字节呢?
因此,在一般情况下,InnoDB 存储引擎的数据都是存放在页类型为 B-tree node 中。但是当发生行溢出时,数据存放在页类型为 Uncompress BLOB 页中。
来看下面一个例子:
1 | mysql> CREATE TABLE t ( |
在上述例子中,首先创建了一个列 a 长度为 65532 的 VARCHAR 类型表 t,然后插入了列 a 长度为 65532 的记录。我们可查看表文件,内容如下:
1 | page offset 00000000, page type <File Space Header> |
可以观察到表空间中有一个数据页节点 B-tree Node,另外有 4 个未压缩的二进制大对象页(Uncompressed BLOB Page)。在这些页中才真正存放了 65532 字节 的数据。
既然实际存放的数据都在 BLOB 页中,那数据页中又存放了些什么内容呢?
可以看到,从 0x0000c093
到 0x0000c392
数据页中其实只保存了 VARCHAR(65532)
的前 768 字节的前缀(prefix)数据(这里都是 'a'
),之后是偏移量,指向行溢出页,也就是前面用户看到的 Uncompressed BLOB Page。因此,对于行溢出数据,其结构如下图:
那么多长的 VARCHAR 是保存在单个数据页中的,从多长开始会保存在 BLOB 页呢?可以这样进行思考:InnoDB 存储引擎表是索引组织的,即 B+Tree 的结构,这样每个页中至少应该有两条记录(否则失去了 B+Tree 的意义,变成链表了)。因此,如果页中只能存放下一条记录,那么 InnoDB 存储引擎会自动将行数据存放到溢出页中。
考虑下面表的一种情况:
1 | mysql> CREATE TABLE t ( |
表 a 变长字段列的长度为 9000,故能存放在一个数据页中,但是这并不能保证两条长度为 9000 的记录都能存放在一个页中。此时查看磁盘文件,如下:
1 | page offset 00000000, page type <File Space Header> |
我们可知,确实行数据被存放到溢出页中。
但是,如果可以在一个页中至少放入两行数据,那 VARCHAR 类型的行数据就不会存放到 BLOB 页中去。经过多次试验测试,发现这个阈值的长度为 8098。
如用户建立一个列为 VARCHAR(8098)
的表,然后插入 2 条记录:
1 | mysql> CREATE TABLE t ( |
接着查看磁盘文件,可以发现此时的行记录都是存放在数据页中,而不是在 BLOB 页中。
1 | page offset 00000000, page type <File Space Header> |
另一个问题是,对于 TEXT 或 BLOB 的数据类型,用户总是以为它们是存放在 Uncompressed BLOB Page 中的,其实这也不准确。是否放在数据页中还是 BLOB 页中,和前面讨论的 VARCHAR 一样,至少保证一个页能存放两条记录。
当然既然用户使用了 BLOB 列类型,一般不可能存放长度这么小的数据。因此在大多数的情况下 BLOB 的行数据还是会发生行溢出,实际数据保存在 BLOB 页中,数据页只保存数据的前 768 字节。
Compressed 和 Dynamic
InnoDB 1.0.x 版本开始引入了新的文件格式(file format,用户可以理解为新的页格式),以前支持的 Compact 和 Redundant 格式称为 Antelope 文件格式,新的文件格式称为 Barracuda 文件格式。
Barracuda 文件格式下拥有两种新的行记录格式:Compressed 和 Dynamic。
新的两种记录格式对于存放在 BLOB 中的数据采用了完全的行溢出方式,如下图:
在数据页中只存放 20 个字节的指针,实际数据都存放在 Off Page 中,而之前的 Compact 和 Redundant 两种格式会存放 768 个前缀字节。
Compressed 行记录格式的另一个功能就是:存储在其中的行数据会以 zlib 的算法进行压缩,因此对于 BLOB、TEXT、VARCHAR 这类大长度类型的数据能进行非常有效的存储。
CHAR 的行结构存储
通常理解 VARCHAR 是存储变长度的字符类型,CHAR 是存储固定长度的字符类型。而在前面的内容中,我们可以发现每行的变长字段长度的列表都没有存储 CHAR 类型的长度。
然而,值得注意的是之前给出的两个例子中的字符集都是单字节的 latin1 格式。从 MySQL 4.1 版本开始,CHAR(N) 中的 N 指的是字符的长度,而不是之前版本的字节长度。
也就是说在不同的字符集中,CHAR 类型列内部存储的可能不是定长的数据。例如下面的这个示例:
1 | mysql> CREATE TABLE j ( |
在上述例子中,表 j 的字符集是 GBK。用户分别插入了两个字符的数据 ‘ab’ 和 ‘我们’,然后查看所占字节,可得如下结果:
1 | mysql> SELECT a,CHAR_LENGTH(a),LENGTH(a) |
通过不同的 CHAR_LENGTH 和 CHAR 函数可以观察到:前两个记录 ‘ab’ 和 ‘我们’ 字符串的长度都是 2。但是内部存储上 ‘ab’ 占用 2 字节,而 ‘我们’ 占用 4 字节。如果通过 HEX 函数查看内部十六进制的存储,可以看到:
1 | mysql> SELECT a,HEX(a) |
因此对于多字节字符编码,如 GBK、UTF-8 等,的 CHAR 数据类型的存储,InnoDB 存储引擎在内部将其视为变长字符类型。这也就意味着在变长长度列表中会记录 CHAR 数据类型的长度。
我们可查看磁盘文件:
整理后可以得到如下结果:
第一行记录:
1 | 02 /* 变长字段长度 2,将 CHAR 视作变长类型 */ |
第二行记录:
1 | 04 /* 变长字段长度 4,将 CHAR 视作变长类型 */ |
第三行记录:
1 | 02 /* 变长字段长度 2,将 CHAR 视作变长类型 */ |
上述例子清楚地显示了 InnoDB 存储引擎内部对 CHAR 类型在多字节字符集类型的存储。CHAR 类型被明确视为变长字符类型,对于未能占满长度的字符还是填充 0x20
。InnoDB 存储引擎内部对字符的存储和我们用 HEX 函数看到的也是一致的。因此可以认为在多字节字符集的情况下,CHAR 和 VARCHAR 的实际行存储基本是没有区别的。
InnoDB 数据页结构
InnoDB 数据页由以下 7 个部分组成,如下图所示:
- File Header(文件头)
- Page Header(页头)
- Infimum 和 Supremum Records
- User Records(用户记录,即行记录)
- Free Space(空闲空间)
- Page Directory(页目录)
- File Trailer(文件结尾信息)
其中 File Header、Page Header、File Trailer 的大小是固定的,分别为 38、56、8 字节,这些空间用于标记该页的一些信息,如 Checksum、数据页所在 B+ 树索引的层数等。User Records、Free Space、Page Directory 这些部分为实际的行记录存储空间,因此大小是动态的。
下图中具体分析了数据页中的各组成部分:
File Header
File Header 用来记录页的一些头信息,由下图中的 8 个部分组成,共占用 38 字节。
名称 | 大小(字节) | 说明 |
---|---|---|
FIL_PAGE_SPACE_OR_CHKSUM |
4 | 当 MySQL 为 MySQL4.0.14 之前的版本时,该值为 0。在之后的 MySQL 版本中,该值代表页的 checksum 值(—种新的 checksum 值)。 |
FIL_PAGE_OFFSET |
4 | 表空间中页的偏移值。如果独立表空间 a.ibd 的大小为 1GB,如果页的大小为 16KB,那么总共有 65 536 个页。FIL_PAGE_OFFSET 表示该页在所有页中的位置。若此表空间的 ID 为 10,那么搜索页 (10, 1) 就表示查找表 a 中的第二个页。 |
FIL_PAGE_PREV |
4 | 当前页的上一个页,B+Tree 特性决定了叶子节点必须是双向列表。 |
FIL_PAGE_NEXT |
4 | 当前页的下一个页,B+Tree 特性决定了叶子节点必须是双向列表。 |
FIL_PAGE_LSN |
8 | 该值代表该页最后被修改的日志序列位置 LSN(Log Sequence Number)。 |
FIL_PAGE_TYPE |
2 | InnoDB 存储引擎页的类型。常见的类型见表 4-4。记住 0x45BF,该值代表了存放的是数据页,即实际行记录的存储空间。 |
FIL_PAGE_FILE_FLUSH_LSN |
8 | 该值仅在系统表空间的一个页中定义,代表文件至少被更新到了该 LSN 值。对于独立表空间,该值都为 0。 |
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID |
4 | 从 MySQL 4.1 开始,该值代表页属于哪个表空间。 |
下图是 InnoDB 中页的类型:
名称 | 十六进制 | 解释 |
---|---|---|
FIL_PAGE_INDEX |
0x45BF |
B+ 树叶节点。 |
FIL_PAGE_UNDO_LOG |
0x0002 |
Undo Log 页,用于存储事务回滚信息。 |
FIL_PAGE_INODE |
0x0003 |
INODE 页,管理表空间内数据页的分配情况。 |
FIL_PAGE_IBUF_FREE_LIST |
0x0004 |
Insert Buffer 空闲列表,记录可用于合并缓冲的空闲页。 |
FIL_PAGE_TYPE_ALLOCATED |
0x0000 |
已分配页,表示此页是最新分配但尚未使用的页。 |
FIL_PAGE_IBUF_BITMAP |
0x0005 |
Insert Buffer 位图,用于标记可用于缓存合并的页。 |
FIL_PAGE_TYPE_SYS |
0x0006 |
系统页,存储 InnoDB 内部元数据。 |
FIL_PAGE_TYPE_TRX_SYS |
0x0007 |
事务系统数据页,存储事务相关系统信息。 |
FIL_PAGE_TYPE_FSP_HDR |
0x0008 |
File Space Header,表空间头信息页。 |
FIL_PAGE_TYPE_XDES |
0x0009 |
扩展描述页,管理区块(extent)使用情况。 |
FIL_PAGE_TYPE_BLOB |
0x000A |
BLOB 页,用于存储大文本或二进制数据。 |
Page Header
接着 File Header 部分的是 Page Header,该部分用来记录数据页的状态信息,由 14 个部分组成,共占用 56 字节,如下图所示。
名称 | 大小(字节) | 说明 |
---|---|---|
PAGE_N_DIR_SLOTS |
2 | 在 Page Directory(页目录)中的 Slot(槽)数。 |
PAGE_HEAP_TOP |
2 | 堆中第一个记录的指针,记录在页中是根据堆的形式存放的。 |
PAGE_N_HEAP |
2 | 堆中的记录数。共占用 2 字节,但第 15 位用于表示行记录格式。 |
PAGE_FREE |
2 | 指向可重用空间的首指针。 |
PAGE_GARBAGE |
2 | 已删除记录的字节数,即行记录结构中 delete_flag 为 1 的记录大小的总数。 |
PAGE_LAST_INSERT |
2 | 最后插入记录的位置。 |
PAGE_DIRECTION |
2 | 最后插入的方向。可能取值为: • PAGE_LEFT (0x01 )• PAGE_RIGHT (0x02 )• PAGE_SAME_REC (0x03 )• PAGE_SAME_PAGE (0x04 )• PAGE_NO_DIRECTION (0x05 ) |
PAGE_N_DIRECTION |
2 | 一个方向连续插入记录的数量。 |
PAGE_N_RECS |
2 | 该页中记录的数量。 |
PAGE_MAX_TRX_ID |
8 | 修改当前页的最大事务 ID,注意该值仅在 Secondary Index 中定义。 |
PAGE_LEVEL |
2 | 当前页在索引树中的位置,0x00 表示叶节点,即叶节点总是在第 0 层。 |
PAGE_INDEX_ID |
8 | 索引 ID,表示当前页属于哪个索引。 |
PAGE_BTR_SEG_LEAF |
10 | B+ 树数据页在叶节点所在段的 segment header 中的位置信息,注意该值仅在 B+ 树的 Root 页中定义。 |
PAGE_BTR_SEG_TOP |
10 | B+ 树数据页在非叶节点所在段的 segment header 中的位置信息,注意该值仅在 B+ 树的 Root 页中定义。 |
需要注意的是,这里的堆结构指的是页内记录的排布方式,并不是数据结构中的堆 heap,也就是在 InnoDB 的一个数据页中,行记录是按照一定规则存储在一个称为“堆”的区域中。这个“堆”具有以下特征:
- 数据页内的记录不是顺序排布,而是通过链表和 slot 引用来组织的;
- 堆中的第一个 slot 是 Infimum(表示最小值),最后一个是 Supremum(最大值);
- 每个记录都包含一个 next record 指针,指向下一条记录;
PAGE_HEAP_TOP
:表示当前堆的顶部,新的记录一般会存入这里。
其次,PAGE_DIRECTION:最后插入的方向,这个属性记录了最后一条插入记录的方向,主要用于优化后续插入操作的位置判断,避免每次都从头或尾查找插入位置。
示例:
假设你有一个数据页,用于存储用户年龄的索引,页中原来已有以下记录(已按顺序):
[20] -> [30] -> [40] -> [50]
现在你要往页里插入新的值:
情况 1:插入 [45]
它的插入位置在 [40] 和 [50] 之间:
- InnoDB 在查找插入位置时,可能从
PAGE_LAST_INSERT
指向的上一个插入位置(比如 [30])出发。 - 发现现在是往右边插入的,所以:
- 设置 PAGE_DIRECTION = PAGE_RIGHT(0x02);
- 这样下次如果还要插入更大的记录,比如 [48],就可以直接从 [45] 或 [50] 向右查找,加速插入位置的定位。
情况 2:插入 [25]
- 插入位置在 [20] 和 [30] 之间,InnoDB 会发现你是在“往左边插入”,
- 设置 PAGE_DIRECTION = PAGE_LEFT(0x01);
- 如果下次你又插入 [22],系统会直接从上次插入的 [25] 向左找,避免从 Supremum 重新查找。
情况 3:乱序插入
- 你现在插入一条 [42],下一次又插入 [21],再下一次插入 [51],插入方向完全没有规律;
- InnoDB 会识别到你插入方向变化太频繁,就会设置:
- PAGE_DIRECTION = PAGE_NO_DIRECTION(0x05);
这时候优化机制关闭,下次插入位置会重新全页查找。
Infimum 和 Supremum Record
在 InnoDB 存储引擎中,每个数据页中有两个模拟的行记录,用来限定记录的边界。Infimum 记录是比该页中任何主键值都要小的值,Supremum 指比任何可能大的值还要大的值。这两个值在页创建时被建立,并且在任何情况下不会被删除。而且在 InnoDB 中,这两个伪行记录的类型是 Char(8)。下图显示了 Infimum 和 Supremum 记录。
User Record 和 Free Space
User Record 就是之前讨论过的部分,即实际存储行记录的内容。再次强调,InnoDB 存储引擎表总是 B+ 树索引组织的。
Free Space 很明显指的就是空闲空间,同样也是个链表数据结构。在一条记录被删除后,该空间会被加入到空闲链表中。
Page Directory
Page Directory 是 InnoDB 数据页中的一个结构,用于帮助快速定位页内记录的位置。它相当于是数据页内部的一个加速查找索引,而且是逆序存放的。
和很多人以为的不同,InnoDB 并不是每条记录都在 Page Directory 中有一个指针。
相反,它是稀疏的目录(sparse directory),即:
- 一个槽通常指向一组记录中最前面的那条;
- 这组记录一般有 4~8 条,由
n_owned
表示。
n_owned
是什么?
n_owned
表示:该槽负责的记录数量。
假设某页中实际有 12 条记录,按主键顺序如下:
a b c d e f g h i j k l
可能在 Page Directory 中只存了以下 3 个槽指针:
Slot 1 -> a
Slot 2 -> e
Slot 3 -> i
这表示:
Slot 1 覆盖记录 a、b、c、d;
Slot 2 覆盖 e、f、g、h;
Slot 3 覆盖 i、j、k、l。
为什么这样做?稀疏目录的好处是什么?
好处是节省空间 + 提高效率:
- 若每条记录都在 Page Directory 里有个槽,占用空间太大;
- 稀疏存储后,只需要少量槽位,通过二分查找就可以迅速定位到一个“接近位置”;
- 然后用链表(通过
next_record
指针)继续遍历几条记录,就能找到目标。
查询流程小结
当你需要在页中找一个记录(比如查主键 = f)时,流程如下:
- 从 Page Directory 中进行二分查找,发现 f 应该在 e 开头的槽中;
- 然后从 e 出发,用链表结构沿着 next_record 指针遍历;
- 依次到 f,查找成功。
⚠️ Page Directory 只是个“粗索引”,最终还要依赖链表来完成精确定位。
File Trailer
为了检测页是否已经完整地写入磁盘(如可能发生的写入过程中磁盘损坏、机器关机等),InnoDB 存储引擎的页中设置了 File Trailer 部分。
File Trailer 只有一个 FIL_PAGE_END_LSN
部分,占用 8 字节。前 4 字节代表该页的 checksum 值,最后 4 字节和 File Header 中的 FIL_PAGE_LSN
相同。将这两个值与 File Header 中的 FIL_PAGE_SPACE_OR_CHKSUM
和 FIL_PAGE_LSN
值进行比较,看是否一致(checksum 的比较需要通过 InnoDB 的 checksum 函数来进行比较,不是简单的等值比较),以此来保证页的完整性(not corrupted)。
在默认配置下,InnoDB 存储引擎每次从磁盘读取一个页就会检测该页的完整性,判断页是否发生 Corrupt,这就是通过 File Trailer 部分进行检测,而该部分的检测会有一定的开销。
InnoDB 数据页结构实例分析
在 MySQL 技术内幕的这一节中,Record Header 中的最后 2 字节内容代表的是下一行中实际存储数据的部分的初始位置。
以 0000c107
对应的行为例,我们从 0000c100 开始看:因为表中只存在一个 Char(10)
类型的字段,而且字符集是 UTF8,因此 Char 字段会被视为可变字符,并会在行记录的开始记录其长度 0a。之后的 00 代表该行中没有为 NULL 的字段,而且该行中页确实没有 NULL 字段。接着是 5 字节的 Header。之后是主键 00 00 00 05,因为该表中我们指定了主键且主键类型为 INT,因为这里存储的是 4 字节的主键的内容,而不是 6 字节的 ROWID。再就是 6 字节的 Transaction ID 和 7 字节的 Roll Pointer。最后是 10 字节的 Char 字段的数据。一共 34 个字节,正好对应着下一条行数据的实际存储数据的起始位置。Perfecto!
Named File Formats 机制
随着 InnoDB 存储引擎的发展,新的页数据结构有时用来支持新的功能特性。例如前面提到的 InnoDB 1.0.x 版本提供了新的页数据结构来支持表压缩功能,完全的溢出(Off page)大变长字符串类型字段的存储。这些新的页数据结构和之前版本的页并不兼容,因此从 InnoDB 1.0.x 版本开始,InnoDB 存储引擎通过 Named File Formats 机制来解决不同版本下页结构兼容性的问题。
InnoDB 存储引擎 1.0.x 版本之前的文件格式(file format)定义为 Antelope,将这个版本支持的文件格式定义为 Barracuda。新的文件格式总是包含之前版本的页格式。图 4-8 显示了 Barracuda 文件格式和 Antelope 文件格式之间的关系,Antelope 文件格式有 Compact 和 Redundant 的行格式,Barracuda 文件格式既包括了 Antelope 所有的文件格式,另外新加入了之前已经提到过的 Compressed 和 Dynamic 行格式。
分区表
分区功能并不是在存储引擎层完成的,因此不是只有 InnoDB 存储引擎支持分区,常见的存储引擎 MyISAM、NDB 等都支持。但也并不是所有的存储引擎都支持,如 CSV、FEDERATED、MERGE 等就不支持。在使用分区功能前,应该对选择的存储引擎对分区的支持有所了解。
MySQL 数据库在 5.1 版本时添加了对分区的支持。分区的过程是将一个表或索引分解为多个更小、更可管理的部分。就访问数据库的应用而言,从逻辑上讲,只有一个表或一个索引,但是在物理上这个表或索引可能由数个小物理分区组组成。每个分区都是独立的对象,可以独自处理,也可以作为一个更大对象的一部分进行处理。
MySQL 数据库支持的分区类型为水平分区,并不支持垂直分区。此外,MySQL 数据库的分区是局部分区索引,一个分区中既存放了数据又存放了索引。而全局分区是指,数据存放在各个分区中,但是所有数据的索引放在一个对象中。目前,MySQL 数据还不支持全局分区。
大多数人会有这样一个误区:只要启用了分区,数据库就会运行得更快。这个结论是存在很多问题的。就我的经验来看,分区可能会给某些 SQL 语句性能带来提高,但是分区主要用于数据库高可用性和性能的管理。在 OLTP 应用中,对于分区的使用应该非常小心。总之,如果只是一味地使用分区,而不理解分区是如何工作的,也不清楚你的应用如何使用分区,那么分区极有可能会对性能产生负面的影响。
目前 MySQL 数据库支持以下几种类型的分区:
- RANGE 分区:行数据基于属于一个给定连续区间的列值被放入分区。
- LIST 分区:和 RANGE 分区类似,只是 LIST 区间内的是离散的值。
- HASH 分区:根据用户自己定义的表达式的返回值来进行分区,返回值不能为负数。
- KEY 分区:根据 MySQL 数据库提供的哈希函数来进行分区。
你不能创建不带索引的分区,如果表中存在主键或唯一索引时,分区列必须是唯一索引的一个组成部分,因此下面创建分区的 SQL 语句会因缺少索引而产生错误:
1 | mysql> create table t1 (id int not null, val int not null, unique key (id)) partition by hash(val) partitions 4; |
唯一索引可以是允许 NULL 值的,并且分区列只要是唯一索引的一个组成部分,不需要整个唯一索引列都是分区列。
如果建表时没有指定主键,唯一索引,可以指定任何一个列为分区列。
分区类型
RANGE 分区
第一种分区类型是 RANGE 分区,也是最常用的一种分区类型。下面的 CREATE TABLE 语句创建了一个 id 列的区间分区表。当 id 小于 10 时,数据插入 p0 分区;当 id 大于等于 10 小于 20 时,数据插入 p1 分区。
1 | CREATE TABLE t( |
查看表在磁盘上的物理文件,启用分区之后,表不再由一个 ibd 文件组成了,而是由建立分区时的各个分区 ibd 文件组成,如下面的 t#P#p0.ibd
、t#P#p1.ibd
:
1 | system ls -lh /usr/local/mysql/data/test2/t* |
接着插入如下数据:
1 | mysql> INSERT INTO t SELECT 9; |
因为表 t 根据列 id 进行分区,所以数据是根据列 id 的值的范围存放在不同的物理文件中的。
由于我们定义了分区,因此对于插入的值应该严格遵守分区的定义,当插入一个不在分区中定义的值时,MySQL 数据库会抛出一个异常。
LIST 分区
LIST 分区和 RANGE 分区非常相似,只是分区列的值是离散的,而非连续的。如:
1 | mysql> CREATE TABLE t ( |
不同于 RANGE 分区中定义的 VALUES LESS THAN 语句,LIST 分区使用 VALUES IN。因为每个分区的值是离散的,因此只能定义值。
在用 INSERT 插入多个行数据的过程中遇到分区未定义的值时,MyISAM 和 InnoDB 存储引擎的处理完全不同。MyISAM 引擎会将之前的行数据都插入,但之后的数据不会被插入。而 InnoDB 存储引擎将其视为一个事务,因此没有任何数据插入。
HASH 分区
HASH 分区的主要目的是将数据均匀地分配到预定义的各个分区中,以确保每个分区中的数据量大致相同。与 RANGE 和 LIST 分区需要显式指定某个列值或值集合应存储在哪个分区不同,HASH 分区的分配过程由 MySQL 自动完成。用户只需基于用于分区的列,指定一个返回整数的表达式,同时设置表应被划分的分区数量即可。
要使用 HASH 分区来分割一个表,要在 CREATE TABLE 语句上添加一个:PARTITION BY HASH(expr) 子句,其中 expr 是一个返回一个整数的表达式。它可以仅仅是字段类型为 MySQL 整型的列名。此外,用户很可能需要在后面再添加一个:PARTITIONS num 子句,其中 num 是一个非负的整数,它表示表将要被分割成分区的数量。如果没有包括一个 PARTITIONS 子句,那么分区的数量将默认为 1。
MySQL 数据库还支持一种称为 LINEAR HASH 的分区,它使用一个更加复杂的算法来确定新行插入到已经分区的表中的位置。它的语法和 HASH 分区的语法相似,只是将关键字 HASH 改为 LINEAR HASH。MySQL 数据库根据以下的方法来进行分区的判断:
- 取大于分区数量 4 的下一个 2 的幂值 V,
V = POWER(2, CEILING(LOG(2, num))) = 4
; - 所在分区
N = YEAR('2010-04-01') & (V - 1) = 2
。
LINEAR HASH 分区的优点在于,增加、删除、合并和拆分分区将变得更加快捷,这有利于处理含有大量数据的表。它的缺点在于,与使用 HASH 分区得到的数据分布相比,各个分区间数据的分布可能不太均衡。
KEY 分区
KEY 分区和 HASH 分区相似,不同之处在于 HASH 分区使用用户定义的函数进行分区,KEY 分区使用 MySQL 数据库提供的函数进行分区。对于 InnoDB 存储引擎,MySQL 数据库使用其内部的哈希函数,这些函数基于与 PASSWORD()
一样的运算法则。如:
1 | mysql> CREATE TABLE t_key ( |
在 KEY 分区中使用关键字 LINEAR 和在 HASH 分区中使用具有同样的效果,分区的编号是通过 2 的幂算法得到的,而不是通过模数算法。
COLUMNS 分区
在前面介绍的 RANGE、LIST、HASH 和 KEY 这四种分区中,分区的条件是:数据必须是整型,如果不是整型,那么就需要通过函数将其转化为整型,如 YEAR()
、TO_DAYS()
、MONTH()
等函数。MySQL 5.5 版本开始支持 COLUMNS 分区,可视为 RANGE 分区和 LIST 分区的一种进化。COLUMNS 分区可以直接使用非整型的数据进行分区,分区根据类型直接比较而得,不需要转化为整型。此外,RANGE COLUMNS 分区可以对多个列的值进行分区。
COLUMNS 分区支持以下的数据类型:
- 所有的整型类型,如 INT、SMALLINT、TINYINT、BIGINT。FLOAT 和 DECIMAL 则不予支持。
- 日期类型,如 DATE 和 DATETIME。其余的日期类型不予支持。
- 字符串类型,如 CHAR、VARCHAR、BINARY 和 VARBINARY。BLOB 和 TEXT 类型不予支持。
对于日期类型的分区,我们不再需要 YEAR()
和 TO_DAYS()
函数了,而是可以直接使用 COLUMNS,如:
1 | CREATE TABLE t_columns_range( |
同样可以直接使用字符串的分区:
1 | CREATE TABLE customers_1 ( |
对于 RANGE COLUMNS 分区,可以使用多个列进行分区,如:
1 | CREATE TABLE rcx ( |
MySQL 5.5 开始支持 COLUMNS 分区,对于之前的 RANGE 和 LIST 分区,用户可以用 RANGE COLUMNS 和 LIST COLUMNS 分区进行很好的代替。
子分区
子分区(subpartitioning)是在分区的基础上再进行分区,有时也称这种分区为复合分区(composite partitioning)。MySQL 数据库允许在 RANGE 和 LIST 的分区上再进行 HASH 或 KEY 的子分区,如:
1 | mysql> CREATE TABLE ts (a INT, b DATE) ENGINE=InnoDB |
表 ts 被 范围分区成 p0、p1、p2;
每个主分区又被 子分区(HASH) 成两个子分区,比如 p0 变成:p0sp0、p0sp1;
最终形成了 3(主分区) × 2(子分区) = 6 个物理分区。
子分区的建立需要注意以下几个问题:
- 每个子分区的数量必须相同。
- 要在一个分区表的任何分区上使用 SUBPARTITION 来明确定义任何子分区,就必须定义所有的子分区。
- 每个 SUBPARTITION 子句必须包括子分区的一个名字。
- 子分区的名字必须是唯一的。
子分区可以用于特别大的表,在多个磁盘间分别分配数据和索引。MySQL 本身并不直接支持通过语法控制每个分区或子分区映射到哪个磁盘路径,但你可以通过以下方式间接实现这一点:
操作方法一:使用多个表空间 + DATA DIRECTORY 和 INDEX DIRECTORY
MyISAM 引擎可以使用如下方式手动指定每个分区/子分区存放的目录(通常每个目录挂载不同磁盘):
1 | CREATE TABLE big_table ( |
⚠️ 仅支持 MyISAM,不支持 InnoDB。并且要求 innodb_file_per_table=OFF,否则无效。
操作方法二:使用 Linux 下的软链接(适用于 InnoDB)
虽然 InnoDB 不支持 DATA DIRECTORY 语法,但可以使用 软链接 将分区/子分区文件(.ibd
)手动移动到不同磁盘,如:
步骤:
一、创建分区表(比如在 /var/lib/mysql
中):
1 | CREATE TABLE big_partition ( |
二、关闭 MySQL 服务
移动分区数据文件到其他磁盘目录
1 | mv /var/lib/mysql/db/big_partition#P#p0.ibd /disk0/big_partition#P#p0.ibd |
三、重启 MySQL 服务
⚠️ 你必须启用 innodb_file_per_table=1
且使用独立表空间
操作方法三:使用 LVM 或 RAID 逻辑卷
将多个磁盘组合成一个逻辑卷,然后整个 MySQL 数据目录或表空间都部署在这个卷上,由底层逻辑卷管理器决定调度和负载均衡,对 MySQL 透明。
分区中的 NULL 值
MySQL 数据库允许对 NULL 值做分区,但是处理的方法与其他数据库可能完全不同。MySQL 数据库的分区是视 NULL 值小于任何一个非 NULL 值,这和 MySQL 数据库中处理 NULL 值的 ORDER BY 操作是一样的。因此对于不同的分区类型,MySQL 数据库对于 NULL 值的处理也是各不相同。
对于 RANGE 分区,如果向分区列插入了 NULL 值,则 MySQL 数据库会将该值放入最左边的分区。例如:
1 | mysql> CREATE TABLE t_range( |
接着向表中插入 (1,1)
、(1,NULL)
两条数据,并观察每个分区中记录的数据:
1 | mysql> INSERT INTO t_range SELECT 1,1; |
可以看到两条数据都放入了 p0 分区,也就是说在 RANGE 分区下,NULL 值会放入最左边的分区中。另外需要注意的是,如果删除 p0 这个分区,删除的将是小于 10 的记录,并且还有 NULL 值的记录,这点非常重要:
1 | mysql> ALTER TABLE t_range DROP PARTITION p0; |
在 LIST 分区下要使用 NULL 值,则必须显式地指出哪个分区中放入 NULL 值,否则会报错,如:
1 | mysql> CREATE TABLE t_list( |
若 p0 分区允许 NULL 值,则插入不会报错:
1 | mysql> CREATE TABLE t_list( |
HASH 和 KEY 分区对于 NULL 的处理方式和 RANGE 分区、LIST 分区不一样。任何分区函数都会将含有 NULL 值的记录返回值为 0。例如:
1 | mysql> CREATE TABLE t_hash( |
分区和性能
我们常听到开发人员说“对表做个分区”,然后数据库的查询就会快了。这是真的么?实际上可能根本感受不到查询速度的提升,甚至会发现查询速度会明显下降。因此,若要合理使用分区之前,必须了解分区的使用环境。
数据库的应用大致分为两类:一类是 OLTP(在线事务处理),如 Blog、电子商务、网络游戏等;另一类是 OLAP(在线分析处理),如数据仓库、数据挖掘。在一个实际的应用环境中,可能看上去属于 OLTP 的应用,也有 OLAP 的应用。例如游戏平台,玩家操作的数据游戏数据库应用属 OLTP 的,但游戏平台厂商可能需要对玩家产生的日志进行分析,通过分析得到的结果来更好地服务于游戏,预测玩家的行为等,而这却是 OLAP 的应用。
对于 OLAP 的应用,分区的确是可以很好地提高查询的性能,因为 OLAP 应用大多数查询需要频繁地扫描一张很大的表。假设有一张 1 亿行的表,其中有一个时间戳属性列,用户的查询需要从这张表中获取一年的数据。如果按照时间戳进行分区,则只需要扫描相应的区即可,这就是前面介绍的 Partition Pruning 技术。
但对于 OLTP 的应用,分区这时就会小心。在这种应用下,通常不可能会去获取一张大表中 10% 的数据,大家的查询是通过索引定位几条记录即回,而根据 B+ 树结构的原理可知,对于一张大表,一般的 B+ 树需要 2~3 次的磁盘 IO。因此 B+ 树可以很好地完成检索,不需要分区的帮助,并且设计不好的分区会带来严重的性能问题。
我发现很多开发团队会认为含有 1000W 行的表是一张非常巨大的表,所以他们往往会选择采用分区,如对主键做 10 个 HASH 的分区,这样每个分区就只有 100W 的数据;同时他们应该这样更快了,SELECT * FROM t WHERE pk=@pk
。但他们没有考虑过这样一种情况:100W 和 1000W 行的数据本身构成的 B+ 树的层级是一样的,而查找都是 2 层。那么上述主键分区的索引并不会带来性能的提升。好吧,如果 1000W 的 B+ 树的高度是 3,100W 的 B+ 树的高度是 2,那么上述主键分区的索引可以避免 1 次 IO,从而提高查询的效率。这没问题,但是张表只有主键索引,没有任何其他的列需要建立查询的。如果还有这样的 SQL 语句:SELECT * FROM TABLE WHERE KEY=@key
,这时对于 KEY 的查询要扫描所有的 10 个分区,即使每个分区的查询开销为 2 次 IO,则一共需要 20 次 IO。而对于原来单表的设计,对于 KEY 的整查询只需要 2~3 次 IO。
接着来看如下的表 Profile,根据主键 ID 进行了 HASH 分区,HASH 分区的数据为 10,表 Profile 有接近 1000W 的数据:
1 | mysql> CREATE TABLE `Profile` ( |
因为是根据 HASH 分区的,所以每个区分的记录数大致是相同的,即数据分布比较均匀:
1 | mysql> SELECT table_name, partition_name, table_rows |
注意:即使是根据自增长主键进行的 HASH 分区,也不能保证分区数据的均匀。因为插入的自增长 ID 并非总是连续的,如果该主键值因为某种原因被回滚了,则该值将不会再次被自动使用。
如果进行主键的查询,可以发现分区的确是有意义的:
1 | mysql> EXPLAIN PARTITIONS SELECT * FROM Profile WHERE id=1\G; |
可以发现只寻找了 p1 分区,但是对于表 Profile 中 nickname 列索引的查询,EXPLAIN PARTITIONS 则会得到如下的结果:
1 | mysql> EXPLAIN PARTITIONS |
可以看到,MySQL 数据库会搜索所有分区,因此查询速度上会慢很多。比较上述语句:
1 | mysql> SELECT * FROM Profile WHERE nickname='david'\G; |
上述简单的索引查找语句竟然需要 1.05 秒,这是因为查询需要遍历所有分区的关系,实际上的 IO 执行了约 20~30 次。而在未分区的同样结构和大小的表上,执行上述同样的 SQL 语句只需要 0.26 秒。
因此对于使用 InnoDB 存储引擎作为 OLTP 应用的表在使用分区时应该十分小心,设计时确认数据的访问模式,否则在 OLTP 应用下分区可能不仅不会带来查询速度的提高,反而可能会使你的应用执行得更慢。
在表和分区间交换数据
MySQL 5.6 开始支持 ALTER TABLE … EXCHANGE PARTITION
语法。该语句允许分区或子分区中的数据与另一个非分区的表中的数据进行交换。如果非分区中的数据为空,那么相当于将分区中的数据移动到非分区表中;若分区表中的数据为空,则相当于将外部表中的数据导入到分区中。
要使用 ALTER TABLE … EXCHANGE PARTITION
语句,必须满足下面的条件:
- 要交换的表需和分区表有相同的表结构,但表不能包含分区;
- 在非分区表中的数据必须在交换的分区定义内;
- 被交换的表中不能有外键,或者其他的表含有对该表的外键引用;
- 用户除了需要 ALTER、INSERT 和 CREATE 权限外,还需要 DROP 的权限。
此外,有两个小细节也需要注意:
- ⚠️ 使用该语句时,不会触发交换表和被交换表上的触发器;
- ⚠️
AUTO_INCREMENT
列将被重置。