MySQL行格式,你了解多少?
1. 前言
MySQL 架构分为 Server 层和存储引擎层,Server 层负责接收处理客户端指令,一旦涉及到数据的读取和写入操作,最终是需要调用存储引擎提供的接口来完成的。在 MySQL 的整个生态里,除 Memory 外,绝大多数存储引擎都是将数据存储在磁盘上的,例如常用的 InnoDB、MyISAM。
大家有没有思考过这样一个问题,我们提交 Insert 语句,MySQL 会帮我们把数据保存下来,当我们查询的时候它又能把数据再返回给我们,那它底层是按照什么格式存储数据的呢?
我们以一条条记录为单位向表中插入数据,MySQL 也是以「记录」为单位进行存储的,记录在磁盘上存储的格式,就是我们今天要讨论的「行格式」。
行格式可以在创建表的时候指定,语法如下:
CREATE TABLE 表名 (列...) ROW_FORMAT = 行格式
也可以对已经存在的表进行行格式的变更:
ALTER TABLE 表名 ROW_FORMAT = 行格式
不同行格式对数据读写的影响?有的行格式设计的不紧凑,同样一条记录会占用更多的磁盘空间,意味着一个页里包含的记录就少了,进而会影响 DML 操作的性能,查询可能需要更多的磁盘 IO。有的行格式还会对数据做压缩处理,磁盘 IO 的效率高了,但是会消耗更多的 CPU 资源。所以,行格式的选择对数据读写的效率是有影响的,具体如何选择,需要根据场景而定。MySQL5.7 版本,默认使用 DYNAMIC 行格式。
2. InnoDB 行格式
InnoDB 存储引擎目前共支持四种行格式,如下表:
行格式 | 紧凑存储 | 增强的可变长度列存储 | 大索引键前缀支持 | 支持压缩 |
---|---|---|---|---|
REDUNDANT | 否 | 否 | 否 | 否 |
COMPACT | 是 | 否 | 否 | 否 |
DYNAMIC | 是 | 是 | 是 | 否 |
COMPRESSED | 是 | 是 | 是 | 是 |
其中 REDUNDANT 是一种非常古老的行格式,现在应该没什么人用了,它存在的意义是与旧版本的 MySQL 做兼容。COMPACT 行格式需要重点了解,DYNAMIC 和 COMPRESSED 不过是它的另外两个变体。我们就先看老的再看新的,顺便感受一下 MySQL 做了哪些优化。
2.1 REDUNDANT
REDUNDANT 属于「非紧凑」的行格式,这意味着它比较占用磁盘空间,间接导致查询时可能需要更多的磁盘 IO。占空间、效率不高,这就是它被淘汰的主要原因。
REDUNDANT 存储记录的格式如下图:1、字段长度偏移列表 REDUNDANT 没有区别对待定长和变长字段,将所有列占用的存储空间都逆序存放在字段长度偏移列表中。根据字段的偏移量就可以定位到字段的存储位置,和下一个偏移量的差值可以计算出字段的长度,从而取出字段的完整信息。
2、记录头信息 REDUNDANT 记录头信息固定占用 6 字节,即 48 个比特位,每个比特位代表的含义如下表:
名称 | 大小(Bit) | 说明 |
---|---|---|
预留位 1 | 1 | 没有使用 |
预留位 2 | 1 | 没有使用 |
deleted_flag | 1 | 记录删除标记 |
min_rec_flag | 1 | B+树非叶子节点的最小目录项标记 |
n_owned | 4 | 同一页内同一组里最大的记录会记录组里的记录数量,其余记录该值为 0 |
heap_no | 13 | 当前记录在页面堆里的相对位置 |
n_field | 10 | 记录中列的数量 |
1byte_offs_flag | 1 | 标识字段长度偏移列表里用 1 字节还是 2 字节存储长度 |
next_record | 16 | 下一条记录的相对位置 |
3、使用几个字节来记录字段长度偏移量?当记录所有列的总长度不超过 127 时,使用 1 字节存储,因为总长度都没拆过 127,单个字段的长度肯定不会超过 127。列总长度大于 127 时,使用 2 字节存储。2 字节最多能表示 65535,有没有可能一行记录占用的空间超过了 65535 呢?是有可能的,但此时该列肯定属于「溢出列」了,记录的真实数据处只会保存前 768 字节的数据+20 字节的指针,剩余的数据则存储在专门的「溢出页」中。
4、如何处理 NULL?REDUNDANT 没有专门的「NULL 值列表」,那它是如何处理 NULL 值的呢?还记得「字段长度偏移列表」吗?1 字节最大能表示 255,为啥超过 127 就开始使用 2 字节呢?原因就在于,REDUNDANT 会把第 0 位用来标记是否为 NULL,第 0 位是 1 则代表值为 NULL,是 0 就不为 NULL。
5、定长列和变长列处理 NULL 值的区别?如果定长列存储的是 NULL 值,则 NULL 值也会占用存储空间,数据全部用0x00
字节填充。例如char(10)
就会占用 10 个字节(与字符集有关,utf8 则直接占用 30 字节),这样做的好处是,以后 update 该列时,可以直接复用这一块空间。如果变长列存储的是 NULL 值,则 NULL 值本身不占空间。
综上所述,可以看出,REDUNDANT 设计的简单粗暴,正因如此也导致它比较浪费磁盘空间,属于非紧凑的行格式。我们接下来看 COMPACT,你就知道它设计的有多紧凑了。
2.2 COMPACT
COMPACT 行格式也将记录分为两部分,如下图所示:1、变长字段长度列表 针对 VARCHAR、TEXT、BLOB 这类变长字段,列中实际存储了多少数据是不固定的,因此除了要把数据本身存下来,还需要记下它的长度。COMPACT 将变长列的实际长度按照字段的顺序,逆序存储在变长字段长度列表里。
-
为啥逆序存储?
记录头信息里有一个指针,将一条条记录串联成单向链表。指针指向的位置并不是一条完整记录的起始位置,而是图中「记录的真实数据」的起始位置。这样的好处是,往右读就是真实数据,往左读就是头信息,根据计算机的局部性原理,更容易提高二者缓存的命中率。
-
使用几个字节存储长度?
这与列使用的字符集有关,假设该字符集最多使用 X 个字节表示一个字符,VARCHAR(N)最多存储 N 个字符,占用的字节数最多是X*N
,假设该列实际长度是 L。如果X*N<=255
,则使用 1 字节,因为最多占用的字节数不会超过 255,1 个字节就足够了。如果X*N>255 && L<=127
,使用 1 字节存储。如果X*N>255 && L>127
就使用 2 字节存储。
-
X*N>255
时,如何判断使用几个字节存储长度?
X*N>255
时,长度可能用 1 个或 2 个字节来表示,REDUNDANT 的方案是在头信息里记下来,而 COMPACT 直接在长度本身做了个小把戏。1 字节能表示的最大值是 255,为啥 L 超过 127 就采用 2 字节存储?因为 MySQL 把第 0 个比特位用来标记是否采用 2 字节存储,如果第 0 位为 0 则采用 1 字节存储,第 0 位为 1 则采用 2 字节存储。
-
CHAR(N)算不算变长?
我们总说“char 是定长的,varchar 是变长的”,那是不是 char 类型的列就不需要记录变长字段长度呢?答案是:要看你使用的字符集!如果是ascii
字符集,所有字符都固定占用 1 字节,那就不需要记录。如果是utf8
,一个字符占用的字节数为1~3
,这种不固定的字符集,就需要存储了变长字段长度了。例如CHAR(10)
使用 utf8 字符集,占用的存储空间范围是10~30
,列的实际长度还是不确定的。
2、NULL 值列表 没有限制NOT NULL
的列是可以设为 NULL 的,COMPACT 是如何存储 NULL 值的呢?答案是:不存储!对于 NULL 值列,它是不会在「记录的真实数据」处占用任何空间的,仅仅是在「NULL 值列表」用 1 个比特位来标记该列值为 NULL。「NULL 值列表」要求必须是整数个字节,不足的高位补 0 处理,按照列的顺序逆序存储,1 代表 NULL,0 代表非 NULL。
3、记录头信息 COMPACT 记录头信息占用固定的 5 字节,即 40 个比特位,对应的含义如下:
名称 | 大小(Bit) | 说明 |
---|---|---|
预留位 1 | 1 | 没有使用 |
预留位 2 | 1 | 没有使用 |
deleted_flag | 1 | 记录删除标记 |
min_rec_flag | 1 | B+树非叶子节点的最小目录项标记 |
n_owned | 4 | 同一页内同一组里最大的记录会记录组里的记录数量,其余记录该值为 0 |
heap_no | 13 | 当前记录在页面堆里的相对位置 |
record_type | 3 | 记录类型。0:普通记录,1:B+树非叶子节点目录项记录,2:Infimum 记录,3:Supremum 记录. |
next_record | 16 | 下一条记录的相对位置 |
-
deleted_flag
DELETE
命令删除记录,并不会真的将它从磁盘中删除,而是仅仅打一个标记,然后把该条记录加入到「垃圾链表」里,垃圾链表占用的空间称为「可重用空间」,以后如果在这个位置插入新的记录就可以重用这部分空间了。如果一个页内所有的记录都被删除了,那么这个页就称为「可重用的页」。
-
min_rec_flag
InnoDB 引擎组织数据的形式采用了 B+树,用户记录存储在叶子节点,目录项(也可叫索引项)存储在非叶子节点,一个个节点就是一个个页,同一个非叶子节点内最小的目录项该比特位为 1,其余均为 0。
-
n_owned
InnoDB 引擎页大小默认是 16KB,同一个页内可能会存储很多的用户记录,甚至上千条。为了提高页内的检索效率,InnoDB 会将记录划分为多个不同的组,组内记录值最大的一条称为“大哥”,其余的都是“小弟”,“大哥”会利用该属性来记录组内的记录数量,各个组的“大哥”的值会按照顺序被记录在页内的「Page Directory」位置。
-
heap_no
用户记录存储在页的「User Records」部分,MySQL 将这部分结构称作堆(Heap),每申请一块记录空间,都会为其分配一个 heap_no,越靠前的记录 heap_no 越小,越靠后的记录 heap_no 越大。
-
record_type
记录类型,目前有 4 种:
record_type | 说明 |
---|---|
0 | 用户自己插入的记录,或二级索引叶子节点记录。 |
1 | B+树非叶子节点目录项记录,冗余的索引项记录。 |
2 | 页内虚拟的最小记录:Infimum |
3 | 页内虚拟的最大记录:Supremum |
-
next_record
用户记录会根据主键值排序并构建一条单向链表,链表就是通过该属性来构建的。它代表当前记录的真实数据到下一条记录的真实数据的距离,值为正数代表下一条记录在后面,值为负数代表下一条记录在前面。MySQL 规定,页中 Infimum 的下一条记录是本页中主键值最小的记录,主键值最大的记录 next_record 一定指向 Supremum。
综上所述,COMPACT 设计的比 REDUNDANT 要紧凑的多。能用 1 字节绝不用 2 字节,能用 bit 表示就绝不用 byte 表示。
2.3 DYNAMIC 和 COMPRESSED
MySQL5.7 默认使用的行格式就是 DYNAMIC 了,它和 COMPACT 非常像,是 COMPACT 的一个变体。区别是在处理「溢出列」时,COMPACT 会在「真实数据」处存储前 768 字节数据+20 字节指针,而 DYNAMIC 只会存储 20 字节指针,溢出列的所有数据全部存储在「溢出页」中。COMPRESSED 的特点是它可以使用压缩算法对页面进行压缩,包括「溢出页」,这对于像长文本、TEXT、BLOB 类型的数据来说,可以极大的节省空间。但是检索数据时,必须先解压才可以进行后续操作,这会消耗更多的 CPU 资源,CPU 和磁盘 IO 两者的开销,需要各位去权衡。
3. 总结
绝大多数存储引擎将数据持久化存储在磁盘中,行格式决定了记录在磁盘上的存储格式。InnoDB 引擎目前共支持四种行格式,古老的 REDUNDANT 设计的简单粗暴,缺点是会占用更多的磁盘空间,间接影响了 DML 操作的效率,查询需要更多的磁盘 IO。COMPACT 行格式设计的非常紧凑,也更加的复杂,能用 Bit 表示就绝不用 Byte,是一个非常经典的行格式。DYNAMIC 和 COMPRESSED 是 COMPACT 的两种变体,在处理溢出列时稍有不同,后者支持对页面进行压缩,通过消耗 CPU 算力还换取磁盘 IO 的性能。