这是我参加「第三节青训营~后端场」笔记创作活动的第6篇笔记
一,记录行格式
我们平时都是以记录为单位向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式,InnoDB存储引擎目前为止有4种行格式,分别是Compact、Redundant、Dynamic、和Compressed行格式
从上图可以看到一条记录可以分为两部分,记录的额外信息和记录的真实数据
记录的额外信息
- 变长字段长度列表
用于存储数据库中的变长数据类型(比如VARCHAR类型等)在该记录中占用的字节数
在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按列的顺序逆序存放。
对于NULL值的长度是不存储的
- NULL值列表
用于存放记录中为NULL值的列,以节省存储空间(NULL值不在占用原来数据类型大小的存储空间了)
InnoDB会将表中可以为 NULL 的每个列对应一个二进制位,并将这些二进制位逆序排序,用 1 表示该列的值为 NULL,用 0 表示该列的值不为 NULL。
该列表只存储允许为NULL的列,NOT NULL列不考虑
Mysql规定NULL值列表必须使用整数个字节的位标识,不足则高位补0
- 记录头信息
| 名称 | 大小(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 | 表示下一条记录的相对位置 |
记录的真实数据
该部分是存储用户输入的真实的数据信息,并且除了一些我们自己定义的列的数据外,还会为每个记录默认添加一些列,称为隐藏列
| 列名 | 是否必须 | 占用空间 | 描述 |
|---|---|---|---|
| DB_ROW_ID | 否(在没有设置主键且没有Unique键可以作为主键时启用该隐藏列) | 6 Byte | 行ID,唯一标识一条记录 |
| DB_TRX_ID | 是 | 6 Byte | 事务ID |
| DB_ROLL_PTR | 是 | 7 Byte | 回滚指针 |
Redundant行格式
该行格式是Mysql5.0之前使用的一种行格式
- 字段长度偏移列表
该列表记录了所有列(包括隐藏列)的长度信息,并按逆序存储,并且记录的是偏移值,需要用来个相邻数值的差值来计算各个列的长度
为了在解析记录时知道每个列的偏移量是使用1个字节还是2个字节表示的,设计Redundant行格式的大叔特意在记录头信息里放置了一个称之为1byte_offs_flag的属性:
-
- 当它的值为1时,表明使用1个字节存储。
- 当它的值为0时,表明使用2个字节存储。
Redundant行格式中NULL值的处理
因为Redundant行格式并没有NULL值列表,所以设计Redundant行格式的大叔在字段长度偏移列表中的各个列对应的偏移量处做了一些特殊处理 —— 将列对应的偏移量值的第一个比特位作为是否为NULL的依据,该比特位也可以被称之为NULL比特位。也就是说在解析一条记录的某个列时,首先看一下该列对应的偏移量的NULL比特位是不是为1,如果为1,那么该列的值就是NULL,否则不是NULL。
- 记录头信息
| 名称 | 大小(单位:bit) | 描述 |
|---|---|---|
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
delete_mask | 1 | 标记该记录是否被删除 |
min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 表示当前记录拥有的记录数 |
heap_no | 13 | 表示当前记录在页面堆的位置信息 |
n_field | 10 | 表示记录中列的数量 |
1byte_offs_flag | 1 | 标记字段长度偏移列表中每个列对应的偏移量是使用1字节还是2字节表示的 |
next_record | 16 | 表示下一条记录的相对位置 |
行溢出数据
VARCHAR(M)类型的列最多可以占用65535个字节。其中的M代表该类型最多存储的字符数量,但是存储一个VARCHAR(M)类型的列,其实需要占用3部分存储空间:
- 真实数据
- 真实数据占用字节的长度 (2字节)
NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间 (1字节)
真实数据的最大值是要65535减去其他部分的
如果存储的数据超过一个页的大小(16KB,16384字节),行格式中不会存储全部数据,只会存储部分数据(前768个字节的数据),并将其他数据分散到其他页,然后在真实数据处存储指向这些页的地址,从而可以找到剩余的数据
对于如何解析这些格式的问题,以前也有疑惑innodb是如何知道行格式中的每一个部分的位置的,后来知道了对于每个表的结构数据库是有存储的,所以根据数据库表结构来解析就可以知道这些字节的边界了
二,数据页(INDEX 页)
页是InnoDB中管理的基本单位,每个页的大小是16KB,InnoDB有很多种不同类型的,其中存放我们存储数据的页叫INDEX页,为了方便理解我们可以叫它做数据页(其实应该是叫索引页的)
| 名称 | 中文名 | 占用空间大小 | 简单描述 |
|---|---|---|---|
File Header | 文件头部 | 38字节 | 页的一些通用信息 |
Page Header | 页面头部 | 56字节 | 数据页专有的一些信息 |
Infimum + Supremum | 最小记录和最大记录 | 26字节 | 两个虚拟的行记录 |
User Records | 用户记录 | 不确定 | 实际存储的行记录内容 |
Free Space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
Page Directory | 页面目录 | 不确定 | 页中的某些记录的相对位置 |
File Trailer | 文件尾部 | 8字节 | 校验页是否完整 |
我们上面讲的记录行就是插入在页的User Recods区域的,Free Space是未使用到的User Records区域,如果这两块区域都填满了数据,那么代表页用完了,下次插入要去申请新页
User Records
用于存储表中记录的
下面是记录的行格式在一个页内存放的示意图(省略了一些列表)
页中存放的记录行需要是有序的(方便查找),那么如何保证有序呢?
回顾行格式里的记录头信息里的三个属性:
delete_mask:这个属性标记着当前记录是否被删除,占用1个二进制位,值为0时记录并没有被删除,为1时记录被删除heap_no:这个属性表示当前记录在本页中的位置next_record:从当前记录的真实数据到下一条记录的真实数据的地址偏移量
从next_record可以看出,记录之间是以单向链表的形式链接起来的,next_record 会指向下一条记录的真实数据的位置。
其中有两条记录不是我们存储的,是数据库自己生成的,那就是最小记录和最大记录
同时页内存储的元素是按主键的大小 从小到大排序链接的(第一个元素是最小记录,最后一个是最大记录)
next_record指针是指向记录行的头信息和真实数据之间的位置,这样方便读取数据和读取记录信息,这也解释了变长字段列表和NULL值列表为什么要逆向存储,这样可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,可能会提高高速缓存的命中率。
其中 delete_mask 的作用是用来标志一条记录是否已经被删除了,当删除一条记录的时候,会将页中对应记录的该标志为置为1,然后调整链表的指向,跳过本条记录。
Page Directory
Page Directory 页目录是用于定位页中的记录的
上面我们说到页中记录是以单向链表存储的,如果我们要查找一条记录,那不得遍历链表?这样的效率不太行,所以InnoDB使用了Page Directory来实现查找
具体实现方式
- 将所有的记录划分为几个组(包括最小最大记录)。每个页生成后默认有两个分组(最小记录所在的组和最大记录所在的组),
InnoDB规定:最小记录所在的组只能有一条记录,最大记录所在的组拥有的记录条数只能在1~8条之间,剩下的其他分组中记录条数只能在4~8条之间。 - 每个分组的最后一条记录的记录头信息中的n_owned属性记录该分组有多少条记录
InnoDB在页的尾部开拓一个空间,即Page Directory,将每个组最后一条记录的地址偏移量单独提取出来存储在页目录内,每个地址偏移量被称为槽(Slot),页目录由多个槽组成。
分组步骤
- 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
- 之后每插入一条记录,都会从
页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。 - 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在
页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
查找方式
先对槽中元素进行二分查找(数量较少,且是数组)效率会很高,然后再到分组内遍历组内的链表,从而提高效率
Page Header(页面头部)
用于存储该页的相关消息的
| 名称 | 占用空间大小 | 描述 |
|---|---|---|
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(文件头部)
文件头部对各种页类型都是通用的
| 名称 | 占用空间大小 | 描述 |
|---|---|---|
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_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_INDEX,也就是所谓的索引页。
FIL_PAGE_PREV和FIL_PAGE_NEXT
双向链表,用于连接每个页
File Trailer
该部分是用于校验一个页的完整性的
- 前4个字节代表页的校验和:
这个部分是和File Header中的校验和相对应的,写入磁盘是先写入文件头,再写入文件尾,如果中途断电或者其他情况同步失败了,那么该校验和和文件头的校验和不一致就可以发现该页是出错的页了
- 后4个字节代表页面被最后修改时对应的日志序列位置(LSN),后面讲
这个File Trailer与File Header类似,都是所有类型的页通用的。
三,B+树索引
聚簇索引
上面说到了,每个数据页都有两个指针进行连接,形成一个双向链表,但是双向链表之间来进行搜索还是太慢了,所以在InnoDB中使用了B+树作为数据结构来组织这些页。
首先为了表示方便我们把行记录竖起来
然后这样表示一个页
然后就构成了B+树结构:
还记得记录行格式中的记录头信息中的record_type字段吗?
它的各个取值代表的意思如下:
0:普通的用户记录1:目录项记录2:最小记录3:最大记录
InnoDB的实现方式是:
-
对于每个数据页,建立一个目录页(页类型同样的数据页,记录部分存储的是页号和索引值,如主键),每个目录项记录存储数据页的页号和该数据页内最小主键,且
record_type设为1。 -
目录页内的记录也要根据最小主键进行排序
-
当目录页内的目录项记录填满时,创建新的目录页,目录页之间采用双向链表连接
-
当目录页的数量过大时,为这些目录页再创建一级目录,以此类推、
我们一般把上述的B+树结构称为聚簇索引,因为该B+树的叶子节点存储的是所有列的数据,一个表只有一个聚簇索引,且该聚簇索引在表创建的时候会自动创建
InnoDB的一个数据页至少可以存放两条记录
二级索引
相比于聚簇索引,二级索引就是我们自己创建的索引,在InnoDB中同样也是使用B+树组织的
并且与聚簇索引不同,二级索引是依靠索引字段进行排序的(聚簇索引考主键排序),同时二级索引的叶子节点不存放全部数据,只存放索引列和主键列(节省空间),所有想要通过二级索引查询到其他数据,需要再到聚簇索引那里查询(也叫做回表)
二级索引目录页页只是存储主键列和索引列,为什么需要主键列呢?因为二级索引不一定是唯一索引,有可能出现重复的记录,所以需要主键列参与排序
\
联合索引
我们可以同时使用多个列作为索引,这就是联合索引,联合索引的排序和建立索引的顺序有关,是按照顺序进行排列的,也就是第一个索引列进行排序,在此基础上再对下一个索引列排序,本质上和二级索引没什么区别,但是联合索引生效会和使用索引的顺序有关
联合索引只会建立一棵B+树
一个B+树索引的根节点自诞生之日起,便不会再移动。这样我们就可以方便的找到一棵B+树了
MyISAM中的索引方案
MyISAM按插入数据顺序将数据单独存放到一个数据文件中,并且该文件页不划分为若干个数据页,我们可以通过行号快速查找到一条数据
由于在插入数据的时候并没有刻意按照主键大小排序,所以我们并不能在这些数据上使用二分法进行查找。
使用MyISAM存储引擎的表会把索引信息另外存储到一个称为索引文件的另一个文件中。MyISAM会单独为表的主键创建一个索引,只不过在索引的叶子节点中存储的不是完整的用户记录,而是主键值 + 行号的组合。也就是先通过索引找到对应的行号,再通过行号去找对应的记录!
所以MysISAM的所有索引都是二级索引,不管怎么查找都需要回表一次,才能查询到数据
我们自己创建的二级索引也是对应的索引列+行号构建的B+树