vlambda博客
学习文章列表

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