Mysql之存储原理(1)

  • Post author:
  • Post category:mysql

首先我们知道,对于Mysql而言,数据是存储在文件中的,为了能够快速的定位我们想要的数据,我们就需要一种数据结构,就是索引。一般操作系统以4kb为一个数据页读取数据,而mysql是以16kb作为一个数据块,已经读取过的数据会放在缓存中,如果多次读取的数据在同一个数据块上,只需要一次磁盘IO就可以了,在mysql中我们用N叉树来代替二叉树的原因是因为在极端的条件下,二叉树会变成链表的结构,所以我们使用N叉树,这个N一般为1200,当树高为4的时候可以存储亿级别的数据,mysql用到的b+树,一般非叶子节点构建索引,叶子节点存储数据。

InnoDB 中,有聚簇索引和普通索引之分,聚簇索引根据主键来构建,叶子节点存放的是该主键对应的这一行记录,而普通索引根据声明这个索引时候的列来构建,叶子节点存放的是这一行记录对应的主键的值,而普通索引中还有唯一索引和联合索引两个特例,唯一索引在插入和修改的时候会校验该索引对应的列的值是否已经存在,而联合索引将两个列的值按照申明时候的顺序进行拼接后在构建索引。

数据是以行为单位存储在聚簇索引里的,根据主键查询可以直接利用聚簇索引定位到所在记录,根据普通索引查询需要先在普通索引上找到对应的主键的值,然后根据主键值去聚簇索引上查找记录,俗称回表。

普通索引上存储的值是主键的值,如果主键是一个很长的字符串并且建了很多普通索引,将造成普通索引占有很大的物理空间,这也是为什么建议使用 自增ID 来替代订单号作为主键,另一个原因是 自增ID 在插入的时候可以保证相邻的两条记录可能在同一个数据块,而订单号的连续性在设计上可能没有自增ID好,导致连续插入可能在多个数据块,增加了磁盘读写次数。

如果我们查询一整行记录的话,一定要去聚簇索引上查找,而如果我们只需要根据普通索引查询主键的值,由于这些值在普通索引上已经存在,所以并不需要回表,这个称为索引覆盖,在一定程度上可以提高查询效率,由于联合索引上通过多个列构建索引,有时候我们可以将需要频繁查询的字段加到联合索引里面,例如如果经常需要根据 name 查找 age 我们可以建一个 name 和 age 的联合索引。

查询的时候如果在索引上用了函数,将导致无法用到根据之前列上的值构建的索引,索引遵循最左匹配原则,所以如果需要查询某个列的值中间是否包含某个字符串,将无法利用索引,如果有这种需求可以利用全文索引,而如果查询是否以某个字符串开头就可以,联合索引根据第一个列查询可以用到索引,仅仅根据第二个列将无法用到索引,查询的时候用 IN 的效率高于 NOT = 。另外建议将索引的列设置为非空,这个和 NULL 字段的存储有关,下文在分析。

有了以上的索引知识我们在来分析数据是怎么存储的,InnoDB 存储引擎的逻辑存储结构从大到小依次可以分为:表空间、段、区、页、行。

 表空间作为存储结构的最高层,所有数据都存放在表空间中,默认情况下用一个共享表空间 ibdata1 ,如果开启了 innodb_file_per_table 则每张表的数据将存储在单独的表空间中,也就是每张表都会有一个文件,表空间由各个段构成,InnoDB存储引擎由索引组织的,而索引中的叶子节点用来记录数据,存储在数据段,而非叶子节点用来构建索引,存储在索引段,而回滚段我们在后面分析锁的时候在聊。

区是由连续的页组成,任何情况下一个区都是 1MB ,一个区中可以有多个页,每个页默认为 16KB ,所以默认情况下一个区中可以包含64个连续的页,页的大小是可以通过 innodb_page_size 设置,页中存储的是具体的行记录。一行记录最终以二进制的方式存储在文件里,我们要能够解析出一行记录中每个列的值,存储的时候就需要有固定的格式,至少需要知道每个列占多少空间,而 MySQL 中定义了一些固定长度的数据类型,例如 int、tinyint、bigint、char数组、float、double、date、datetime、timestamp 等,这些字段我们只需要读取对应长度的字节,然后根据类型进行解析即可,对于变长字段,例如 varchar、varbinary 等,需要有一个位置来单独存储字段实际用到的长度,当然还需要头信息来存储元数据,例如记录类型,下一条记录的位置等。下面我们以 Compact 行格式分析一行数据在 InnoDB 中是怎么存储的。

 变长字段长度列表,该位置用来存储所声明的变长字段中非空字段实际占有的长度列表,例如有3个非空字段,其中第一个字段长度为3,第二个字段为空,第三个字段长度为1,则将用 01 03 表示,为空字段将在下一个位置进行标记。变长字段长度不能超过 2 个字节,所以 varchar 的长度最大为 65535。

NULL 标志位,占 1 个字节,如果对应的列为空则在对应的位上置为 1 ,否则为 0 ,由于该标志位占一个字节,所以列的数量不能超过 255。如果某字段为空,在后面具体的列数据中将不会在记录。这种方式也导致了在处理索引字段为空的时候需要进行额外的操作。

记录头信息,固定占 5 字节,包含下一条记录的位置,该行记录总长度,记录类型,是否被删除,对应的 slot 信息等

列数据 包含具体的列对应的值,加上两个隐藏列,事务 ID 列和回滚指针列。如果没有申明主键,还会增加一列记录内部 ID。

举个例子

CREATE TABLE mytest(
t1 varchar(10),
t2 varchar(10),
t3 char(10),
t4 varchar(10)
) engine = innodb;

insert into mytest VALUES('a','bb','bb','ccc');
insert into mytest VALUES('d',NULL,NULL,'fff');

该表定义了 3 个变长字段和 1 个定长字段,然后插入两行记录,第二行记录包含空值,我们打开表空间 mytest.ibd 文件,转换为 16 进制,并定位到如下内容:

//第一行记录
03 02 01 为变长字段长度列表,这里是倒序存放的,分别对应 ccc、bb、a 的长度。
00 表示没有为空的字段
00 00 10 00 2c 为记录头
00 00 00 2b 68 00 没有申明主键,维护内部 ID
00 00 00 00 06 05 事务ID
80 00 00 00 32 01 10 回滚指针
61 第一列 a 的值
62 62 第二列 bb 的值
62 62 20 20 20 20 20 20 20 20 第三列 bb 的值,固定长度 char(10) 以20进行填充
63 63 63 第四列 ccc 的值

//第二行记录
03 01 为变长字段长度列表,这里是倒序存放的,分别对应 fff、a 的长度,第二列位空。
06 转换为二进制为 00000110 表示第二列和第三列为空
00 00 20 ff 98 为记录头
00 00 00 2b 68 01 没有申明主键,维护内部 ID
00 00 00 00 06 06 事务ID
80 00 00 00 32 01 10 回滚指针
64 第一列 d 的值
65 65 65 第四列 fff 的值

到此,我们了解了一个数据行是怎么存储的,然而数据行并不是存储引擎管理的最小存储单位,索引只能够帮助我们定位到某个数据页,每一次磁盘读写的最小单位为也是数据页,而一个数据页内存储了多个数据行,我们需要了解数据页的内部结构才能知道存储引擎怎么定位到某一个数据行。InnoDB 的数据页由以下 7 个部分组成:

  • 文件头(File Header) 固定 38 个字节 (页的位置,上一页下一页位置,checksum , LSN)

  • 数据页头( Page Header)固定 56 个字节 包含slot数目,可重用空间起始地址,第一个记录地址,记录数,最大事务ID等

  • 虚拟的最大最小记录 (Infimum + Supremum Record)

  • 用户记录 (User Records) 包含已经删除的记录以链表的形式构成可重用空间

  • 待分配空间 (Free spaces) 未分配的空间

  • 页目录 (Page Directory) slot 信息,下面单独介绍

  • 文件尾 (File Trailer) 固定8个字节,用来保证页的完整性

 

页目录里维护多个 slot ,一个 slot 包含多个行记录。每个 slot 占 2 个字节,记录这个 slot 里的行记录相对页初始位置的偏移量。由于索引只能定位到数据页,而定位到数据页内的行记录还需要在内存中进行二分查找,而这个二分查找就需要借助 slot 信息,先找到对应的 slot ,然后在 slot 内部通过数据行中记录头里的下一个记录地址进行遍历。每一个 slot 可以包含 4 到 8 个数据行。如果没有 slot 辅助,链表本身是无法进行二分查找的。

 

 


版权声明:本文为Chen_leilei原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。