Mysql第二篇—InnoDB数据存储结构
数据库的存储结构: 页
索引结构给我们提供了高效的索引方式, 不过索引信息以及数据记录都是保存在文件上的(innodb的ibd文件, MyISAM的MyI和MyD文件), 确切的说是存储在页结构中. 另一方面, 索引是在存储引擎中实现的, MySQL服务器上的存储引擎负责对表中数据的读取和写入工作. 不同存储引擎中存放的格式一般是不同的, 甚至有的存储引擎比如Memory都不用磁盘来存储数据
由于InnoDB是MySQL的默认存储引擎, 所以本章剖析InnoDB存储引擎的数据结构
磁盘与内存交互的基本单位 : 页
InnoDB将数据划分为若干个页, InnoDB中页的大小默认为16KB。
以页作为磁盘和内存之间交互的基本单位, 也就是一次最少从磁盘中读取16KB的内容到内存中, 一次最少把内存中的16KB内容刷新到磁盘中. 也就是说, 在数据库中, 不论读一行还是读多行, 都是将这些行所在的页进行加载. 也就是说, 数据库管理存储空间的基本单位是页(Page), 数据库I/O操作的最小单位是页, 一个页中可以存储多行记录。
记录是按照行来存储的, 但是数据库的读取并不以行为单位, 否则一次读取(也就是一次I/O操作)只能处理一行数据, 效率会非常低。
页结构概述
页a, 页b, 页c… 页n这些页可以不在物理结构上相连, 只要通过双向链表相关联即可. 每个数据页中的记录会按照主键值从小到达的顺序组成一个单向链表, 每个数据页都会为存储在它里面的记录生成一个页目录, 在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽位, 然后再遍历该槽对应分组中的记录即可快速找到指定的记录。
- 页目录等我们下面详细介绍页结构的会讲解
- 注意: 这些页是同一层的页, 可以不在物理结构上相连, 但是页分配的时候确实是相连的, 但是不是同一层相连。就是B+树比如有三层,而且是聚簇索引,那么第二层就是目录项页(里面不包含一行的全部记录),第二层有好多个页,但是这些页在物理存储上并不是连续的,比如可能有三个页,分别是23,12,33 ;但是呢,如果把B+树所有层的页都算上,它是一个段连续的页,比如说从1到100;
- 但是注意: 数据库分配的单位是段, 段又分为了索引段和数据段, 数据段就是索引结构的叶子结点层, 所以说其实叶子结点层的页是相连的。
页的大小
不同的数据库管理系统(简称DBMS)的页大小不同. 比如在MySQL的InnoDB存储引擎中, 默认页的大小是16KB, 我们可以通过下面的命令来进行查看:
show variables like '%innodb_page_size';
SQL Server中页的大小为8KB, 而在Oracle中我们用术语"块"(Block)来代表页, Oracle支持的块大小为2KB, 4KB, 8KB, 16K, 32KB, 64KB。
页的上层结构
另外在数据库中, 还存在着区(Extent), 段(Segment)和表空间(Tablespace)的概念.。行, 页, 区, 段, 表空间的关系如下图所示:
区(Extent)是比页大一级的存储结构, 在InnoDB存储引擎中, 一个区会分配64个连续的页。因为InnoDB中的页的大小默认是16KB, 所以一个区的大小是64*16KB = 1MB。
在mysql中一个表中的数据量过大的时候,有一个优化方式是分区,就是创建表的时候使用partition by关键字,从外部来看分区就是给原表分成好多个子表。比如原表中有一个时间time字段,那么我们可以把时间是1月份的记录放到month1分区,把时间是2月份的记录放到month2分区,。。。把时间是12月份记录的放到month12分区。首先需要知道一个知识点,就是一个页中的记录行会根据我们的索引列比如就先当成主键吧去进行从小到大的排序,比如页38的主键排序是333到444,然后我们把这个页38放到区里面,现在问题来了,区里面有多个页,这些页在物理上是连续的,那么下一个页我们找谁?把谁放到区里面?需要找一个页,它里面的主键排序要从445到556,这个页就是物理上相邻的页39,那么我们一个区里面的前两个页就是页38和页39。注意聚簇索引中叶子节点中列出的所有页物理上都是相邻的;但是非叶子结点的层,页与页之间不是物理空间上相邻,仅仅只是逻辑上相邻。之前在做上面的数据库优化的时候遇到了一个问题,就是分区字段time必须要是主键或者联合主键里面的字段,这是因为这样的话能保证页里面的分区字段是连续递增的,比如说页11里面全是时间为2月份的记录,这样我们才能把页11放到month2分区里面,不然的话,假设分区字段不是联合主键或者是主键,那么就会出现页11里面的前几条时间都是6月份,而后面时间就变成了1月份,再往后又变成了10月份,这样我们这个页11就不能假如到month2分区里面了,因为month2分区里面假如的页要求里面的时间对应的月份全都是2月份。因此只要根据主键或者联合主键里面的字段分区的时候,我们的页里面的数据才会根据分区字段递增,因为页里面的记录是根据主键递增的。这样我们才能把页假如到区里面,不然的话我们的页是不配加入到区里面的。
段(Segment) 由一个或者多个区组成, 区在文件系统是一个连续分配的空间(在InnoDB中是连续的64个页), 不过在段中不要求区与区是相邻的. 段是数据库中的分配单位, 不同类型的数据库对象以不同的段形式存在, 当我们创建数据表, 索引的时候, 就会相应创建对应的段, 比如创建一张表时会创建一个表段, 创建一个索引时会创建一个索引段。
表空间(Tablespace) 是一个逻辑容器, 表空间存储的对象是段, 在一个表空间中可以有一个或者多个段, 但是一个段只能属于一个表空间。数据库由一个或者多个表空间组成, 表空间从管理上可以划分为系统表空间, 用户表空间, 撤销表空间, 临时表空间等。
页的内部结构
页如果按照类型划分的话, 常见的有数据页(保存B+树结点), 系统页, Unod页和事物数据页等, 数据页是我们最常使用的页。我们这里说的数据页包括了B+树叶子结点和非叶子结点, 但是其实也可以把非叶子结点分出去称之为目录页。
数据页的16KB大小的存储空间被划分为了7个部分, 分别是文件头(File Header), 页头(Page Header), 最大最小记录(Infimum+supremum), 用户记录(User Records), 空闲空间(Free Space), 页目录(Page Directory)和文件尾(File Tailer)。
页结构的示意图如下所示:
这7个部分作用分别如下: 我们简单的梳理如下表所示:
我们可以把这7个结构分成3个部分:
第一部分: File Header(文件头部)和File Trailer(文件尾部)
首先是文件通用部分, 也就是文件头和文件尾
1. 文件头:
作用 : 描述各种页的通用信息(比如页的编号, 其上一页, 下一页是谁等等)
文件头的构成如下图:
我们说几个比较重要的组成:
1. fil_page_offset(4个字节) :
每一个页都有一个单独的页号, 就和你的身份证号码一样, InnoDB通过页号可以唯一定位一个页。
2. fil_page_type
这个代表当前页的类型
可以看到页其实是不区分数据页和索引页的, 我们说索引页也好, 说数据页也好都是代表的是索引 或者 数据。段是分为索引段和数据段的, 因为我们要区分索引和数据进行分开存储, 至于好处就是为了能多一点顺序IO, 减少随机IO。
3. fil_page_prev(4字节)和fil_page_next(4字节)
InnoDB都是以页为单位来存放数据的, 如果数据分散到多个不连续的页中存储的话需要把这些页关联起来, fil_page_prev和fil_page_next就分别代表本页的上一个和下一个页的页号. 这样通过建立一个双向链表把许许多多的页就串联起来了, 保证这些页之间不需要是物理上连续, 而是逻辑上连续 (区上的页的分配是连续的, 但是经过很多操作后, 可能逻辑连续的页物理位置就不连续了)。
fil_page_space_or_chksum(4字节)
代表当前页面的校验和(checksum)。文件头部和文件尾部都有属性fil_page_space_or_chksum。
什么是校验和?
就是对于很长的字节串来说, 我们会通过某种算法来计算一个比较短的值来代表这个很长的字符串, 这个比较短的值就称之为校验和。
在比较两个很长的字节串之前, 先比较这两个长字节串的校验和, 如果校验和都不一样, 则两个长字节串肯定是不同的, 所以省去了直接比较两个比较长的字节串的时间损耗。
校验和的作用?
InnoDB存储引擎以页为单位把数据加载到内存中处理, 如果该页中的数据在内存中被修改了, 那么在修改后的某个时间需要把数据同步到磁盘中. 但是在同步了一半的时候断电了, 造成该页传输的不完整。
为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况), 这时可以通过文件尾的校验和与文件头的校验和做对比, 如果两个值不相等则证明页的传输有问题, 需要重新进行传输, 否则认为页的传输已经完成。
后面我们讲到redo日志的时候就会讲到刷盘, 以及刷盘策略, 我们开始的时候将数据加载到内存中, 对内存中数据操作之后要以页为单位将数据写回到磁盘中, 那么如何判断我们磁盘中的文件是刷新完的, 如何确保刷新的过程是正确的, 我们就要用到检验和, 修改内存中页的数据之后我们就会同时改变校验和, 那么如果刷盘的过程中刷盘一般停机了, 这个时候我们就可以判断磁盘中的页的文件头和文件尾的校验和是否相等, 如果不相等, 那不就直接说明是失败的? 就节省了性能。
具体的?
每当一个页面在内存中修改了, 在同步之前就要把它的校验和算出来. 因为File Headeer在页面的前面, 所以校验和会被首先同步到磁盘, 当完全写完时, 校验和也会被写到页的尾部, 如果完全同步成功, 则页的首部和尾部的校验和应该是一致的. 如果写一般断电了, 那么在File Header中的校验和就代表着已经修改过的页, 而在File Trailer中的校验和代表着原先的页, 二者不同则意味着同步中间出了错. 这里, 校验方式就是采用Hash算法进行校验。
5. fil_page_lsn(8字节)
页面被最后修改时对应的日志序列位置(英文名是: Log Sequence Number)。日志序列位置也是为了校验页的完整性的, 如果首部和尾部LSN值校验不成功的话, 就说明同步过程出现了问题。
文件尾(8字节)
1. 前4个字节代表页的校验和
这个部分是和文件头(File Header)中的校验和相对应的。
2.后4个字节代表页面被最后修改时对应的日志序列位置(LSN):
这个部分也是为了校验页的完整性的, 如果首部和尾部的LSN值校验不成功的话, 就说明同步过程出现了问题。
第二部分: 记录部分
第二个部分是记录部分, 页的主要作用是存储记录, 所以"最大和最小记录"和"用户记录"部分占了页结构的主要空间。记录部分分为: 1.空闲空间, 2.用户记录, 3.最小最大记录。
1. Free Space(空闲空间)
我们自己存储的记录会按照指定的行格式存储到User Records(用户记录)部分, 但是在一开始生成页的时候, 其实并没有User Records这个部分, 每当我们插入一条记录, 都会从Free Space部分, 也就是尚未使用的存储空间申请一个记录大小的空间划分到User Records部分, 当Free Space部分的空间全部被User Records部分代替掉之后, 也就意味着这个页使用完了, 如果还有新的记录插入的话, 就需要去申请新的页了。
2. User Records(用户记录)
User Records中的这些记录按照指定的行格式一条一条摆在User Records部分, 相互之间形成单链表。那么如何证明记录之间是使用的单链表? —> 这里我们要先讲解一下记录的行格式的记录头信息。
3.Infimum+Supremum(最小最大记录)
就是上阙界和下阙界。
最大记录和最小记录是一个固定的字符串信息, 所以并不是我们理解上的最大和最小记录, 只是一个链表中值最小的记录的前一个记录(最小记录), 一个是链表中值最大的记录的后一个记录(最大记录)。
记录可以比较大小吗?
是的, 记录可以比较大小, 对于一条完整的记录来说, 比较记录的大小就是比较主键的大小. 比方说我们插入的4行记录的主键值分别是: 1, 2, 3, 4, 这也就意味着这4条记录了是从小到达依次递增
InnoDB规定了最小记录与最大记录这两条记录的构造十分简单, 都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的, 如图所示:
这两条记录不是我们自己定义的记录, 所以它们并不存放在页的User Records部分, 它们被单独放在了一个称为Infimum+Supremum的部分, 如图所示:
补充:
Infimum 和 Supremum
有人可能会说了,你在 User Records 中还不是通过遍历来解决的,你就是简单的把数据分了个组而已。如果我的数据根本不在当前这个页中,那我难道还是得把之前的页中的每一条数据全部遍历完?这效率也太低了。
当然,MySQL 也考虑到了这个问题,所以实际上在页中还存在一块区域叫做 The Infimum and Supremum Records ,代表了当前页中最大和最小的记录。
有了 Infimum Record 和 Supremum Record ,现在查询不需要将某一页的 User Records 全部遍历完,只需要将这两个记录和待查询的目标记录进行比较。比如我要查询的数据 id = 101 ,那很明显不在当前页。接下来就可以通过下一页指针跳到下页进行检索。
使用Page Directory
可能有人又会说了,你这 User Records 里不也全是单链表吗?即使我知道我要找的数据在当前页,那最坏的情况下,不还是得挨个挨个的遍历100次才能找到我要找的数据?你管这也叫效率高?
不得不说,这的确是个问题,不过是一个 MySQL 已经考虑到的问题。不错,挨个遍历确实效率很低。为了解决这个问题,MySQL 又在页中加入了另一个区域 Page Directory 。
顾名思义,Page Directory 是个目录,里面有很多个槽位(Slots),每一个槽位都指向了一条 User Records 中的记录。大家可以看到,每隔几条数据,就会创建一个槽位。其实我图中给出的数据是非常严格按照其设定来的,在一个完整的页中,每隔6条数据就会有一个 Slot。
MySQL 会在新增数据的时候就将对应的 Slot 创建好,有了 Page Directory ,就可以对一张页的数据进行粗略的二分查找。至于为什么是粗略,毕竟 Page Directory 中不是完整的数据,二分查找出来的结果只能是个大概的位置,找到了这个大概的位置之后,还需要回到 User Records 中继续的进行挨个遍历匹配。
不过这样的效率已经比我们刚开始聊的原始版本高了很多了。
第三部分: 页部分
1. 页目录(Page Directory):
为什么需要页目录?
在页中, 记录是以单向链表的形式进行存储的, 单向链表的特点就是插入, 删除非常方便, 但是检索效率不高, 最差的情况下需要遍历链表上的所有结点才能完成检索. 因此在页结构中专门设计了页目录这个模块, 专门给记录做一个目录, 通过二分查找法的方式进行检索, 提升效率。页目录包含所有槽, 其实就是一个页中所有的槽加载一起构成页目录。
需求:根据主键值查找页中的某条记录, 如何实现快速查找呐?
select * from page_demo where c1 = 3;
方式1 : 顺序查找
从Infimum记录(最小记录)开始, 沿着链表一直往后找, 总有一天会找到(或者最终也找不到就说明是没有这条记录), 在找的时候还能投机取巧, 因为链表中各个记录的值是按照从小到达的顺序排列的, 所以当链表的某个结点代表的记录的主键值大于你想要查找的主键值时, 你就可以停止查找了, 因为该结点以后的结点的值主键依次递增。
如果一个页中存储了非常多的记录, 那么查找性能很差。
方式2 : 使用页目录, 二分法查找
页目录是一个数组结构, 我们可以在页目录中使用二分查找的方式进行搜索:
- 将所有的记录分成几个组, 这些记录包括最小记录和最大记录, 但是不包括标记为已删除的记录
- 第1组, 也就是最小记录所在的分组( 第一组就只有一条记录就是最小记录自己 )。最后一组, 就是最大记录所在的分组, 会有1-8条记录;其余的组记录数量在4-8条之间;这样做的好处是, 除了第一组(最小记录所在组)以外, 其余组的记录数会尽量平分;
- 在每个组中最后一条记录的头信息会存储该组一共有多少条记录, 作为n_owned字段值
- 页目录用来存储每组最后一条记录的地址偏移量, 这些地址偏移量会按照先后顺序存储起来, 每组的地址偏移量也被称之为槽(slot), 每个槽相当于指针指向了不同组的最后一个记录
那么我们如何通过页目录, 通过槽来定位到二分位置? 由于槽指向的是一组中最大的值, 所以如果我们判断到某个值比我们的上一个槽值大, 比下一个槽值小的时候, 那么我们就应该到上一个槽的位置, 上一个槽指向的就是上一个页目录中最大的, next_record指针指向的就是下一个槽中的最小值了, 因为页内是单向指针, 所以我们必须要从前往后找。
页目录分组的个数如何确定?
InnoDB规定: 对于最小记录所在的分组只能有一条记录, 最大记录所在的分组拥有的记录了条数只能在1-8条之间, 剩下的分组中记录的条数范围只能在4-8条之间。
分组是按照下边的步骤进行的:
- 初始情况下一个数据页里只会有最小记录和最大记录这两条记录, 它们属于两个分组。
- 之后每插入一条记录, 都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽, 然后把该槽对应的记录的n_owned值加1, 表示本组内又添加了一条记录, 知道该组中的记录数等于8个。
- 在一个组中的记录数等于8个后再插入一条记录时, 会将组中的记录拆分成两个组, 一个组中4条记录, 另一个5条记录, 这个过程会在页目录中新增一个槽位来记录这个新增分组中最大的那条记录的偏移量。
2. 页面头部(Page Header):
为了能得到一个数据页中存储的状态信息, 比如本页中已经存储了多少条记录, 第一条记录的地址值是什么, 页目录中存储了多少个槽等等, 特意在页中定义了一个叫做Page Header的部分, 这部分占用固定的56个字节, 专门存储各种状态信息
关于记录插入方向:
page_direction :
假如新插入的一条记录的主键值比上一条记录的主键值大, 我们说这条记录的插入方向是右边, 反之就是左边, 用来表示最后一条记录插入方向的状态就是page_direction。
page_n_direction
假设连续几次插入新记录的方向都是一致的, InnnoDB会把沿着同一个方向插入记录的条数记下来, 这个条数就用page_n_direction这个字段表示. 当然, 如果最后一条记录的插入方向改变了的话, 这个状态的值会被清零重新统计。
记录行格式的记录头:
先给出一个表:
给出该表中记录的行格式示意图:
由于表中是指明了主键的, 所以隐藏字段只有两个 : 1.transaction_id(事物id)和2. roll_pointer(回滚指针), 如果没有指明主键, 也没有指明非空且唯一的字段, 那么就会有一个row_id隐藏字段作为聚簇索引列。
记录头组成如下图:
预留位是没有使用的空间, 所以我们直接去掉预留位, 得到简化后的行格式示意图:
然后我们插入4条记录:
然后我们来讲解记录头组成(上面图中我们可以看到记录头一共是分为了6个部分: ):
1. delete_mask
这个属性标记着当前记录是否被删除, 占用1个二进制位:
- 值为0 : 代表记录并没有被删除
- 值为1 : 代表记录被删除掉了
被删除的记录为什么还在页中存储呐?
你以为它删除了, 可它还在真实的磁盘上. 这些被删除的记录之所以不立即从磁盘上移除, 是因为移除它们之后其他的记录在磁盘上需要重新排列, 导致性能消耗. 所以只是打一个删除标记而已, 所有被删除掉的记录都会组成一个所谓的垃圾链表, 在这个链表中的记录占用的空间称之为可重用空间, 之后如果有新记录插入到表中的话, 可能把这些被删除的记录占用的存储空间覆盖掉。
2. min_rec_mask
B+树的每层非叶子结点中最小记录都会添加该标记, min_rec_mask值为1。
我们自己插入的四条记录的min_rec_mask值都是0, 意味着他们都不是B+树的非叶子结点中的最小记录(我们添加的这四条记录都是叶子结点)。
我们之前在讲数据库索引(B+树索引)设计的时候就提到过, 数据记录(叶子结点)和目录项(索引)记录(非叶子结点)在记录头上有两个不同 : 1. record_type不同, 一个是数据记录, 一个是索引记录, 2. min_rec_mask不同, 非叶子结点记录的min_rec_mask值可能为1。
3. record_type
这个属性表示当前记录的类型, 一共有4种类型的记录:
- 0 : 表示普通记录。
- 1 : 表示B+树非叶子结点记录。
- 表示最小记录。
- 表示最大记录。
从图中我们也可以看出来, 我们自己插入的记录就是普通记录, 它们的record_type值都是0, 而最小记录和最大记录的record_type值分别为2和3。
注意: 最小记录和最大记录是MySQL为我们自动生成的两个记录, 最小记录和最大记录由于是MySQL自动生成的, 所以我们常常将之称之为: 虚拟记录。
4. heap_no
这个属性表示当前记录在本页中的位置。从上面(插入的数据的行记录格式图)图中可以看出, 我们插入的4条记录在本页中的位置分别是: 2,3,4,5。
怎么不见heap_no值为0和1的记录呐?
MySQL会自动给每个叶里加了两个记录, 由于这两个记录并不是我们自己插入的, 所以有时候也称之为伪记录或者虚拟记录. 这两个伪记录一个代表最小记录, 一个代表最大记录. 最小记录和最大记录了的位置最靠前, 所以最小记录和最大记录的heap_no值分别是0和1。
5. n_owned
页目录中每个组最后一条记录的头信息中会存储该组一共多少条记录, 作为n_owned字段。可以看到n_owned和页目录是密切相关的。
6. next_record
记录头信息里该属性非常重要, 它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。
比如: 第一条记录的next_record值为32, 意味着从第一条记录的真实数据的地址处向后找32个字节便是下一条记录的真实数据。
注意 : 下一条记录指的并不是按照我们插入顺序的下一条记录, 而是按照主键值从小到大的顺序的下一条记录, 而本页中主键值最大的用户记录的下一条记录就是Supremum记录(也就是最大记录)。也即是说: 我们的最大记录虽然位置是页中的第二个位置, 第一个位置是最小记录, 但是在逻辑上却是页中的最后一条记录(也就是值最大的记录的下一条记录), 也即是物理地址靠前但是逻辑地址靠后。
删除操作举例:
从图中可以看出来, 删除第二条记录前后主要发生了这些变化:
- 第2条记录并没有从存储空间中移除, 而是把该条记录的delete_mask值设置为1
- 第2条记录的next_record的值变为了0, 意味着该记录没有下一条记录了
- 第1条记录的next_record指向了第3条记录
- 最大记录的n_owned值从5变成了4(原本删除之前页目录中有5条记录)
所以, 不论我们怎么对页中的记录做增删改操作, InnoDB始终会维护一条记录的单链表, 链表中的各个节点是按照主键值由小到大的顺序连接起来的.
添加操作:
直接复用了原来被删除记录的存储空间。
说明:
当数据页中存在多条被删除掉的记录时, 这些记录了的next_record属性将会把这些被删除掉的记录组成一个垃圾链表, 以备之后重用这部分存储空间。