B+Tree索引的背后

摘要

本文基于MySQL5.7为基础,讨论与数据库索引相关的一些内容。MySQL是一个支持多存储引擎插件的数据库,各种引擎已插件的方式接入,而各种存储引擎对索引的支持各不相同。本文只讨论InnoDB的B+Tree索引,因为这是MySQL引用最广泛的索引,至于哈希索引和全文索引本文暂不讨论。

聚集索引和二级索引

每个InnoDB表都有一个特殊的索引,称为聚集索引(有的翻译为聚簇索引) ,用于存储行数据,通常与主键是一个意思。为了从查询中获取最佳的性能,必须了解使用聚集索引优化查询:

  • 为每个表定义主键。如果没有逻辑唯一且非空的列或列集,请添加一个自动递增列作为主键。

  • 如果表没有定义主键,MySQL会查找所有键列都不为空的第一个唯一索引,InnoDB 会将其用作聚集索引。

  • 如果表没有主键或没有合适的唯一索引,则在InnoDB 内部生成一个隐藏的聚集索引GEN_CLUST_INDEX,该索引在row_id列上(InnoDB 表若没有定义主键,会使用系统的一个默认递增row_id) 。row_id是一个6字节的字段,随着插入新行而单调增加。因此,按row_id排序实际上是按插入顺序排序。

    这里需要注意的是row_id的取值范围,row_id 被定义为一个unsigned long long,但由于它只有6个字节,因此最大值是2^48,row_id超过这个值还是会递增,只是写入的时候只取低48位。那如果由于MySQL长时间运行导致row_id重复的情况,会怎么样呢?会导致重复的row_id行被覆盖,导致数据丢失。(特别注意的是row_id是库实例变量,所有表共享。)

聚集索引如何加快查询速度

通过聚集索引访问行是很快的,因为聚集索引搜索直接指向包含所有行数据的页。聚集索引体系结构通常可以节省磁盘I / O操作。

二级索引与聚集索引的关系

除聚集索引之外的所有索引都称为二级索引。在中InnoDB,二级索引中的每个记录都包含该行的主键列以及为二级索引指定的列。 InnoDB使用此主键值在聚集索引中搜索行。如果主键较长,则辅助索引将使用更多空间,因此短主键能节省空间。后面会以实际样例解析二者的关系。

物理结构

除空间索引外,InnoDB 索引是 B-Tree数据结构。索引页(index page)的默认大小为16KB。可通过参数 innodb_page_size 进行调整,如: 4K、8K、32K、64K 等。当插入新的记录到InnoDB聚集索引中时,InnoDB会保留页面的1/16空间,以便将来插入和更新索引记录。如果是随机插入,则保留比例最多达到1/2。保留的空间为了应对今后数据的指数增长。那什么是页呢?

IBD File的结构

MySQL 把每个表的数据存储在一个 IBD File 也就是 .ibd 文件中,我们先来看看它的结构。

在这里插入图片描述

从图中可以看到,除了前3个必须的页以外,从第3页(下标重0开始)开始分配索引页(index page),按表定义中索引的顺序进行分配:

  • 第一个索引的root页(一般为聚集索引)。
  • 第二个索引的root页(一般为第一个二级索引),如果这个表还有其他索引,依次类推。第5页开始是结点页。
  • 结点页存放的是除了root页和叶子页(leaf page)的所有索引的节点。
  • 接着就是叶子页,叶子页存放的是聚集索引关联的数据行。因为大部分InnoDB 的记录结构都存放在系统表空间,分配给独立表空间的每个page将会是一个索引类型,同时存储了表的数据。

INDEX结构

既然知道了索引储存在索引页,那我们再来看看索引页的结构。

在这里插入图片描述

从上图可以看到,index page中包含FIL Header/Trailer,他们的结构如下:

在这里插入图片描述

其中Previous PageNext Page分别代表上一页的指针和下一页的指针。根据这两个指针,我们很容易就能想到,page连起来就是一个双向链表Offset代表这个页的唯一编号。

再回到索引页的结构,除了FIL Header/Trailer之外,还有一个重要的组成成分,那就是User Records,User Records 就是整个页面中真正用于存放记录的部分,包括指向下一页的指针(非叶子页)或者行记录(叶子页),而 Free Space 就是空余空间了,它是一个单向链表的数据结构,必须要维护好单链表的有序性,行记录在物理存储上并不是按照顺序的,它们之间的顺序是由 next_record 这一指针控制的。

一个索引页的结构是这样子的:

在这里插入图片描述

page分为叶子页(leaf page)和非叶子节页(non-leaf page),叶子页包含了实际的行数据,非叶子页只包含了非叶子页指针或叶子页指针。InnoDB为每一个page分配了一个level级别:叶子页的level是0,随着树分支往上走,level不断递增。

所有的非root页和非叶子页的页,都被称为internal page。

B+Tree

下面用B+Tree的结构图解释一下什么是root page,什么是internal page,什么是leaf page。

在这里插入图片描述

从上图就很清楚的标出了三者在树中的位置。并且能看出**User Records **中leaf page和non-leaf page的区别:

leaf page中存的是行记录(上图中的A和B),non-leaf page存的是指向下一页的指针(上图中的6和7)。

叶子页的简化结构图:

在这里插入图片描述

非叶子页的简化结构图:

在这里插入图片描述

无论是叶子页还是非叶子页, 里面包含的行User Records都有指向下一行的指针(在同一个page里的偏移量)。

到这里,索引的主要结构基本讲完了。

索引检索

前面已经讲到了,索引有两种,聚集索引和二级索引。那MySQL是如何通过他们找到数据的呢?下面我将分别以两个例子讲述查找过程,所用到的索引结构全部为简化后的结构。

聚集索引

eg1. 有以下一条SQL语句,其中ID为聚集索引:

SELECT * FROM T WHERE T.id=1;

它的搜索过程如下图所示:

在这里插入图片描述

当MySQL优化器选择聚集索引ID进行查询时,就会按照如上图所示,在ID索引树中查找ID=1的记录(查找过程这里就不多说了),找到ID=1的叶子页,前面讲叶子页的时候已经说过了,叶子页中User Records存储的是行记录,直接把数据返回即可。

二级索引

eg2. 有以下一条SQL语句,其中ID为聚集索引,a为二级索引:

SELECT * FROM T WHERE T.a=10;

它的搜索过程如下图所示:

在这里插入图片描述

当MySQL选择二级索引a进行查询时,会按照上图所示的查找流程,首先在二级索引树a中查找a=10的记录,该记录存储的内容是聚集索引的值。拿到聚集索引的值以后,MySQL就会到聚集索引树中查找ID=6的记录,就像eg1一样,这个过程叫做回表

也就是说,基于二级索引的查询需要多扫描一棵索引树。性能肯定不如聚集索引。那么有没有什么办法可以避免回表?有的。那就是覆盖索引。eg2中我们是SELECT *,查询所有字段,但如果我们SELECT id,只查询ID呢,由于a的索引树就存储了ID的值,所以就不用回表了。覆盖索引是一种常用的SQL优化手段。索引下推也能减少回表的次数,有关索引下推的内容,可以参考SQL优化之Select语句

最左前缀

上面提到的索引都是只引用了单个的列,实际上,MySQL中的索引可以引用多个列,这种索引叫做联合索引,也属于二级索引的一种。一般以元组(col1, col2, …, coln)的形式存在,其中各个元素均为数据表的一列,逐级有序。

eg. 有以下语句,创建一个联合索引a_b_index (a,b)

CREATE TABLE `t` (
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  KEY `a_b_index` (`a`,`b`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

联合索引的检索也和单列二级索引一样,只是索引中节点储存的值由独立值变为元组。

eg.3

SELECT * FROM T WHERE T.a=10 and T.b=10;

如下图:

在这里插入图片描述

B+Tree的优势

大家应该都知道InnoDB中最常用的索引类型是B+Tree,但是为什么要用B+Tree呢?我用平衡二叉树不行么?我用hash不行么?我用数组不行么?针对这些问题,我们来看看B+Tree有什么优势。

hash索引的数据结构是hash表,不支持范围查询,这个缺点就无法满足大部分人的需求。

数组的缺点也非常明显,当我插入和删除数据时,要做大批量数据的移动,代价太大。

平衡二叉树虽然满足上面的两个要求,但是平衡二叉树会导致树的层级太高。一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往也会像数据一样持久化到磁盘上。从上面说到的IBD File的结构就能证明这一点。如果使用平衡二叉树的话,你可以想象一下,一棵100万节点的平衡二叉树,树高20。一次查询可能需要访问20个索引页。虽然现在固态硬盘的普及,访问磁盘的速度有所提升,但20次也未免太多了点。那有没有什么办法可以减少磁盘的访问次数呢?有的,那就是减少树的高度,树的高度降下来了,访问次数自然就降低了。这就是为什么InnoDB选择B+Tree的原因。

B+Tree其实是一个“N叉树”,在相同层级的约束下,N叉树明显比二叉树容纳更多的数据。以InnoDB的一个整数字段索引为例,这个N差不多是1200。这棵树高是4的时候,就可以存1200的3次方个值,这已经17亿了。由于root page总是常驻内存的,所以查找一个值最多访问3次磁盘。

索引选择性

既然索引能加速查询,是不是越多越好呢?答案是否定的,你要知道凡事都有优缺点,虽然索引能加速查询,但是它会消耗存储空间,并且会给增加,删除,更新带来负担,所以说并不是越多越好,你要在查询和更改性能上做取舍。以下两种情况下不建议建索引:

  • 数据少的表不建索引,样表做全表扫描可能会有更好的性能。对于行数少于10行且行长度较短的表,这种情况很常见。
  • 索引的选择性较低。即(distinct key/key)的比值。取值范围为(0, 1],选择性越高的索引价值越大。

索引的维护

上面已经说过了,索引会给增加,删除,更新带来负担,那为什么会带来负担呢?主要是维护索引的有序性导致的。那InnoDB又是如何维护索引的有序性的呢?由于User Records中的内容是单链表结构,所以叶子页的增加,删除,更新完全遵循单链表的规则。

Insert

首先InnoDB会遍历root page和node page找到合适的leaf page进行插入,并更新他们,来看看插入的过程:

在这里插入图片描述

在插入的过程,必须保证单链表的有序性。由于每个page的大小默认为16kb,随着我们不断的插入数据,page的剩余容量会逐渐减小,虽然我们这上面提到过InnoDB会为每页保留1/16的空间,来应对今后数据的指数增长,但是空间总会用完的,到那时我们需要在这一页上插入数据又该怎么办呢?

这时候需要申请一个新的页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。

Delete

了解了Insert的过程,我们在来看看Delete的过程又是怎么样的:

在这里插入图片描述

删除i=5的记录,需要在leaf page中的User Records中查找k为5的节点,由于leaf page中User Records为单链表,所以需要遍历单链表查找k为5的节点,找到后就断开与它的连接,并打上删除标记(D: Yes),然后更新垃圾偏移量为@258,被删除的节点的Next record指针指向它自己,并更新垃圾的总大小。到这里为止,InnoDB只是完成了逻辑的删除,i=5的记录任然存在物理磁盘上。那什么时候回触发物理删除呢? 严格来说,删除标记记录的写磁盘操作将在清除操作期间稍后删除。

Update

我们在来看看Update的过程又是怎么样的:

在这里插入图片描述

更新i=5的记录为“abcde”,需要在leaf page中的User Records中查找k为5的节点,由于leaf page中User Records为单链表,所以需要遍历单链表查找k为5的节点,找到后修改v=abcde。

总结

这篇文章写了一周的时间,由于对mysql低层原理了解有限,有些东西并未深入展开,期间也查阅的相关的资料,让自己对MySQL的认知更进一步。本文只是聊了聊InnoDB的B+Tree索引,对于MySQL的其他索引并未涉及到,例如全文索引,hash索引等等,之后会找机会进行补充。

参考

[1] MySQL 5.7 Reference Manual

[2] The basics of InnoDB space file layout

发布了11 篇原创文章 · 获赞 40 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_36011946/article/details/104995332