深入理解 InnoDB 行格式
平时在使用MySQL时,我们往往都是以记录为单位插入数据的。这些记录是如何存放到磁盘中的呢?这就要牵扯到InnoDB的行格式了。
InnoDB共有四种行格式。
COMPACT
COMPACT这种格式的记录存储起来是这样的:

大体上分为了记录信息与记录数据两大部分,我们来看一下这两部分的组成:
记录信息
变长字段列表
MySQL支持许多变长字段,例如VARCHAR、TEXT等,除了这些变长字段外,还有一些字符集本身就是不定长的(比如GBK表示一个字符需要1到2字节)。变长字段和一些字符集存储的数据是不定长的,所以需要想办法记录下来变长字段占用的字节大小。在COMPACT格式中,变长字段列表就存储了变长字段的使用字节数,而且是按照所有变长字段组成的列的顺序进行逆序存放的。
例如有一张表有三个VARCHAR字段C1、C2、C3,分别存储了1字节、2字节、3字节的顺序,那么变长字段列表的数据就是|03|02|01|。
当然了,这个变长字段的大小肯定不是固定两个字节存储,而是一个动态的数据,具体来说,对于一个VARCHAR(M)的字段,假设这个字段的一个字符占用W字节,且实际的字符串使用了L个字节,如果 M x W 大于255,且L大于127,则使用两个字节,否则就是一个。
为什么这样设计呢?因为一个字节能表示的数字最大就是255,超不了的话用一个字节就够了。而127的限制则比较有意思,因为128往上的话一个字节的最高位就是1了,MySQL这样做就可以通过直接判断第一个字节的最高位来得知是使用一个字节还是两个字节来表示大小(这里虽然可能浪费了一定的空间,但是判断与计算逻辑对CPU极为友好,只需要判断一下最高位是否为1就能快速知晓如何计算,一个字节就是b0,两个字节是:(b0 & 0b0111111) * 256 + b1)。
可能还有人会问如果两个字节都表示不了长度呢?这就需要溢出页机制了,在后面会展开讲一下。
还有一点需要注意,如果变长字段的值为NULL的话,那么变长字段列表会直接跳过这个字段,而不是记录长度为0。
NULL值列表
一个记录的列数据可能存在NULL值,这些NULL值在COMPACT中会统一记录下来,处理过程如下:
- 统计表中允许为NULL的字段有哪些,如果没有直接不需要NULL值列表;
- 否则,每个允许为NULL的字段将对应一个二进制位的标志(1代表NULL,0代表非NULL),按照列的顺序逆序存储到NULL值列表。
- 除此之外,这些位还必须使用整数个的字节(1字节等于8位)来存储,不够8个的话需要在高位补0。
记录头信息
记录头使用固定5个字节,存储记录的一些属性,详细如下:
| 名称 | 大小(位) | 描述 |
|---|---|---|
| 预留位1 | 1 | 没有使用 |
| 预留位2 | 1 | 没有使用 |
| deleted_flag | 1 | 标记记录是否删除 |
| min_rec_flag | 1 | 标记B+树的每层非叶子节点的最小目录项记录 |
| n_owned | 4 | 记录的组长用于表示该组的记录条数 |
| heap_no | 13 | 表示当前页面在页面堆的相对位置 |
| record_type | 3 | 表示当前记录的类型,0是普通记录,1是B+树非叶子节点目录项记录,2是Infimum记录,3是Supreme记录 |
| next_record | 16 | 表示下一条记录的相对位置 |
另外,记录头的前4bit也被称为info bit。
记录数据
记录数据除了我们定义的列之外,还会存储一些隐藏列:
| 列名 | 是否必需 | 占用字节 | 用途 |
|---|---|---|---|
| DB_ROW_ID | 否 | 6 | 行ID |
| DB_TRX_ID | 是 | 6 | 事务ID |
| DB_ROLL_PTR | 是 | 7 | 回滚指针 |
其中DB_ROW_ID是用于唯一标识一行的,如果用户定义了主键,或者有不为NULL的UNIQUE列就不会添加。
还有对于CHAR(M)列来说,如果使用的字符集是不定长的,那么COMPACT规定这个字段也需要加入变长字段列表,而且至少需要占用M个字节,哪怕什么也没有(可以看作是一种预分配优化机制)。
REDUNDANT
REDUNDANT这种格式是MySQL5.0前的一种古老格式,记录存储起来是这样的:

有了前面COMPACT格式的理解,这里主要对比一下两种格式的不同之处。
字段长度偏移列表
REDUNDANT没有”变长”的概念,而是将所有字段的长度都集中存储起来了(当然也是逆序存放的)。
而且这里存储的是一种偏移量而不是长度本身。例如偏移列表如果是|0C|06|,那么我们逆过来就是06和0C,那么第一列的长度就是0x06个字节,而第二列的长度是(0x0C-0x06)个字节。
记录头信息
和COMPACT相比,REDUNDANT的记录头信息占用了6个字节,多出的字段如下:
| 名称 | 大小(位) | 描述 |
|---|---|---|
| n_field | 10 | 记录中列的数量 |
| 1byte_offs_flag | 1 | 标记字段偏移列表的每个偏移值是使用一字节还是两字节(1代表1,0代表2) |
除了多出的字段,REDUNDANT缺少了一个record_type属性。
1byte_offs_flag字段的值是如何取的呢?当整个记录的真实数据占用不大于127时,将使用一个字节表示偏移值,否则就是两个字节(这里能看出判断粒度是所有记录的大小,也算是一个和COMPACT比落后的缩影吧)。
NULL值处理
REDUNDANT没有NULL值列表,但这并不代表不需要特殊机制来应对NULL字段。处理办法是将NULL值的偏移量的最高位设为1(所以记录的真实数据大于127时只能使用两个字节表示,而不能将最高位置为1)。
CHAR处理
对于不定长字符集的CHAR列,REDUNDANT的处理是直接分配一个最大需要的空间。这样的好处是后续不需要再分配存储空间,坏处是浪费了许多空间。
对于特别长的字段,无论是COMPACT还是REDUNDANT,其实都依靠一种名为溢出页的机制。具体来说,会使用768字节来存储数据,20字节存储一个指向剩余数据的指针。
多长的字段才会触发溢出页机制呢?这玩意其实需要考虑一下,一个页至少需要两行记录,然后又不能超过16KB,然后经过一系列计算才能得到,总之不用太过关注。。。
DYNAMIC
DYNAMIC是MySQL许多版本默认的行格式。
DYNAMIC不一样的一点是,它不会使用768字节来存储数据,而是直接将列的真实数据全都存入溢出页,只保留20字节的指针。
如果按照之前的768字节方案,一个表假设存在很多个特别长的变长字段,就会吃掉好几个768字节的内存,有可能会导致”Row size too large”的报错;还有一点是,只存指针可以让一个页的数据更多,这样可以提升检索与IO的效率。
COMPRESSED
COMPRESSED相比DYNAMIC会采用压缩算法对页面进行压缩,可以节省空间。但是在存取时需要将数据解压到内存中去,往往会影响性能,因而在高IO型的业务中很少使用。
虽然现代的MySQL使用了透明页压缩(Transparent Page Compression,简称 TPC)机制,可以在一定程度上优化压缩的性能消耗,然而其依赖文件系统的”打洞”机制,且更适合固态硬盘,还有4KB的对齐单位,往往还是不如DYNAMIC更加通用和迅速。