You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

211 lines
15 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 04 | 深入浅出索引(上)
提到数据库索引我想你并不陌生在日常工作中会经常接触到。比如某一个SQL查询比较慢分析完原因之后你可能就会说“给某个字段加个索引吧”之类的解决方案。但到底什么是索引索引又是如何工作的呢今天就让我们一起来聊聊这个话题吧。
数据库索引的内容比较多,我分成了上下两篇文章。索引是数据库系统里面最重要的概念之一,所以我希望你能够耐心看完。在后面的实战文章中,我也会经常引用这两篇文章中提到的知识点,加深你对数据库索引的理解。
一句话简单来说索引的出现其实就是为了提高数据查询的效率就像书的目录一样。一本500页的书如果你想快速找到其中的某一个知识点在不借助目录的情况下那我估计你可得找一会儿。同样对于数据库的表而言索引其实就是它的“目录”。
# 索引的常见模型
索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,所以这里也就引入了索引模型的概念。可以用于提高读写效率的数据结构很多,这里我先给你介绍三种常见、也比较简单的数据结构,它们分别是哈希表、有序数组和搜索树。
下面我主要从使用的角度,为你简单分析一下这三种模型的区别。
哈希表是一种以键-值key-value存储数据的结构我们只要输入待查找的键即key就可以找到其对应的值即Value。哈希的思路很简单把值放在数组里用一个哈希函数把key换算成一个确定的位置然后把value放在数组的这个位置。
不可避免地多个key值经过哈希函数的换算会出现同一个值的情况。处理这种情况的一种方法是拉出一个链表。
假设,你现在维护着一个身份证信息和姓名的表,需要根据身份证号查找对应的名字,这时对应的哈希索引的示意图如下所示:
![](https://static001.geekbang.org/resource/image/0c/57/0c62b601afda86fe5d0fe57346ace957.png)
图1 哈希表示意图
图中User2和User4根据身份证号算出来的值都是N但没关系后面还跟了一个链表。假设这时候你要查ID\_card\_n2对应的名字是什么处理步骤就是首先将ID\_card\_n2通过哈希函数算出N然后按顺序遍历找到User2。
需要注意的是图中四个ID\_card\_n的值并不是递增的这样做的好处是增加新的User时速度会很快只需要往后追加。但缺点是因为不是有序的所以哈希索引做区间查询的速度是很慢的。
你可以设想下,如果你现在要找身份证号在\[ID\_card\_X, ID\_card\_Y\]这个区间的所有用户,就必须全部扫描一遍了。
所以,**哈希表这种结构适用于只有等值查询的场景**比如Memcached及其他一些NoSQL引擎。
而**有序数组在等值查询和范围查询场景中的性能就都非常优秀**。还是上面这个根据身份证号查名字的例子,如果我们使用有序数组来实现的话,示意图如下所示:
![](https://static001.geekbang.org/resource/image/bf/49/bfc907a92f99cadf5493cf0afac9ca49.png)
图2 有序数组示意图
这里我们假设身份证号没有重复这个数组就是按照身份证号递增的顺序保存的。这时候如果你要查ID\_card\_n2对应的名字用二分法就可以快速得到这个时间复杂度是O(log(N))。
同时很显然,这个索引结构支持范围查询。你要查身份证号在\[ID\_card\_X, ID\_card\_Y\]区间的User可以先用二分法找到ID\_card\_X如果不存在ID\_card\_X就找到大于ID\_card\_X的第一个User然后向右遍历直到查到第一个大于ID\_card\_Y的身份证号退出循环。
如果仅仅看查询效率,有序数组就是最好的数据结构了。但是,在需要更新数据的时候就麻烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。
所以,**有序数组索引只适用于静态存储引擎**比如你要保存的是2017年某个城市的所有人口信息这类不会再修改的数据。
二叉搜索树也是课本里的经典数据结构了。还是上面根据身份证号查名字的例子,如果我们用二叉搜索树来实现的话,示意图如下所示:
![](https://static001.geekbang.org/resource/image/04/68/04fb9d24065635a6a637c25ba9ddde68.png)
图3 二叉搜索树示意图
二叉搜索树的特点是父节点左子树所有结点的值小于父节点的值右子树所有结点的值大于父节点的值。这样如果你要查ID\_card\_n2的话按照图中的搜索顺序就是按照UserA -> UserC -> UserF -> User2这个路径得到。这个时间复杂度是O(log(N))。
当然为了维持O(log(N))的查询复杂度你就需要保持这棵树是平衡二叉树。为了做这个保证更新的时间复杂度也是O(log(N))。
树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。
你可以想象一下一棵100万节点的平衡二叉树树高20。一次查询可能需要访问20个数据块。在机械硬盘时代从磁盘随机读一个数据块需要10 ms左右的寻址时间。也就是说对于一个100万行的表如果使用二叉树来存储单独访问一个行可能需要20个10 ms的时间这个查询可真够慢的。
为了让一个查询尽量少地读磁盘就必须让查询过程访问尽量少的数据块。那么我们就不应该使用二叉树而是要使用“N叉”树。这里“N叉”树中的“N”取决于数据块的大小。
以InnoDB的一个整数字段索引为例这个N差不多是1200。这棵树高是4的时候就可以存1200的3次方个值这已经17亿了。考虑到树根的数据块总是在内存中的一个10亿行的表上一个整数字段的索引查找一个值最多只需要访问3次磁盘。其实树的第二层也有很大概率在内存中那么访问磁盘的平均次数就更少了。
N叉树由于在读写上的性能优点以及适配磁盘的访问模式已经被广泛应用在数据库引擎中了。
不管是哈希还是有序数组或者N叉树它们都是不断迭代、不断优化的产物或者解决方案。数据库技术发展到今天跳表、LSM树等数据结构也被用于引擎设计中这里我就不再一一展开了。
你心里要有个概念,数据库底层存储的核心就是基于这些数据模型的。每碰到一个新数据库,我们需要先关注它的数据模型,这样才能从理论上分析出这个数据库的适用场景。
截止到这里,我用了半篇文章的篇幅和你介绍了不同的数据结构,以及它们的适用场景,你可能会觉得有些枯燥。但是,我建议你还是要多花一些时间来理解这部分内容,毕竟这是数据库处理数据的核心概念之一,在分析问题的时候会经常用到。当你理解了索引的模型后,就会发现在分析问题的时候会有一个更清晰的视角,体会到引擎设计的精妙之处。
现在,我们一起进入相对偏实战的内容吧。
在MySQL中索引是在存储引擎层实现的所以并没有统一的索引标准即不同存储引擎的索引的工作方式并不一样。而即使多个存储引擎支持同一种类型的索引其底层的实现也可能不同。由于InnoDB存储引擎在MySQL数据库中使用最为广泛所以下面我就以InnoDB为例和你分析一下其中的索引模型。
# InnoDB 的索引模型
在InnoDB中表都是根据主键顺序以索引的形式存放的这种存储方式的表称为索引组织表。又因为前面我们提到的InnoDB使用了B+树索引模型所以数据都是存储在B+树中的。
每一个索引在InnoDB里面对应一棵B+树。
假设我们有一个主键列为ID的表表中有字段k并且在k上有索引。
这个表的建表语句是:
```
mysql> create table T(
id int primary key,
k int not null,
name varchar(16),
index (k))engine=InnoDB;
```
表中R1~R5的(ID,k)值分别为(100,1)、(200,2)、(300,3)、(500,5)和(600,6),两棵树的示例示意图如下。
![](https://static001.geekbang.org/resource/image/dc/8d/dcda101051f28502bd5c4402b292e38d.png)
图4 InnoDB的索引组织结构
从图中不难看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。
主键索引的叶子节点存的是整行数据。在InnoDB里主键索引也被称为聚簇索引clustered index
非主键索引的叶子节点内容是主键的值。在InnoDB里非主键索引也被称为二级索引secondary index
根据上面的索引结构说明,我们来讨论一个问题:**基于主键索引和普通索引的查询有什么区别?**
* 如果语句是select \* from T where ID=500即主键查询方式则只需要搜索ID这棵B+树;
* 如果语句是select \* from T where k=5即普通索引查询方式则需要先搜索k索引树得到ID的值为500再到ID索引树搜索一次。这个过程称为回表。
也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。
# 索引维护
B+树为了维护索引有序性在插入新值的时候需要做必要的维护。以上面这个图为例如果插入新的行ID值为700则只需要在R5的记录后面插入一个新记录。如果新插入的ID值为400就相对麻烦了需要逻辑上挪动后面的数据空出位置。
而更糟的情况是如果R5所在的数据页已经满了根据B+树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。
除了性能外页分裂操作还影响数据页的利用率。原本放在一个页的数据现在分到两个页中整体空间利用率降低大约50%。
当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。
基于上面的索引维护过程说明,我们来讨论一个案例:
> 你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该。
自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO\_INCREMENT。
插入新记录的时候可以不指定ID的值系统会获取当前ID最大值加1作为下一条记录的ID值。
也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。
而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?
由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键那么每个二级索引的叶子节点占用约20个字节而如果用整型做主键则只要4个字节如果是长整型bigint则是8个字节。
**显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。**
所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。
有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:
1. 只有一个索引;
2. 该索引必须是唯一索引。
你一定看出来了这就是典型的KV场景。
由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。
这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。
# 小结
今天我跟你分析了数据库引擎可用的数据结构介绍了InnoDB采用的B+树结构以及为什么InnoDB要这么选择。B+树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。
由于InnoDB是索引组织表一般情况下我会建议你创建一个自增主键这样非主键索引占用的空间最小。但事无绝对我也跟你讨论了使用业务逻辑字段做主键的应用场景。
最后我给你留下一个问题吧。对于上面例子中的InnoDB表T如果你要重建索引 k你的两个SQL语句可以这么写
```
alter table T drop index k;
alter table T add index(k);
```
如果你要重建主键索引,也可以这么写:
```
alter table T drop primary key;
alter table T add primary key(id);
```
我的问题是,对于上面这两个重建索引的作法,说出你的理解。如果有不合适的,为什么,更好的方法是什么?
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上一篇文章末尾给你留下的问题是:如何避免长事务对业务的影响?
这个问题,我们可以从应用开发端和数据库端来看。
**首先,从应用开发端来看:**
1. 确认是否使用了set autocommit=0。这个确认工作可以在测试环境中开展把MySQL的general\_log开起来然后随便跑一个业务逻辑通过general\_log的日志来确认。一般框架如果会设置这个值也就会提供参数来控制行为你的目标就是把它改成1。
2. 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用begin/commit框起来。我见过有些是业务并没有这个需要但是也把好几个select语句放到了事务中。这种只读事务可以去掉。
3. 业务连接数据库的时候根据业务本身的预估通过SET MAX\_EXECUTION\_TIME命令来控制每个语句执行的最长时间避免单个语句意外执行太长时间。为什么会意外在后续的文章中会提到这类案例
**其次,从数据库端来看:**
1. 监控 information\_schema.Innodb\_trx表设置长事务阈值超过就报警/或者kill
2. Percona的pt-kill这个工具不错推荐使用
3. 在业务功能测试阶段要求输出所有的general\_log分析日志行为提前发现问题
4. 如果使用的是MySQL 5.6或者更新版本把innodb\_undo\_tablespaces设置成2或更大的值。如果真的出现大事务导致回滚段过大这样设置后清理起来更方便。
感谢 @壹笙☞漂泊 @王凯 @易翔 留下的高质量评论。