我们经常在做数据库慢查询优化时,最容易得出的结论便是:给这个字段加个索引吧。然而大部分同学,对于索引的工作实际上是知其然不知其所以然。今天分享的这篇文章,便是希望大家对索引有一个更深层次的掌握,以便于聊起索引时,能做到心中有蓝图,口中吐”芬芳“。

    首先简单的来说,索引的出现是为了提高查询效率,但是实现索引的方式却有很多种。因此,我们需要引入索引模型的概念。而可以用于提高读写效率的数据结构也很多,这里我们暂且引入三种较为常见也比较基础的数据结构:哈希表、有序数组和搜索树。

我们学习知识是为了加以使用,因此下面我将以使用的角度,来简要分析一下这三种模型的区别。

1. 哈希表

    哈希表是一种以键值对(key-value)形式存储数据的结构,我们只需要输入待查找的key,即可得到对应的value。因此,哈希的思路非常简单,把value放在数组里,用一个哈希函数把key换算成一个确定的位置,然后把value放在数组的这个位置。(对于哈希表更加详细的描述可以参照jdk对哈希表的实现:HashMap)

不可避免的,当键值对数据越来越多时,多个key之间通过哈希运算可能会求出相同的一个数组位置,处理这种情况的一种常见思路便是:在该位置拉出一个链表。

需要注意的是,哈希运算得出的数组位置并不能保证是有序递增的,这样的好处是每次新增时都只需要往后追加,无需挪动其他数据。但缺点是,因为不是有序的,因此使用哈希索引做区间查询时的速度是极慢的,基本上需要全部扫描一遍。

因此,哈希表这种数据结构只适用于只有等值查询的场景,比如Memcached以及其他的一些NoSQL引擎。

2. 有序数组

    有序数组在等值查询和区间(范围)查询的场景中的性能都非常优秀。正因为这种数据结构保证了数据存储的有序,稍微了解算法知识的同学就能想到,可以使用二分查找快速定位数据位置,这个时间复杂度也仅是O(log(N))。

同时很显然,这种数据结构也支持范围查询,假设你要查询值在5(start)~10(end)之间的数据,此时你只需要使用二分查找定位到5的位置,然后计算出5~10之间的偏移量,然后向右遍历,直到查到第一个大于end的数据,然后退出循环即可。

若仅仅只看查询效率,有序数组便是最好的数据结构了,但是,在需要更新数据的时候则是一个灾难。你往中间插入一个记录就必须得挪动后面所有的数据,成本极高。

因此,有序数组索引只适用于静态存储引擎,比如你需要存储2008年某个城市的所有人口信息,这类不在修改的数据(只有读场景)。

3. 二叉搜索树

    二叉树是学习过程中一门非常经典的数据结构了,它的特点是:父节点左子树所有节点的值小于父节点的值,右子树所有节点的值大于父节点的值,时间复杂度为:O(log(N))

当然,为了维持二叉树O(log(N))的复杂度,就需要保持这棵树是平衡二叉树,为了做这个保证,更新的时间也将是O(log(N))。

而树可以有二叉,也可以多叉。多叉树即每个节点有多个子节点,而子节点之间的大小保证从左到右递增。

二叉树的搜索效率是最高的,但实际上大多数的数据库存储却并不会使用二叉树。原因是索引不止存在于内存中,还会写到磁盘上。

你可以想象有一颗100万节点的平衡二叉树,树高20。一次查询可能需要访问20个数据块。在机械硬盘时代,从磁盘随机读取一个数据块大概需要10ms左右的寻址时间,也就是说,对于一个100万行的表,如果使用二叉树来存储,单独访问一个行可能需要20个10ms的时间,这往往是不可接受的。

为了让一个查询尽量少的读磁盘,就必须让查询过程尽量少的访问数据块。那么我们就不应该使用二叉树,而是使用“N”叉树。这里“N”叉树中的“N”取决于数据块的大小。

以InnoDB的一个整数字段索引为例,这个N差不多是1200。这棵树高是4的时候,就可以存1200的3次方值(≈17亿)。考虑到树根的数据块总是在内存中的,一个10亿行的表上一个整数字段的索引,查找一个值最多只需要访问3次磁盘。并且,树的第二层也有很大的概率被写入内存中,因此访问磁盘的平均次数就更少。

N叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中。

    不管是哈希还是有序数组,又或者是N叉树,它们都是前人不断迭代、优化的产物或者解决方案。数据库技术发展到今天,跳表、LSM树等数据结构也被应用在各种数据库引擎设计中。

而我们学习这些知识点只是为了让自己有一个概念,即数据库底层存储的核心实际上就是基于这些数据模型的。每碰到一个新数据库,我们首先应该关注的是它的数据模型,从下而上才能从理论上分析出这个数据库的适用场景。

而理论却终究偏于枯燥乏味,下面我们将以几条SQL执行过程中索引的工作方式,来加深对索引的理解。

InnoDB索引模型

    InnoDB中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称之为索引组织表。而InnoDB使用了B+树索引模型,所以数据都是存储在B+树中的。

这里我们需要注意的一个点是:每一个索引在InnoDB里面都对应一颗B+树。

假设,我们有一张表T,有两个字段,分别为主键:id,字段k,并且在k上有索引。这个表的建表语句为:

mysql> create table T(id int primary key,k int not null,name varchar(16),index (k)) engine=InnoDB;

表结构与测试数据如下图:

idx 返回索引值 id做索引_主键

而这张表如前所述,存在两个索引,一是id的主键索引,二是k上的非主键索引。

在InnoDB中,他们的索引数据结构如图所示:

idx 返回索引值 id做索引_根据一个id查找出数组里面的数据并改掉_02

主键索引的叶子节点存的是整行数据,在InnoDB里,主键索引也被称为聚簇索引(clustered index)。

非主键索引的叶子节点的内容是主键的值,在InnoDB里,非主键索引也被称为 二级索引(secondary index)。

下面我们将以一条SQL来学习基于主键索引和普通索引的查询有什么区别。

如果有SQL:

select * from T where id = 500

即主键查询方式,则只需要搜索id这颗B+树;

如果有SQL:

select * from T where k = 5

即普通索引查询方式,则需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次,得到整行数据,这个过程便是回表。

也就是说,基于非主键索引的查询需要多扫描一颗索引树。因此,我们在实际开发中应该尽可能的使用主键查询。

索引维护

    B+树为了维护索引的有序性,在插入新值的时候需要做必要的维护。以上面的图为例,如果新插入行id值为700,则只需要在行5后面插入一个新记录(行记录从0开始)。如果新插入的id值为400,就相对麻烦了,需要逻辑上移动后面的数据,空出位置。

而更加糟糕的情况是,如果row 5所在的数据页已经满了,根据B+树的算法,这时候需要申请一个新的数据页,然后挪动部分数据到新的数据页中,这个过程称之为页分裂。在这种情况下,性能自然会受到较大的影响。

除了性能外,页分裂操作还会影响数据页的利用率,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。

基于上面索引维护过程说明,我们可以回过头去看看我们工作中时常讨论的一个案例:

我们在建表的时候往往需要设置主键,而主键是设置为递增主键还是非递增呢?

自增主键是指自增列上定义的主键,在建表语句中一般是这样定义的:

NOT NULL PRIMARY KEY AUTO_INCREMENT

在递增键上,插入新记录可以不指定它的值,系统会获取当前id最大值然后加一作为下一条记录的id值。

也就是说,自增主键的插入数据模式,正符合了我们提到的递增插入的场景,每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。

而用不递增的字段做主键,则往往不容易保证插入的有序性,这样写数据的成本相对较高。

除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号码,那应该用身份证号做主键,还是用自增字段做主键呢?

由于每个非主键索引的叶子节点上都是主键的值,如果用身份证号码做主键,那么每个二级索引(非主键索引)上叶子节点的值存储的占用约为20个字节(非主键索引存储的内容是主键值,即身份证号码)。而如果用整型做主键,则只需要4个字节,如果是长整型,则也不过8个字节。

很显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。

所以从性能和存储空间方面考量,自增主键往往是更合理的选择。

但事无绝对,这并不代表非递增的字段就不适于作为主键,有以下场景:

  1. 只有一个索引;
  2. 该索引必须是唯一索引;

仔细思考一番,你会发现,这就是典型的KV场景。

由于没有其他索引,所以不需要考虑其他索引的叶子节点大小的问题,这时候我们优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询都需要搜索两棵树。