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的性能。