深入思考索引在查询中的使用


MySQL学习系列


索引在查询中的作用到底是什么? 在我们的查询中发挥着什么样的作用呢?
请记住:
1、 一个索引就是一个 B+树, 索引让我们的查询可以快速定位和扫描到我们需要的数据记录上, 加快查询的速度。
2、 一个 select 查询语句在执行过程中一般最多能使用一个二级索引, 即使在 where 条件中用了多个二级索引。


假设表信息如下
深入思考索引在查询中的使用

1. 扫描区间

对于某个查询来说,最简单粗暴的执行方案就是扫描表中的所有记录,判断每一条记录是否符合搜索条件。如果符合,就将其发送到客户端,否则就跳过该记录。这就是全表扫描。

对于使用InnoDB存储引擎的表来说,全表扫描意味着从聚簇索引第一个叶子节点的第一条记录开始,沿着记录所在的单向链表向后扫描,直到最后一个叶子节点的最后一条记录。虽然全表扫描是一种很笨的执行方案,但却是一种万能的执行方案,所有的查询都可以使用这种方案来执行,只是效率不高。

我们有了索引,利用B+树查找索引列值等于某个值的记录,这样可以明显减少需要扫描的记录数量。由于B+树叶子节点中的记录是按照索引列值由小到大的顺序排序的,所以即使只扫描某个区间或者某些区间中的记录也可以明显减少需要扫描的记录数量。比如下面这个查询语句:

SELECT * FROM order_exp WHERE id >= 3 AND id<= 99;

这个语句其实是想查找id值在[3,99]区间中的所有聚簇索引记录。我们可以通过聚簇索引对应的B+树快速地定位到id值为3的那条聚簇索引记录,然后沿着记录所在的单向链表向后扫描,直到某条聚簇索引记录的id值不在[3,99]区间中为止。

与全表扫描相比,扫描id值在[3,99]区间中的记录已经很大程度地减少了需要扫描的记录数量,所以提升了查询效率。其实所谓的全表扫描,我们可以理解为扫描的区间是[负无穷,正无穷]或者[第一条记录,最后一条记录]。
再看下面这个查询语句

SELECT * FROM order_exp WHERE id in(3,9) OR (id>=23 AND id<= 99);

这里有几个扫描区间? 三个, 两个单独扫描区间[3,3]、 [9,9], 一个范围扫描区间[23,99]。

再看下面这个查询语句:

SELECT * FROM order_exp WHERE order_no <'DD00_10S' AND expire_time>'2021-03-22 18:28:28' AND order_note > '7 排';

这个语句里, order_no 和 expire_time 都有索引, order_note 没有索引, 那会有两个扫描区间吗? 并不会, 请记住, 一个 Select 查询语句在执行过程中一般最多能使用一个二级索引。 那么也就是说:

如果用 idx_order_no 执行查询,那扫描区间就是[第一条记录,‘DD00_10S’],expire_time > ‘2021-03-22 18:28:28’ AND order_note > ‘7排’ 只能成为普通的搜索或者说判定条件。

如果说用idx_expire_time执行查询,那扫描区间就是[‘2021-03-22 18:28:28’,最后一条记录],order_no < ‘DD00_10S’ AND order_note > ‘7排’ 只能成为普通的搜索或者说判定条件。

无论用哪个索引执行查询,都需要获取到索引中的记录后,进行回表,获取到完整的用户记录后再根据判定条件判断这条记录是否满足SQL语句的要求。


2. 范围区间扫描

其实对于 B+树索引来说, 只要索引列和常数使用=、 <=>、 IN、 NOT IN、 IS NULL、IS NOT NULL、 >、 <、 >=、 <=、 BETWEEN、 !=(不等于也可以写成<>) 或者 LIKE 操作符连接起来, 就可以产生一个区间。
1、 IN 操作符的效果和若干个等值匹配操作符=之间用OR连接起来是一样的, 也就是说会产生多个单点区间, 比如下边这两个语句的效果是一样的:

SELECT * FROM order_exp WHERE insert_time IN (2021-03-22 18:23:42, yyyy);
SELECT * FROM order_exp WHERE insert_time= 2021-03-22 18:23:42 OR insert_time = yyyy;

2、 !=产生的扫描区间呢? 比如

SELECT * FROM order_exp WHERE order_no != 'DD00_9S'

此时使用 idx_expire_time 执行查询时对应的扫描区间就是[第一条记录 ,‘DD00_9S’]和[ ‘DD00_9S’,最后一条记录]。

3、 LIKE 操作符比较特殊, 只有在匹配完整的字符串或者匹配字符串前缀时才产生合适的扫描区间。

对于某个索引列来说, 字符串前缀相同的记录在由记录组成的单向链表中肯 定是相邻的。 比如我们有一个搜索条件是 note LIKE’ b%’, 对于二级索引 idx_note 来说, 所有字符串前缀为’b’的二级索引记录肯定是相邻的。 这也就意味着我们只要定位到 idx_note 值的字符串前缀为’b’的第一条记录, 就可以沿着记录所在的单向链表向后扫描, 直到某条二级索引记录的字符串前缀不为 a 为止。

深入思考索引在查询中的使用
很显然, note LIKE’ b%’ 形成的扫描区间相当于[‘b’, ‘c’)。

不过在日常的工作中, 一个查询的 WHERE 子句可能有很多个小的搜索条件,这些搜索条件需要使用 AND 或者 OR 操作符连接起来, 我们来看看怎么从由 AND 或 OR 组成的复杂搜索条件中提取出正确的范围区间。


3. 所有搜索条件都可以使用某个索引的情况

有时候每个搜索条件都可以使用到某个索引, 比如下边这个查询语句:

SELECT * FROM order_exp WHERE order_no > 'DD00_6S' AND order_no > 'DD00_9S';

这个查询中的搜索条件都可以使用到 idx_order_no, 也就是说每个搜索条件都对应着一个 idx_order_no 的范围区间。 这两个小的搜索条件使用 AND 连接起来, 也就是要取两个范围区间的交集, 两者交集当然就是 order_no > 'DD00_9S’了, 也就是说上边这个查询使用 idx_order_no 的范围区间就是(‘DD00_9S’, 最后一条记录)。
再看一下使用 OR 将多个搜索条件连接在一起的情况:

SELECT * FROM order_exp WHERE order_no > 'DD00_6S' OR order_no > 'DD00_9S';

OR 意味着需要取各个范围区间的并集, 所以上边这个查询使用 idx_expire_time 的范围区间就是( ‘DD00_6S’ ,最后一条记录)。


4. 有的搜索条件无法使用索引的情况

比如下边这个查询:

SELECT * FROM order_exp WHERE expire_time> '2021-03-22 18:35:09' AND order_note = 'abc';

请注意, 这个查询语句中能利用的索引只有 idx_expire_time 一个, 而idx_expire_time 这个二级索引的记录中又不包含 order_note 这个字段, 所以在使用二级索引 idx_expire_time 定位记录的阶段用不到 order_note = 'abc’这个条件,这个条件是在回表获取了完整的用户记录后才使用的, 而范围区间是为了到索引中取记录中提出的概念, 所以在确定范围区间的时候不需要考虑order_note = 'abc’这个条件。

我们把上边的查询中用不到 idx_expire_time 的搜索条件化简之后就是这样:

SELECT * FROM order_exp WHERE expire_time> '2021-03-22 18:35:09';

也就是说最上边那个查询使用 idx_expire_time 的范围区间就是: (‘2021-03-22 18:35:09’,最后一条记录)。
再来看一下使用 OR 的情况:

SELECT * FROM order_exp WHERE expire_time> '2021-03-22 18:35:09' OR order_note = 'abc';

这条语句在搜索时可以化简为:

SELECT * FROM order_exp ;

这也就说如果我们使用 idx_expire_time 执行查询的话, 对应的范围区间就是[第一条记录,最后一条记录], 也就是需要将全部二级索引的记录进行回表, 这个代价肯定比直接全表扫描都大了。 也就是说一个使用到索引的搜索条件和没有使用该索引的搜索条件使用 OR 连接起来后是无法使用该索引的。 为什么? 道理很简单, idx_expire_time 这个二级索引的记录中不包含 order_note 这个字段, 那就说, 即使二级索引 idx_expire_time 中找到了满足 expire_time> '2021-03-22
18:35:09’的记录, 是无法判定 order_note 是否满足 order_note = 'abc’的, 又因为是 OR 条件, 所以必须要在主键索引中从第一条记录到最后一条记录逐条判定order_note 是否等于 ‘abc’。


5. 复杂搜索条件下找出范围匹配的区间

有的查询的搜索条件可能特别复杂, 比方说下边这个:

SELECT * FROM order_exp WHERE (order_no > 'DD00_9S' AND expire_time =
'2021-03-22 18:35:09' ) OR (order_no < 'DD00_6S' AND order_no > 'DD00_9S') OR
(order_no LIKE '%0S' AND order_no > 'DD00_12S' AND (expire_time < '2021-03-22
18:28:28' OR order_note = 'abc')) ;

分析一下:
首先查看 WHERE 子句中的搜索条件都涉及到了哪些列, 哪些列可能使用到索引。这个查询的搜索条件涉及到了 order_no、 expire_time、 order_note 这 3 个列,然后 order_no 列有二级索引 idx_order_no, expire_time 列有二级索引idx_expire_time。
对于那些可能用到的索引, 分析它们的范围区间。
使用 idx_order_no 执行查询
我们需要把那些用不到该索引的搜索条件暂时移除掉。 上边的查询中除了有关 expire_time 和 order_note 列不能使用到 idx_order_no 索引外, order_no LIKE '%0S’也使用不到索引。

如果条件太复杂, 看着演化怕出错, 我们可以把所有用不到的搜索条件视为True 来进行中间替换, 所以把这些搜索条件替换为 True 之后的样子就是这样:

(order_no > 'DD00_9S' AND TRUE ) OR
(order_no < 'DD00_6S' AND order_no > 'DD00_9S') OR
(TRUE AND order_no > 'DD00_12S' AND (TRUE OR TRUE))

再化简:

(order_no > 'DD00_9S') OR
(order_no < 'DD00_6S' AND order_no > 'DD00_9S') OR
(order_no > 'DD00_12S')

接下来替换掉永远为 TRUE 或 FALSE 的条件
因为符合 order_no < ‘DD00_6S’ AND order_no > 'DD00_9S’永远为 FALSE, 所以上边的搜索条件可以被写成这样:

(order_no > 'DD00_9S') OR (order_no > 'DD00_12S')

很明显, 两者使用 OR 操作符连接起来的, 意味着要取并集, 所以最终的结果化简的到的区间就是: order_no > ‘DD00_12S’。 也就是说: 上边那个复杂搜索条件的查询语句如果使用 idx_order_no 索引执行查询的话, 需要把满足order_no > 'DD00_12S’的二级索引记录都取出来, 然后拿着这些记录的 id 再进行回表, 得到完整的用户记录之后再使用其他的搜索条件进行过滤。 记住, 我们说的是如果使用 idx_order_no 索引执行查询, 不代表 MySQL 一定会使用, 因为MySQL 需要做整体评估, 才能确定是否使用这个索引还是别的索引, 或者是干脆全表扫描。

使用 idx_expire_time 执行查询

我们需要把那些用不到该索引的搜索条件暂时使用 TRUE 条件替换掉, 其中有关 order_no 和 order_note 的搜索条件都需要被替换掉, 替换结果就是:

(TRUE AND expire_time = '2021-03-22 18:35:09' ) OR
(TRUE AND TRUE) OR
(TRUE AND TRUE AND (expire_time < '2021-03-22 18:28:28' OR TRUE))

安装布尔运算的规则, expire_time < ‘2021-03-22 18:28:28’ OR TRUE 的结果肯定是 TRUE, 也就是说化简之后的搜索条件成这样了:

expire_time = '2021-03-22 18:35:09' OR TRUE

这个化简之后的结果就更简单了: TRUE
这个结果也就意味着如果我们要使用 idx_expire_time 索引执行查询语句的话, 需要扫描idx_expire_time 二级索引的所有记录, 然后再回表, 这种情况下为啥 MySQL 不直接全表扫描呢? 所以一定不会使用 idx_expire_time 索引的。

上面这个 SQL 语句, 执行全表扫描的代价大概是 2169, 用 idx_order_no索引的代价大概是 6211, 所以, 实际执行的时候, MySQL 会选择全表扫描。 至于代价怎么算来的, 我们后面会学到。


6. 使用联合索引执行查询时对应的扫描区间

联合索引的索引列包含多个列, B+树每一层页面以及每个页面中的记录采用的排序规则较为复杂, 以 order_exp 表的 u_idx_day_status 联合索引为例, 它采用的排序规则如下所示:
先按照 insert_time 列的值进行排序。
在 insert_time 列的值相同的情况下, 再按照 order_status 列的值进行排序。
在 insert_time 和 order_status 列的值都相同的情况下, 再按照 expire_time列的值进行排序。
深入思考索引在查询中的使用
对于下边这个查询 Q1 来说:

SELECT * FROM order_exp WHERE insert_time = '2021-03-22 18:34:55';

由于二级索引记录是先按照 insert_time 列的值进行排序的, 所以所有符合insert_time = '2021-03-22 18:34:55’条件的记录肯定是相邻的, 我们可以定位到第一条符合 insert_time = '2021-03-22 18:34:55’条件的记录, 然后沿着记录所在的单向链表向后扫描, 直到某条记录不符合 insert_time = '2021-03-22 18:34:55’条件为止(当然, 对于获取到的每一条二级索引记录都要执行回表操作)。

也就是说, 如果我们使用 u_idx_day_status 索引执行查询 Q1 时, 对应的扫描区间就是[‘2021-03-22 18:34:55’, ‘2021-03-22 18:34:55’], 形成这个扫描区间的条件就是 insert_time = ‘2021-03-22 18:34:55’。

对于下边这个查询 Q2 来说:

SELECT * FROM order_exp WHERE insert_time = '2021-03-22 18:34:55' AND order_status = 0;

由于二级索引记录是先按照 insert_time 列的值进行排序的; 在 insert_time列的值相等的情况下, 再按照 order_status 列进行排序。 所以符合 insert_time = ‘2021-03-22 18:34:55’ AND order_status = 0 条件的二级索引记录肯定是相邻的,我们可以定位到第一条符合 insert_time=‘2021-03-22 18:34:55’ AND order_status=0 条件的记录, 然后沿着记录所在的链表向后扫描, 直到某条记录不符合 insert_time='2021-03-22 18:34:55’条件或者 order_status=0 条件为止。

也就是说, 如果我们使用 u_idx_day_status 索引执行查询 Q2 时, 可以形成扫描区间[(‘2021-03-22 18:34:55’, 0), (‘2021-03-22 18:34:55’, 0)], 形成这个扫描区间的条件就是 insert_time = ‘2021-03-22 18:34:55’ AND order_status = 0。

对于下边这个查询 Q3 来说:

SELECT * FROM order_exp WHERE insert_time = '2021-03-22 18:34:55' AND order_status = 0 AND expire_time = '2021-03-22 18:35:13';

由于二级索引记录是先按照 insert_time 列的值进行排序的; 在 insert_time列的值相等的情况下, 再按照 order_status 列进行排序; 在 insert_time 和order_status 列的值都相等的情况下, 再按照 expire_time 列进行排序。 所以符合insert_time = ‘2021-03-22 18:34:55’ AND order_status = 0 AND expire_time ='2021-03-22 18:35:13’条件的二级索引记录肯定是相邻的, 我们可以定位到第一条符合 insert_time=‘2021-03-22 18:34:55’ AND order_status=0 AND
expire_time='2021-03-22 18:35:13’条件的记录, 然后沿着记录所在的链表向后扫描, 直到某条记录不符合 insert_time='2021-03-22 18:34:55’条件或者order_status=0 条件或者 expire_time='2021-03-22 18:35:13’条件为止。 如果我们使用 u_idx_day_status 索引执行查询 Q3 时, 可以形成扫描区间[(‘2021-03-22 18:34:55’, 0, ‘2021-03-22 18:35:13’), (‘2021-03-22 18:34:55’, 0, ‘2021-03-22 18:35:13’)], 形成这个扫描区间的条件就是 insert_time = ‘2021-03-22 18:34:55’ AND order_status = 0 AND expire_time = ‘2021-03-22 18:35:13’。

对于下边这个查询 Q4 来说:

SELECT * FROM order_exp WHERE insert_time < '2021-03-22 18:34:55';

由于二级索引记录是先按照 insert_time 列的值进行排序的, 所以所有符合insert_time < '2021-03-22 18:34:55’条件的记录肯定是相邻的, 我们可以定位到第一条符合 insert_time < '2021-03-22 18:34:55’条件的记录(其实就是u_idx_day_status 索引第一个叶子节点的第一条记录), 然后沿着记录所在的链表向前扫描, 直到某条记录不符合 insert_time < '2021-03-22 18:34:55’为止。

也就是说, 如果我们使用 u_idx_day_status 索引执行查询 Q4 时, 可以形成扫描区间(第一条记录, ‘2021-03-22 18:34:55’), 形成这个扫描区间的条件就是insert_time < ‘2021-03-22 18:34:55’。

对于下边这个查询 Q5 来说:

SELECT * FROM order_exp WHERE insert_time = '2021-03-22 18:34:55' AND order_status > =0 ;

由于二级索引记录是先按照 insert_time 列的值进行排序的; 在 insert_time列的值相等的情况下, 再按照 order_status 列进行排序。 也就是说在符合insert_time = '2021-03-22 18:34:55’条件的二级索引记录中, 是按照 order_status列的值进行排序的, 那么此时符合 insert_time = ‘2021-03-22 18:34:55’ AND order_status > =0 ;条件的二级索引记录肯定是相邻的。 我们可以定位到第一条符合 insert_time = ‘2021-03-22 18:34:55’ AND order_status > =0 ;条件的记录, 然后沿着记录所在的链表向后扫描, 直到某条记录不符合 insert_time='2021-03-22 18:34:55’条件或者 order_status > =0 条件为止。

也就是说, 如果我们使用 u_idx_day_status 索引执行查询 Q5 时, 可以形成扫描区间, 条件就是 insert_time = ‘2021-03-22 18:34:55’ AND order_status > =0 ;。

对于下边这个查询 Q6 来说:

SELECT * FROM order_exp WHERE order_status = 1;

由于二级索引记录不是直接按照 order_status 列的值排序的, 所以符合order_status = 1 的二级索引记录可能并不相邻, 也就意味着我们不能通过这个order_status = 1 搜索条件来减少需要扫描的记录数量。 在这种情况下, 我们是不会使用 u_idx_day_status 索引执行查询的。

对于下边这个查询 Q7 来说:

SELECT * FROM order_exp WHERE insert_time = '2021-03-22 18:34:55' AND expire_time = '2021-03-22 18:35:12';

由于二级索引记录是先按照 insert_time 列的值进行排序的, 所以符合insert_time = '2021-03-22 18:34:55’条件的二级索引记录肯定是相邻的, 但是对于符合 insert_time = '2021-03-22 18:34:55’条件的二级索引记录来说, 并不是直接按照 expire_time 列进行排序的, 也就是说我们不能根据搜索条件 expire_time = '2021-03-22 18:35:12’来进一步减少需要扫描的记录数量。 那么如果我们使用u_idx_day_status 索引执行查询的话, 可以定位到第一条符合insert_time='2021-03-22 18:34:55’条件的记录, 然后沿着记录所在的单向链表向后扫描, 直到某条记录不符合 insert_time = '2021-03-22 18:34:55’条件为止。 所以在使用 u_idx_day_status 索引执行查询 Q7 的过程中, 对应的扫描区间其实是[‘2021-03-22 18:34:55’, ‘2021-03-22 18:34:55’], 形成该扫描区间的搜索条件是insert_time = ‘2021-03-22 18:34:55’, 与 expire_time = '2021-03-22 18:35:12’无关。

对于下边这个查询 Q8 来说:

SELECT * FROM order_exp WHERE insert_time < '2021-03-22 18:34:57' AND order_status = 1;

由于二级索引记录是先按照 insert_time 列的值进行排序的, 所以符合insert_time < '2021-03-22 18:34:57’条件的二级索引记录肯定是相邻的, 但是对于符合 insert_time < '2021-03-22 18:34:57’条件的二级索引记录来说, 并不是直接按照 order_status 列进行排序的, 也就是说我们不能根据搜索条件 order_status = 0来进一步减少需要扫描的记录数量。 那么如果我们使用 u_idx_day_status 索引执行查询的话, 可以定位到第一条符合 insert_time 的记录, 其实就是
u_idx_day_status 索引第一个叶子节点的第一条记录, 所以在使用u_idx_day_status 索引执行查询 Q8 的过程中, 对应的扫描区间其实是[第一条记录, ‘2021-03-22 18:34:57’)。


上一篇:汇编程序设计-18-修改CS和IP寄存器的汇编指令


下一篇:MySQL连续出现的数字