Mysql以其还不错的性能,简单易用、免费开源的优势深受开发人员喜爱。网上有很多关于Mysql的优秀文章,其中有很多主题值得深入讨论,比如:索引、事物、锁、数据表拆分等等。在这些主题背后都有很多知识理论,笔者认为这些知识理论都和Mysql的数据的存储有关系,理解Mysql的数据存储非常重要,是将来深入学习探索Mysql的高级特性的基础,所以整理了这篇文章,笔者能力有限,有理解不到位的地方,欢迎大家留言指正。
1.磁盘的存取原理
为了实现数据的持久化存储,mysql将数据存储到了磁盘上,所以客户端对数据库中数据进行查询,mysql需要将磁盘上的相关数据加载到内存中(这就是所谓的磁盘IO),根据查询sql筛选出数据返回给客户端。操作系统读写磁盘的基本单位是扇区,而文件系统的基本单位是簇(不明白“扇区”和“簇”概念的读者不要着急,下边会进行介绍的),也就是说操作系统将磁盘上的数据文件加载到内存中是一整块一整块的读取的,即便仅需要查询一块数据中的一个字节,也需要将这块数据全加载到内存中来。mysql数据库5.5版本之后,默认以InnoDB作为存储引擎,InnoDB就是以数据页来存储数据的,数据页的大小默认为16KB,操作系统将数据库中的数据加载到内存也是以数据页为基本单位。
下面我们来看一下磁盘是怎样存取数据的,下面是一个磁盘的物理结构示意图:
可以看出磁盘有多个盘面套在心轴上,每个盘的正反面有一个磁头用于读写数据,磁盘在工作时,盘面的高速旋转引起空气动力,使得磁头悬浮在盘面上,与盘面距离不到1微米,可以在极短的时间内精确定位到计算机指令指定的磁道上。下面我们来了解一下磁道、扇区、柱面和簇的概念。
每个盘片的每个盘面被划分成多个狭窄的同心圆环,数据就是存储在这样的同心圆环上,我们将这样的圆环称为磁道(Track),每个盘面可以划分多个磁道。在每个盘面的最外圈,离盘心最远的地方是“0”磁道,向盘心方向依次增长为1磁道,2磁道,等等。硬盘数据的存放就是从最外圈开始。磁头只能沿着盘面的径向移动,移动到要读取数据的磁道上方,这段时间称为寻道时间,
磁盘的盘面上磁道数有成千上万个。每个磁道上可以存储数KB的数据,但计算机并不需要一次读写这么多数据,基于此又把每个磁道划分成若干弧段,每段称为一个扇区(Sector)。扇区是硬盘上存储的物理单位(从DOS时代起,每个扇区存储512字节的数据,已经成为业界不成文的规定)。扇区的编号是从1开始的,而不是0。磁头到达指定磁道后,盘片通过旋转,使得要读取的扇区转到读写磁头的下方,这段时间称为旋转延迟时间(rotational latencytime)。
柱面其实是我们抽象出来的一个逻辑概念,前面说过,离盘心最远的磁道为0磁道,依此往里为1磁道,2磁道,3磁道....,不同盘面上相同磁道编号则组成了一个圆柱面,即所称的柱面(Cylinder)。磁盘数据的读写都是按照柱面进行的,即磁头读写数据时首先在同一柱面内从0磁头开始进行操作,依次向下在同一柱面的不同盘面(即磁头上)进行操作,只有在同一柱面所有的磁头全部读写完毕后磁头才转移到下一柱面,因为选取磁头只需通过电子切换即可,而选取柱面则必须通过机械切换。电子切换比从在机械上磁头向邻近磁道移动快得多。因此,数据的读写按柱面进行,而不按盘面进行。 读写数据都是按照这种方式进行,尽可能提高了硬盘读写效率。
物理相邻的若干个扇区称为了一个簇,文件系统的基本单位是簇(Cluster)。 簇一般有这几类大小 4K,8K,16K,32K,64K等。簇越大存储性能越好,但空间浪费严重。簇越小性能相对越低,但空间利用率高。
从上边对磁盘结构分析我们知道:磁盘读取数据时,磁头通过盘面径向移动到磁道上,然后盘面旋转到目标扇区的时候开始读取数据,如果数据都存在相邻的扇区,相比于数据存储在不同的磁道的扇区上来讲,磁盘读取数据的效率就会高很多(不需要寻道时间,只需要很少的旋转时间),这就是顺序I/O性能优于随机I/O的原因。
2. InnoDB引擎数据存储
Mysql数据库查询数据效率的瓶颈在于磁盘I/O(这是一件很耗时的工作)。数据库中数据存储的基本单位是一张表中的一条记录,记录是按照行为单位存储的,但是数据库加载磁盘数据到内存并不是以行为单位(这样的话每读取一条记录都需要一次磁盘I/O,效率非常低。),前边说过,Mysql新版本默认存储引擎是InnoDB,InnoDB按照页来存储数据,页的大小默认为16KB。(可通过下面命令行查看:)
mysql> show variables like 'innodb_page_size';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| innodb_page_size | 16384 |
+------------------+-------+
1 row in set (0.02 sec)
复制代码
因此数据库加载数据到内存,不论读一条记录,还是读多条记录,都是将这些记录所在的页进行加载。也即是说,数据库管理存储空间的基本单位是页(Page)。
2.1 记录行的存储
我们平时向Mysql中插入数据以“记录”为基本单位,记录在磁盘上的存储方式被称为“行格式”或“记录格式”。InnoDB存储引擎支持不同的行格式(Compact、Redundant、Dynamic和Compressed),它们的原理基本上是相同的。我们以Compact行格式为例,下边是其存储示意图:
一条记录数据的存储可以分为两部分:“额外信息”和“真实数据”。 额外信息用来描述记录,分为变长字段列表、NULL值列表和记录头信息,这部分信息非常重要,我们分别来看一下:
变长字段列表: MySQL支持的一些变长的数据类型,比如VARCHAR(M)、TEXT等。这些变长字段中存储的字节数量是不固定的,存储这个数据的真实字节数很有必要,这在解析数据的时候,数据库就知道从真实数据区域取出哪部分数据了。所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放。
我们在项目使用到char类型,比如使用char(32)来存储用户的密码,那么使用char类型的字段会被添加到变长字段列表中吗?答案是有可能会!因为项目中数据表使用的字符集是不确定的,最常用的utf8mb4使用1~4个变长字节来编码数据,中文和英文所占用的字节数是不同的。
对于项目中绝大多数使用存储量小的字段,比如说varchar(32),tinyint(4)等,假设使用utf8字符集,这种字段存储的真实数据长度一定不会超过255,使用一个字节表示就可以了。但是如果字段的真实数据可能会超过了255该怎么表示呢?分为两种情况:当真实数据字节数小于127的时候,用1个字节表示,大于127的时候使用2个字节表示,也就是说字节的最高位表示该字节表示的是一个变长数据的一部分还是全部。
NULL值列表:数据表中的某些列可能存储NULL值,把这些NULL值都放到记录的真实数据中存储很浪费存储空间,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中。在表中,主键列和使用NOT NULL修改过的列不允许存储NULL值,其他的列如果也没有NULL值,那么NULL值列表也就不存在了,也就是说NULL值列表并不总是存在的,上边说到的变长字段列表也是一样。标示一条记录中的数据是否为NULL使用一个二进制位就可以搞定了,1为NULL,0为非NULL,NULL值列表标示也是按照记录列的顺序逆序存放的。
问:变长字段长度列表、NULL值列表中的信息之所以按照列的顺序逆序存放?答:这样可以使记录中位置靠前的字段和它们对应的字段长度信息加载到内存中时,位置距离更近,可能会提高高速缓存的命中率。
记录头信息:记录头信息由5个固定的字节组成,5个字节是40个二进制位,这40个二进制位描述了记录的不同属性信息,这些信息非常重要!
下面是对记录头标志位信息的详细描述:
名称
占用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
下一条记录的相对位置
记录头信息非常多,但是这些信息在数据查找和存储的过程中非常有用,就比如说next_record记录着当前记录下一条记录的相对位置,对于数据查找非常方便。delete_mark标示记录是否被删除,由此我们可以知道,数据库的物理删除并不是直接从磁盘上物理删除,而是通过一个标志位标示,等将来要用到这一部分磁盘空间的时候再释放。其他的标志位信息也比较重要,我们下边用到的时候会详细说明。
我们再来看记录的真实数据部分,记录的真实数据中除了我们数据表中自定义的一些字段外,数据库会额外的添加一些隐藏列用于完成数据快速查找、事物提交、回滚等操作。隐藏列信息如下:
列名
真实名称
是否必须
占用空间
描述
roll_id
DB_ROW_ID
否
6字节
唯一标示一条记录的行ID
transaction_id
DB_TRX_ID
是
6字节
事物ID
roll_pointer
DB_ROLL_PTR
是
7字节
回滚指针
这些隐藏列的信息非常重要,我们来逐一说明一下:
DB_ROW_ID:我们知道它是可有可无的,这跟Innodb主键的生成策略有关。Innodb优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique键作为主键,如果表中连Unique键都没有定义的话,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。为什么InnoDB非要生成主键呢?因为数据库为了更好的进行范围查找和数据匹配,总是将记录按照主键从小到大存储的,有序对于数据查找非常重要,记录存储有序了,就能高效使用二分查找策略了。
DB_TRX_ID:我们知道InnoDB和MySIAM一个重要的区别是,InnoDB支持事物,事物要保证数据操作的ACID,InnoDB为数据表中的每条记录都生成一个transaction_id列,当记录被事物使用到时,使用它来存储事物id。
InnoDB支持行锁,很多人认为事物必须要配合InnoDB的行锁才能完成工作,这种观点是错误的!事物本身有自己的隔离级别,隔离级别不同,对数据一致性的要求也不同,事物本身有自己的一套MCVV(版本链控制),不一定使用到行锁。
DB_ROLL_PTR:上边我们提到了事物有自己的MVCC,事物失败后,需要回滚。而具体回滚到什么状态,或者回滚到事物执行的哪个节点(Mysql的事物支持使用savepoint进行打点,将来可以回滚到指定节点),这些工作需要有记录有一个字段标示,这就是回滚指针列的作用。
真实数据存储,还有一点非常重要,Innodb将记录存在默认大小为16KB的数据页中,而我们真实存储的数据记录,比如text类型,超过16KB也是有可能的,前边我们也提到,记录存储本身还有一些额外的信息需要存储,这样我们一个数据页中往往会存不下这些大记录。这个时候就会发生页分裂,多出来的部分数据会被存储到溢出页中。
InnoDB默认采用Dynamic的行格式储存记录,dynamic行格式中列存储是否放到off-page页(溢出页),主要取决于行大小,它会把行中最长的那一列放到off-page页,直到数据页能存放下两行。
以上就是Innodb引擎关于行记录的介绍,下边我们再来看一直强调的数据页的结构,以及记录是怎样组织起来存储到数据页中的。
2.2 Innodb页结构和记录存储
Innodb为了实现不同的目的设置了很多种页,我们上边提到的储存记录数据的页叫做数据页,此外还有一些其他的页来完成不同的工作:比如存放INODE信息的页,存放undo日志信息的页等等,本节我们主要探讨数据页的存储结构,其他类型的页在“表空间”中会简单提到,不作为重点。
为了实现数据的快速检索和存储的科学性,16KB大小的数据页又被大致分成了7个部分:
先简单介绍一下File Header、FileTailer,然后再重点详细说明行记录是怎样组织存储到数据页中的。File Header 和 File Tailer 是所有类型的页都通用的结构。Mysql是以页为单位将磁盘数据加载到内存中,然后进行查询、修改,之后再以页为单位,将修改过的数据刷到磁盘上。为了保证数据的完整性,InnoDB在每个页的尾部都加了一个File Trailer部分,这个部分由8个字节组成,存储页面的校验和(4字节)、日志序列位置(LSN)(4字节),同FileHeader中的校验和、LSN相对应,用来校验文件数据同步的完整性。FileHeader还存储了另外一些页的通用信息,比如页属于哪个表空间(下一节我们会说什么是表空间),页的上一页、下一页信息,这是非常重要的,数据页基于File Header中记录的上一页、下一页信息构成一个双向链表:
数据页之间通过双向链表连接意味着在物理空间存储上,数据页之间并不一定是连续的,但是Mysql尽可能的会去保证数据页在物理空间上的连续,因为Mysql经常进行范围查找,物理空间连续意味着可能更多的使用到顺序IO,更详细的细节我们下一节会具体讲到。
数据记录会存储到User Records部分,一开始数据页中User Records部分并不存在,不占据任何存储空间。随着插入数据的增多,User Records存储的用户记录越来越多,Free Space的空间越来越少(像海绵一样自由压缩),直到没有了Free Space,用户数据记录插入时,申请新的数据页进行插入。我们前边提到,Innodb会为每一条记录生成主键,如果定义的数据表中没有主键,就会生成一个隐藏的row_id列,在User Records存储区存储的记录是按照由小到大的顺序排列的,为了更好的管理数据记录,InnoDB定义了两条伪记录:最小记录与最大记录(infimun+suprenum)。这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成,一共占用26个字节。上述描述的数据插入过程如下图所示:
我们再回过头来说说记录行格式中的头部信息。记录行的头部信息40个字节是固定的表示记录的属性信息,我们上边提到的最小记录和最大记录的record_type分别为2、3,而我们用户字节插入的数据记录record_type为0。我们还知道next_record表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。 这块我们要重点理解一下,next_record存储的是当前记录到下一条真实数据的偏移量而不是到到下一条记录的偏移量,因为记录还包含有额外的信息。记录在数据页的User Records区按照主键大小排序储存,这样通过记录的next_record信息,数据页中的数据记录之间就形成了一条单向链表:
最小记录和最大记录的头部信息中heap_no是最小的0和1,在数据页中是排在最前边的,最大记录的next_record值为0,从最小记录到最大记录通过next_record存储的偏移量构成了逻辑上的单向链表(图中将每条记录分开表示是为了展示记录之间的关联,真实数据存储中,next_record存储的是偏移量,数据记录在物理空间存储上是连续的)。记录中的delete_mask都是0,表示记录并没有被删除。假如用户记录2被删除,会发生什么事情呢?mysql会将用户记录的记录头部信息的delete_mask置为1(并没有马上清理存储空间),同时将指向它的上一条记录的next_record改为指向它下一条记录数据起始的偏移量,依旧维护着一条单向链表:
记录头标志中的n_owned存储的是当前槽拥有的记录数,槽是什么?它和数据页部分的Page Directory又有什么样的关系呢?为了弄明白这两个问题,我们先来看看在一个数据页中查找一条用户记录是怎样实现的。我们知道用户记录在数据页中按照主键大小顺序存储,根据主键id查找记录,可以使用二分查找提高效率。二分查找的次数和记录数量有关系,数据页中存储的用户记录可能成百上千条,有没有什么办法能加快二分查找的速度呢?另外我们知道二分查找在数据量大的情况下,非常的高效,在用户记录只有比较少(0-8)个的情况下,也许并没有顺序查找来的简单高效。所以InnoDB在数据页中为了加快数据查找速度,将用户记录做了分组,每个组中最后一条记录(也就是组中最大用户记录)的头信息中n_owned标志位记录着当前组拥有的记录数量;同时,将每个组中最后一条记录的地址偏移量单独提取出来存储到Page Directory处,形成了所谓的页目录。页目录中这些地址偏移量被称为槽。正如刚刚所说的,组中分配记录数量的多少是有考量的。InnoDB有这样的规则:最小记录的分组只能有1条记录(就是它自己,最小记录),最大分组拥有的记录数是1~8条(包括用户记录和它本身),其他分组中记录的条数在4~8条。我们来分析下随着用户记录的插入,分组和“槽”变化的过程:
初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
每插入一条记录,都会从页目录中找到主键值比该记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。
在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
有了数据页的目录部分(Page Directory),我们再来梳理一下在一个数据页中查找一条记录的过程。分为两步:第一步:首先通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。第二步:通过记录的next_record属性遍历该槽所在的组中的各个记录。到这里我们就清楚了InnoDB数据页的结构,也明白了怎样在一个数据页中查找一条指定的记录了,下面我们从更大维度,更广阔的视角来看一看Mysql的数据存储的设计。
2.3 简单理解Innodb表空间
为了更好的管理数据页,Innodb引入了表空间的概念。(Oracle的存储结构是按照表空间进行管理的,Innodb模仿了Oracle的数据存储方式)。
我们先来区分一下Mysql的安装目录和数据目录的区别:在操作系统上安装完Mysql之后,我们就得到了Mysql的安装目录,其中安装目录下的bin文件夹下有管理Mysql的可执行文件,例如mysql、mysqld、mysqld_safe等等。而Mysql在运行期间产生的数据却是在数据目录下存放的,可以通过命令查看mysql的数据目录:
mysql> show variables like 'datadir';
+---------------+------------------------+
| Variable_name | Value |
+---------------+------------------------+
| datadir | /usr/local/mysql/data/ |
+---------------+------------------------+
1 row in set (0.04 sec)
复制代码
我们再来看看数据库和数据表在操作系统上是如何表示的:每个数据库对应mysql数据目录下的一个同名文件。在我们使用create database创建数据库的时候,如果使用Innodb引擎,会在数据库同名文件下创建一个名为db.opt的文件,该文件包含了数据库的字集、比较规则等属性信息,它是一个二进制文件,笔者使用cat file查看信息时是一些乱码信息。和数据库一样,数据表的存储也需要一个文件存储表的属性信息,例如:表中的列、每个列类型(int,varchar之类的)、字符集、使用了哪些索引等等。**在Innodb引擎中,创建表的时候会生成一个.frm的数据表同名文件来表示数据表结构。**我们知道Innodb以页为单位存储数据,为了更好的管理数据,Innodb引擎将数据表中的数据存储到表空间下,表空间是一个抽象的概念,它对应着文件系统上的很多文件,表空间下有许多许多页,我们的表数据就存储在这些页中。Mysql为Innodb引擎设计了很多表空间,例如:系统表空间、独立表空间、通用表空间(general tablespace)、undo表空间(undo tablespace)、临时表空间(temporary tablespace)等等。我们就来简单介绍一下系统表空间和独立表空间:
系统表空间:默认情况下,Innodb会在数据目录下创建一个名为ibdata1,大小为12M的文件,系统表空间只有一份,随着存储数据量的增大自增长。MySQL5.5.7到MySQL5.6.6之间的各个版本中,我们表中的数据都会被默认存储到这个 系统表空间。
独立表空间:MySQL5.6.6之后的版本中,Mysql会把使用了Innodb引擎的数据表存储到数据表对应的独立表空间中去,数据表的独立表空间文件的名字和数据表名相同,拓展名为.ibd,也就是我们在创建一个数据表的时候,会生成两个文件:表.frm用来存储数据表的属性信息,表.opt用来存储数据表的数据和索引信息。
和Innodb不同的是,MyISAM并没有表空间,表数据都放在对应的数据库子目录下,使用MyISAM创建数据表之后会生成三个文件:表.frm、表.MYD、表.MYI,其中表.frm和Innodb一样,存储数据表的属性信息,表.MYI存储表的索引文件,表.MYD存储表的数据文件。这点和Innodb不同,我们说Innodb将表数据和索引都存储到表空间下,因为Innodb索引的设计和MyISAM是不同的,Innodb中数据即索引,索引即数据。
下边对表空间的存储结构做简单介绍:
数据页的结构我们已经很清楚了,为什么好端端的又提出了一个区的概念呢?我们前边说过,Mysql尽可能的将数据顺序储存来尽可能高的使用顺序IO提高磁盘加载速度,一个页默认大小16KB,是可以存放更多记录,这些记录已经顺序存储了,还不够,所以表空间又添加了区的概念,一个区默认由64个数据页组成,一个数据页默认大小是16KB,一个区也就是1M。这样1M的数据顺序存储了,在插入大量数据的时候,Innodb还可以申请多个连续的区来存放用户数据,这样,多个区就又顺序存储了,这些细节的考虑对于Mysql磁盘IO加载数据性能的提升是不可忽略的。
区存储数据的粒度还是太粗了,在数据量比较少的情况下,区中往往有很多空间不能被有效利用,基于此,又将区分为了四种:空闲的区、有剩余空间的碎片区、没有剩余空间的碎片区、附属于某个段的区。这样在数据表存储数据比较少的情况下,就可以使用有剩余空间的碎片区存储数据了。
那段呢?段又是什么?Innodb引擎是将数据和索引都存储成一个B+树结构,也就是索引和数据是放在一起的(这是聚簇索引的概念),存储索引的页和存储数据的页是不同类型的,在表空间中,索引和数据也是分开存储的,另外还有关于事物的处理,也有不同的段用来存储数据。为了更好的区分这些,Innodb使用段来将不同的数据做区分,存储用户数据记录的叫数据段,存储索引的叫索引段。一个段由很多附属于它的区和一些其他区的零散的页组成。(附属于一个段的区存储的数据都会这个段的相关数据),这样就是实现了对特定数据的区分管理。
3.总结与回顾
本节绝大部分内容都是在探讨Innodb引擎的数据存储的,从记录的存储到数据页的设计,后边简单谈了一下表空间概念,循序渐进的帮助大家了解了Mysql数据存储的概貌。文中提到了很多细节,比如数据记录都有哪些隐藏列,数据页的Page Directoy区是怎样设计的等等,理解这些非常重要,Mysql的索引、事物有很多原理都是基于这些基础的数据存储结构设计实现的,希望本文能给想要深入学习Mysql的读者带来一些收获,谢谢大家!