MySQL8索引与调优篇2-InnoDB数据存储结构

欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

1. 数据库的存储结构:页

索引结构给我们提供了高效的索引方式,不过索引信息以及数据记录都是保存在文件上的,确切说是存储在页结构中。另一方面,索引是在存储引擎中实现的,MySQL服务器的存储引擎负责对表中数据的读取和写入工作。 不同存储引擎中存放的格式一般是不同的,甚至有的存储引擎比如Memory的都不用磁盘来存储数据。

由于InnoDBMySQL的默认存储引擎,所以本章剖析InnoDB存储引擎的数据存储结构。

1.1 磁盘与内存交互基本单位:页

InnoDB 将数据划分为若干个页,InnoDB中页的大小默认 16KB

作为磁盘和内存之间交互的 基本单位,也就是一次最少从磁盘中读取16KB的内容到内存中,一次最少把内 存中的16KB内容刷新到磁盘中。也就是说,在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行 加载。也就是说,数据库管理存储空间的基本单位是页(Page) ,数据库I/O操作的最小单位是页。一个页中可 以存储多个行记录。

记录是按照行来存储的,但是数据库的读取并不以行为单位,否则一次读取(也就是一次I/。操作)只能处 理一行数据,效率会非常低。

image.png

1.2 页结构概述

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

1.3 页的大小

不同的数据库管理系统(简称DBMS)的页大小不同。比如在MySQL的InnoDB存储引擎中,默认页的大小是 16KB,我们可以通过下面的命令来进行查看:

mysql> show variables like %innodb_page_size%';
复制代码

SQL Server中页的大小为8KB,而在Oracle中我们用术语“块”(Block)来代表“页”,Ora Ice支持的块大小为 2KB, 4KB, 8KB, 16KB, 32KB 和 64KB。

1.4 页的上层结构

另外在数据库中,还存在着区(Extent)、段(Segment)和表空间(Tablespace)的概念。行、页、区、段、表 空间的关系如下图标:

image.png

区(Extent)是比页大一级的存储结构,在InnoDB存储引擎中,一个区会分配64个连续的页。因为InnoDB中 的页大小默认是16KB,所以一个区的大小是64 * 6KB=1 MB。

段(Segment)由一个或多个区组成,区在文件系统是一个连续分配的空间(在InnoDB中是连续的64个页), 不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当 我们创建数据表、索引的时候,就会相应创建对应的段,比如创建一张表时会创建一个表段,创建一个索引时会 创建一个索引段。

表空间(Tablespace)是一个逻辑容器,表空间存储的又搀是段,在一个表空间中可以有一个或多个段,但是一 个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间、用户表空 间、撤销表空间、临时表空间等。

2. 页的内部结构

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

数据页的16KB大小的存储空间被划分为七个部分,分别是文件头(File Header)、页头(Page Header)、最大 最小记录(Infimum+supremum) 、 用户记录(User Records)、空闲空间(Free Space)、页目录(Page Directory)和文件尾(File Tailer) ₒ 页结构的示意图如下所示:

image.png

这7个部分作用分别如下:

名称 中文名 占用空间大小 简单描述
File Header 文件头部 38字节 文件头,页的一些通用信息
Page Header 页面头部 56字节 页头,数据页专有的一些信息
Infimum + Supremum 最小记录和最大记录 26字节 最大最小记录,两个虚拟的行记录
User Records 用户记录 不确定 用户记录,实际存储的行记录内容
Free Space 空闲空间 不确定 空闲记录,页中尚未使用的空间
Page Directory 页面目录 不确定 页目录,页中的某些记录的相对位置
File Trailer 文件尾部 8字节 文件尾,校验页是否完整

2.1 第1部分:File Header(文件头部)和File Trailer(文件尾部)

2.1.1 File Header(文件头部 38字节)

它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁啦等等~ 这个部分占用固定的38个字节,是由下面这些内容组成的:

名称 占用空间大小 描述
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 4字节

      这个代表当前页面的校验和(checksum)。什么是个校验和?就是对于一个很长很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为校验和。这样在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。

    文件头部和文件尾部都有属性:FIL_PAGE_SPACE_OR_CHKSUM 

    作用:  

    • InnoDB存储引擎以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么 在修改后的某个时间需要把数据同步到磁盘中。 但是在同步了一半的时候断电了,造成了该页传输的不完整。 

    • 为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),这时可以通过文件尾的校验和(checksum 值)与文件头的校验和做比对,如果两个值不相等则证明页的传输有问题,需要重新进行传输,否则认为页的传输已经完成。 

    具体的:  

    • 每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trailer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。这里,校验方式就是采用 Hash 算法进行校验。
  • FIL_PAGE_OFFSET 4字节

      每一个都有一个单独的页号,就跟你的身份证号码一样,InnoDB通过页号来可以唯一定位一个

  • FIL_PAGE_TYPE 2字节

      这个代表当前的类型,我们前面说过,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 BLOB页
    FIL_PAGE_INDEX 0x45BF 索引页,也就是我们所说的数据页

      我们存放记录的数据页的类型其实是FIL_PAGE_INDEX,也就是所谓的索引页。至于什么是个索引,且听下回分解~

  • FIL_PAGE_PREVFIL_PAGE_NEXT 各4字节

      我们前面强调过,InnoDB都是以页为单位存放数据的,有时候我们存放某种类型的数据占用的空间非常大(比方说一张表中可以有成千上万条记录),InnoDB可能不可以一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREVFIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着,而是逻辑上的连续。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,不过我们本集中介绍的数据页(也就是类型为FIL_PAGE_INDEX的页)是有这两个属性的,所以所有的数据页其实是一个双链表,就像这样:

  • FIL_PAGE_LSN 8字节

    页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number)

关于File Header的其他属性我们暂时用不到,等用到的时候再提~

2.1.2 File Trailer(文件尾部 8字节)

我们知道InnoDB存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办,这不是莫名尴尬么?为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),设计InnoDB的大佬们在每个页的尾部都加了一个File Trailer部分,这个部分由8个字节组成,可以分成2个小部分:

  • 前4个字节代表页的校验和

      这个部分是和File Header中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前面,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trialer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。

  • 后4个字节代表页面被最后修改时对应的日志序列位置(LSN)

      这个部分也是为了校验页的完整性的,只不过我们目前还没说LSN是个什么意思,所以大家可以先不用管这个属性。

2.2 第2部分:空闲、用户和最大最小记录

第二个部分是记录部分,页的主要作用是存储记录,所以“最大和最小记录”和“用户记录”部分占了页结构的主要空间。

image.png

2.2.1 Free Space 空闲记录

我们自己存储的记录会按照指定的 行格式 存储到 User Records 部分。但是在一开始生成页的时候,其实并没有User Records这个部分, 每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分 ,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去 申请新的页 了。
image.png

2.2.2 User Records 用户记录

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

用户记录里的一条条数据如何记录?  

这里需要讲讲记录行格式的 记录头信息 。在下一节行格式中详细讲解。

2.2.3 Infimum + Supremum 最大最小记录 26字节

记录可以比较大小吗? 

是的,记录可以比大小,对于一条完整的记录来说,比较记录的大小就是 比较主键 的大小。比方说我们插入的4行记录的主键值分别是:1、2、3、4,这也就意味着这4条记录是从小到大依次递增。 

InnoDB规定的最小记录与最大记录这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的,如图所示:

image.png
这两条记录 不是我们自己定义的记录 ,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分,如图所示:

image.png

2.3 Page Directory 页目录和Page Header页面头部

2.3.1 Page Directory 页目录

为什么需要页目录? 

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

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

SELECT * FROM page_demo WHERE c1 = 3; 
复制代码
  • 方式1:顺序查找 

    Infimum记录(最小记录)开始,沿着链表一直往后找,总有一天会找到(或者找不到),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递增。
    如果一个页中存储了非常多的记录,这么查找性能很差。 

  • 方式2:使用页目录,二分法查找 

    1. 将所有的记录 分成几个组 ,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录。 

    2. 第 1 组,也就是最小记录所在的分组只有 1 个记录; 

      最后一组,就是最大记录所在的分组,会有 1-8 条记录; 

      其余的组记录数量在 4-8 条之间。 

    这样做的好处是,除了第 1 组(最小记录所在组)以外,其余组的记录数会 尽量平分 。 

    1. 在每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段。 

    2. 页目录用来存储每组最后一条记录的地址偏移量 ,这些地址偏移量会按照 先后顺序存储 起来,每组的地址偏移量也被称之为 槽(slot) ,每个槽相当于指针指向了不同组的最后一个记录。 

  

举例1: 

image.png  

举例2: 

现在的page_demo表中正常的记录共有6条,InnoDB会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录。如下图: 

image.png   从这个图中我们需要注意这么几点: 

  • 现在页目录部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1中的值是112,代表最大记录的地址偏移量(就是从页面的0字节开始数,数112个字节);槽0中的值是99,代表最小记录的地址偏移量。 

  • 注意最小和最大记录的头信息中的n_owned属性 

    • 最小记录的n_owned值为1,这就代表着以最小记录结尾的这个分组中只有1条记录,也就是最小记录本身。 
    • 最大记录的n_owned值为5,这就代表着以最大记录结尾的这个分组中只有5条记录,包括最大记录本身还有我们自己插入的4条记录。 

用箭头指向的方式替代数字,这样更易于我们理解,修改后如下: 

image.png   再换个角度看一下:(单纯从逻辑上看一下这些记录和页目录的关系) image.png

页目录分组的个数如何确定?

为什么最小记录的n_owned值为1,而最大记录的n_owned值为5呢? 

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

分组是按照下边的步骤进行的: 

  • 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。 
  • 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。 
  • 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。

页目录结构下如何快速查找记录?

现在向page_demo表中添加更多的数据。如下: 

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个组,如图所示: image.png

因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以只保留了用户记录头信息中的n_ownednext_record属性,也省略了各个记录之间的箭头,我没画不等于没有啊!现在看怎么从这个页目录中查找记录。因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法来进行快速查找。4个槽的编号分别是:01234,所以初始情况下最低的槽就是low=0,最高的槽就是high=4。比方说我们想找主键值为6的记录,过程是这样的:

  1. 计算中间槽的位置:(0+4)/2=2,所以查看槽2对应记录的主键值为8,又因为8 > 6,所以设置high=2low保持不变。
  2. 重新计算中间槽的位置:(0+2)/2=1,所以查看槽1对应的主键值为4,又因为4 < 6,所以设置low=1high保持不变。
  3. 因为high - low的值为1,所以确定主键值为5的记录在槽2对应的组中。此刻我们需要找到槽2中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。但是我们前面又说过,每个槽对应的记录都是该组中主键值最大的记录,这里槽2对应的记录是主键值为8的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。

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

  1. 通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录
  2. 通过记录的next_record属性遍历该槽所在的组中的各个记录。`

3.3.2 Page Header页面头部 56字节

设计InnoDB的大佬们为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫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页定义

  如果大家认真看过前面的文章,从PAGE_N_DIR_SLOTSPAGE_LAST_INSERT以及PAGE_N_RECS的意思大家一定是清楚的,如果不清楚,对不起,你应该回头再看一遍前面的文章。剩下的状态信息看不明白不要着急,饭要一口一口吃,东西要一点一点学(一定要稍安勿躁哦,不要被这些名词吓到)。在这里我们先介绍一下PAGE_DIRECTIONPAGE_N_DIRECTION的意思:

  • PAGE_DIRECTION

      假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION

  • PAGE_N_DIRECTION

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

  至于我们没提到的那些属性,我没说是因为现在不需要大家知道。不要着急,当我们学完了后边的内容,你再回头看,一切都是那么清晰。

小贴士:说到这个有些东西后边我们学过后回头看就很清晰的事儿不禁让我想到了乔布斯在斯坦福大学的演讲,摆一下原文:  
    “You can't connect the dots looking forward; you can only connect them looking backwards. So you have to trust that the dots will somehow connect in your future.You have to trust in something - your gut, destiny, life, karma, whatever. This approach has never let me down, and it has made all the d  ifference in my life.”  
    上面这段话纯属心血来潮写的,大意是坚持做自己喜欢的事儿,你在做的时候可能并不能搞清楚这些事儿对自己之后的人生有什么影响,但当你一路走来回头看时,一切都是那么清晰,就像是命中注定的一样。上述内容跟MySQL毫无干系,请忽略~  
复制代码

3. InnoDB行格式(或记录格式)

我们平时的数据以行为单位来向表中插入数据,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。InnoDB存储引擎设计了4种不同类型的行格式,分别是CompactRedundantDynamicCompressed行格式。 

查看MySQL8的默认行格式: 

mysql> SELECT @@innodb_default_row_format; 

+-------------------------------------+ 

| @@innodb_default_row_format | 

+-------------------------------------+ 

| dynamic                                   | 

+-------------------------------------+ 

1 row in set (0.00 sec) 
复制代码

   也可以使用如下语法查看具体表使用的行格式: 

SHOW TABLE STATUS like '表名'\G
复制代码

3.1 指定行格式的语法

在创建或修改表的语句中指定行格式: 

CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称 

ALTER TABLE 表名 ROW_FORMAT=行格式名称 
复制代码

   举例: 

mysql> CREATE TABLE record_format_demo ( 

    ->     col1 VARCHAR(8), 

    ->     col2 VARCHAR(8) NOT NULL, 

    ->     col3 CHAR(8), 

    ->     col4 VARCHAR(8) 

    -> ) CHARSET= ascii  ROW_FORMAT= COMPACT ; 

Query OK, 0 rows affected (0.03 sec) 
复制代码

向表中插入两条记录: 

INSERT INTO record_format_demo(col1, col2, col3, col4)  

VALUES 

('zhangsan', 'lisi', 'wangwu', 'songhk'),  

('tong', 'chen', NULL, NULL);
复制代码

3.2 Compact 行格式

在MySQL 5.1版本中,默认设置为Compact行格式。一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分。废话不多说,直接看图:

COMPACT行格式

3.2.1 变长字段长度列表(1 or 2字节)

我们知道MySQL支持一些变长的数据类型,比如VARCHAR(M)VARBINARY(M)、各种TEXT类型,各种BLOB类型,我们也可以把拥有这些数据类型的列称为变长字段,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来,这样才不至于把MySQL服务器搞懵,所以这些变长字段占用的存储空间分为两部分:

  • 真正的数据内容
  • 占用的字节数

  在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放,我们再次强调一遍,是逆序存放

  我们拿record_format_demo表中的第一条记录来举个例子。因为record_format_demo表的c1c2c4列都是VARCHAR(10)类型的,也就是变长的数据类型,所以这三个列的值的长度都需要保存在记录开头处,因为record_format_demo表中的各个列都使用的是ascii字符集,所以每个字符只需要1个字节来进行编码,来看一下第一条记录各变长字段内容的长度:

列名 存储内容 内容长度(十进制表示) 内容长度(十六进制表示)
c1 'aaaa' 4 0x04
c2 'bbb' 3 0x03
c4 'd' 1 0x01

  又因为这些长度值需要按照列的逆序存放,所以最后变长字段长度列表的字节串用十六进制表示的效果就是(各个字节之间实际上没有空格,用空格隔开只是方便理解):

01 03 04 
复制代码

  把这个字节串组成的变长字段长度列表填入上面的示意图中的效果就是:

填入变长字段长度列表

  由于第一行记录中c1c2c4列中的字符串都比较短,也就是说内容占用的字节数比较小,用1个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用2个字节来表示。具体用1个还是2个字节来表示真实数据占用的字节数,InnoDB有它的一套规则,我们首先声明一下WML的意思:

  1. 假设某个字符集中表示一个字符最多需要使用的字节数为W,也就是使用SHOW CHARSET语句的结果中的Maxlen列,比方说utf8字符集中的W就是3gbk字符集中的W就是2ascii字符集中的W就是1
  2. 对于变长类型VARCHAR(M)来说,这种类型表示能存储最多M个字符(注意是字符不是字节),所以这个类型能表示的字符串最多占用的字节数就是M×W
  3. 假设它实际存储的字符串占用的字节数是L

  所以确定使用1个字节还是2个字节表示真正字符串占用的字节数的规则就是这样:

  • 如果M×W <= 255,那么使用1个字节来表示真正字符串占用的字节数。

    就是说InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数不大于255时,可以认为只使用1个字节来表示真正字符串占用的字节数。

  • 如果M×W > 255,则分为两种情况:

    • 如果L <= 127,则用1个字节来表示真正字符串占用的字节数。
    • 如果L > 127,则用2个字节来表示真正字符串占用的字节数。

InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数大于255时,该怎么区分它正在读的某个字节是一个单独的字段长度还是半个字段长度呢?设计InnoDB的大佬使用该字节的第一个二进制位作为标志位:如果该字节的第一个位为0,那该字节就是一个单独的字段长度(使用一个字节表示不大于127的二进制的第一个位都为0),如果该字节的第一个位为1,那该字节就是半个字段长度。

对于一些占用字节数非常多的字段,比方说某个字段长度大于了16KB,那么如果该记录在单个页面中无法存储时,InnoDB会把一部分数据存放到所谓的溢出页中(我们后边会介绍),在变长字段长度列表处只存储留在本页面中的长度,所以使用两个字节也可以存放下来。Copy to clipboardErrorCopied

  总结一下就是说:如果该可变字段允许存储的最大字节数M×W超过255字节并且真实存储的字节数(L)超过127字节,则使用2个字节,否则使用1个字节

  另外需要注意的一点是,变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。也就是说对于第二条记录来说,因为c4列的值为NULL,所以第二条记录的变长字段长度列表只需要存储c1c2列的长度即可。其中c1列存储的值为'eeee',占用的字节数为4c2列存储的值为'fff',占用的字节数为3。数字4可以用1个字节表示,3也可以用1个字节表示,所以整个变长字段长度列表共需2个字节。填充完变长字段长度列表的两条记录的对比图如下:

填充完变长字段长度列表的两条记录的对比图

小贴士:并不是所有记录都有这个 变长字段长度列表 部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。

3.2.2 NULL值列表

  我们知道表中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占地方,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中,它的处理过程是这样的:

  1. 首先统计表中允许存储NULL的列有哪些。

      我们前面说过,主键列、被NOT NULL修饰的列都是不可以存储NULL值的,所以在统计的时候不会把这些列算进去。比方说表record_format_demo的3个列c1c3c4都是允许存储NULL值的,而c2列是被NOT NULL修饰,不允许存储NULL值。

  2. 如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了,否则将每个允许存储NULL的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:

    • 二进制位的值为1时,代表该列的值为NULL

    • 二进制位的值为0时,代表该列的值不为NULL

        因为表record_format_demo有3个值允许为NULL的列,所以这3个列和二进制位的对应关系就是这样:

      record_format_demo表3值对应关系

        再一次强调,二进制位按照列的顺序逆序排列,所以第一个列c1和最后一个二进制位对应。

  3. MySQL规定NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0

      表record_format_demo只有3个值允许为NULL的列,对应3个二进制位,不足一个字节,所以在字节的高位补0,效果就是这样:

      以此类推,如果一个表中有9个允许为NULL,那这个记录的NULL值列表部分就需要2个字节来表示了。

      知道了规则之后,我们再返回头看表record_format_demo中的两条记录中的NULL值列表应该怎么储存。因为只有c1c3c4这3个列允许存储NULL值,所以所有记录的NULL值列表只需要一个字节。

  • 对于第一条记录来说,c1c3c4这3个列的值都不为NULL,所以它们对应的二进制位都是0,画个图就是这样:

    所以第一条记录的NULL值列表用十六进制表示就是:0x00

  • 对于第二条记录来说,c1c3c4这3个列中c3c4的值都为NULL,所以这3个列对应的二进制位的情况就是:

所以第二条记录的NULL值列表用十六进制表示就是:0x06

  所以这两条记录在填充了NULL值列表后的示意图就是这样:

3.2.3 记录头信息(5字节)

我们先创建一个表:

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)Copy to clipboardErrorCopied
复制代码

  这个新创建的page_demo表有3个列,其中c1c2列是用来存储整数的,c3列是用来存储字符串的。需要注意的是,我们把 c1 列指定为主键,所以在具体的行格式中InnoDB就没必要为我们去创建那个所谓的 row_id 隐藏列了。而且我们为这个表指定了ascii字符集以及Compact的行格式。所以这个表中记录的行格式示意图就是这样的:

  从图中可以看到,我们特意把记录头信息的5个字节的数据给标出来了,说明它很重要,我们再次先把这些记录头信息中各个属性的大体意思浏览一下(我们目前使用Compact行格式进行演示):

名称 大小(单位: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表的行格式演示图中画出有关的头信息属性以及c1c2c3列的信息(其他信息没画不代表它们不存在啊,只是为了理解上的方便在图中省略了~),简化后的行格式示意图就是这样:

  下面我们试着向page_demo表中插入几条记录:

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: 0Copy to clipboardErrorCopied
复制代码

  为了方便大家分析这些记录在User Records部分中是怎么表示的,我把记录中头信息和实际的列数据都用十进制表示出来了(其实是一堆二进制位),所以这些记录的示意图就是:

  看这个图的时候需要注意一下,各条记录在User Records中存储的时候并没有空隙,这里只是为了大家观看方便才把每条记录单独画在一行中。我们对照着这个图来看看记录头信息中的各个属性是什么意思:

  • delete_mask

      这个属性标记着当前记录是否被删除,占用1个二进制位,值为0的时候代表记录并没有被删除,为1的时候代表记录被删除掉了。

      什么?被删除的记录还在中么?是的,摆在台面上的和背地里做的可能大相径庭,你以为它删除了,可它还在真实的磁盘上[摊手](忽然想起冠希~)。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为所谓的可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。

提示:将这个delete_mask位设置为1和将被删除的记录加入到垃圾链表中其实是两个阶段,我们后边在介绍事务的时候会详细介绍删除操作的详细过程,稍安勿躁。

  • min_rec_mask

      B+树的每层非叶子节点中的最小记录都会添加该标记,什么是个B+树?什么是个非叶子节点?好吧,等会再聊这个问题。反正我们自己插入的四条记录的min_rec_mask值都是0,意味着它们都不是B+树的非叶子节点中的最小记录。

  • n_owned

      这个暂时保密,稍后它是主角~

  • heap_no

      这个属性表示当前记录在本中的位置,从图中可以看出来,我们插入的4条记录在本中的位置分别是:2345。是不是少了点什么?是的,怎么不见heap_no值为01的记录呢?

      这其实是设计InnoDB的大佬们玩的一个小把戏,他们自动给每个页里边儿加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录,等一下~,记录可以比大小么?

      是的,记录也可以比大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。比方说我们插入的4行记录的主键值分别是:1234,这也就意味着这4条记录的大小从小到大依次递增。

  • 但是不管我们向中插入了多少自己的记录,设计InnoDB的大佬们都规定他们定义的两条伪记录分别为最小记录与最大记录。这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的,如图所示

      由于这两条记录不是我们自己定义的记录,所以它们并不存放在User Records部分,他们被单独放在一个称为Infimum + Supremum的部分,如图所示:

      从图中我们可以看出来,最小记录和最大记录的heap_no值分别是01,也就是说它们的位置最靠前。

  • record_type

      这个属性表示当前记录的类型,一共有4种类型的记录,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录。从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type值都是0,而最小记录和最大记录的record_type值分别为23

      至于record_type1的情况,我们之后在说索引的时候会重点强调的。

  • next_record

      这玩意儿非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的next_record值为32,意味着从第一条记录的真实数据的地址处向后找32个字节便是下一条记录的真实数据。如果你熟悉数据结构的话,就立即明白了,这其实是个链表,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录) ,为了更形象的表示一下这个next_record起到的作用,我们用箭头来替代一下next_record中的地址偏移量:

      从图中可以看出来,我们的记录按照主键从小到大的顺序形成了一个单链表。最大记录next_record的值为0,这也就是说最大记录是没有下一条记录了,它是这个单链表中的最后一个节点。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:

    mysql> DELETE FROM page_demo WHERE c1 = 2;
    Query OK, 1 row affected (0.02 sec)Copy to clipboardErrorCopied
    复制代码

      删掉第2条记录后的示意图就是:

      从图中可以看出来,删除第2条记录前后主要发生了这些变化:

    • 第2条记录并没有从存储空间中移除,而是把该条记录的delete_mask值设置为1

    • 第2条记录的next_record值变为了0,意味着该记录没有下一条记录了。

    • 第1条记录的next_record指向了第3条记录。

    • 还有一点你可能忽略了,就是最大记录n_owned值从5变成了4,关于这一点的变化我们稍后会详细说明的。

        所以,不论我们怎么对页中的记录做增删改操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。

      提示:你会不会觉得next_record这个指针有点儿怪,为什么要指向记录头信息和真实数据之间的位置呢?为什么不干脆指向整条记录的开头位置,也就是记录的额外信息开头的位置呢?因为这个位置刚刚好,向左读取就是记录头信息,向右读取就是真实数据。我们前面还说过变长字段长度列表、NULL值列表中的信息都是逆序存放,这样可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,可能会提高高速缓存的命中率。当然如果你看不懂这句话的话就不要勉强了,果断跳过

        再来看一个有意思的事情,因为主键值为2的记录被我们删掉了,但是存储空间却没有回收,如果我们再次把这条记录插入到表中,会发生什么事呢?

      mysql> INSERT INTO page_demo VALUES(2, 200, 'bbbb');
      Query OK, 1 row affected (0.00 sec)Copy to clipboardErrorCopied
      复制代码

        我们看一下记录的存储情况:

  从图中可以看到,InnoDB并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间。

提示:当数据页中存在多条被删除掉的记录时,这些记录的next_record属性将会把这些被删除掉的记录组成一个垃圾链表,以备之后重用这部分存储空间。

3.2.4 记录的真实记录

对于record_format_demo表来说,记录的真实数据除了c1c2c3c4这几个我们自己定义的列的数据以外,MySQL会为每个记录默认的添加一些列(也称为隐藏列),具体的列如下:

列名 是否必须 占用空间 描述
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。

  这里需要提一下InnoDB表对主键的生成策略:

  • 优先使用用户自定义主键作为主键,
  • 如果用户没有定义主键,则选取一个Unique键作为主键,
  • 如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。

所以我们从上表中可以看出:InnoDB存储引擎会为每条记录都添加 transaction_id 和 roll_pointer 这两个列,但是 row_id 是可选的(在没有自定义主键以及Unique键的情况下才会添加该列)。这些隐藏列的值不用我们操心,InnoDB存储引擎会自己帮我们生成的。

  因为表record_format_demo并没有定义主键,所以MySQL服务器会为每条记录增加上述的3个列。现在看一下加上记录的真实数据的两个记录长什么样吧:

  看这个图的时候我们需要注意几点:

  1. record_format_demo使用的是ascii字符集,所以0x61616161就表示字符串'aaaa'0x626262就表示字符串'bbb',以此类推。
  2. 注意第1条记录中c3列的值,它是CHAR(10)类型的,它实际存储的字符串是:'cc',而ascii字符集中的字节表示是'0x6363',虽然表示这个字符串只占用了2个字节,但整个c3列仍然占用了10个字节的空间,除真实数据以外的8个字节的统统都用空格字符填充,空格字符在ascii字符集的表示就是0x20
  3. 注意第2条记录中c3c4列的值都为NULL,它们被存储在了前面的NULL值列表处,在记录的真实数据处就不再冗余存储,从而节省存储空间。

举例1:分析Compact行记录的内部结构: 

CREATE TABLE mytest( 

col1 VARCHAR(10), 

col2 VARCHAR(10), 

col3 CHAR(10), 

col4 VARCHAR(10) 

)ENGINE=INNODB CHARSET=LATIN1 ROW_FORMAT=COMPACT; 

INSERT INTO mytest 

VALUES('a','bb','bb','ccc'); 

INSERT INTO mytest 

VALUES('d','ee','ee','fff');  

INSERT INTO mytest 

VALUES('d',NULL,NULL,'fff'); 
复制代码

  

在Windows操作系统下,可以选择通过程序UltraEdit打开表空间文件mytest.ibd这个二进制文件。内容如下: 


0000c070 73 75 70 72 65 6d 75 6d  03 02 01  00   00 00 10 00 |supremum........| 

0000c080  2c  00 00 00 2b 68 00   00 00 00 00 06 05   80 00 00 |,...+h..........| 

0000c090  00 32 01 10   61  62 62   62 62 20 20 20 20 20 20 20 |.2..abbbb| 

0000c0a0  20   63 63 63  03 02 01 00 00 00 18 00 2b 00 00 00|ccc........+...| 

0000c0b0 2b 68 01 00 00 00 00 06 06 80 00 00 00 32 01 10|+h...........2..| 

0000c0c0 64 65 65 65 65 20 20 20 20 20 20 20 20 66 66 66|deeeefff| 

0000c0d0  03 01   06   00 00 20 ff 98   00 00 00 2b 68 02   00 00 |..........+h...| 

0000c0e0  00 00 06 07   80 00 00 00 32 01 10   64   **66 66 66 ** 00|........2..dfff.| 


该行记录从0000c078开始,若整理一下,相信大家会有更好的理解: 

--------------------------------------------------------------------- 

03 02 01                     /*变长字段长度列表,逆序*/ 

00                              /*NULL标志位,第一行没有NULL值*/ 

00 00 10 00 2c            /*Record Header,固定5字节长度*/ 

00 00 00 2b 68 00       /*RowID InnoDB自动创建,6字节*/ 

00 00 00 00 06 05       /*TransactionID*/ 

80 00 00 00 32 01 10   /*Roll Pointer*/ 

61                               /*列1数据'a'*/ 

62 62                          /*列2数据'bb'*/ 

62 62 20 20 20 20 20 20 20 20/*列3数据'bb'*/ 

63 63 63                     /*列4数据'ccc'*/ 

--------------------------------------------------------------------- 
复制代码

注意1:InnoDB每行有隐藏列TransactionID和Roll Pointer

注意2:固定长度CHAR字段在未能完全占用其长度空间时,会用0x20来进行填充

   接着再来分析下Record Header的最后两个字节,这两个字节代表next_recorder,0x2c代表下一个记录的偏移量,即当前记录的位置加上偏移量0x2c就是下条记录的起始位置。 

第二行将不做整理,除了RowID不同外,它和第一行大同小异,现在来分析有NULL值的第三行: 

--------------------------------------------------------------------- 

03 01                               /*变长字段长度列表,逆序*/ 

06                                   /*NULL标志位,第三行有NULL值*/ 

00 00 20 ff 98                  /*Record Header*/ 

00 00 00 2b 68 02           /*RowID*/ 

00 00 00 00 06 07           /*TransactionID*/ 

80 00 00 00 32 01 10       /*Roll Pointer*/ 

64                                   /*列1数据'd'*/ 

66 66 66                         /*列4数据'fff'*/ 

--------------------------------------------------------------------- 
复制代码

第三行有NULL值,因此NULL标志位不再是00而是06,转换成二进制为00000110,为1的值代表第2列和第3列的数据为NULL。在其后存储列数据的部分,用户会发现没有存储NULL列,而只存储了第1列和第4列非NULL的值。 

因此这个例子很好地说明了:不管是CHAR类型还是VARCHAR类型,在compact格式下NULL值都不占用任何存储空间

CHAR(M)列的存储格式

  record_format_demo表的c1c2c4列的类型是VARCHAR(10),而c3列的类型是CHAR(10),我们说在Compact行格式下只会把变长类型的列的长度逆序存到变长字段长度列表中,就像这样:

  但是这只是因为我们的record_format_demo表采用的是ascii字符集,这个字符集是一个定长字符集,也就是说表示一个字符采用固定的一个字节,如果采用变长的字符集(也就是表示一个字符需要的字节数不确定,比如gbk表示一个字符要1~2个字节utf8表示一个字符要1~3个字节等)的话,c3列的长度也会被存储到变长字段长度列表中,比如我们修改一下record_format_demo表的字符集:

mysql> ALTER TABLE record_format_demo MODIFY COLUMN c3 CHAR(10) CHARACTER SET utf8;
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0Copy to clipboardErrorCopied
复制代码

  修改该列字符集后记录的变长字段长度列表也发生了变化,如图:

  这就意味着:对于 CHAR(M) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表

  另外有一点还需要注意,变长字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)却没有这个要求。比方说对于使用utf8字符集的CHAR(10)的列来说,该列存储的数据字节长度的范围是10~30个字节。即使我们向该列中存储一个空字符串也会占用10个字节,这是怕将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。(这里你感受到设计Compact行格式的大佬既想节省存储空间,又不想更新CHAR(M)类型的列产生碎片时的纠结心情了吧。)

3.3 Redundant 行格式

其实知道了Compact行格式之后,其他的行格式就是依葫芦画瓢了。我们现在要介绍的Redundant行格式是MySQL5.0之前用的一种行格式,也就是说它已经非常老了,但是本着知识完整性的角度还是要提一下,大家乐呵乐呵的看就好。

  画个图展示一下Redundant行格式的全貌:

  现在我们把表record_format_demo的行格式修改为Redundant

mysql> ALTER TABLE record_format_demo ROW_FORMAT=Redundant;
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0Copy to clipboardErrorCopied
复制代码

  为了方便大家理解和节省篇幅,我们直接把表record_format_demoRedundant行格式下的两条记录的真实存储数据提供出来,之后我们着重分析两种行格式的不同即可。

  下面我们从各个方面看一下Redundant行格式有什么不同的地方:

3.3.1 字段长度偏移列表

  注意Compact行格式的开头是变长字段长度列表,而Redundant行格式的开头是字段长度偏移列表,与变长字段长度列表有两处不同:

  • 没有了变长两个字,意味着Redundant行格式会把该条记录中所有列(包括隐藏列)的长度信息都按照逆序存储到字段长度偏移列表

  • 多了个偏移两个字,这意味着计算列值长度的方式不像Compact行格式那么直观,它是采用两个相邻数值的差值来计算各个列值的长度。

    比如第一条记录的字段长度偏移列表就是:

    25 24 1A 17 13 0C 06Copy to clipboardErrorCopied
    复制代码

    因为它是逆序排放的,所以按照列的顺序排列就是:

    06 0C 13 17 1A 24 25Copy to clipboardErrorCopied
    复制代码

    按照两个相邻数值的差值来计算各个列值的长度的意思就是:

    第一列(`row_id`)的长度就是 0x06个字节,也就是6个字节。
    第二列(`transaction_id`)的长度就是 (0x0C - 0x06)个字节,也就是6个字节。
    第三列(`roll_pointer`)的长度就是 (0x13 - 0x0C)个字节,也就是7个字节。
    第四列(`c1`)的长度就是 (0x17 - 0x13)个字节,也就是4个字节。
    第五列(`c2`)的长度就是 (0x1A - 0x17)个字节,也就是3个字节。
    第六列(`c3`)的长度就是 (0x24 - 0x1A)个字节,也就是10个字节。
    第七列(`c4`)的长度就是 (0x25 - 0x24)个字节,也就是1个字节。
    复制代码

3.3.2 记录头信息(record header)

Redundant行格式的记录头信息占用6字节,48个二进制位,这些二进制位代表的意思如下:

|         名称        | 大小(单位: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`    |            表示下一条记录的相对位置            |
|                   |            |                                    |
|     第一条记录中的头信息是:  |            |                                    |

```
00 00 10 0F 00 BCCopy to clipboardErrorCopied
```

  根据这六个字节可以计算出各个属性的值,如下:

```
预留位1:0x00
预留位2:0x00
delete_mask: 0x00
min_rec_mask: 0x00
n_owned: 0x00
heap_no: 0x02
n_field: 0x07
1byte_offs_flag: 0x01
next_record:0xBCCopy to clipboardErrorCopied
```

  与`Compact`行格式的记录头信息对比来看,有两处不同:

1.  Redundant 行格式多了 n_field 和 1byte_offs_flag 这两个属性。
1.  Redundant 行格式没有 record_type 这个属性。
复制代码
  • 1byte_offs_flag的值是怎么选择的

      字段长度偏移列表实质上是存储每个列中的值占用的空间在记录的真实数据处结束的位置,还是拿record_format_demo第一条记录为例,0x06代表第一个列在记录的真实数据第6个字节处结束,0x0C代表第二个列在记录的真实数据第12个字节处结束,0x13代表第三个列在记录的真实数据第19个字节处结束,等等等等,最后一个列对应的偏移量值为0x25,也就意味着最后一个列在记录的真实数据第37个字节处结束,也就意味着整条记录的真实数据实际上占用37个字节。

      我们前面说过每个列对应的偏移量可以占用1个字节或者2个字节来存储,那到底什么时候用1个字节,什么时候用2个字节呢?其实是根据该条Redundant行格式记录的真实数据占用的总大小来判断的:

    • 当记录的真实数据占用的字节数不大于127(十六进制0x7F,二进制01111111)时,每个列对应的偏移量占用1个字节。

      提示:如果整个记录的真实数据占用的存储空间都不大于127个字节,那么每个列对应的偏移量值肯定也就不大于127,也就可以使用1个字节来表示喽。

    • 当记录的真实数据占用的字节数大于127,但不大于32767(十六进制0x7FFF,二进制0111111111111111)时,每个列对应的偏移量占用2个字节。

    • 有没有记录的真实数据大于32767的情况呢?有,不过此时的记录已经存放到了溢出页中,在本页中只保留前768个字节和20个字节的溢出页面地址(当然这20个字节中还记录了一些别的信息)。因为字段长度偏移列表处只需要记录每个列在本页面中的偏移就好了,所以每个列使用2个字节来存储偏移量就够了。

        大家可以看出来,设计Redundant行格式的大佬还是比较简单粗暴的,直接使用整个记录的真实数据长度来决定使用1个字节还是2个字节存储列对应的偏移量。只要整条记录的真实数据占用的存储空间大小大于127,即使第一个列的值占用存储空间小于127,那对不起,也需要使用2个字节来表示该列对应的偏移量。简单粗暴,就是这么简单粗暴(所以这种行格式有些过时了~)。

      提示:大家有没有疑惑,一个字节能表示的范围是0~255,为什么在记录的真实数据占用的存储空间大于127时就采用2个字节表示各个列的偏移量呢?稍安勿躁,后边马上揭晓。

        为了在解析记录时知道每个列的偏移量是使用1个字节还是2个字节表示的,设计Redundant行格式的大佬特意在记录头信息里放置了一个称之为1byte_offs_flag的属性:

    • 当它的值为1时,表明使用1个字节存储。

    • 当它的值为0时,表明使用2个字节存储。

  • Redundant行格式中NULL值的处理

      因为Redundant行格式并没有NULL值列表,所以设计Redundant行格式的大佬在字段长度偏移列表中的各个列对应的偏移量处做了一些特殊处理 —— 将列对应的偏移量值的第一个比特位作为是否为NULL的依据,该比特位也可以被称之为NULL比特位。也就是说在解析一条记录的某个列时,首先看一下该列对应的偏移量的NULL比特位是不是为1,如果为1,那么该列的值就是NULL,否则不是NULL

      这也就解释了上面介绍为什么只要记录的真实数据大于127(十六进制0x7F,二进制01111111)时,就采用2个字节来表示一个列对应的偏移量,主要是第一个比特位是所谓的NULL比特位,用来标记该列的值是否为NULL

      但是还有一点要注意,对于值为NULL的列来说,该列的类型是否为定长类型决定了NULL值的实际存储方式,我们接下来分析一下record_format_demo表的第二条记录,它对应的字段长度偏移列表如下:

    A4 A4 1A 17 13 0C 06Copy to clipboardErrorCopied
    复制代码

      按照列的顺序排放就是:

    06 0C 13 17 1A A4 A4Copy to clipboardErrorCopied
    复制代码

    我们分情况看一下:

    • 如果存储NULL值的字段是定长类型的,比方说CHAR(M)数据类型的,则NULL值也将占用记录的真实数据部分,并把该字段对应的数据使用0x00字节填充。

        如图第二条记录的c3列的值是NULL,而c3列的类型是CHAR(10),占用记录的真实数据部分10字节,所以我们看到在Redundant行格式中使用0x00000000000000000000来表示NULL值。

        另外,c3列对应的偏移量为0xA4,它对应的二进制实际是:10100100,可以看到最高位为1,意味着该列的值是NULL。将最高位去掉后的值变成了0100100,对应的十进制值为36,而c2列对应的偏移量为0x1A,也就是十进制的2636 - 26 = 10,也就是说最终c3列占用的存储空间为10个字节。

    • 如果该存储NULL值的字段是变长数据类型的,则不在记录的真实数据处占用任何存储空间。

        比如record_format_demo表的c4列是VARCHAR(10)类型的,VARCHAR(10)是一个变长数据类型,c4列对应的偏移量为0xA4,与c3列对应的偏移量相同,这也就意味着它的值也为NULL,将0xA4的最高位去掉后对应的十进制值也是3636 - 36 = 0,也就意味着c4列本身不占用任何记录的实际数据处的空间。

        除了以上的几点之外,Redundant行格式和Compact行格式还是大致相同的。

CHAR(M)列的存储格式

  我们知道Compact行格式在CHAR(M)类型的列中存储数据的时候还挺麻烦,分变长字符集和定长字符集的情况,而在Redundant行格式中十分干脆,不管该列使用的字符集是什么,只要是使用CHAR(M)类型,占用的真实数据空间就是该字符集表示一个字符最多需要的字节数和M的乘积。比方说使用utf8字符集的CHAR(10)类型的列占用的真实数据空间始终为30个字节,使用gbk字符集的CHAR(10)类型的列占用的真实数据空间始终为20个字节。由此可以看出来,使用Redundant行格式的CHAR(M)类型的列是不会产生碎片的。

3.4 行溢出数据

3.4.1 VARCHAR(M)最多能存储的数据

  我们知道对于VARCHAR(M)类型的列最多可以占用65535个字节。其中的M代表该类型最多存储的字符数量,如果我们使用ascii字符集的话,一个字符就代表一个字节,我们看看VARCHAR(65535)是否可用:

mysql> CREATE TABLE varchar_size_demo(
    ->     c VARCHAR(65535)
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs
mysql>Copy to clipboardErrorCopied
复制代码

  从报错信息里可以看出,MySQL对一条记录占用的最大存储空间是有限制的,除了BLOB或者TEXT类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过``65535个字节。所以MySQL服务器建议我们把存储类型改为TEXT或者BLOB的类型。这个65535个字节除了列本身的数据之外,还包括一些其他的数据(storage overhead),比如说我们为了存储一个VARCHAR(M)类型的列,其实需要占用3部分存储空间:

  • 真实数据
  • 真实数据占用字节的长度
  • NULL值标识,如果该列有NOT NULL属性则可以没有这部分存储空间

  如果该VARCHAR类型的列没有NOT NULL属性,那最多只能存储65532个字节的数据,因为真实数据的长度可能占用2个字节,NULL值标识需要占用1个字节:

mysql> CREATE TABLE varchar_size_demo(
    ->      c VARCHAR(65532)
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.02 sec)
复制代码

  如果VARCHAR类型的列有NOT NULL属性,那最多只能存储65533个字节的数据,因为真实数据的长度可能占用2个字节,不需要NULL值标识:

mysql> DROP TABLE varchar_size_demo;
Query OK, 0 rows affected (0.01 sec)

mysql> CREATE TABLE varchar_size_demo(
    ->      c VARCHAR(65533) NOT NULL
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.02 sec)
复制代码

  如果VARCHAR(M)类型的列使用的不是ascii字符集,那会怎么样呢?来看一下:

mysql> DROP TABLE varchar_size_demo;
Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE varchar_size_demo(
    ->       c VARCHAR(65532)
    -> ) CHARSET=gbk ROW_FORMAT=Compact;
ERROR 1074 (42000): Column length too big for column 'c' (max = 32767); use BLOB or TEXT instead

mysql> CREATE TABLE varchar_size_demo(
    ->       c VARCHAR(65532)
    -> ) CHARSET=utf8 ROW_FORMAT=Compact;
ERROR 1074 (42000): Column length too big for column 'c' (max = 21845); use BLOB or TEXT insteadCopy to clipboardErrorCopied
复制代码

  从执行结果中可以看出,如果VARCHAR(M)类型的列使用的不是ascii字符集,那M的最大取值取决于该字符集表示一个字符最多需要的字节数。在列的值允许为NULL的情况下,gbk字符集表示一个字符最多需要2个字节,那在该字符集下,M的最大取值就是32766(也就是:65532/2),也就是说最多能存储32766个字符;utf8字符集表示一个字符最多需要3个字节,那在该字符集下,M的最大取值就是21844,就是说最多能存储21844(也就是:65532/3)个字符。

小贴士:上述所言在列的值允许为NULL的情况下,gbk字符集下M的最大取值就是32766,utf8字符集下M的最大取值就是21844,这都是在表中只有一个字段的情况下说的,一定要记住一个行中的所有列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节!

3.4.2 记录中的数据太多产生的溢出

  我们以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)
复制代码

  其中的REPEAT('a', 65532)是一个函数调用,它表示生成一个把字符'a'重复65532次的字符串。前面说过,MySQL中磁盘和内存交互的基本单位是,也就是说MySQL是以为基本单位来管理存储空间的,我们的记录都会被分配到某个中存储。而一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65532个字节,这样就可能造成一个页存放不了一条记录的尴尬情况。

  在CompactReduntant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页,如图所示:

  从图中可以看出来,对于CompactReduntant行格式来说,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做行溢出,存储超出768字节的那些页面也被称为溢出页。画一个简图就是这样:

  最后需要注意的是,不只是 VARCHAR(M) 类型的列,其他的 TEXTBLOB  类型的列在存储数据非常多的时候也会发生行溢出

3.4.3 行溢出的临界点

  那发生行溢出的临界点是什么呢?也就是说在列存储多少字节的数据时就会发生行溢出

  MySQL中规定一个页中至少存放两行记录,至于为什么这么规定我们之后再说,现在看一下这个规定造成的影响。以上面的varchar_size_demo表为例,它只有一个列c,我们往这个表中插入两条记录,每条记录最少插入多少字节的数据才会行溢出的现象呢?这得分析一下页中的空间都是如何利用的。

  • 每个页除了存放我们的记录以外,也需要存储一些额外的信息,乱七八糟的额外信息加起来需要136个字节的空间(现在只要知道这个数字就好了),其他的空间都可以被用来存储记录。

  • 每个记录需要的额外信息是27字节。

    这27个字节包括下面这些部分:

    • 2个字节用于存储真实数据的长度
    • 1个字节用于存储列是否是NULL值
    • 5个字节大小的头信息
    • 6个字节的row_id
    • 6个字节的transaction_id
    • 7个字节的roll_pointer

  假设一个列中存储的数据字节数为n,那么发生行溢出现象时需要满足这个式子:

136 + 2×(27 + n) > 16384
复制代码

  求解这个式子得出的解是:n > 8098。也就是说如果一个列中存储的数据不大于8098个字节,那就不会发生行溢出,否则就会发生行溢出。不过这个8098个字节的结论只是针对只有一个列的varchar_size_demo表来说的,如果表中有多个列,那上面的式子和结论都需要改一改了,所以重点就是:你不用关注这个临界点是什么,只要知道如果我们向一个行中存储了很大的数据时,可能发生行溢出的现象。

3.5 Dynamic和Compressed行格式

Compact和Reduntant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中进行 分页存储 ,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页。 

这称为页的扩展,举例如下:

Compressed行格式和Dynamic不同的一点是,Compressed行格式会采用压缩算法对页面进行压缩,以节省空间。

MySQL 8.0中,默认行格式就是Dynamic, Dynamic、Compressed行格式 和Compact行格式挺像,只不过在处理行溢出数据时有分歧: 

  • Compressed和Dynamic两种记录格式对于存放在BLOB中的数据采用了完全的行溢出的方式。如图,在数据页中只存放20个字节的指针(溢出页的地址),实际的数据都存放在Off Page(溢出页)中。 

  • Compact和Redundant两种格式会在记录的真实数据处存储一部分数据(存放768个前缀字节)。 

Compressed行记录格式的另一个功能就是,存储在其中的行数据会以zlib的算法进行压缩,因此对于BLOB、TEXT、VARCHAR这类大长度类型的数据能够进行非常有效的存储。

4. 区、段与碎片区

4.1 为什么要有区?

B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页 之间的物理位置可能离得非常远.我们介绍B+树索引的适用场景的时候特别提到范围查询只需要定位到最左边的 记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远, 就是所谓的随机I/O。再一次强调,磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置也相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O

引入的概念,一个区就是在物理位置上连续的64个页。因为InnoDB中的页大小默认是16KB,所以一个区的 大小是64 * 6KB=1MB。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是 按照区为单位分配,甚至在表中的数据特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间 的浪费(数据不足以填充满整个区),但是从性能角度看,可以消除很多的随机I/O。功大于过!

4.2 为什么要有段?

对于范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节 点代表的页面放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点非 叶子节点进行了区别对待,也就是说叶子节点有自己独有的区非叶子节点也有自己独有的区存放叶子节点的 区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个 段,一个叶子节点段,一个非叶子节点段

除了索引的叶子节点段和非叶子节点段之外,InnoDB中还有为存储一些特殊的数据而定义的段,比如回滚段。所 以,常见的段有数据段、索引段、回滚段。数据段即为B+树的叶子节点,索引段即为B+树的非叶子节点。

在InnoDB存储引擎中,对段的管理都是由引擎自身所完成,DBA不能也没有必要对其进行控制。应从一定程度上 简化了 DBA对于段的管理。

段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念,由若干个零散的页面以及一些完整的区。

4.3 为什么要有碎片区?

默认情况下,一个使用InnoDB存储引擎的表只有一个聚簇索引,一个索引会生成2个段,而段是以区为单位申请 存储空间的,一个区默认占用1M (64* 16Kb = 1024Kb)存储空间,所以默认情况下一个只存了几条记录的小表也 需要2M的存储空间么?以后每次添加一个索引都要多申请2M的存储空间么?这对于存储记录比较少的表简直是 天大的浪费。这个问题的症结在于到现在为止我们介绍的区都是非常纯粹的,也就是一个区被整个分配给某一个 段,或者说区中的所有页面都是为了存储同一个段的数据而存在的,即使段的数据填不满区中所有的页面,那余 下的页面也不能挪作他用。

为了考虑以完整的区为单位分配给某个段对于数据量较小的表太浪费存储空间的这种情况,InnoDB提出了一个碎 片(fragment)区的概念。在一个碎片区中,并不是所有的页都是为了存储同一个段的数据而存在的,而是碎片 区中的页可以用于不同的目的,比如有些页用于段A,有些页用于段B,有些页甚至哪个段都不属于。碎片区直属 于表空间,并不属于任何一个段。

所以此后为某个段分配存储空间的策略是这样的:

  • 在刚开始向表中插入数据的时候,段是从某个碎片区以单个页面为单位来分配存储空间的。
  • 当某个段已经占用了 32个碎片区页面之后,就会申请以完整的区为单位来分配存储空间。I

所以现在段不能仅定义为是某些区的集合,更精确的应该是某些零散的页面以及一些完整的区的集合

4.4 区的分类

区大体上可以分为4种类型:

  • 空闲的区(FREE):现在还没有用到这个区中的任何页面.
  • 有剩余空间的碎片区(FREE_FRAG):表示碎片区中还有可用的页面.
  • 没剩余空间的碎片区(FULL_FRAG):表示碎片区中的所有页面都被使用,没有空闲页面.
  • 附属于某个段的区(FSEG):每一个索引都可以分为叶子节点段AHE叶子节点段.

处于FREE、FREE_FRAG以及FULL_FRAG这三种状态的区都是独立的,直属于表空间.而处于FSEG状态的区是 附属于某个段的.

如果把表空间比作是一个集团军,段就相争于师,区就相当于团.一般的团都是梨JS于某个师的,就像是处 于FSEG的区全都隶属于某个段,而处于FREE、FREE.FRAG以及FULL.FRAG这三种状态的区却直接隶属于 表空间,就像独立团直接听命于军部一样.

5. 表空间

表空间可以看做是InnoDB存储引擎逻辑结构的最高层,所有的数据都存放在表空间中。

表空间是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一 个表空间。表空间数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间(System tablespace)独立表空间(File-per-table tablespace)撤销表空间(Undo Tablespace)临时表空间 (Temporary Tablespace)等。

5.1 独立表空间

独立表空间,即每张表有一个独立的表空间,也就是数据和索引信息都会保存在自己的表空间中。独立的表空间 (即:单表)可以在不同的数据库之间进行迁移

空间可以回收(DROP TABLE操作可自动回收表空间;其他情况,表空间不能自己回收)。如果对于统计分析或 是日志表,删除大量数据后可以通过:alter table TableName engine=innodb;[回收不用的空间。对于使用 独立表空间的表,不管怎么删除,表空间的碎片不会太严重的影响性能,而且还有机会处理。

独立表空间结构

独立表空间由段、区、页组成。前面已经讲解过了。

真实表空间对应的文件大小

我们到数据目录里看,会发现新建的表对应的.ibd文件只占用了 96K,才6个页面大小(MySQL5.7中),这 是因为一开始表空间占用的空间很小,因为表里边都没有数据。不过别忘了这些.ibd文件是自扩展的,随着表中 数据的增多,表空间对应的文件也逐渐增大。

查看InnoDB 的表空间类型:

mysql > show variables like 'innodb_file_per_table';
复制代码

image.png 你能看到innodb_file_per_table=ON,这就意味着每张表都会单独保存为一个.ibd文件。

5.2系统表空间

系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会 额外记录一些有关整个系统信息的页面,这部分是独立表空间中没有的。

InnoDB数据字典
I 每当我们向一个表中插入一条记录的时候,MySQL校验过程如下:

先要校验一下插入语句对应的表存不存在,插入的列和表中的列是否符合,如果语法没有问题的话,还需要知道 该表的聚簇索引和所有二级索引对应的根页面是哪个表空间的哪个页面,然后把记录插入对应索引的B+树中。所 以说,MySQL除了保存着我们插入的用户数据之外,还需要保存许多额外的信息,比方说:

  • 某个表属下哪个表空间,表里边有多少列
  • 表对应的每一个列的类型是什么
  • 该表有多少索引,每个索引对应哪几个字段,该索引对应的根页面在哪个表空间的哪个页面
  • 该表行哪些外键,外键对应哪个表的哪些列
  • 某个表空间对应文件系统上.文件路径是什么

上述这些数据并不是我们使用工NSERT语句插入的用户数据,实际上是为了更好的管理我们这些用户数据而不得 已引入的一些额外数据,这些数据也称为元数据。InnoDB存储引擎特意定义了一些列的内部系统表(internal system table)来记录这些这些元数据:

表名 描述
SYS.TABLES 整个InnoDB存储引擎中所有的表的信息
SYS_COLUMNS 整个1 nnoDB存储引擎中所有的列的信息
SYS_INDEXES 整个InnoDB存储引擎中所有的索引的信息
SYS.FIELDS 整个InnoDB存储引擎中所有的索引对应的列的信息
SYS_FOREIGN 整个InnoDB存储引擎中所有的外键的信息
SYS_FOREIGN_COLS 整个1 nnoDB存储引擎中所有的夕隧对应列的信息
SYS_TABLESPACES 整个InnoDB存储引擎中所有的表空间信息
SYS.DATAFILES 整个InnoDB存储引擎中所有的表空间对应文件系统的文件路径信息
SYS_VIRTUAL 整个InnoDB存储引擎中所有的虚拟生成列的信息

这些系统表也被称为数据字典,它们都是以B+树的形式保存在系统表空间的某些页面中,其中SYS_TABLESSYS_COLUMNSSYS_INDEXES SYS_F工ELDS 这四个表尤其重要]称之为基本系统表(basic system tables), 我们先看看这4个表的结构:

SYS_TABLES 表结构

列名 描述
NAME 表的名称。主键
ID InnoDB存储引擎中每个表都有一个唯一的ID。(二级索引)
N_C0LS 该表拥有列的个数
TYPE 表的类型,记录了一些文件格式、行格式、压缩等信息
MIX_ID 已过时,忽略
MIX_LEN 表的一些额外的属性
CLUSTERED 未使用,忽略
SPACE 该表所属表空间的ID

SYSJZOLUMNS表结构

列名 描述
TABLE.ID 该列所属表对应的ID。(与POS一起构成联合主键)
POS 该列在表中是第几列
NAME 该列的名称
MTYPE main data type,主数据类型,就是那堆INT、CHAR、VARCHAR. FLOAT、DOUBLE之类的东东
PRTYPE precise type,精确数据类型,就是修饰主数据类型的那堆东东,比如是否允许NU山直,是否允许负数啥的
LEN 该列最多占用存储空间的字节数
PREC 该列的精度,不过这列貌似都没有使用,默认值都是0

SYS_INDEXES表结构

列名 描述
TABLE_ID 该索引所属表对应的ID。(与工D一起构成联合主键)
ID InnoDB存储引擎中每个索引都有一个唯一的ID
NAME 该索引的名称
N_FIELDS 该索引包含列的个数
TYPE 该索引的类型,比如聚簇索引、唯一索引、更改缓冲区的索引、全文索引、普通的二级索引等等各种类型
SPACE 该索引根页面所在的表空间ID
PAGE_NO 该索引根页面所在的页面号
MERGE_THRESHOLD 如果页面中的记录被删除到某个比例,就把该页面和相邻页面合并,这个值就是这个比例

SYS_FIELDS 表结构

列名 描述
INDEX_ID 该索引列所属的索引的ID。(与POS一起构成联合主键)
POS 该索引列在某个索引中星第几列
COL_NAME 该索引列的名称

注意:用户是不能直接访问InnoDB的这些内部系统表,除非你直接去解析系统表空间对应文件系统上的文件。不 过考虑到查看这些表的内容可能有助于大家分析问题,所以在系统数据库information_schema中提供了一些 以innodb.sys开头的表:

image.png

information_schema数据库中的这些以INNODB_SYS开头的表并不是真正的内部系统表(内部系统表就是我 们上边以SYS开头的那些表),而是在存储引擎启动时读取这些以SYS开头的系统表,然后填充到这些以 工NNODB_SYS开头的表中。以工INNODB_SYS开头的表和以SYS开头的表中的字段并不完全一样,但供大家参考已经足矣。

附录:数据页加载的三种方式

InnoDB从磁盘中读取数据的最小单位是数据页。而你想得到的id=xxx的数据,就是这个数据页众多行中的一行。 对于MySQL存放的数据,逻辑概念上我们称之为表,在磁盘等物理层面而言是按数据页形式进行存放的,当其加 载到MySQL中我们称之为缓存页

如果缓冲池中没有该页数据,那么缓冲池有以下三种读取数据的方式,每种方式的读取效率都是不同的:

1. 内存读取

如果该数据存在于内存中,基本上执行时间在1ms左右,效率还是很高的工

image.png

2. 随机读取

如果数据没有在内存中,就需要在磁盘上对该页进行查找,整体时间预估在10ms左右,这10ms中有6ms是磁 盘的实际繁忙时间(包括了寻道和半圈旋转时间),有3ms是对可能发生的排队时间的估计值,另外还有1ms的传输时间,将页从施服务器缓冲区传输到数据库缓冲区中。这10ms看起来很快,但实际上对于数据库来说消 耗的时间已经非常长了,因为这还只是一个页的读取时间。

image.png

3. 顺序读取

顺序读取其实是一种批量读取的方式,因为我们请求的数据在磁盘上往往都是相邻存储的,顺序读取可以帮我们批 量读取页面,这样的话,一次性加载到缓冲池中就不需要再对其他页面单独进行磁盘I/O操作了。如果一个磁盘 的吞吐量是40MB/S,那虫对于一个16KB大小的页来说,一次可以顺序读取2560(40MB/16KB)个页,相当于一 个页的读取时间为0.4ms。采用批量读取的方式,即使是从磁盘上进行读取,效率也比从内存中只单独读取一个 页的效率要高。

参考文章

MySQL从入门到精通
MySQL是怎样运行的 从根儿上理解MySQL 第4、5和9章
《MySQL技术内幕:InnoDB存储引擎(第2版)》

《数据库索引设计与优化》

猜你喜欢

转载自juejin.im/post/7069006450265161764