MySQL是基于磁盘进行数据存储的关系型数据库, 所有的数据、索引等数据均以磁盘文件的方式存储, 在有需要时载入内存读取。 为了加快数据查询的效率, 通常会在一些字段上添加索引, 但是许多文档都会告诉我们, 不要添加太多的索引, 索引不要太长, 使用数字或者空字符串来代替NULL值, 为什么会有这些建议? 这些建议又是否正确? 答案都能够从MySQL数据的物理存储方式中找到。
1. InnoDB文件格式
由于InnoDB是MySQL使用最为广泛的存储引擎, 所以本篇博文基于InnoDB存储引擎来讨论其数据存储方式。
当我们创建一个table时, InnoDB会创建三个文件。 一个是表结构定义文件, 另一个为数据实际存储文件, 并且所有的索引也将存放在这个文件中。 最后一个文件保存该table所制定的字符集。
2. InnoDB行记录格式
当我们使用SQL查询一条或者是多条数据时, 数据将会以一行一行的方式返回, 而实际上数据在文件中也的确是使用行记录的方式进行存储的。
不同的InnoDB引擎版本可能有着不同的行记录格式来存放数据, 可以说, 行记录格式的变更将会直接影响到InnoDB的查询以及DML效率。 在MySQL 5.7版本中, 如果对某个table执行:
SHOW TABLE STATUS LIKE "table_name" G;
将会得到该table的一系列信息, 在这里, 我们只需要知道Row_format
的值即可, 5.7将会返回Dynamic
。
在官网上给出了不同格式的行记录格式之间的差别, 详细内容见官方文档:
https://dev.mysql.com/doc/refman/5.7/en/innodb-row-format.html
在这里我们只需要知道Dynamic
行记录格式在存储可变字符(Varchar)时, 与Compact
行记录格式有着同样的表现即可。
Compact行记录格式将以上图的方式保存在文件中, 需要注意的是, 如果一个table中没有任何的varchar类型, 那么变长字段长度列表将为空。
Compact行记录格式的首部是一个非NULL变长字段长度列表, 并且是按照列的顺序逆序放置的, 其长度表现为:
- 若列的长度小于255字节, 用1字节表示
- 若列的长度大于255字节, 用2字节表示
变长字段的长度最大不会超过2字节, 这是因为MySQL中VARCAHR类型的最大长度限制为65535。 变长字段之后的第二个部分为NULL标识位, 该位指示了该行数据中是否存在NULL值, 有则用1表示, 本质上是一个bitmap。
下面用一个实际的例子来具体分析Compact行记录格式的实际存储。
-- 创建database
CREATE SCHEMA `coco` DEFAULT CHARACTER SET latin1 ;
-- 创建table
CREATE TABLE one (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(10),
nickname VARCHAR(10),
PRIMARY KEY (id),
KEY (nickname)
) ENGINE=InnoDB CHARSET=LATIN1;
-- 插入代表性数据
INSERT INTO one (name, nickname) VALUES ("a", "AAA");
INSERT INTO one (name, nickname) VALUES ("b", "BBB");
INSERT INTO one (name, nickname) VALUES ("c", NULL);
INSERT INTO one (name, nickname) VALUES ("d", "DDD");
INSERT INTO one (name, nickname) VALUES ("e", "");
INSERT INTO one (name, nickname) VALUES ("f", "FFF");
而后在/var/lib/mysql/coco
中即可找到该表的.ibd
文件了, 使用hexdump -C one.ibd
对其进行16进制的数据解析并查看。 由于数据太长, 所以仅截取部分数据:
0000c070 73 75 70 72 65 6d 75 6d 03 01 00 00 00 10 00 1d |supremum........|
0000c080 80 00 00 01 00 00 00 08 d1 29 bd 00 00 01 35 01 |.........)....5.|
0000c090 10 61 41 41 41 03 01 00 00 00 18 00 1c 80 00 00 |.aAAA...........|
0000c0a0 02 00 00 00 08 d1 29 bd 00 00 01 35 01 1d 62 42 |......)....5..bB|
0000c0b0 42 42 01 02 00 00 20 00 1a 80 00 00 03 00 00 00 |BB.... .........|
0000c0c0 08 d1 29 bd 00 00 01 35 01 2a 63 03 01 00 00 00 |..)....5.*c.....|
0000c0d0 28 00 1d 80 00 00 04 00 00 00 08 d1 29 bd 00 00 |(...........)...|
0000c0e0 01 35 01 37 64 44 44 44 00 01 00 00 00 30 00 1a |.5.7dDDD.....0..|
0000c0f0 80 00 00 05 00 00 00 08 d1 29 bd 00 00 01 35 01 |.........)....5.|
0000c100 44 65 03 01 00 00 00 38 ff 66 80 00 00 06 00 00 |De.....8.f......|
0000c110 00 08 d1 29 bd 00 00 01 35 01 51 66 46 46 46 00 |...)....5.QfFFF.|
实际存储数据从0000c078
开始, 使用Compact行记录格式对其进行整理:
03 01 /* 变长字段长度列表, 逆序, 第一行varchar数据为('a', 'AAA') */
00 /* NULL标识位, 该值表示该行未有NULL值的列 */
00 00 10 00 1d /* 记录头(Record Header)信息, 固定长度为5字节 */
80 00 00 01 /* Row ID, 这里即为该行数据的主键值(paimary key),长度为4 */
00 00 00 08 d1 29 /* Transaction ID, 即事务ID, 默认为6字节 */
bd 00 00 01 35 01 10 /* 回滚指针, 默认为7字节 */
61 /* 列1数据'a' */
41 41 41 /* 列2数据'AAA' */
第2行数据与第1行数据大同小异, 值得关注的是包含有NULL值以及空值的行, 即第3行和第5行, 首先来看第3行数据:
01 /* 由于该行中只有一列数据类型为varchar,并且非NULL, 所以列表长度为1 */
02 /* 02转换为2进制结果为10, 表示第二列数据为NULL(注意是逆序) */
00 00 20 00 1a /* 记录头(Record Header)信息, 固定长度为5字节 */
80 00 00 03 /* 第3行数据的主键id */
00 00 00 08 d1 29 /* Transaction ID, 即事务ID, 默认为6字节 */
bd 00 00 01 35 01 2a /* 回滚指针, 默认为7字节 */
63 /* 列1数据'c' */
可以非常明显的看到, NULL值并没有在文件中进行存储, 而是仅使用NULL标识位来标记某一列是否为NULL。 所以说, NULL值不会占据任何的物理存储空间, 相反, varchar类型的NULL值还会少占用变长字段长度列表空间。
再来看空字符串所在的第5行数据:
00 01 /* 表示第2列的varchar长度为0 */
00 /* 该行没有NULL值的列 */
00 00 30 00 1a /* 记录头(Record Header)信息, 固定长度为5字节 */
80 00 00 05 /* 第5行数据的主键id */
00 00 00 08 d1 29 /* Transaction ID, 即事务ID, 默认为6字节 */
bd 00 00 01 35 01 44 /* 回滚指针, 默认为7字节 */
65 /* 列1数据'e' */
可以看到, 空字符串和NULL值一样, 也不占用任何的磁盘存储空间。 只不过与NULL值不同的是, 在首部的变长字符长度列表中仍然占据存储空间, 但是值为0。
3. 数据的聚集索引组织方式
有些人将聚集索引(Cluster Index)理解成为主键, 或者是主键索引, 这是不准确的。 聚集索引并不是一种索引结构, 而是一种数据的组织方式, 用唯一且不为空的主键来对所有的数据进行组织。 主键, 是最为常见的聚集索引对外表现的形式。
聚集索引最大的特点就在于数据在逻辑上是一定是连续的, 但是在物理是并不一定连续。 比如我们常见的自增主键, 当我们对查询语句不做任何处理时, 默认就是按照主键的递增顺序返回的。
而辅助索引, 或者是二级索引, 是由程序员人为的在某些列上所添加的索引。 辅助索引所代表的数据在逻辑上不一定连续, 物理存储上也不一定连续。
MySQL使用B+Tree来组织数据和索引(关于B+Tree的详细内容, 可见下方传送门), 在非叶子节点中保存着索引和指针, 在叶子节点保存着数据。 情况又分两种:
- 聚集索引的叶子节点保存着实际的数据,即一行完整的数据
- 辅助索引的叶子节点保存着该行数据的主键ID
那些有趣的数据结构与算法(04)–B-Tree与B+Tree
也就是说, 假设聚集索引和辅助索引的B+Tree树高均为3的话, 使用主键查询需要3次逻辑I/O。 而使用辅助索引则需要6次逻辑I/O才能找到该行数据。
还记得在上面的Compact行记录格式中的行记录头, 也就是Record Header信息吗? Record Header的最后两个字节表示下一行数据的偏移量, 其实这个就是B+Tree中的指针。 例如第一行的起始位置为c078, Record Header最后两个字节为001d, 加起来等于c095, 刚好是第二行的起始位置。
在上面的例子中, 我们创建了这样的一张表:
CREATE TABLE one (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(10),
nickname VARCHAR(10),
PRIMARY KEY (id),
KEY (nickname)
) ENGINE=InnoDB CHARSET=LATIN1;
其中nickname
字段被我们添加了辅助索引, 同样地, 可以使用.ibd
文件来具体对其结构进行分析。 使用hexdump -C one.ibd
解析文件并找到辅助索引开始的地方:
00010060 02 00 37 69 6e 66 69 6d 75 6d 00 07 00 0b 00 00 |..7infimum......|
00010070 73 75 70 72 65 6d 75 6d 03 00 00 00 10 00 0e 41 |supremum.......A|
00010080 41 41 80 00 00 01 03 00 00 00 18 00 18 42 42 42 |AA...........BBB|
00010090 80 00 00 02 01 00 00 20 00 19 80 00 00 03 03 00 |....... ........|
000100a0 00 00 28 00 19 44 44 44 80 00 00 04 00 00 00 00 |..(..DDD........|
000100b0 30 ff cc 80 00 00 05 03 00 00 00 38 ff b2 46 46 |0..........8..FF|
000100c0 46 80 00 00 06 00 00 00 00 00 00 00 00 00 00 00 |F...............|
000100d0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
索引数据从00010078的位置开始, 逐行进行分析即可:
03 /* 当前索引字段的长度 */
00 00 00 10 00 0e /* 不知道是啥 */
41 41 41 /* 索引值 */
80 00 00 01 /* 指向的主键id */
第2行与第1行基本类似, 现在来看看比较特殊的第3行与第5行。 第3行索引数据内容:
01
00 00 20 00 19
80 00 00 03 /* 指向的主键id */
当索引的内容为NULL值时, 辅助索引的文件格式也变得奇怪了起来, 和第一行完全不一样, 再来看看第5行:
00 /* 当前索引字段的长度 */
00 00 00 30 ff cc
80 00 00 05 /* 指向的主键id */
和正常索引内容基本类似, 空字符串仍然没有表示, 仅使用了00表示该字段长度为0。
4. 辅助索引叶子节点存储方式
在MySQL中, 数据管理的最小单元为页(page), 而并非一行一行的数据。 数据保存在页中, 当我们使用主键查找一行数据时, 其实MySQL并不能直接返回这一行数据, 而是将该行所在的页载入内存, 然后在内存页中进行查找。
通常情况下页大小为16K, 在某些情况下可能会对页进行压缩, 使得页大小为8K或者是4K。 由于B+Tree的特点, 使得每一页内最少为2行数据, 再少就将退化成链表, 显然出于效率的考量不会让此种情况出现。 故而一行数据大小至多为16K, 通过该特性, 就可以研究二级索引的叶子节点是什么样子的了。
CREATE TABLE two (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(10),
nickname VARCHAR(8000),
PRIMARY KEY (id),
KEY (nickname(2000))
) ENGINE=InnoDB CHARSET=LATIN1;
INSERT INTO two SELECT 1, 'a', REPEAT('A', 8000);
INSERT INTO two SELECT 2, 'b', NULL;
INSERT INTO two SELECT 3, 'c', REPEAT('C', 8000);
INSERT INTO two SELECT 4, 'd', NULL;
INSERT INTO two SELECT 5, 'e', REPEAT('E', 8000);
INSERT INTO two SELECT 6, 'f', REPEAT('F', 8000);
INSERT INTO two SELECT 7, 'g', NULL;
INSERT INTO two SELECT 8, 'h', REPEAT('H', 8000);
INSERT INTO two SELECT 9, 'i', REPEAT('G', 8000);
INSERT INTO two SELECT 10, 'i', "";
由于索引长度的限制, 这里仅取nickname的前2000个字符进行索引, 并插入一些具有代表性的数据。 同样使用hexdump -C two.ibd
对索引结构进行分析:
00010070 73 75 70 72 65 6d 75 6d d0 87 00 05 00 10 07 e6 |supremum........|
00010080 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 |AAAAAAAAAAAAAAAA|
*
00010850 80 00 00 01 01 00 00 18 07 e6 80 00 00 02 d0 87 |................|
00010860 00 00 00 20 07 e6 43 43 43 43 43 43 43 43 43 43 |... ..CCCCCCCCCC|
00010870 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 43 |CCCCCCCCCCCCCCCC|
*
00011030 43 43 43 43 43 43 80 00 00 03 01 00 00 28 0f c2 |CCCCCC.......(..|
00011040 80 00 00 04 d0 87 00 00 00 30 07 dc 45 45 45 45 |.........0..EEEE|
00011050 45 45 45 45 45 45 45 45 45 45 45 45 45 45 45 45 |EEEEEEEEEEEEEEEE|
*
00011810 45 45 45 45 45 45 45 45 45 45 45 45 80 00 00 05 |EEEEEEEEEEEE....|
00011820 d0 87 00 00 00 38 0f c2 46 46 46 46 46 46 46 46 |.....8..FFFFFFFF|
00011830 46 46 46 46 46 46 46 46 46 46 46 46 46 46 46 46 |FFFFFFFFFFFFFFFF|
*
00011ff0 46 46 46 46 46 46 46 46 80 00 00 06 01 00 00 40 |FFFFFFFF.......@|
00012000 0f c3 80 00 00 07 d0 87 00 00 00 48 e0 62 48 48 |...........H.bHH|
00012010 48 48 48 48