DDIA 2. 存储与检索

目录

  1. 索引的优缺点
  2. LSM树 VS B树
  3. OLTP VS OLAP
  4. 列存储和作用
  5. 物化视图和作用

1. 索引的优缺点

索引的优点

  1. 能提高数据的搜索及检索速度,符合数据库建立的初衷。

  2. 能够加快表与表之间的连接速度,这对于提高数据的参考完整性方面具有重要作用。

  3. 在信息检索过程中,若使用分组及排序子句进行时,通过建立索引能有效的减少检索过程中所需的分组及排序时间,提高检索效率。

  4. 建立索引之后,在信息查询过程中可以使用优化隐藏器,这对于提高整个信息检索系统的性能具有重要意义。

索引的缺点

  1. 在数据库建立过程中,需花费较多的时间去建立并维护索引,特别是随着数据总量的增加,所花费的时间将不断递增。

  2. 在数据库中创建的索引需要占用一定的物理存储空间,这其中就包括数据表所占的数据空间以及所创建的每一个索引所占用的物理空间。

  3. 在对表中的数据进行修改时,例如对其进行增加、删除或者是修改操作时,索引还需要进行动态的维护,这给数据库的维护速度带来了一定的麻烦。

2. LSM树 VS B树

B树引擎:

B树:多路搜索树,每个结点存储M/2到M个关键字,非叶子结点存储指向关键字范围的子结点;所有关键字在整颗树中出现,且只出现一次,非叶子结点可以命中;

B+树:在B-树基础上,为叶子结点增加链表指针,所有关键字都在叶子结点中出现,非叶子结点作为叶子结点的索引;B+树总是到叶子结点才命中;

B+树虽然优点很多,但是B树也有优点,其优点在于,由于B树的每一个节点都包含key和value,因此经常访问的元素可能离根节点更近,因此访问也更迅速。下面是B 树和B+树的区别图:

这里写图片描述

为什么说B+tree比B树更适合实际应用中操作系统的文件索引和数据库索引?

(1) B+tree的磁盘读写代价更低
B+tree的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。

举个例子,假设磁盘中的一个盘块容纳16bytes,而一个关键字2bytes,一个关键字具体信息指针2bytes。一棵9阶B-tree(一个结点最多8个关键字)的内部结点需要2个盘快。而B+ 树内部结点只需要1个盘快。当需要把内部结点读入内存中的时候,B 树就比B+ 树多一次盘块查找时间(在磁盘中就是盘片旋转的时间)。

(2)B+tree的查询效率更加稳定
由于非叶子结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

(3)B树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题。正是为了解决这个问题,B+树应运而生。B+树只要遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作(或者说效率太低)。

哈希存储引擎:

是哈希表的持久化实现,支持增、删、改以及随机读取操作,但不支持顺序扫描,对应的存储系统为key-value存储系统。对于key-value的插入以及查询,哈希表的复杂度都是O(1),明显比树的操作O(n)快,如果不需要有序的遍历数据,哈希表性能最好。

这里我们通过哈希索引来分析一下上文提及的那个简易的键值数据库。最简单的索引策略是:保持一个内存的哈希映射,其中每一个键都映射到数据文件中的字节偏移量,通过偏移量可以找到该值的位置,如下图所示:

image.png

每当向文件追加一个新的键值对时,也会同时更新哈希映射以反映刚才写入的数据的偏移量(这既可以用于插入新的键值对,也可以用于更新现有的键值对)。在查找值时,使用哈希映射查找数据文件中的偏移量,查找该位置并读取该值。

那么我们如何避免最终耗尽磁盘空间呢?一个好的解决方案是,我们可以对这些文件执行压缩,如下图所示。压缩意味着在文件中扔掉重复的键,并且只保留每个键的最新更新。

image.png

合并和压缩可以由后台线程完成,并且在进行合并和压缩操作时,我们仍然可以使用旧的文件继续正常地服务读写请求。在合并过程完成后,我们将读取请求转换为使用新合并的文件,然后旧的文件可以简单地删除。
缺点
(1)哈希索引严重依赖于内存,所以如果Key的数量庞大,需要匹配足够的内存空间。
(2)范围查询效率不高,每查找一个值都需要一次键值对映射。

LSM树(Log-Structured MergeTree)存储引擎

同样支持增、删、读、改、顺序扫描操作。而且通过批量存储技术规避磁盘随机写入问题。当然凡事有利有弊,LSM树和B+树相比,LSM树牺牲了部分读性能,用来大幅提高写性能。

由哈希索引我们可以引申出更加高效的索引结构:SSTable(Sorted String Table),SSTable要求键值对序列按照键来排序。乍一看,这个要求似乎破坏了顺序写的性能,但是它大大提高了维护数据以及索引结构的效率。

  • 合并文件既简单又高效,使用简单的归并排序算法。
image.png
*   不再需要保留所有键在内存中的索引,只需要保留部分键的索引,利用键在SSTable之中有序的特点。
image.png
*   可以进行分组压缩,每个索引可以指向压缩块的起始点,来节省存储空间与减少I/O带宽的使用。

LSM树核心思想的核心就是放弃部分读能力,换取写入的最大化能力。LSM Tree ,这个概念就是结构化合并树的意思,它的核心思路其实非常简单,就是假定内存足够大,因此不需要每次有数据更新就必须将数据写入到磁盘中,而可以先将最新的数据驻留在内存中,等到积累到足够多之后,再使用归并排序的方式将内存内的数据合并追加到磁盘队尾(因为所有待排序的树都是有序的,可以通过合并排序的方式快速合并到一起)。

日志结构的合并树(LSM-tree)是一种基于硬盘的数据结构,与B+tree相比,能显著地减少硬盘寻道的开销,并能在较长的时间提供对文件的高速插入(删除)。然而LSM-tree在某些情况下,特别是在查询需要快速响应时性能不佳。通常LSM-tree适用于索引插入比检索更频繁的应用系统。

LSM树和B+树的差异主要在于读性能和写性能进行权衡。在牺牲的同时寻找其余补救方案:

(a)LSM具有批量特性,存储延迟。当写读比例很大的时候(写比读多),LSM树相比于B树有更好的性能。因为随着insert操作,为了维护B+树结构,节点分裂。读磁盘的随机读写概率会变大,性能会逐渐减弱。

(b)B树的写入过程:对B树的写入过程是一次原位写入的过程,主要分为两个部分,首先是查找到对应的块的位置,然后将新数据写入到刚才查找到的数据块中,然后再查找到块所对应的磁盘物理位置,将数据写入去。当然,在内存比较充足的时候,因为B树的一部分可以被缓存在内存中,所以查找块的过程有一定概率可以在内存内完成,不过为了表述清晰,我们就假定内存很小,只够存一个B树块大小的数据吧。可以看到,在上面的模式中,需要两次随机寻道(一次查找,一次原位写),才能够完成一次数据的写入,代价还是很高的。

(c)LSM优化方式:

Bloom filter: 就是个带随机概率的bitmap,可以快速的告诉你,某一个小的有序结构里有没有指定的那个数据的。于是就可以不用二分查找,而只需简单的计算几次就能知道数据是否在某个小集合里啦。效率得到了提升,但付出的是空间代价。

compact:小树合并为大树:因为小树性能有问题,所以要有个进程不断地将小树合并到大树上,这样大部分的老数据查询也可以直接使用log2N的方式找到,不需要再进行(N/m)*log2n的查询了

一些优缺点的探讨

(1)顺序写入通常比随机写入快得多,所以SSTable通常的写入性能是相对优秀的。
(2)由于SSTable压缩与清理的线程存在,通常会有较低的存储开销。但是压缩和清理磁盘的过程之中会与正常的请求服务产生磁盘竞争,导致吞吐量的下降。
(3)由于SSTable会存在同一个键值的多个副本,对于实现事务等对于一致性要求更高的场景,树型索引会表现的更加出色。

3. OLTP VS OLAP

联机事务处理过程(On-Line Transaction Processing)也就是我们通常称之的OLTP。
联机分析处理过程(On-Line Analysis Processing)则被称为OLAP。

在文中,作者列出了两类处理过程的区别,我们来一一梳理一下:

OLTP的应用通常读写较少的数据,处理的记录数目也较小。而OLAP的应用处理的数据量级通常是OLTP应用的数十,甚至数百倍。

OLTP的应用通常直接面对应用程序,读写延迟容忍度低。而OLAP的应用通常作为内部数据分析,作为决策支持,读写延迟的容忍度相对较高。

OLTP的应用通常读写的都是最新的数据。而OLAP的应用通常处理的都是海量的历史数据。
SQL语言它适用于OLTP类型的查询以及OLAP类型查询。但是将两者类型的应用混杂与同一个数据库,会大大提升DBA的运维难度,同时数据库也没办法因地制宜的更好来设计优化不同的应用。

OLTP系统通常解决的是应用程序高可用性和低延迟的读写请求,往往是业务运行的关键所在。DBA也并不愿意让数据分析师在OLTP数据库上运行特殊的解析查询,因为这些查询通常需要扫描数据集的大部分,这会损害并发执行事务的性能。 所以随着海量数据不断增长,越来越多的公司选择将OLAP应用运行在一个单独的数据库来分析。这个单独的数据库称为数据仓库。

4. 列存储和作用

在典型的数据仓库中,表的结构通常非常宽。事实表通常有超过一百列,有时设置为几百列。而通常数据仓库的查询只访问一次4或5列的查询。

大多数的OLTP数据库,存储是面向行的:一行之中的所有值会连续存放。
但是,当一个OLAP的存储查询需要少数的列时(每行由100多个列组成),需要将数据从磁盘加载到内存中,并解析它们,并过滤掉那些不符合所需条件的列。这会造成很多不必要的查询消耗。

  • 列存储
    面向列存储的思想很简单:不要将所有值从一行存储在一起,而是将每个列中的所有值存储在一起。如果每个列都存储在一个单独的文件中,那么查询只需要读取和解析查询中使用的那些列,并且同样的列会更加易于压缩存储,这样就可以减少大量的工作。
image.png
  • 列压缩
    通常列中的数据会出现重复,这就大大适用于压缩策略。可以根据列中的数据,使用不同的压缩技术。位图编码是数据仓库中的十分有效的压缩技术:
image.png
  • 列排序

在列存储中,存储行的顺序并不重要。最简单的就是将它们按照插入的顺序排序,因为插入一个新行只意味着追加到每个列文件中。但是,选择逻辑顺序,可以带来几点好处。
(1) 排序之后的列是有序的,更有利于定位查询数据。(如:按照时间排序,查询某个时间段内产生的数据)
(2) 它有助于压缩列。如果主排序列没有许多不同的值,那么在排序之后,它将有许多重复的序列。简单的编码压缩之后,就可以极大的降低存储开销。

注意,对每个列进行独立排序是没有意义的,因为我们将不再知道列中属于哪一行。可以新建一个索引来指向对应的行。有序又要求高效,所以排序列的存储通常都是通过上文提及的SSTable格式在内存之中灵活处理。

5.物化视图

数据仓库另一个常用的优化方式是:物化视图。如前所述,数据仓库查询通常涉及聚合函数,如SQL中的计数、总和、平均值、最小值或最大值。如果相同的聚合被许多不同的查询使用,那么每次都对原始数据进行处理是十分浪费的。为什么不缓存查询中经常使用的一些计数或总数呢?

在关系型的数据模型中,它通常被定义为标准(虚拟)视图:一个表一样的对象,其内容是一些查询的结果。虚拟视图只是编写查询的快捷方式。当您从虚拟视图中读取时,SQL引擎将它展开为视图的底层查询,然后处理展开的查询。而物化视图是将实际的查询结果写入磁盘,不需要额外的计算过程。但是当底层数据发生变化时,物化视图需要更新,因为它是一个非规范化的数据复制。(类似于触发器的工作原理)。所以物化视图是不常用于OLTP数据库,而在数据仓库进行ETL时进行更新。

image.png

物化视图的好处是:某些查询变得非常快因为他们已经被预先计算。
但物化视图的缺点是:查询原始数据的灵活性不足。 例如,没有办法计算哪种销售成本超过100美元的商品的比例。因此,大多数数据仓库尽量保留尽可能多的原始数据,并且只使用物化视图作为对某些常用查询的性能提升。

b树索引实战

MyISAM索引实现

MyISAM引擎使用B+Tree作为索引结构,叶节点的data域存放的是数据记录的地址。下图是MyISAM索引的原理图:

image

图8

这里设表一共有三列,假设我们以Col1为主键,则图8是一个MyISAM表的主索引(Primary key)示意。可以看出MyISAM的索引文件仅仅保存数据记录的地址。在MyISAM中,主索引和辅助索引(Secondary key)在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。如果我们在Col2上建立一个辅助索引,则此索引的结构如下图所示:

image

图9

同样也是一颗B+Tree,data域保存数据记录的地址。因此,MyISAM中索引检索的算法为首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其data域的值,然后以data域的值为地址,读取相应数据记录。

MyISAM的索引方式也叫做“非聚集”的,之所以这么称呼是为了与InnoDB的聚集索引区分。

InnoDB索引实现

虽然InnoDB也使用B+Tree作为索引结构,但具体实现方式却与MyISAM截然不同。

第一个重大区别是InnoDB的数据文件本身就是索引文件。从上文知道,MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。而在InnoDB中,表数据文件本身就是按B+Tree组织的一个索引结构,这棵树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。

image

图10

图10是InnoDB主索引(同时也是数据文件)的示意图,可以看到叶节点包含了完整的数据记录。这种索引叫做聚集索引。因为InnoDB的数据文件本身要按主键聚集,所以InnoDB要求表必须有主键(MyISAM可以没有),如果没有显式指定,则MySQL系统会自动选择一个可以唯一标识数据记录的列作为主键,如果不存在这种列,则MySQL自动为InnoDB表生成一个隐含字段作为主键,这个字段长度为6个字节,类型为长整形。

第二个与MyISAM索引的不同是InnoDB的辅助索引data域存储相应记录主键的值而不是地址。换句话说,InnoDB的所有辅助索引都引用主键作为data域。例如,图11为定义在Col3上的一个辅助索引:

image

图11

这里以英文字符的ASCII码作为比较准则。聚集索引这种实现方式使得按主键的搜索十分高效,但是辅助索引搜索需要检索两遍索引:首先检索辅助索引获得主键,然后用主键到主索引中检索获得记录。

了解不同存储引擎的索引实现方式对于正确使用和优化索引都非常有帮助,例如知道了InnoDB的索引实现后,就很容易明白为什么不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。再例如,用非单调的字段作为主键在InnoDB中不是个好主意,因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。

也就是说,联合索引(col1, col2,col3)也是一棵B+Tree,其非叶子节点存储的是第一个关键字的索引,而叶节点存储的则是三个关键字col1、col2、col3三个关键字的数据,且按照col1、col2、col3的顺序进行排序。

配图可能不太让人满意,因为col1都是不同的,也就是说在col1就已经能确定结果了。自己又画了一个图(有点丑),col1表示的是年龄,col2表示的是姓氏,col3表示的是名字。如下图:

image.png

索引使用策略及优化

MySQL的优化主要分为结构优化(Scheme optimization)和查询优化(Query optimization)。本章讨论的高性能索引策略主要属于结构优化范畴。本章的内容完全基于上文的理论基础,实际上一旦理解了索引背后的机制,那么选择高性能的策略就变成了纯粹的推理,并且可以理解这些策略背后的逻辑。

为了讨论索引策略,需要一个数据量不算小的数据库作为示例。本文选用MySQL官方文档中提供的示例数据库之一:employees。这个数据库关系复杂度适中,且数据量较大。下图是这个数据库的E-R关系图(引用自MySQL官方手册):

image

图12

MySQL官方文档中关于此数据库的页面为http://dev.mysql.com/doc/employee/en/employee.html。里面详细介绍了此数据库,并提供了下载地址和导入方法,如果有兴趣导入此数据库到自己的MySQL可以参考文中内容。

最左前缀原理与相关优化

高效使用索引的首要条件是知道什么样的查询会使用到索引,这个问题和B+Tree中的“最左前缀原理”有关,下面通过例子说明最左前缀原理。

这里先说一下联合索引的概念。在上文中,我们都是假设索引只引用了单个的列,实际上,MySQL中的索引可以以一定顺序引用多个列,这种索引叫做联合索引,一般的,一个联合索引是一个有序元组,其中各个元素均为数据表的一列,实际上要严格定义索引需要用到关系代数,但是这里我不想讨论太多关系代数的话题,因为那样会显得很枯燥,所以这里就不再做严格定义。另外,单列索引可以看成联合索引元素数为1的特例。

以employees.titles表为例,下面先查看其上都有哪些索引:


1.  SHOW INDEX FROM employees.titles;
2.  +--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+
3.  |  Table  |  Non_unique  |  Key_name  |  Seq_in_index  |  Column_name  |  Collation  |  Cardinality  |  Null  |  Index_type  |
4.  +--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+
5.  | titles |  0  | PRIMARY |  1  | emp_no | A | NULL |  | BTREE |
6.  | titles |  0  | PRIMARY |  2  | title | A | NULL |  | BTREE |
7.  | titles |  0  | PRIMARY |  3  | from_date | A |  443308  |  | BTREE |
8.  | titles |  1  | emp_no |  1  | emp_no | A |  443308  |  | BTREE |
9.  +--------+------------+----------+--------------+-------------+-----------+-------------+------+------------+

从结果中可以到titles表的主索引为,还有一个辅助索引。为了避免多个索引使事情变复杂(MySQL的SQL优化器在多索引时行为比较复杂),这里我们将辅助索引drop掉:


1.  ALTER TABLE employees.titles DROP INDEX emp_no;

这样就可以专心分析索引PRIMARY的行为了。

情况一:全列匹配。


1.  EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title='Senior Engineer' AND from_date='1986-06-26';
2.  +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
5.  |  1  | SIMPLE | titles |  const  | PRIMARY | PRIMARY |  59  |  const,const,const  |  1  |  |
6.  +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+

很明显,当按照索引中所有列进行精确匹配(这里精确匹配指“=”或“IN”匹配)时,索引可以被用到。这里有一点需要注意,理论上索引对顺序是敏感的,但是由于MySQL的查询优化器会自动调整where子句的条件顺序以使用适合的索引,例如我们将where中的条件顺序颠倒:

1.  EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26' AND emp_no='10001' AND title='Senior Engineer';
2.  +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+
5.  |  1  | SIMPLE | titles |  const  | PRIMARY | PRIMARY |  59  |  const,const,const  |  1  |  |
6.  +----+-------------+--------+-------+---------------+---------+---------+-------------------+------+-------+

效果是一样的。

情况二:最左前缀匹配。

1.  EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001';
2.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------+
5.  |  1  | SIMPLE | titles |  ref  | PRIMARY | PRIMARY |  4  |  const  |  1  |  |
6.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------+

当查询条件精确匹配索引的左边连续一个或几个列时,如,所以可以被用到,但是只能用到一部分,即条件所组成的最左前缀。上面的查询从分析结果看用到了PRIMARY索引,但是key_len为4,说明只用到了索引的第一列前缀。

情况三:查询条件用到了索引中列的精确匹配,但是中间某个条件未提供。


1.  EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26';
2.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
5.  |  1  | SIMPLE | titles |  ref  | PRIMARY | PRIMARY |  4  |  const  |  1  |  Using  where  |
6.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+

此时索引使用情况和情况二相同,因为title未提供,所以查询只用到了索引的第一列,而后面的from_date虽然也在索引中,但是由于title不存在而无法和左前缀连接,因此需要对结果进行扫描过滤from_date(这里由于emp_no唯一,所以不存在扫描)。如果想让from_date也使用索引而不是where过滤,可以增加一个辅助索引,此时上面的查询会使用这个索引。除此之外,还可以使用一种称之为“隔离列”的优化方法,将emp_no与from_date之间的“坑”填上。

首先我们看下title一共有几种不同的值:


1.  SELECT DISTINCT(title) FROM employees.titles;
2.  +--------------------+
3.  | title |
4.  +--------------------+
5.  |  Senior  Engineer  |
6.  |  Staff  |
7.  |  Engineer  |
8.  |  Senior  Staff  |
9.  |  Assistant  Engineer  |
10.  |  Technique  Leader  |
11.  |  Manager  |
12.  +--------------------+

只有7种。在这种成为“坑”的列值比较少的情况下,可以考虑用“IN”来填补这个“坑”从而形成最左前缀:


1.  EXPLAIN SELECT * FROM employees.titles
2.  WHERE emp_no='10001'
3.  AND title IN ('Senior Engineer',  'Staff',  'Engineer',  'Senior Staff',  'Assistant Engineer',  'Technique Leader',  'Manager')
4.  AND from_date='1986-06-26';
5.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
6.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
7.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
8.  |  1  | SIMPLE | titles | range | PRIMARY | PRIMARY |  59  | NULL |  7  |  Using  where  |
9.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

这次key_len为59,说明索引被用全了,但是从type和rows看出IN实际上执行了一个range查询,这里检查了7个key。看下两种查询的性能比较:


1.  SHOW PROFILES;
2.  +----------+------------+-------------------------------------------------------------------------------+
3.  |  Query_ID  |  Duration  |  Query  |
4.  +----------+------------+-------------------------------------------------------------------------------+
5.  |  10  |  0.00058000  | SELECT * FROM employees.titles WHERE emp_no='10001' AND from_date='1986-06-26'|
6.  |  11  |  0.00052500  | SELECT * FROM employees.titles WHERE emp_no='10001' AND title IN ...  |
7.  +----------+------------+-------------------------------------------------------------------------------+

“填坑”后性能提升了一点。如果经过emp_no筛选后余下很多数据,则后者性能优势会更加明显。当然,如果title的值很多,用填坑就不合适了,必须建立辅助索引。

情况四:查询条件没有指定索引第一列。

1.  EXPLAIN SELECT * FROM employees.titles WHERE from_date='1986-06-26';
2.  +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
5.  |  1  | SIMPLE | titles | ALL | NULL | NULL | NULL | NULL |  443308  |  Using  where  |
6.  +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+

由于不是最左前缀,索引这样的查询显然用不到索引。

情况五:匹配某列的前缀字符串。


1.  EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title LIKE 'Senior%';
2.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
5.  |  1  | SIMPLE | titles | range | PRIMARY | PRIMARY |  56  | NULL |  1  |  Using  where  |
6.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

此时可以用到索引,但是如果通配符不是只出现在末尾,则无法使用索引。(原文表述有误,如果通配符%不出现在开头,则可以用到索引,但根据具体情况不同可能只会用其中一个前缀)

情况六:范围查询。


1.  EXPLAIN SELECT * FROM employees.titles WHERE emp_no <  '10010'  and title='Senior Engineer';
2.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
5.  |  1  | SIMPLE | titles | range | PRIMARY | PRIMARY |  4  | NULL |  16  |  Using  where  |
6.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

范围列可以用到索引(必须是最左前缀),但是范围列后面的列无法用到索引。同时,索引最多用于一个范围列,因此如果查询条件中有两个范围列则无法全用到索引。


1.  EXPLAIN SELECT * FROM employees.titles
2.  WHERE emp_no <  '10010'
3.  AND title='Senior Engineer'
4.  AND from_date BETWEEN '1986-01-01' AND '1986-12-31';
5.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
6.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
7.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
8.  |  1  | SIMPLE | titles | range | PRIMARY | PRIMARY |  4  | NULL |  16  |  Using  where  |
9.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

可以看到索引对第二个范围索引无能为力。这里特别要说明MySQL一个有意思的地方,那就是仅用explain可能无法区分范围索引和多值匹配,因为在type中这两者都显示为range。同时,用了“between”并不意味着就是范围查询,例如下面的查询:


1.  EXPLAIN SELECT * FROM employees.titles
2.  WHERE emp_no BETWEEN '10001' AND '10010'
3.  AND title='Senior Engineer'
4.  AND from_date BETWEEN '1986-01-01' AND '1986-12-31';
5.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
6.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
7.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+
8.  |  1  | SIMPLE | titles | range | PRIMARY | PRIMARY |  59  | NULL |  16  |  Using  where  |
9.  +----+-------------+--------+-------+---------------+---------+---------+------+------+-------------+

看起来是用了两个范围查询,但作用于emp_no上的“BETWEEN”实际上相当于“IN”,也就是说emp_no实际是多值精确匹配。可以看到这个查询用到了索引全部三个列。因此在MySQL中要谨慎地区分多值匹配和范围匹配,否则会对MySQL的行为产生困惑。

情况七:查询条件中含有函数或表达式。

很不幸,如果查询条件中含有函数或表达式,则MySQL不会为这列使用索引(虽然某些在数学意义上可以使用)。例如:


1.  EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND left(title,  6)='Senior';
2.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+
5.  |  1  | SIMPLE | titles |  ref  | PRIMARY | PRIMARY |  4  |  const  |  1  |  Using  where  |
6.  +----+-------------+--------+------+---------------+---------+---------+-------+------+-------------+

虽然这个查询和情况五中功能相同,但是由于使用了函数left,则无法为title列应用索引,而情况五中用LIKE则可以。再如:

1.  EXPLAIN SELECT * FROM employees.titles WHERE emp_no -  1='10000';
2.  +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+
5.  |  1  | SIMPLE | titles | ALL | NULL | NULL | NULL | NULL |  443308  |  Using  where  |
6.  +----+-------------+--------+------+---------------+------+---------+------+--------+-------------+

显然这个查询等价于查询emp_no为10001的函数,但是由于查询条件是一个表达式,MySQL无法为其使用索引。看来MySQL还没有智能到自动优化常量表达式的程度,因此在写查询语句时尽量避免表达式出现在查询中,而是先手工私下代数运算,转换为无表达式的查询语句。

索引选择性与前缀索引

既然索引可以加快查询速度,那么是不是只要是查询语句需要,就建上索引?答案是否定的。因为索引虽然加快了查询速度,但索引也是有代价的:索引文件本身要消耗存储空间,同时索引会加重插入、删除和修改记录时的负担,另外,MySQL在运行时也要消耗资源维护索引,因此索引并不是越多越好。一般两种情况下不建议建索引。

第一种情况是表记录比较少,例如一两千条甚至只有几百条记录的表,没必要建索引,让查询做全表扫描就好了。至于多少条记录才算多,这个个人有个人的看法,我个人的经验是以2000作为分界线,记录数不超过 2000可以考虑不建索引,超过2000条可以酌情考虑索引。

另一种不建议建索引的情况是索引的选择性较低。所谓索引的选择性(Selectivity),是指不重复的索引值(也叫基数,Cardinality)与表记录数(#T)的比值:

Index Selectivity = Cardinality / #T

显然选择性的取值范围为(0, 1],选择性越高的索引价值越大,这是由B+Tree的性质决定的。例如,上文用到的employees.titles表,如果title字段经常被单独查询,是否需要建索引,我们看一下它的选择性:

1.  SELECT count(DISTINCT(title))/count(*) AS Selectivity FROM employees.titles;
2.  +-------------+
3.  |  Selectivity  |
4.  +-------------+
5.  |  0.0000  |
6.  +-------------+

title的选择性不足0.0001(精确值为0.00001579),所以实在没有什么必要为其单独建索引。

有一种与索引选择性有关的索引优化策略叫做前缀索引,就是用列的前缀代替整个列作为索引key,当前缀长度合适时,可以做到既使得前缀索引的选择性接近全列索引,同时因为索引key变短而减少了索引文件的大小和维护开销。下面以employees.employees表为例介绍前缀索引的选择和使用。

从图12可以看到employees表只有一个索引,那么如果我们想按名字搜索一个人,就只能全表扫描了:


1.  EXPLAIN SELECT * FROM employees.employees WHERE first_name='Eric' AND last_name='Anido';
2.  +----+-------------+-----------+------+---------------+------+---------+------+--------+-------------+
3.  | id | select_type | table | type | possible_keys | key | key_len |  ref  | rows |  Extra  |
4.  +----+-------------+-----------+------+---------------+------+---------+------+--------+-------------+
5.  |  1  | SIMPLE | employees | ALL | NULL | NULL | NULL | NULL |  300024  |  Using  where  |
6.  +----+-------------+-----------+------+---------------+------+---------+------+--------+-------------+

如果频繁按名字搜索员工,这样显然效率很低,因此我们可以考虑建索引。有两种选择,建,看下两个索引的选择性:


1.  SELECT count(DISTINCT(first_name))/count(*) AS Selectivity FROM employees.employees;
2.  +-------------+
3.  |  Selectivity  |
4.  +-------------+
5.  |  0.0042  |
6.  +-------------+
7.  SELECT count(DISTINCT(concat(first_name, last_name)))/count(*) AS Selectivity FROM employees.employees;
8.  +-------------+
9.  |  Selectivity  |
10.  +-------------+
11.  |  0.9313  |
12.  +-------------+

显然选择性太低,选择性很好,但是first_name和last_name加起来长度为30,有没有兼顾长度和选择性的办法?可以考虑用first_name和last_name的前几个字符建立索引,例如,看看其选择性:


1.  SELECT count(DISTINCT(concat(first_name, left(last_name,  3))))/count(*) AS Selectivity FROM employees.employees;
2.  +-------------+
3.  |  Selectivity  |
4.  +-------------+
5.  |  0.7879  |
6.  +-------------+

选择性还不错,但离0.9313还是有点距离,那么把last_name前缀加到4:


1.  SELECT count(DISTINCT(concat(first_name, left(last_name,  4))))/count(*) AS Selectivity FROM employees.employees;
2.  +-------------+
3.  |  Selectivity  |
4.  +-------------+
5.  |  0.9007  |
6.  +-------------+

这时选择性已经很理想了,而这个索引的长度只有18,比短了接近一半,我们把这个前缀索引 建上:


1.  ALTER TABLE employees.employees
2.  ADD INDEX `first_name_last_name4`  (first_name, last_name(4));

此时再执行一遍按名字查询,比较分析一下与建索引前的结果:


1.  SHOW PROFILES;
2.  +----------+------------+---------------------------------------------------------------------------------+
3.  |  Query_ID  |  Duration  |  Query  |
4.  +----------+------------+---------------------------------------------------------------------------------+
5.  |  87  |  0.11941700  | SELECT * FROM employees.employees WHERE first_name='Eric' AND last_name='Anido'  |
6.  |  90  |  0.00092400  | SELECT * FROM employees.employees WHERE first_name='Eric' AND last_name='Anido'  |
7.  +----------+------------+---------------------------------------------------------------------------------+

性能的提升是显著的,查询速度提高了120多倍。

前缀索引兼顾索引大小和查询速度,但是其缺点是不能用于ORDER BY和GROUP BY操作,也不能用于Covering index(即当索引本身包含查询所需全部数据时,不再访问数据文件本身)。

InnoDB的主键选择与插入优化

在使用InnoDB存储引擎时,如果没有特别的需要,请永远使用一个与业务无关的自增字段作为主键。

经常看到有帖子或博客讨论主键选择问题,有人建议使用业务无关的自增主键,有人觉得没有必要,完全可以使用如学号或身份证号这种唯一字段作为主键。不论支持哪种论点,大多数论据都是业务层面的。如果从数据库索引优化角度看,使用InnoDB引擎而不使用自增主键绝对是一个糟糕的主意。

上文讨论过InnoDB的索引实现,InnoDB使用聚集索引,数据记录本身被存于主索引(一颗B+Tree)的叶子节点上。这就要求同一个叶子节点内(大小为一个内存页或磁盘页)的各条数据记录按主键顺序存放,因此每当有一条新的记录插入时,MySQL会根据其主键将其插入适当的节点和位置,如果页面达到装载因子(InnoDB默认为15/16),则开辟一个新的页(节点)。

如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。如下图所示:

image

图13

这样就会形成一个紧凑的索引结构,近似顺序填满。由于每次插入时也不需要移动已有数据,因此效率很高,也不会增加很多开销在维护索引上。

如果使用非自增主键(如果身份证号或学号等),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到现有索引页得中间某个位置:

image

图14

此时MySQL不得不为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面。

因此,只要可以,请尽量在InnoDB上采用自增字段做主键。

你可能感兴趣的:(DDIA 2. 存储与检索)