从数据页的角度看 B+ 树

大家背八股文的时候,都知道 MySQL 里 InnoDB 存储引擎是采用 B+ 树来组织数据的。

这点没错,但是大家知道 B+ 树里的节点里存放的是什么呢?查询数据的过程又是怎样的?

这次,我们从数据页的角度看 B+ 树,看看每个节点长啥样。

类别

InnoDB 是如何存储数据的?

MySQL 支持多种存储引擎,不同的存储引擎,存储数据的方式也是不同的,我们最常使用的是 InnoDB 存储引擎,所以就跟大家图解下InnoDB 是如何存储数据的。

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

因此,InnoDB 的数据是按「数据页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。

数据库的 I/O 操作的最小单位是页,InnoDB 数据页的默认大小是 16KB,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。

数据页包括七个部分,结构如下图:

InnoDB 是如何存储数据的?

这 7 个部分的作用如下图:

作用

在 File Header 中有两个指针,分别指向上一个数据页和下一个数据页,连接起来的页相当于一个双向的链表,如下图所示:

file header

采用链表的结构是让数据页之间不需要是物理上的连续的,而是逻辑上的连续。

数据页的主要作用是存储记录,也就是数据库的数据,所以重点说一下数据页中的 User Records 是怎么组织数据的。

数据页中的记录按照「主键」顺序组成单向链表,单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。

因此,数据页中有一个页目录,起到记录的索引作用,就像我们书那样,针对书中内容的每个章节设立了一个目录,想看某个章节的时候,可以查看目录,快速找到对应的章节的页数,而数据页中的页目录就是为了能快速找到记录。

那 InnoDB 是如何给记录创建页目录的呢?

页目录与记录的关系如下图:

关系

页目录创建的过程如下:

将所有的记录划分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录; 每个记录组的最后一条记录就是组内最大的那条记录,并且最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段(上图中粉红色字段) 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),每个槽相当于指针指向了不同组的最后一个记录。 从图可以看到,页目录就是由多个槽组成的,槽相当于分组记录的索引。然后,因为记录是按照「主键值」从小到大排序的,所以我们通过槽查找记录时,可以使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到对应的记录,无需从最小记录开始遍历整个页中的记录链表。

以上面那张图举个例子,5 个槽的编号分别为 0,1,2,3,4,我想查找主键为 11 的用户记录:

先二分得出槽中间位是 (0+4)/2=2 ,2号槽里最大的记录为 8。因为 11 > 8,所以需要从 2 号槽后继续搜索记录; 再使用二分搜索出 2 号和 4 槽的中间位是 (2+4)/2= 3,3 号槽里最大的记录为 12。因为 11 < 12,所以主键为 11 的记录在 3 号槽里; 这里有个问题,「槽对应的值都是这个组的主键最大的记录,如何找到组里最小的记录」?比如槽 3 对应最大主键是 12 的记录,那如何找到最小记录 9。解决办法是:通过槽 3 找到 槽 2 对应的记录,也就是主键为 8 的记录。主键为 8 的记录的下一条记录就是槽 3 当中主键最小的 9 记录,然后开始向下搜索 2 次,定位到主键为 11 的记录,取出该条记录的信息即为我们想要查找的内容。 看到第三步的时候,可能有的同学会疑问,如果某个槽内的记录很多,然后因为记录都是单向链表串起来的,那这样在槽内查找某个记录的时间复杂度不就是 O(n) 了吗?

这点不用担心,InnoDB 对每个分组中的记录条数都是有规定的,槽内的记录就只有几条:

第一个分组中的记录只能有 1 条记录; 最后一个分组中的记录条数范围只能在 1-8 条之间; 剩下的分组中记录条数范围只能在 4-8 条之间。

B+ 树是如何进行查询的?

上面我们都是在说一个数据页中的记录检索,因为一个数据页中的记录是有限的,且主键值是有序的,所以通过对所有记录进行分组,然后将组号(槽号)存储到页目录,使其起到索引作用,通过二分查找的方法快速检索到记录在哪个分组,来降低检索的时间复杂度。

但是,当我们需要存储大量的记录时,就需要多个数据页,这时我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。

为了解决这个问题,InnoDB 采用了 B+ 树作为索引。磁盘的 I/O 操作次数对索引的使用效率至关重要,因此在构造索引的时候,我们更倾向于采用“矮胖”的 B+ 树数据结构,这样所需要进行的磁盘 I/O 次数更少,而且 B+ 树 更适合进行关键字的范围查询。

InnoDB 里的 B+ 树中的每个节点都是一个数据页,结构示意图如下:

结构图

通过上图,我们看出 B+ 树的特点:

  1. 只有叶子节点(最底层的节点)才存放了数据,非叶子节点(其他上层节)仅用来存放目录项作为索引。

  2. 非叶子节点分为不同层次,通过分层来降低每一层的搜索量;

  3. 所有节点按照索引键大小排序,构成一个双向链表,便于范围查询;

我们再看看 B+ 树如何实现快速查找主键为 6 的记录,以上图为例子:

从根节点开始,通过二分法快速定位到符合页内范围包含查询值的页,因为查询的主键值为 6,在[1, 7)范围之间,所以到页 30 中查找更详细的目录项;

在非叶子节点(页30)中,继续通过二分法快速定位到符合页内范围包含查询值的页,主键值大于 5,所以就到叶子节点(页16)查找记录;

接着,在叶子节点(页16)中,通过槽查找记录时,使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到主键为 6 的记录。

可以看到,在定位记录所在哪一个页时,也是通过二分法快速定位到包含该记录的页。

定位到该页后,又会在该页内进行二分法快速定位记录所在的分组(槽号),最后在分组内进行遍历查找。

聚簇索引和二级索引

另外,索引又可以分成聚簇索引和非聚簇索引(二级索引),它们区别就在于叶子节点存放的是什么数据:

  1. 聚簇索引的叶子节点存放的是实际数据,所有完整的用户记录都存放在聚簇索引的叶子节点;

  2. 二级索引的叶子节点存放的是主键值,而不是实际数据。

因为表的数据都是存放在聚簇索引的叶子节点里,所以 InnoDB 存储引擎一定会为表创建一个聚簇索引,且由于数据在物理上只会保存一份,所以聚簇索引只能有一个。

InnoDB 在创建聚簇索引时,会根据不同的场景选择不同的列作为索引:

  1. 如果有主键,默认会使用主键作为聚簇索引的索引键;

  2. 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键;

  3. 在上面两个都没有的情况下,InnoDB 将自动生成一个隐式自增 id 列作为聚簇索引的索引键;

一张表只能有一个聚簇索引,那为了实现非主键字段的快速搜索,就引出了二级索引(非聚簇索引/辅助索引),它也是利用了 B+ 树的数据结构,但是二级索引的叶子节点存放的是主键值,不是实际数据。

二级索引的 B+ 树如下图,数据部分为主键值:

聚簇索引和二级索引

因此,如果某个查询语句使用了二级索引,但是查询的数据不是主键值,这时在二级索引找到主键值后,需要去聚簇索引中获得数据行,这个过程就叫作「回表」,也就是说要查两个 B+ 树才能查到数据。不过,当查询的数据是主键值时,因为只在二级索引就能查询到,不用再去聚簇索引查,这个过程就叫作「索引覆盖」,也就是只需要查一个 B+ 树就能找到数据。

总结

InnoDB 的数据是按「数据页」为单位来读写的,默认数据页大小为 16 KB。每个数据页之间通过双向链表的形式组织起来,物理上不连续,但是逻辑上连续。

数据页内包含用户记录,每个记录之间用单向链表的方式组织起来,为了加快在数据页内高效查询记录,设计了一个页目录,页目录存储各个槽(分组),且主键值是有序的,于是可以通过二分查找法的方式进行检索从而提高效率。

为了高效查询记录所在的数据页,InnoDB 采用 b+ 树作为索引,每个节点都是一个数据页。

如果叶子节点存储的是实际数据的就是聚簇索引,一个表只能有一个聚簇索引;如果叶子节点存储的不是实际数据,而是主键值则就是二级索引,一个表中可以有多个二级索引。

在使用二级索引进行查找数据时,如果查询的数据能在二级索引找到,那么就是「索引覆盖」操作,如果查询的数据不在二级索引里,就需要先在二级索引找到主键值,需要去聚簇索引中获得数据行,这个过程就叫作「回表」。

关于索引的内容还有很多,比如索引失效、索引优化等等,这些内容我下次在讲啦!

MySQL 单表不要超过 2000W 行,靠谱吗?

作为在后端圈开车的多年老司机,是不是经常听到过:

“MySQL 单表最好不要超过 2000W”

“单表超过 2000W 就要考虑数据迁移了”

实验

实验一把看看… 建一张表

CREATE TABLE person(
    id int NOT NULL AUTO_INCREMENT PRIMARY KEY comment '主键',
    person_id tinyint not null comment '用户id',
    person_name VARCHAR(200) comment '用户名称',
    gmt_create datetime comment '创建时间',
    gmt_modified datetime comment '修改时间'
) comment '人员信息表';

不断的插入数据。

此处需要注意的是,也许你在执行到近 800w 或者 1000w 数据的时候,会报错:The total number of locks exceeds the lock table size。

这是由于你的临时表内存设置的不够大,只需要扩大一下设置参数即可。

SET GLOBAL tmp_table_size =512*1024*1024; 512M
SET global innodb_buffer_pool_size= 1*1024*1024*1024 (1G);

看到这组数据似乎好像真的和标题对应,当数据达到 2000W 以后,查询时长急剧上升,难道这就是铁律吗?

那下面我们就来看看这个建议值 2000W 是怎么来的?

单表数量限制

首先我们先想想数据库单表行数最大多大?

CREATE TABLE person(
    id int(10) NOT NULL AUTO_INCREMENT PRIMARY KEY comment '主键',
    person_id tinyint not null comment '用户id',
    person_name VARCHAR(200) comment '用户名称',
    gmt_create datetime comment '创建时间',
    gmt_modified datetime comment '修改时间'
) comment '人员信息表';

看看上面的建表 sql。id 是主键,本身就是唯一的,也就是说主键的大小可以限制表的上限:

如果主键声明 int 类型,也就是 32 位,那么支持 2^32-1 ~~21 亿;

如果主键声明 bigint 类型,那就是 2^62-1 (36893488147419103232),难以想象这个的多大了,一般还没有到这个限制之前,可能数据库已经爆满了!!

表空间

下面我们再来看看索引的结构,我们下面讲内容都是基于 Innodb 引擎的,大家都知道 Innodb 的索引内部用的是 B+ 树。

这张表数据,在硬盘上存储也是类似如此的,它实际是放在一个叫 person.ibd (innodb data)的文件中,也叫做表空间;虽然数据表中,他们看起来是一条连着一条,但是实际上在文件中它被分成很多小份的数据页,而且每一份都是 16K。

大概就像下面这样,当然这只是我们抽象出来的,在表空间中还有段、区、组等很多概念,但是我们需要跳出来看。

表空间

页的数据结构

实际页的内部结构像是下面这样的:

页的数据结构

从图中可以看出,一个 InnoDB 数据页的存储空间大致被划分成了 7 个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。

在页的 7 个组成部分中,我们自己存储的记录会按照我们指定的行格式存储到 User Records 部分。

但是在一开始生成页的时候,其实并没有 User Records 这个部分,每当我们插入一条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records 部分。

当 Free Space 部分的空间全部被 User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。

这个过程的图示如下:

过程

刚刚上面说到了数据的新增的过程。

那下面就来说说,数据的查找过程,假如我们需要查找一条记录,我们可以把表空间中的每一页都加载到内存中,然后对记录挨个判断是不是我们想要的。

在数据量小的时候,没啥问题,内存也可以撑。但是现实就是这么残酷,不会给你这个局面。

为了解决这问题,MySQL 中就有了索引的概念,大家都知道索引能够加快数据的查询,那到底是怎么个回事呢?

下面我就来看看

索引的数据结构

在 MySQL 中索引的数据结构和刚刚描述的页几乎是一模一样的,而且大小也是 16K,。

但是在索引页中记录的是页 (数据页,索引页) 的最小主键 id 和页号,以及在索引页中增加了层级的信息,从 0 开始往上算,所以页与页之间就有了上下层级的概念。

索引的数据结构

看到这个图之后,是不是有点似曾相似的感觉,是不是像一棵二叉树啊,对,没错!它就是一棵树。

只不过我们在这里只是简单画了三个节点,2 层结构的而已,如果数据多了,可能就会扩展到 3 层的树,这个就是我们常说的 B+ 树,最下面那一层的 page level =0, 也就是叶子节点,其余都是非叶子节点。

索引页

看上图中,我们是单拿一个节点来看,首先它是一个非叶子节点(索引页),在它的内容区中有 id 和 页号地址两部分:

id :对应页中记录的最小记录 id 值;

页号:地址是指向对应页的指针;

而数据页与此几乎大同小异,区别在于数据页记录的是真实的行数据而不是页地址,而且 id 的也是顺序的。

单表建议值

下面我们就以 3 层,2 分叉(实际中是 M 分叉)的图例来说明一下查找一个行数据的过程。

单表建议值

比如说我们需要查找一个 id=6 的行数据:

因为在非叶子节点中存放的是页号和该页最小的 id,所以我们从顶层开始对比,首先看页号 10 中的目录,有 [id=1, 页号 = 20],[id=5, 页号 = 30], 说明左侧节点最小 id 为 1,右侧节点最小 id 是 5。6>5, 那按照二分法查找的规则,肯定就往右侧节点继续查找; 找到页号 30 的节点后,发现这个节点还有子节点(非叶子节点),那就继续比对,同理,6>5 && 6<7, 所以找到了页号 60; 找到页号 60 之后,发现此节点为叶子节点(数据节点),于是将此页数据加载至内存进行一一对比,结果找到了 id=6 的数据行。 从上述的过程中发现,我们为了查找 id=6 的数据,总共查询了三个页,如果三个页都在磁盘中(未提前加载至内存),那么最多需要经历三次的磁盘 IO。

需要注意的是,图中的页号只是个示例,实际情况下并不是连续的,在磁盘中存储也不一定是顺序的。

至此,我们大概已经了解了表的数据是怎么个结构了,也大概知道查询数据是个怎么的过程了,这样我们也就能大概估算这样的结构能存放多少数据了。

从上面的图解我们知道 B+ 数的叶子节点才是存在数据的,而非叶子节点是用来存放索引数据的。

所以,同样一个 16K 的页,非叶子节点里的每条数据都指向新的页,而新的页有两种可能

如果是叶子节点,那么里面就是一行行的数据

如果是非叶子节点的话,那么就会继续指向新的页

假设

非叶子节点内指向其他页的数量为 x

叶子节点内能容纳的数据行数为 y

B+ 数的层数为 z

如下图中所示,Total =x^(z-1) *y 也就是说总数会等于 x 的 z-1 次方 与 Y 的乘积。

total

X =?

在文章的开头已经介绍了页的结构,索引也也不例外,都会有 File Header (38 byte)、Page Header (56 Byte)、Infimum + Supermum(26 byte)、File Trailer(8byte), 再加上页目录,大概 1k 左右。

我们就当做它就是 1K, 那整个页的大小是 16K, 剩下 15k 用于存数据,在索引页中主要记录的是主键与页号,主键我们假设是 Bigint (8 byte), 而页号也是固定的(4Byte), 那么索引页中的一条数据也就是 12byte。

所以 x=15*1024/12≈1280 行。

Y=?

叶子节点和非叶子节点的结构是一样的,同理,能放数据的空间也是 15k。

但是叶子节点中存放的是真正的行数据,这个影响的因素就会多很多,比如,字段的类型,字段的数量。每行数据占用空间越大,页中所放的行数量就会越少。

这边我们暂时按一条行数据 1k 来算,那一页就能存下 15 条,Y = 15*1024/1000 ≈15。

算到这边了,是不是心里已经有谱了啊。

根据上述的公式,Total =x^(z-1) *y,已知 x=1280,y=15:

假设 B+ 树是两层,那就是 z = 2, Total = (1280 ^1 )*15 = 19200 假设 B+ 树是三层,那就是 z = 3, Total = (1280 ^2) *15 = 24576000 (约 2.45kw) 哎呀,妈呀!这不是正好就是文章开头说的最大行数建议值 2000W 嘛!对的,一般 B+ 数的层级最多也就是 3 层。

你试想一下,如果是 4 层,除了查询的时候磁盘 IO 次数会增加,而且这个 Total 值会是多少,大概应该是 3 百多亿吧,也不太合理,所以,3 层应该是比较合理的一个值。

到这里难道就完了?

我们刚刚在说 Y 的值时候假设的是 1K ,那比如我实际当行的数据占用空间不是 1K , 而是 5K, 那么单个数据页最多只能放下 3 条数据。

同样,还是按照 z = 3 的值来计算,那 Total = (1280 ^2) *3 = 4915200 (近 500w)

所以,在保持相同的层级(相似查询性能)的情况下,在行数据大小不同的情况下,其实这个最大建议值也是不同的,而且影响查询性能的还有很多其他因素,比如,数据库版本,服务器配置,sql 的编写等等。

MySQL 为了提高性能,会将表的索引装载到内存中,在 InnoDB buffer size 足够的情况下,其能完成全加载进内存,查询不会有问题。

但是,当单表数据库到达某个量级的上限时,导致内存无法存储其索引,使得之后的 SQL 查询会产生磁盘 IO,从而导致性能下降,所以增加硬件配置(比如把内存当磁盘使),可能会带来立竿见影的性能提升哈。

总结

MySQL 的表数据是以页的形式存放的,页在磁盘中不一定是连续的。

页的空间是 16K, 并不是所有的空间都是用来存放数据的,会有一些固定的信息,如,页头,页尾,页码,校验码等等。

在 B+ 树中,叶子节点和非叶子节点的数据结构是一样的,区别在于,叶子节点存放的是实际的行数据,而非叶子节点存放的是主键和页号。

索引结构不会影响单表最大行数,2000W 也只是推荐值,超过了这个值可能会导致 B + 树层级更高,影响查询性能。

参考资料

https://xiaolincoding.com/mysql/index/page.html#innodb-%E6%98%AF%E5%A6%82%E4%BD%95%E5%AD%98%E5%82%A8%E6%95%B0%E6%8D%AE%E7%9A%84

https://xiaolincoding.com/mysql/index/2000w.html#%E5%8D%95%E8%A1%A8%E5%BB%BA%E8%AE%AE%E5%80%BC