转自:MySQL为什么不用数组、哈希表、二叉树等数据结构作为索引呢 - 墨天轮
前言
在上一篇博客中详细说明了 MySQL 的索引使用的是 B+Tree 这种数据结构,而不是 B-Tree,然而平时我们接触到了很多高效的数据结构,例如数组、哈希表、二叉搜索树、红黑树等,那为什么 MySQL 不选择这些数据结构作为索引呢?
MySQL 作为存储数据的组件,它的主要操作就是数据的增删改查,其中查询操作又是重中之重。我们经常所说的数据库优化,大部分优化的就是查询相关的操作。因此一个数据库选择何种数据结构作为索引,主要考虑因素就是这种数据结构对增删改查操作的效率如何,尤其是查询操作(通常查询操作包括等值查询、范围查询等)。本文接下来将从增删改查的角度来分析,为什么 MySQL 最终选用的是 B+Tree 作为索引的数据结构。
因为 MySQL 在 5.5 版本以后,默认的存储引擎是 InnoDB,所以本文所讨论的内容,全是基于存储引擎是 InnoDB 为前提。
数组
数组这种数据结构,应该是我们接触编程起,最先遇到的数据结构了。数组是内存中一块连续的内存空间,定义一个数组对象,这个对象的指针指向了这块内存的起始地址,如果知道数组元素的下标,那么就可以计算出该下标所对应的元素的内存地址了,因此可以在 O(1)的时间复杂度内获取到元素,非常快速。
对于一个有序数组,它的查找过程可以使用二分查找法进行查找,时间复杂度为 O(logn),效率也非常高。因为是有序数组,因此对范围查找也十分友好,只需要找到起始元素即可。如果在知道元素下标的情况下,更新操作也非常快,对于删除操作,如果我们不考虑空洞的话(如果直接将对应下标处的元素置为 null,这样这块连续内存块中相当于有个空洞),删除操作也很快。
这么一分析,数组对查询、删除、更新操作的效率非常高,选数组作为 MySQL 索引的数据结构看起来似乎不错。然而我们忽略了还有插入操作,如果我们要往数组中间插入一个数据,我们需要将数组中要插入的目标位置后的所有元素先往后挪动一个位置,然后才能插入新的数据,也就是涉及到了数组的复制操作,要插入的数据越靠前,那么我们需要复制的数据就越多,这个不仅需要额外开辟内存,复制数据消耗的时间也很长。而我们在平时的开发中,生产环境的一张表的大小动辄就 1GB 以上了,如果要往中间插入一条数据时,那得复制多少数据啊!
因此,从插入数据这一角度来看,数组不太适合作为 MySQL 索引的数据结构。
哈希表
哈希表是一种 key-value 形式的数据结构,底层采用数组+链表结构来实现,它是将 key 通过一个哈希函数计算出一个数字,然后以该数字作为数组的下标,然后将 value 存放到对应下标的数组中。对于不同的 key,在经过哈希函数计算后,可能出现相同的值,这种情况叫做哈希冲突,这时候就意味着同一个数组下标处要存放两个元素了,所以这个时候将数组中的元素变为一个链表,通过链表将这两个元素串联起来。
根据上面哈希表的特点来看,哈希表对于删除、查找、更新、插入操作,都是先根据 key 计算出一个值,就能定位到数据的目标位置了,时间复杂度都是 O(1),速度特别快。但是我们在使用 MySQL 时,经常会遇到查找某个范围内的数据,例如 between...and、>=、<=等范围查找操作。这个时候哈希表应该怎么办呢?
因为哈希表的所有 key 都会经过哈希函数计算,然后再存放数据,本来可能有序的 key,但经过哈希函数计算出来的值就不是有序的了,所以这个时候,如果要在哈希表中进行范围查找,就只能对整个哈希表进行遍历了,只有符合条件范围的数据,才取出来。当我们数据库中的数据越来越多,达到几百万甚至几千万条的时候,这个时候,对整个表的遍历是非常耗时的。
因此,从范围查询的场景来看,哈希表也不太适合作为 MySQL 索引的数据结构。哈希表适用于什么场景呢?适用于等值查询的场景,最经典的场景就是 NOSQL 数据库,例如我们最常用的 redis,redis 中不就是全都是 key-value 吗?
实际上,MySQL 中也有地方使用哈希作为索引。使用 MySQL 的同学大部分都应该使用过 Navicat 这款工具吧,这是一个可视化的 MySQL 客户端远程连接工具,在这个工具中,我们可以在可视化界面中进行数据库的相关操作,例如增删改查、修改表结构、创建索引等。在创建索引时,我们其实可以选择索引的数据结构,它有两个选项:B+Tree 和 HASH,如果你不勾选,默认情况下是 B+Tree。如下图所示。
通常情况下,不建议将索引的结构选为 HASH,除非业务的场景的确符合 key-value 这种场景,例如业务系统的一些 key-value 形式的配置项的表、数据字典等功能。
二叉树
每个节点最多有两个子结点的树称之为二叉树,比较特殊且常用的二叉树有二叉搜索树、AVL 树(平衡树)、红黑树等。
对于二叉搜索树而言,它的查找操作的时间复杂度就是树的高度,最理想的情况下,也就是满二叉树的情况下,查找的时间复杂度为 O(logn)。当我们在不停地动态地往树中插入数据、删除数据时,在极端情况下,二叉搜索树可能退化成链表,它的查找时间复杂度就变成了 O(n),性能不够稳定。
平衡树是在二叉查找树的基础上,增加了一条限制,左右两个子树的高度差不能超过 1,左右两边相对平衡,因此称之为平衡树。而平衡树在数据动态地删除、插入地过程中,为了维护平衡,避免树退化成链表,因此需要在删除或者插入数据后进行额外的旋转操作,会损耗一定的性能,但整体来讲,它的查找、删除、插入、更新的复杂度均为 O(logn)。它的中序遍历,数据是有序的,因此也适合范围查找。但是它的缺点是,为了维护平衡,它的旋转操作过于复杂。
在平衡二叉树的基础上又出现了红黑树,关于红黑树的性质就在这里不细说了,内容太多了,后面会单独写博客介绍。整体上来说,红黑树是一种近似平衡(不完全平衡),结点非黑即红的树,它的树高最高不会超过 2logn,因此查找的时间复杂度为 O(logn),无论是增删改查,它的性能都十分稳定。工程上,很多地方都使用的是红黑树这种数据结构,例如 Java 中的 HashMap、TreeMap 等。
咋一看,AVL 树和红黑树的增删改查性能都十分稳定,虽然中间涉及到很多旋转操作,实现过于复杂,但是还是能用代码实现出来的,只要能实现出来,这都不是事啊,性能好、够稳定就行,那为什么不用 AVL 树和红黑树去作为 MySQL 的索引数据结构呢?
这是因为无论是二叉搜索树,还是 AVL 树,亦或是红黑树,它们都是二叉树的一种,特点都是每个结点最多只有两个子结点,如果存储大量数据的话,那么树的高度会非常高。而 MySQL 存储的数据最终是要落地到磁盘的,MySQL 应用程序读取数据时,需要将数据从磁盘先加载到内存后才能继续操作,所以这中间会发生磁盘 IO,而如果树太高,每遍历一层结点时,就需要从磁盘读取一次数据,也就是发生一次 IO,如果数据在树高为 20 的地方,那查找一次数据就得发生 20 次 IO,这对应用程序简直就是灾难性的,耗时太长了。因此二叉树在 MySQL 这种需要存储大量数据的场景下,是不适合当做索引的数据结构的,因为树太高,操作数据时会发生多次磁盘 IO,性能太差。
B-Tree 与 B+Tree
既然二叉树因为每个结点最多只有两个子结点,最终在存储大量数据时导致树高太高,因此不适合当做 MySQL 的索引,那么让树的每个结点尽可能多的拥有多个子结点,也就是多叉树,那这样在大量储存数据时,树高就低很多了,那这样总应该可以了吧!而多叉树中典型的例子有 B-Tree 和 B+Tree。
B-Tree 的特点是无论叶子结点和非叶子结点,它都存有索引值和数据;B+Tree 的特点是只有叶子结点才会存放索引值和数据,非叶子结点只会存放索引值本身。因此对于非叶子结点,一个结点中,B+Tree 存放的索引值数量会远远大于 B-Tree(因为一个结点的空间是有限的,B-Tree 要存放索引+数据,而 B+Tree 只需要存放索引),这样就导致了每个结点中,B+Tree 能向下分出更多的叉,子结点数更多。
那么在要存储同样大小的数据文件的场景下,用 B+Tree 存储,最终树的高度会远远小于用 B-Tree 存储的高度,所以使用 B+Tree 作为 MySQL 索引的数据结构,将来在读取数据时,发生的磁盘 IO 次数会更少,性能更优,因此最终 MySQL 索引的数据结构使用的是 B+Tree。