innodb一个支持事务安全的存储引擎,同时也是mysql的默认存储引擎。本文主要从数据结构的角度,详细介绍innodb行记录格式和数据页的实现原理,从底层看清innodb存储引擎。
本文主要内容是根据掘金小册《从根儿上理解 mysql》整理而来。如想详细了解,建议购买掘金小册阅读。
innodb简介
大家都知道mysql中数据是存储在物理磁盘上的,而真正的数据处理又是在内存中执行的。由于磁盘的读写速度非常慢,如果每次操作都对磁盘进行频繁读写的话,那么性能一定非常差。为了上述问题,innodb将数据划分为若干页,以页作为磁盘与内存交互的基本单位,一般页的大小为16kb。这样的话,一次性至少读取1页数据到内存中或者将1页数据写入磁盘。通过减少内存与磁盘的交互次数,从而提升性能。
其实,这本质上就是一种典型的缓存设计思想,一般缓存的设计基本都是从时间维度
或者空间维度
进行考量的:
时间维度
:如果一条数据正在在被使用,那么在接下来一段时间内大概率还会再被使用。可以认为热点数据缓存
都属于这种思路的实现。空间维度
:如果一条数据正在在被使用,那么存储在它附近的数据大概率也会很快被使用。innodb的数据页
和操作系统的页缓存
则是这种思路的体现。
innodb行格式
mysql是以记录(一行数据)为单位向数据表中插入数据的,这些记录在磁盘上的存放方式称为行格式
。mysql支持4种不同类型的行格式:compact
、redundant
(比较老,本文就不具体介绍了)、dynamic
、compressed
。
我们可以在创建或修改表的语句中指定行格式:
create table 表名 (列的信息) row_format=行格式名称
alter table 表名 row_format=行格式名称
比如,我们要创建一个行格式为compact
,字符集为ascii
的数据表record_format_demo
,sql如下:
mysql> create table record_format_demo (
-> c1 varchar(10),
-> c2 varchar(10) not null,
-> c3 char(10),
-> c4 varchar(10)
-> ) charset=ascii row_format=compact;
query ok, 0 rows affected (0.03 sec)
假设我们向record_format_demo
表中插入了2行数据:
mysql> select * from record_format_demo;
------ ----- ------ ------
| c1 | c2 | c3 | c4 |
------ ----- ------ ------
| aaaa | bbb | cc | d |
| eeee | fff | null | null |
------ ----- ------ ------
2 rows in set (0.00 sec)
compact行格式
从上图可以看出,一条完整的记录包含记录的额外信息
和记录的真实数据
两大部分。
记录的额外信息
记录的额外信息主要包含3类:变长字段长度列表
、null值列表
和记录头信息
。
变长字段长度列表
mysql中支持一些变长数据类型(比如varchar(m)
、text
等),它们存储数据占用的存储空间不是固定的,而是会随着存储内容的变化而变化。为了准确描述这种数据,这种变长字段占用的存储空间要同时包含:
- 真正的数据内容
- 占用的字节数
在compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序
存放。
我们以record_format_demo
第一行数据为例。由于c1
、c2
和c4
都是变成数据类型(varchar(10)
),因此要将这3列值得长度保存在记录的开头处。
另外需要注意的一点是,变长字段长度列表中只存储值为 非null 的列内容占用的长度,值为 null 的列的长度是不储存的。也就是说对于第二条记录来说,因为c4列的值为null,所以第二条记录的变长字段长度列表只需要存储c1和c2列的长度即可。
null值列表
对于可为null的列,为了节约存储空间,mysql不会将null
值保存在记录的真实数据
部分。而是会将其保存在记录的额外信息
里面的null值列表
中。
具体的做法是先统计表中允许存储null
值的列,然后将每个允许存储null
值的列对应一个二进制位(1:值为null
,0:值不为null
)用来表示是否存储null
值,并按照逆序排列。mysql规定null值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0。
对应record_format_demo
表中,c1
、c3
、c4
都是允许存储null值的。前两条记录在填充了null
值列表后的示意图就是这样:
记录头信息
记录头信息是由固定的5个字节(40位)组成, 不同的位代表不同的含义:
暂时不详细展开。
记录的真实数据
记录的真实数据除了包含各列具体的数据外,还会自动添加一些隐藏列数据。
列名 | 是否必须 | 占用空间 | 描述 |
---|---|---|---|
row_id | 否 | 6字节 | 行id,唯一标识一条记录 |
transaction_id | 是 | 6字节 | 事务id |
roll_pointer | 是 | 7字节 | 回滚指针 |
实际上这几个列的真正名称其实是:db_row_id、db_trx_id、db_roll_ptr,为了美观才写成了row_id、transaction_id和roll_pointer。
只有当数据库没有定义主键
或者唯一键
时,隐藏列row_id
才会存在,并且将其作为数据表主键
。
因为表record_format_demo
并没有定义主键,所以mysql服务器会为每条记录增加上述的3个列。现在看一下加上记录的真实数据
的两个记录的数据结构:
char(m)列的存储格式
对于 char(m) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。
另外有一点还需要注意,变长字符集的char(m)
类型的列要求至少占用m
个字节,而varchar(m)
却没有这个要求。比方说对于使用utf8
字符集的char(10)
的列来说,该列存储的数据字节长度的范围是10~30
个字节,即使我们向该列中存储一个空字符串也会占用10
个字节。
行溢出数据
varchar(m)最多能存储的数据
mysql对一条记录占用的最大存储空间是有限制的,除了blob
或者text
类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。可以不严谨的认为,mysql一行记录占用的存储空间不能超过65535个字节。这个65535个字节除了列本身的数据之外,还包括一些其他的数据(storage overhead),比如说我们为了存储一个varchar(m)类型的列,其实需要占用3部分存储空间:
- 真实数据
- 真实数据占用字节的长度
- null值标识,如果该列有not null属性则可以没有这部分存储空间
假设varchar_size_demo
只有一个varchar
类型的字段,那么该字段最大占用的65532个字节。因为真实数据的长度可能占用2个字节,null值标识
需要占用1个字节。如果该varchar
类型的列没有not null
属性,那最多只能存储65532
个字节的数据。如果该列是ascii
字符集,对应的最大字符数最大为65532
;如果是utf8
字符集,则对应的最大字符数为21844
。
记录中的数据太多产生的溢出
我们以ascii字符集下的varchar_size_demo
表为例,插入一条记录:
mysql> create table varchar_size_demo(
-> c varchar(65532)
-> ) charset=ascii row_format=compact;
query ok, 0 rows affected (0.01 sec)
mysql> insert into varchar_size_demo(c) values(repeat('a', 65532));
query ok, 1 row affected (0.00 sec)
mysql中磁盘与内存交互的基本单位是页,一般为16kb,16384个字节,而一行记录最大可以占用65535
个字节,这就造成了一页存不下一行数据的情况。在compact和redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址,从而可以找到剩余数据所在的页,如图所示:
这种在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中的情况就叫做行溢出
,存储超出768字节的那些页面也被称为溢出页
。
行溢出的临界点
mysql中规定一个页中至少存放两行记录。以上边的varchar_size_demo
表为例,它只有一个列c
,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。
- 每个页除了存放我们的记录以外,也需要存储一些额外的信息,大概132个字节。
- 每个记录需要的额外信息是27字节。
假设一个列中存储的数据字节数为n,如要要保证该列不发生溢出,则需要满足:
132 2×(27 n) < 16384
结果是n < 8099
。也就是说如果一个列中存储的数据小于8099个字节,那么该列就不会成为溢出列。如果表中有多个列,那么这个值更小。
dynamic和compressed行格式
mysql中默认的行格式就是dynamic
。dynamic
和compressed
行格式和compact
行格式很像,只是在处理行溢出
数据上有差异。dynamic
和compressed
行格式不会在记录的真实数据
出存放前768个字节,而是将所有字节都存储在其它页面中。compressed
行格式会采用压缩算法对页面进行压缩,以节省空间。
innodb数据页结构
我们已经知道页是innodb管理存储空间的基本单位,一个页的大小一般是16kb。innodb为了不同的目的设计了许多不同类型的页,我们这里主要关注存储数据记录
的页,官方称为索引页
。由于还没介绍索引,暂且我们先称为数据页
吧。
数据页结构的快速浏览
数据页在结构上可以划分为多个部分,不同的部分有不同的功能,如下图所示:
一个innodb数据页被划分为了7个部分,下面大概描述一下这7个部分内容。
名称 | 中文名 | 占用空间大小 | 简单描述 |
---|---|---|---|
file header | 文件头部 | 38字节 | 页的一些通用信息 |
page header | 页面头部 | 56字节 | 数据页专有的一些信息 |
infimum supremum | 最小记录和最大记录 | 26字节 | 两个虚拟的行记录 |
user records | 用户记录 | 不确定 | 实际存储的行记录内容 |
free space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
page directory | 页面目录 | 不确定 | 页中的某些记录的相对位置 |
file trailer | 文件尾部 | 8字节 | 校验页是否完整 |
记录在页中的存储
用户自己的存储的数据会按照对应的行格式
存在user records
中。实际上,新生成的页面是没有user records
的,只有当我们第一次插入数据时,才会从free space
划一个记录大小的空间给user records
。当free space
用完之后,就意味着当前的数据页也使用完了。
为了能够将user records
讲清楚,我们先得理解前面提到的记录头信息
。
理解记录头信息
先简单介绍一下记录头信息各属性描述:
名称 | 大小(单位:bit) | 描述 |
---|---|---|
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
delete_mask | 1 | 标记该记录是否被删除 |
min_rec_mask | 1 | b 树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 表示当前记录拥有的记录数 |
heap_no | 13 | 表示当前记录在记录堆的位置信息 |
record_type | 3 | 表示当前记录的类型,0表示普通记录,1表示b 树非叶子节点记录,2表示最小记录,3表示最大记录 |
next_record | 16 | 表示下一条记录的相对位置 |
接下来以page_demo
表为例,并插入一些数据,详细介绍记录头信息。
mysql> create table page_demo(
-> c1 int,
-> c2 int,
-> c3 varchar(10000),
-> primary key (c1)
-> ) charset=ascii row_format=compact;
query ok, 0 rows affected (0.03 sec)
mysql> insert into page_demo values(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd');
query ok, 4 rows affected (0.00 sec)
records: 4 duplicates: 0 warnings: 0
这4条记录在innodb中的行格式如下(只展示记录头和真实数据),列中数据均用十进制表示:
我们对照着这个图来重点介绍几个属性的详细信息:
delete_mask
:标记着当前记录是否被删除,0表示未删除,1表示删除。未删除的记录不会立即从磁盘上移除,而是先打上删除标记,所有被删除的记录会组成一个垃圾链表
。之后新插入的记录可能会重用垃圾链表
占用的空间,因此垃圾链表占用的存储空间也被成为可重用空间
。heap_no
:表示当前记录在本页中的位置,比如上边4条记录在本页中的位置分别是2、3、4、5
。实际上,innodb会自动为每页加上两条虚拟记录,一条是最小记录
,另一条是最大记录
。这两条记录的构造十分简单,都是由5字节大小的记录头信息
和8字节大小的固定部分
(其实内容就是infimum或者supremum)组成的。这两条记录被单独放在infimum supremum
的部分。
从图中我们可以看出来,最小记录和最大记录的heap_no
值分别是0和1,也就是说它们的位置最靠前。next_record
:表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。可以简单理解为是一个单向链表,最小记录的下一个是第一条记录,最后一条记录的下一个是最大记录。为了更加形象的展示,我们可以用箭头来替代一下next_record中的地址偏移量:
从图中也能看出来,用户记录实际上按照主键大小正序排序行成一个单向链表。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:
- 第2条记录并没有从存储空间中移除,而是把该条记录的
delete_mask
值设置为1。 - 第2条记录的
next_record
值变为了0,意味着该记录没有下一条记录了。 - 第1条记录的
next_record
指向了第3条记录。
- 第2条记录并没有从存储空间中移除,而是把该条记录的
page directory(页目录)
我们已经知道,记录在页中按照主键大小正序串联成了一个单链表。如果我们要根据主键查找具体的某条记录应该怎么办,简单的方式是根据链表进行遍历。但是在数据量比较大的情况下,这种方式显然效率太差了。因此mysql使用了page directory(页目录)
来解决这个问题。page directory(页目录)
大致的原理如下:
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。怎么划分先不关注。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该组内共有几条记录。
- 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页尾部的地方,这个地方就是所谓的
page directory
。
mysql规定对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1-8 条之间,剩下的分组中记录的条数范围只能在是 4-8 条之间。
比方说现在的page_demo
表中正常的记录共有18条,innodb会把它们分成5组,第一组中只有一个最小记录,如下所示:
通过page directory
在一个数据页中查找指定主键值的记录的过程分为两步:
- 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
- 通过记录的
next_record
属性遍历该槽所在的组中的各个记录。
对于链表的查询性能优化,思想上基本上都是通过
二分法
实现的。上面介绍的page directory
,跳跃表
和查找树
都是如此。
page header(页面头部)
page header
专门用来存储数据页相关的各种状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等。固定占用56个字节,各部分字节属性含义如下:
名称 | 占用空间大小 | 描述 |
---|---|---|
page_n_dir_slots | 2字节 | 在页目录中的槽数量 |
page_heap_top | 2字节 | 还未使用的空间最小地址,也就是说从该地址之后就是free space |
page_n_heap | 2字节 | 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) |
page_free | 2字节 | 第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) |
page_garbage | 2字节 | 已删除记录占用的字节数 |
page_last_insert | 2字节 | 最后插入记录的位置 |
page_direction | 2字节 | 最后一条记录插入的方向 |
page_n_direction | 2字节 | 一个方向连续插入的记录数量,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。 |
page_n_recs | 2字节 | 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) |
page_max_trx_id | 8字节 | 修改当前页的最大事务id,该值仅在二级索引中定义 |
page_level | 2字节 | 当前页在b 树中所处的层级 |
page_index_id | 8字节 | 索引id,表示当前页属于哪个索引 |
page_btr_seg_leaf | 10字节 | b 树叶子段的头部信息,仅在b 树的root页定义 |
page_btr_seg_top | 10字节 | b 树非叶子段的头部信息,仅在b 树的root页定义 |
这里只是罗列出来,暂时不需要全部理解。
file header(文件头部)
file header
是用来描述各种页都适用的一些通用信息的,由以下内容组成:
名称 | 占用空间大小 | 描述 |
---|---|---|
fil_page_space_or_chksum | 4字节 | 页的校验和(checksum值) |
fil_page_offset | 4字节 | 页号 |
fil_page_prev | 4字节 | 上一个页的页号 |
fil_page_next | 4字节 | 下一个页的页号 |
fil_page_lsn | 8字节 | 页面被最后修改时对应的日志序列位置(英文名是:log sequence number) |
fil_page_type | 2字节 | 该页的类型 |
fil_page_file_flush_lsn | 8字节 | 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的lsn值 |
fil_page_arch_log_no_or_space_id | 4字节 | 页属于哪个表空间 |
这里只是罗列出来,暂时不需要全部理解。我们重点关注一下几个属性:
fil_page_space_or_chksum
当前页面的校验和(checksum)。对于一个很长的字节串来说,我们可以通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为校验和
。通过校验和
可以大幅度提升字符串等值比较的效率。fil_page_offset
每一个页都有一个唯一的页号,innodb
通过页号来可以定位一个页。fil_page_type
代表当前页的类型,我们前边说过,innodb为了不同的目的而把页分为不同的类型。
类型名称 十六进制 描述 fil_page_type_allocated 0x0000 最新分配,还没使用 fil_page_type_allocated 0x0000 最新分配,还没使用 fil_page_undo_log 0x0002 undo日志页 fil_page_inode 0x0003 段信息节点 fil_page_ibuf_free_list 0x0004 insert buffer空闲列表 fil_page_ibuf_bitmap 0x0005 insert buffer位图 fil_page_type_sys 0x0006 系统页 fil_page_type_trx_sys 0x0007 事务系统数据 fil_page_type_fsp_hdr 0x0008 表空间头部信息 fil_page_type_xdes 0x0009 扩展描述页 fil_page_type_blob 0x000a 溢出页 fil_page_index 0x45bf 索引页,也就是我们所说的数据页 fil_page_prev
和fil_page_next
表示本页的上一个和下一个页的页号,各个页通过fil_page_prev
和fil_page_next
形成双向链表。
file trailer
mysql中内存和磁盘的基本交互单位是页。如果内存中页被修改了,那么某个时刻一定会将内存页同步到磁盘中。如果在同步的过程中,系统出现问题,就可能导致磁盘中的页数据没能完全同步,也就是发生了脏页
的情况。为了避免发生这种问题,mysql在每个页的尾部加上了file trailer
来校验页的完整性。file trailer
由8个字节组成:
- 前4个字节代表页的校验和
这个部分是和file header中的校验和相对应的。简单理解,就是file header
和file trailer
都有校验和,如果两者一致则表示数据页是完整的。否则,则表示数据页是脏页
。 - 后4个字节代表页面被最后修改时对应的日志序列位置(lsn)
这个部分也是为了校验页的完整性的,暂不详细了解。