MySQL进阶之(三)InnoDB数据存储结构之数据页结构

3.1 数据库的存储结构

前文中说到,索引结构给我们提供了高效的查询方式,而索引信息以及数据记录都是保存在文件上的,确切地说是保存在页结构中。

索引是在存储引擎中实现的,MySQL 服务器上的存储引擎负责对表中的数据进行读取和写入。但是不同的存储引擎对数据的存放格式一般是不同的,比如:InnoDB 是将数据存储到磁盘上、Memory 直接将数据存储在内存中…

注:MySQL 默认的存储引擎是 InnoDB,所以此文章以 InnoDB 存储引擎展开。

3.1.1 MySQL 数据存储目录

使用命令查看目录路径:

show variables like 'datadir';
# 或
select @@datadir

在这里插入图片描述
这个是修改后的目录路径,实际上 MySQL 默认的存储路径为:/var/lib/mysql

我们每创建一个数据库 database_name,这个目录下(包括自定义的)就会创建一个以数据库名为名的目录,然后里面存储表结构和表数据文件。

InnoDB 存储引擎创建的任何一张表的都会有两个文件:

  • test_table.frm:存储表结构的文件,保存表的原数据信息。
  • test_table.ibd:存储表数据的文件,表数据既可以存储在共享表空间文件中(ibdata1中),也可以存储在独占表空间中(后缀 .idb中)。是否存储在独占表中,可以通过参数 innodb_file_per_table控制,设置为 1,则会存储在独占表空间中,从 MySQL 5.6.6 版本之后,innodb_file_per_table 默认值就为 1 了,因此此后的版本,表数据都是存储在独占表中的。

3.1.2 页的引入

为什么会提到页呢?

InnoDB 是一个将表中的数据存储到磁盘上的存储引擎,即使我们关闭并重启服务器,数据还是存在。而真正处理数据的过程发生在内存中,所以需要把磁盘中的数据加载到内存中。如果是要处理写入或修改请求,还需要把内存中的内容刷新到磁盘上。而我们知道读写磁盘的速度是非常慢的,与读写内存差了几个数量级。当我们想从表中获取某些记录时,InnoDB 存储引擎需要一条一条地把记录从磁盘上读出来么?这样会慢死,InnoDB 采取的方式是,将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位。 —— 摘自《MySQL是怎样运行的》

InnoDB 中页的大小一般为 16KB,一般情况下,一次最少从磁盘中读取 16KB 的内容到内存中,一次最少把内存中 16KB 的内容刷新到磁盘中。也就是说,数据库 I/O 操作的最小单位是页每次刷盘时都必须是页的整数倍

SQL Server 中页的大小为 8KB。
Oracle 中用 “块” 来代表页,支持的块大小有 2KB、4KB、8KB(默认)、16KB、32KB、64KB。

3.1.3 页的概述

将数据划分为若干个页,这些页可以不在物理结构上相连,只需要通过双向链表使其在逻辑结构上相连即可。每个数据页中的记录都会按照主键值从小到大的顺序组成一个单向链表,每个数据页都会为存储在它里边的记录生成一个页目录,再通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。

在 InnoDB 中,默认页的大小是 16KB,可以通过命令来查看:

show variables like '%innodb_page_size%'
# 或者
select @@innodb_page_size

在这里插入图片描述

3.1.4 页的上层结构

另外,在数据库中,还存在着区、段、表空间的概念。行、页、区、段、表空间的关系:
在这里插入图片描述

  • 区(Extent)是比页大一级的存储结构,在 InnoDB 存储引擎中,一个区会分配 64 个连锁的页,因为页默认是 16KB,所以一个区的大小是 64 * 16KB = 1MB。
  • 段(Segment)是由一个或多个区组成,区在文件系统是一个连续分配的空间,但是在段中不要求区与区之间是相邻的。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当我们要创建数据表、索引的时候,就会相应地创建对应的段(索引段、表段…)。
  • 表空间(Tablespace)是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以分为系统表空间、用户表空间、撤销表空间、临时表空间等。

3.2 数据页结构

页如果按照类型划分的话,常见的有:数据页(保存 B+ 树节点)、系统页、Undo 页、事务数据页等。其中,数据页是我们最常使用的页。

前面也有提到,数据页的大小为 16KB,这 16KB 大小的存储空间被划分为七个部分:

  1. 文件头部(File Header):占用 38 字节,主要描述页的一些通用信息;
  2. 页面头部(Page Header):占用 56 字节,专门存储页的各种状态信息;
  3. 页面中的最小记录和最大记录(Infimum + Supremum):占用 26 字节,这是两个虚拟的行记录;
  4. 用户记录(User Records):占用空间由实际的记录数据决定,主要存储行记录内容;
  5. 空闲空间(Free Space):占用空间由用户记录决定,是页中还没有被使用的空间;
  6. 页目录(Page Directory):占用空间由用户记录有关,存储用户记录的相对位置,以便于查找;
  7. 文件尾部(File Trailer):占用 8 字节,校验页是否完整。

在这里插入图片描述
可以将以上 7 个结构归为以下 3 个部分。

3.2.1 文件头和文件尾

01、File Header(文件头部)

文件头部占用 38 字节,主要描述页的一些通用信息,比如:页的编号、其上一页页号、下一页页号…

File Header 的结构及描述如下:
在这里插入图片描述
其中,我们需要重点关注一下标黄的属性。

FIL_PAGE_OFFSET(4字节):页号,好比我们的身份证号,用来定位唯一的页

FIL_PAGE_TYPE(2字节):代表当前页的类型。前面说过,InnoDB 为了不同的目的而把页分为不同的类型,除了我们常用的数据页之外,还有很多其他类型的页。
在这里插入图片描述

FIL_PAGE_PREV(4字节)和 FIL_PAGE_NEXT(4字节):InnoDB 都是以页为单位存放数据的,如果数据分散到多个不连续的页中存储,就需要把这些页关联起来,FIL_PAGE_PREV 和 FIL_PAGE_NEXT 就分别代表本页的上一页和下一页的页号
通过建立一个双向链表,保证这些页与页之间在逻辑上连接而非物理连接。在这里插入图片描述

FIL_PAGE_SPACE_OR_CHKSUM(4字节):代表当前页面的校验和(checksum)
什么是校验和?
就类似于一个很长的字符串,通过某种算法计算出一个比较短的值来代表这个字符串,这个比较短的值就是校验和。
为什么要使用校验和?
在比较两个很长的字符串时,如果直接进行比较的话,肯定会比较慢的。但是,如果通过比较两个字符串的校验和(其中,生成校验和耗时可以忽略不计):校验和相同就代表两个字符串相同,反之则不同。这种方式很明显会缩短比较时的耗时。
校验和在页面上有什么作用?
校验和这个属性是存在于文件头部和文件尾部的。InnoDB 以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间就需要把数据同步刷新到磁盘中了。但是同步时可能会出现断电、磁盘损坏等一系列不可抗因素,从而造成数据页传输的不完整。
为了检测一个页是否刷盘成功,就可以通过文件头部的校验和与文件尾部的校验和做对比,如果两个值不相等则说明页刷盘失败,需要重新刷盘(数据重试或回滚操作);否则认为页刷盘成功。

FIL_PAGE_LSN(8字节):页面被最后修改时对应的日志序列位置(Log Sequence Number,简称:LSN)。

02、File Trailer(文件尾部)

  • 前 4 个字节:代表页的校验和,这个部分与 File Header 中的校验和相对应。
  • 后 4 个字节:代表页面被最后修改时对应的日志序列位置(LSN),这个部分也是为了校验页的完整性,如果首部和尾部的 LSN 值校验不成功的话,也说明同步过程出现了错了(刷盘失败)。

3.2.2 空闲空间、用户记录、最大最小记录(上下确界)

页的主要作用是存储记录,所以,用户记录最大最小记录占了页结构的主要空间。
在这里插入图片描述

01、Free Space(空闲空间)

当我们存储一条数据的时候,存储的记录会按照指定的行格式存储到 User Records 部分。但是在最开始生成页的时候,其实是没有 User Record 这个部分的。当每次插入一条记录时,都会从 Free Space(空闲空间)中申请一个记录大小的空间划分到 User Record 部分。当 Free Space 的空间被划分完之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
在这里插入图片描述

02、User Record(用户记录)

User Record 中的这些记录按照指定的行格式一条一条地摆在 User Record 部分,相互之间形成单链表。

至于这里的一条一条记录是如何存储的,可以查看:InnoDB 行格式

03、Infimum + Supremum(最小最大记录)

对于一条完整的记录来说,比较记录的大小就是比较主键的大小,记录会按照主键值从小到大的顺序形成一个单向链表。

InnoDB 规定的最小记录和最大记录这两条记录的构造其实很简单,都是由 5 字节大小的记录头信息和 8 字节大小的一个固定的部分组成的:
在这里插入图片描述
这两条记录其实不是用户自己插入的,而是在生成页的时候默认自动创建的,并称为伪记录虚拟记录,它们并不存放在 User Record 部分,而是单独存放在 Infimum + Supremum 部分:
在这里插入图片描述
这里记录头信息中有一个属性:heap_no(记录在堆中的相对位置)。InnoDB 把记录一条一条亲密无间排列的结构称之为堆,其实此属性就是当前记录在本页中的位置,并把这两条伪记录的值分别设为 0 和 1。

3.2.3 页目录、页面头部

01、Page Directory(页目录)

为什么需要页目录?

在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。所以在页结构中专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索,提升效率

假设现在有一条查询语句:

select * from page_demo where c1 = 3;

根据主键查找页中的某条记录,如何实现快速查找呢?

⭐ 方式一

顺序查找:从 Infimum 记录(最小记录)开始,沿着链表一直往后找,数据量非常大的时候,性能非常差。

⭐ 方式二

使用页目录,二分法查找。

  1. 将所有的记录分成若干个组,这些记录中包含最小记录和最大记录,但不包括被标记为删除的记录。
  2. 第 1 组只有一条记录,最小记录所在的组。最后一组,也就是最大记录所在的分组,会有 1-8 条记录。其他分组,会有 4-8 条记录。【这样做的好处是除了第 1 组外,其余组的记录数会尽量平分】。
  3. 每个组中的最后一条记录的头信息中会存储该组中一共有多少条记录,来作为 n_owned 字段的值。
  4. 页目录用来存储最后一条记录的地址偏移量,这些地址偏移量会按照顺序存储起来,每组的地址偏移量也被称为槽(Slot),每个槽相当于指针指向了不同组的最后一条记录。

假设现在的 page_demo 表中正常的记录共有 6 条,InnoDB 会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的 5 条记录。分组后:
在这里插入图片描述

上图的槽位:

  • 槽 0:指向的是最小记录的地址偏移量。
  • 槽 1:指向的是最大记录的地址偏移量。

用指针代替数字后:
在这里插入图片描述

再换个角度看,单纯从逻辑上看一下这些记录和页目录的关系:
在这里插入图片描述
对页目录有个大致的了解后,引申出两个问题:

问题一:页目录分组的个数是如何确定的?

在上述分组中,为什么最小记录的 n_owned 为 1,而最大记录的 n_owned 的值为 5 呢?

InnoDB 规定:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能是在 4~8 条之间。

分组的步骤是这样的:

  • 初始情况下只有两条记录(两个槽):最大记录和最小记录,它们分别属于两个组。
  • 每当插入一条记录,都会从页目录中找到主键值比本身记录的主键值大并且差值最小的槽(从本质上来说,槽是一个组内最大的那条记录在页面中的地址偏移量,通过槽可以快速找到对应的主键值),然后把该槽对应记录的 n_owned 值加一(表示本组内又添加了一条记录),直到该组中的记录数等于 8 个。
  • 当一个组中的记录数等于 8 后,再插入一条记录时,会将组中的记录拆分成两个组,其中一个组中 4 条记录,另一个 5 条记录。这个拆分的过程会在页目录中新增一个槽 ,记录这个新增分组中最大的那条记录的偏移量。

问题二:页目录结构下如何快速查找记录?

假设现在新增了 12 条数据(模拟大数据量下查找记录的过程):

INSERT INTO page_demo 
VALUES
(5, 500, 'zhou'), 
(6, 600, 'chen'), 
(7, 700, 'deng'), 
(8, 800, 'yang'), 
(9, 900, 'wang'), 
(10, 1000, 'zhao'), 
(11, 1100, 'qian'), 
(12, 1200, 'feng'), 
(13, 1300, 'tang'), 
(14, 1400, 'ding'), 
(15, 1500, 'jing'), 
(16, 1600, 'quan');

新添加 12 条记录后,页里共有 18 条记录了(包括最大记录和最小记录),这些记录被分成了 5 组:
在这里插入图片描述
这里为了方便展示,只保留了 16 条记录的记录头信息中的 n_owned 和 next_record 属性,省略了各个记录之间的箭头。

这五个槽位的编号分别为:0、1、2、3、4,所以最初情况下最低的槽就是 low = 0,最高槽就是 high = 4。由于各个槽代表的记录的主键值都是从小到大排序的,所以可以采用二分法来进行快速查找主键值为 6 的记录:

  1. 计算中间槽的位置:(0 + 4)/ 2 = 2,所以查看槽 2 对应记录的主键值为 8,而 8 > 6,所以设置 high = 2,low 保持不变。
  2. 重新计算中间槽的位置:(0 + 2)/ 2 = 1,所以查看槽 1 对应记录的主键值为 4,而 4 < 6,所以设置 low = 1,high 保持不变。
  3. 因为 high - low 的值为 1,所以确定主键值为 6 的记录在槽 2 对应的组中。沿着槽 2 中主键值所在的单链表中遍历即可得到主键值为 6 的记录。

由于每个组中包含的记录的条数只能是 1~8 条,所以遍历一个组中的记录的代价是很小的。

在一个数据页中查找指定主键值的记录的过程分为两步:

  1. 通过二分法在页目录中确定要查找的主键所在槽位的上一个槽位,并找到该槽所在分组中主键值最大的记录;
  2. 从当前主键值最大的记录开始,通过 next_record 属性往后遍历,直到找到要查找的记录为止。

02、Page Header(页面头部)

为了能得到一个数据页中存储的记录的状态信息,比如:

  • 本页中已经存储了多少条记录?
  • 第一条记录的地址是什么?
  • 页目录中存储了多少个槽?…

特意在页中定义了一个叫 Page Header 的部分,这个部分占用固定的 56 个字节,专门存储各种状态信息。

有这些属性:
在这里插入图片描述

PAGE_DIRECTION:记录插入的方向。
比如:新插入的一条记录的主键值比上一条记录的主键值大,就说这条记录的插入方向是右边;反之则是左边。

PAGE_N_DIRECTION:一个方向连续插入的记录数量。
假设连续几次插入新纪录的方向都是一致的,InnoDB 会把沿着同一个方向插入记录的条数记录下来,这个条数就使用此状态表示。但如果最后一条记录的插入方向改变了的话,这个状态的值是会被清零重新统计的。

3.3 从数据页的角度看 B+ 树如何查询

一颗 B+ 树按照节点类型可以分成两部分:

  1. 叶子节点:B+ 树最底层的节点,节点的高度为 0,存储行记录。
  2. 非叶子节点:节点的高度大于 0,存储索引键和页面指针,并不存储行记录本身。

在这里插入图片描述

3.3.1 B+ 树是如何进行记录检索的?

通过 B+Tree 的索引查询记录,首先是从根节点开始逐层检索,直到找到记录所在的叶子节点,然后将整个数据页从磁盘中加载到内存中,页目录中的槽(slot)可以通过 二分查找的方式定位到记录所在的槽(分组),通过链表遍历的方式查找到记录。

3.3.2 普通索引和唯一索引在查询效率上有什么不同?

唯一索引其实就是在普通索引上增加了约束,也就是关键字唯一,找到关键字之后就停止检索。

普通索引存在用户记录中的关键字相同的情况。根据页结构的原理,当我们读取一条记录的时候,不是单独将这条记录从磁盘中读出去,而是将这个记录所在的页加载到内存中进行读取,而一个页中可能存储着上千个记录。因为普通索引可能存在关键字重复的情况,所以查找到关键字后仍需往后再多几次 “判断下一条记录” 的操作,而此时页已经被加载到内存中去了,所以不会涉及 I/O 操作了,而在内存中判断所消耗的时间是可以忽略不计的。

所以,对一个索引字段进行检索时,采用普通索引还是唯一索引在检索效率上基本是没有区别的

  • 22
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值