欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈
1. 数据库的存储结构:页
索引结构给我们提供了高效的索引方式,不过索引信息以及数据记录都是保存在文件上的,确切说是存储在页结构中。另一方面,索引是在存储引擎中实现的,MySQL服务器的存储引擎
负责对表中数据的读取和写入工作。 不同存储引擎中存放的格式
一般是不同的,甚至有的存储引擎比如Memory的都不用磁盘来存储数据。
由于InnoDB
是MySQL
的默认存储引擎,所以本章剖析InnoDB存储引擎的数据存储结构。
1.1 磁盘与内存交互基本单位:页
InnoDB 将数据划分为若干个页,InnoDB中页的大小默认 16KB
。
以 页
作为磁盘和内存之间交互的 基本单位
,也就是一次最少从磁盘中读取16KB的内容到内存中,一次最少把内 存中的16KB内容刷新到磁盘中。也就是说,在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行 加载。也就是说,数据库管理存储空间的基本单位是页(Page) ,数据库I/O操作的最小单位是页。
一个页中可 以存储多个行记录。
记录是按照行来存储的,但是数据库的读取并不以行为单位,否则一次读取(也就是一次I/。操作)只能处 理一行数据,效率会非常低。
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)的概念。行、页、区、段、表 空间的关系如下图标:
区(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) ₒ 页结构的示意图如下所示:
这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_PREV
和FIL_PAGE_NEXT
各4字节我们前面强调过,
InnoDB
都是以页为单位存放数据的,有时候我们存放某种类型的数据占用的空间非常大(比方说一张表中可以有成千上万条记录),InnoDB
可能不可以一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV
和FIL_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部分:空闲、用户和最大最小记录
第二个部分是记录部分,页的主要作用是存储记录,所以“最大和最小记录”和“用户记录”部分占了页结构的主要空间。
2.2.1 Free Space 空闲记录
我们自己存储的记录会按照指定的 行格式
存储到 User Records
部分。但是在一开始生成页的时候,其实并没有User Records这个部分, 每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分
,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去 申请新的页
了。
2.2.2 User Records 用户记录
User Records中的这些记录按照 指定的行格式
一条一条摆在User Records部分,相互之间形成 单链表
。
用户记录里的一条条数据如何记录?
这里需要讲讲记录行格式的 记录头信息
。在下一节行格式中详细讲解。
2.2.3 Infimum + Supremum 最大最小记录 26字节
记录可以比较大小吗?
是的,记录可以比大小,对于一条完整的记录来说,比较记录的大小就是 比较主键
的大小。比方说我们插入的4行记录的主键值分别是:1、2、3、4,这也就意味着这4条记录是从小到大依次递增。
InnoDB规定的最小记录与最大记录这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的,如图所示:
这两条记录 不是我们自己定义的记录
,所以它们并不存放在页的User Records部分
,他们被单独放在一个称为Infimum + Supremum的部分
,如图所示:
2.3 Page Directory 页目录和Page Header页面头部
2.3.1 Page Directory 页目录
为什么需要页目录?
在页中,记录是以 单向链表
的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是 检索效率不高
,最差的情况下需要遍历链表上的所有节点才能完成检索。因此在页结构中专门设计了页目录这个模块
, 专门给记录做一个目录 ,通过 二分查找法
的方式进行检索,提升效率。
需求:根据主键值查找页中的某条记录,如何实现快速查找呢?
SELECT * FROM page_demo WHERE c1 = 3;
复制代码
-
方式1:
顺序查找
从
Infimum记录(最小记录)
开始,沿着链表一直往后找,总有一天会找到(或者找不到),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递增。
如果一个页中存储了非常多的记录,这么查找性能很差。
-
方式2:
使用页目录,二分法查找
-
将所有的记录
分成几个组
,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录。 -
第 1 组,也就是最小记录所在的分组只有 1 个记录;
最后一组,就是最大记录所在的分组,会有 1-8 条记录;
其余的组记录数量在 4-8 条之间。
这样做的好处是,除了第 1 组(最小记录所在组)以外,其余组的记录数会
尽量平分
。-
在每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为
n_owned
字段。 -
页目录用来存储每组最后一条记录的地址偏移量
,这些地址偏移量会按照先后顺序存储
起来,每组的地址偏移量也被称之为槽(slot)
,每个槽相当于指针指向了不同组的最后一个记录。
-
举例1:
举例2:
现在的page_demo表中正常的记录共有6条,InnoDB会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录。如下图:
从这个图中我们需要注意这么几点:
-
现在页目录部分中有两个槽,也就意味着我们的记录被分成了两个组,槽1中的值是112,代表最大记录的地址偏移量(就是从页面的0字节开始数,数112个字节);槽0中的值是99,代表最小记录的地址偏移量。
-
注意最小和最大记录的头信息中的
n_owned
属性- 最小记录的n_owned值为1,这就代表着以最小记录结尾的这个分组中只有1条记录,也就是最小记录本身。
- 最大记录的n_owned值为5,这就代表着以最大记录结尾的这个分组中只有5条记录,包括最大记录本身还有我们自己插入的4条记录。
用箭头指向的方式替代数字,这样更易于我们理解,修改后如下:
再换个角度看一下:(单纯从逻辑上看一下这些记录和页目录的关系)
页目录分组的个数如何确定?
为什么最小记录的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个组,如图所示:
因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以只保留了用户记录头信息中的n_owned
和next_record
属性,也省略了各个记录之间的箭头,我没画不等于没有啊!现在看怎么从这个页目录
中查找记录。因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法
来进行快速查找。4个槽的编号分别是:0
、1
、2
、3
、4
,所以初始情况下最低的槽就是low=0
,最高的槽就是high=4
。比方说我们想找主键值为6
的记录,过程是这样的:
- 计算中间槽的位置:
(0+4)/2=2
,所以查看槽2
对应记录的主键值为8
,又因为8 > 6
,所以设置high=2
,low
保持不变。 - 重新计算中间槽的位置:
(0+2)/2=1
,所以查看槽1
对应的主键值为4
,又因为4 < 6
,所以设置low=1
,high
保持不变。 - 因为
high - low
的值为1,所以确定主键值为5
的记录在槽2
对应的组中。此刻我们需要找到槽2
中主键值最小的那条记录,然后沿着单向链表遍历槽2
中的记录。但是我们前面又说过,每个槽对应的记录都是该组中主键值最大的记录,这里槽2
对应的记录是主键值为8
的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到槽1
对应的记录(主键值为4
),该条记录的下一条记录就是槽2
中主键值最小的记录,该记录的主键值为5
。所以我们可以从这条主键值为5
的记录出发,遍历槽2
中的各条记录,直到找到主键值为6
的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。
所以在一个数据页中查找指定主键值的记录的过程分为两步:
通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录
。- 通过记录的
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_SLOTS
到PAGE_LAST_INSERT
以及PAGE_N_RECS
的意思大家一定是清楚的,如果不清楚,对不起,你应该回头再看一遍前面的文章。剩下的状态信息看不明白不要着急,饭要一口一口吃,东西要一点一点学(一定要稍安勿躁哦,不要被这些名词吓到)。在这里我们先介绍一下PAGE_DIRECTION
和PAGE_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种不同类型的行格式
,分别是Compact
、Redundant
、Dynamic
和Compressed
行格式。
查看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行格式
。一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分。废话不多说,直接看图:
3.2.1 变长字段长度列表(1 or 2字节)
我们知道MySQL
支持一些变长的数据类型,比如VARCHAR(M)
、VARBINARY(M)
、各种TEXT
类型,各种BLOB
类型,我们也可以把拥有这些数据类型的列称为变长字段
,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来,这样才不至于把MySQL
服务器搞懵,所以这些变长字段占用的存储空间分为两部分:
- 真正的数据内容
- 占用的字节数
在Compact
行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放,我们再次强调一遍,是逆序存放
!
我们拿record_format_demo
表中的第一条记录来举个例子。因为record_format_demo
表的c1
、c2
、c4
列都是VARCHAR(10)
类型的,也就是变长的数据类型,所以这三个列的值的长度都需要保存在记录开头处,因为record_format_demo
表中的各个列都使用的是ascii
字符集,所以每个字符只需要1个字节来进行编码,来看一下第一条记录各变长字段内容的长度:
列名 | 存储内容 | 内容长度(十进制表示) | 内容长度(十六进制表示) |
---|---|---|---|
c1 |
'aaaa' |
4 |
0x04 |
c2 |
'bbb' |
3 |
0x03 |
c4 |
'd' |
1 |
0x01 |
又因为这些长度值需要按照列的逆序存放,所以最后变长字段长度列表
的字节串用十六进制表示的效果就是(各个字节之间实际上没有空格,用空格隔开只是方便理解):
01 03 04
复制代码
把这个字节串组成的变长字段长度列表
填入上面的示意图中的效果就是:
由于第一行记录中c1
、c2
、c4
列中的字符串都比较短,也就是说内容占用的字节数比较小,用1个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用2个字节来表示。具体用1个还是2个字节来表示真实数据占用的字节数,InnoDB
有它的一套规则,我们首先声明一下W
、M
和L
的意思:
- 假设某个字符集中表示一个字符最多需要使用的字节数为
W
,也就是使用SHOW CHARSET
语句的结果中的Maxlen
列,比方说utf8
字符集中的W
就是3
,gbk
字符集中的W
就是2
,ascii
字符集中的W
就是1
。 - 对于变长类型
VARCHAR(M)
来说,这种类型表示能存储最多M
个字符(注意是字符不是字节),所以这个类型能表示的字符串最多占用的字节数就是M×W
。 - 假设它实际存储的字符串占用的字节数是
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
,所以第二条记录的变长字段长度列表
只需要存储c1
和c2
列的长度即可。其中c1
列存储的值为'eeee'
,占用的字节数为4
,c2
列存储的值为'fff'
,占用的字节数为3
。数字4
可以用1个字节表示,3
也可以用1个字节表示,所以整个变长字段长度列表
共需2个字节。填充完变长字段长度列表
的两条记录的对比图如下:
小贴士:并不是所有记录都有这个
变长字段长度列表
部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。
3.2.2 NULL值列表
我们知道表中的某些列可能存储NULL
值,如果把这些NULL
值都放到记录的真实数据
中存储会很占地方,所以Compact
行格式把这些值为NULL
的列统一管理起来,存储到NULL
值列表中,它的处理过程是这样的:
-
首先统计表中允许存储
NULL
的列有哪些。我们前面说过,主键列、被
NOT NULL
修饰的列都是不可以存储NULL
值的,所以在统计的时候不会把这些列算进去。比方说表record_format_demo
的3个列c1
、c3
、c4
都是允许存储NULL
值的,而c2
列是被NOT NULL
修饰,不允许存储NULL
值。 -
如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了,否则将每个允许存储
NULL
的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:-
二进制位的值为
1
时,代表该列的值为NULL
。 -
二进制位的值为
0
时,代表该列的值不为NULL
。因为表
record_format_demo
有3个值允许为NULL
的列,所以这3个列和二进制位的对应关系就是这样:再一次强调,二进制位按照列的顺序逆序排列,所以第一个列
c1
和最后一个二进制位对应。
-
-
MySQL
规定NULL值列表
必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0
。表
record_format_demo
只有3个值允许为NULL
的列,对应3个二进制位,不足一个字节,所以在字节的高位补0
,效果就是这样:以此类推,如果一个表中有9个允许为
NULL
,那这个记录的NULL
值列表部分就需要2个字节来表示了。知道了规则之后,我们再返回头看表
record_format_demo
中的两条记录中的NULL值列表
应该怎么储存。因为只有c1
、c3
、c4
这3个列允许存储NULL
值,所以所有记录的NULL值列表
只需要一个字节。
-
对于第一条记录来说,
c1
、c3
、c4
这3个列的值都不为NULL
,所以它们对应的二进制位都是0
,画个图就是这样:所以第一条记录的
NULL值列表
用十六进制表示就是:0x00
。 -
对于第二条记录来说,
c1
、c3
、c4
这3个列中c3
和c4
的值都为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个列,其中c1
和c2
列是用来存储整数的,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
表的行格式演示图中画出有关的头信息属性以及c1
、c2
、c3
列的信息(其他信息没画不代表它们不存在啊,只是为了理解上的方便在图中省略了~),简化后的行格式示意图就是这样:
下面我们试着向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条记录在本页
中的位置分别是:2
、3
、4
、5
。是不是少了点什么?是的,怎么不见heap_no
值为0
和1
的记录呢?这其实是设计
InnoDB
的大佬们玩的一个小把戏,他们自动给每个页里边儿加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录
或者虚拟记录
。这两个伪记录一个代表最小记录
,一个代表最大记录
,等一下~,记录可以比大小么?是的,记录也可以比大小,对于一条完整的记录来说,比较记录的大小就是比较
主键
的大小。比方说我们插入的4行记录的主键值分别是:1
、2
、3
、4
,这也就意味着这4条记录的大小从小到大依次递增。 -
但是不管我们向
页
中插入了多少自己的记录,设计InnoDB
的大佬们都规定他们定义的两条伪记录分别为最小记录与最大记录。这两条记录的构造十分简单,都是由5字节大小的记录头信息
和8字节大小的一个固定的部分组成的,如图所示由于这两条记录不是我们自己定义的记录,所以它们并不存放在
页
的User Records
部分,他们被单独放在一个称为Infimum + Supremum
的部分,如图所示:从图中我们可以看出来,最小记录和最大记录的
heap_no
值分别是0
和1
,也就是说它们的位置最靠前。 -
record_type
这个属性表示当前记录的类型,一共有4种类型的记录,
0
表示普通记录,1
表示B+树非叶节点记录,2
表示最小记录,3
表示最大记录。从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type
值都是0
,而最小记录和最大记录的record_type
值分别为2
和3
。至于
record_type
为1
的情况,我们之后在说索引的时候会重点强调的。 -
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
表来说,记录的真实数据
除了c1
、c2
、c3
、c4
这几个我们自己定义的列的数据以外,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个列。现在看一下加上记录的真实数据
的两个记录长什么样吧:
看这个图的时候我们需要注意几点:
- 表
record_format_demo
使用的是ascii
字符集,所以0x61616161
就表示字符串'aaaa'
,0x626262
就表示字符串'bbb'
,以此类推。 - 注意第1条记录中
c3
列的值,它是CHAR(10)
类型的,它实际存储的字符串是:'cc'
,而ascii
字符集中的字节表示是'0x6363'
,虽然表示这个字符串只占用了2个字节,但整个c3
列仍然占用了10个字节的空间,除真实数据以外的8个字节的统统都用空格字符填充,空格字符在ascii
字符集的表示就是0x20
。 - 注意第2条记录中
c3
和c4
列的值都为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
表的c1
、c2
、c4
列的类型是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_demo
在Redundant
行格式下的两条记录的真实存储数据提供出来,之后我们着重分析两种行格式的不同即可。
下面我们从各个方面看一下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
,也就是十进制的26
。36 - 26 = 10
,也就是说最终c3
列占用的存储空间为10个字节。 -
如果该存储
NULL
值的字段是变长数据类型的,则不在记录的真实数据
处占用任何存储空间。比如
record_format_demo
表的c4
列是VARCHAR(10)
类型的,VARCHAR(10)
是一个变长数据类型,c4
列对应的偏移量为0xA4
,与c3
列对应的偏移量相同,这也就意味着它的值也为NULL
,将0xA4
的最高位去掉后对应的十进制值也是36
,36 - 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
个字节,这样就可能造成一个页存放不了一条记录的尴尬情况。
在Compact
和Reduntant
行格式中,对于占用存储空间非常大的列,在记录的真实数据
处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据
处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页,如图所示:
从图中可以看出来,对于Compact
和Reduntant
行格式来说,如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前768
个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做行溢出
,存储超出768
字节的那些页面也被称为溢出页
。画一个简图就是这样:
最后需要注意的是,不只是 VARCHAR(M) 类型的列,其他的 TEXT、BLOB 类型的列在存储数据非常多的时候也会发生行溢出
。
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';
复制代码
你能看到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_TABLES
、 SYS_COLUMNS
、 SYS_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
开头的表:
在information_schema
数据库中的这些以INNODB_SYS
开头的表并不是真正的内部系统表(内部系统表就是我 们上边以SYS开头的那些表),而是在存储引擎启动时读取这些以SYS开头的系统表,然后填充到这些以 工NNODB_SYS开头的表中。以工INNODB_SYS开头的表和以SYS开头的表中的字段并不完全一样,但供大家参考已经足矣。
附录:数据页加载的三种方式
InnoDB从磁盘中读取数据的最小单位是数据页
。而你想得到的id=xxx的数据,就是这个数据页众多行中的一行。 对于MySQL存放的数据,逻辑概念上我们称之为表,在磁盘等物理层面而言是按数据页
形式进行存放的,当其加 载到MySQL中我们称之为缓存页
。
如果缓冲池中没有该页数据,那么缓冲池有以下三种读取数据的方式,每种方式的读取效率都是不同的:
1. 内存读取
如果该数据存在于内存中,基本上执行时间在1ms左右,效率还是很高的工
2. 随机读取
如果数据没有在内存中,就需要在磁盘上对该页进行查找,整体时间预估在10ms
左右,这10ms中有6ms是磁 盘的实际繁忙时间(包括了寻道和半圈旋转时间
),有3ms是对可能发生的排队时间的估计值,另外还有1ms的传输时间,将页从施服务器缓冲区传输到数据库缓冲区中。这10ms看起来很快,但实际上对于数据库来说消 耗的时间已经非常长了,因为这还只是一个页的读取时间。
3. 顺序读取
顺序读取其实是一种批量读取的方式,因为我们请求的数据在磁盘上往往都是相邻存储的
,顺序读取可以帮我们批 量读取页面,这样的话,一次性加载到缓冲池中就不需要再对其他页面单独进行磁盘I/O操作了。如果一个磁盘 的吞吐量是40MB/S,那虫对于一个16KB大小的页来说,一次可以顺序读取2560(40MB/16KB)个页,相当于一 个页的读取时间为0.4ms。采用批量读取的方式,即使是从磁盘上进行读取,效率也比从内存中只单独读取一个 页的效率要高。
参考文章
MySQL从入门到精通
MySQL是怎样运行的 从根儿上理解MySQL 第4、5和9章
《MySQL技术内幕:InnoDB存储引擎(第2版)》
《数据库索引设计与优化》