看下面这张用户表,包含主键id、身份证号id_card、姓名name、年龄 age和性别gender,并且在id_card上建立了辅助索引(也叫普通索引/非聚集索引)
CREATE TABLE `user` (
`id` int(11) NOT NULL COMMENT '主键',
`id_card` varchar(32) DEFAULT NULL COMMENT '身份证号',
`name` varchar(64) DEFAULT NULL COMMENT '姓名',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`gender` varchar(32) DEFAULT NULL COMMENT '姓名',
PRIMARY KEY (`id`),
KEY `id_card` (`id_card`)
) DEFAULT CHARSET = utf8mb4 ENGINE=InnoDB COMMENT = '用户表'
如果现在有一个高频请求,要根据市民的身份证号查询他的姓名:
select name from user where id_card = xxx;
众所周知,这会导致回表查询,通过id_card这棵辅助索引树只能找到主键id,然后需要再回到主键索引(聚集索引)树上根据主键id查找相应的name。
所以,这个时候,我们可以建立一个 (id_card, name) 的联合索引来进行优化,对于这条语句来说,也就是覆盖索引,在这个高频请求上用到覆盖索引,不再需要回表查整行记录,大幅减少了语句的执行时间。
不过,索引字段的维护总是有代价的,如果为每一种查询都设计一个联合索引,索引是不是太多了?反过来说,单独为一个不频繁的请求创建一个联合索引是不是有点浪费了。因此在建立冗余索引来支持覆盖索引时就需要我们去做出一些权衡考虑了。
具体来说,我们应该怎么做呢?
B+树这种索引结构,可以利用联合索引的 “最左前缀” 来定位记录。从本质上来说,联合索引也是一棵B+树,不同的是联合索引的键值的数量不是1,而是大于等于2。我们来看下两个整型列组成的联合索引,假定两个键值的名称分别为a、b
从图中可以看到多个键值的B+树情况,键值都是排序的。通过叶子节点可以逻辑上顺序读取所有数据,就上面图中所示,即为 (1,1)、(1、2)、(2、1)、(2、4)、(3、1)、(3、2),数据是按照 (a, b) 的顺序进行存放。
这里 “键值都是排好序” 的这种说法可能会让人很疑惑,似乎只有a列是排序的,b列并没有排序啊。注意!这里的排序,意思是确定了第一个键,对于第一个键相同的记录来说,查询的结果是根据第二个键进行了排序。
这也是使用联合索引的第二个好处,即已经对第二个键值进行了排序处理,可以避免多一次排序操作。举个例子:有些应用程序都需要查询某个用户的购物情况,并按照时间进行排序,取出最近n次的购买记录,这时使用联合索引就可以避免多一次排序操作,因为索引本身在叶子节点已经排序了。
更进一步说,假设有联合索引 (a, b, c),下列语句可以直接通过联合索引得到结果:
SELECT ... FROM table_name WHERE a=xxx ORDER BY b;
SELECT ... FROM table_name WHERE a=xxx AND b=xxx ORDER BY c;
但是对于下面两条语句,联合索引不能直接得到结果,其还需要执行一次排序操作,因为索引 (a, c)并未排序:
SELECT ... FROM table_name WHERE a=xxx ORDER BY c;
SELECT ... FROM table_name WHERE a=xxx and b > xxx ORDER BY c;
考虑下,对于下面这条语句,能否用到联合索引 (a, b)
SELECT ... FROM table_name WHERE a=xxx AND b=yyy;
这个当然没问题。
那对于 a 列的单独查询,能否用到联合索引 (a, b)
SELECT ... FROM table_name WHERE a=xxx;
当然也可以,因为a列是已经排好序的
但是对于 b 列的单独查询则不能使用联合索引 (a, b)
SELECT ... FROM table_name WHERE b=xxx;
因为把叶子节点中的b列单独拎出来看它不是有序的:1、2、1、4、1、2,因此对于b的查询是使用不到 (a, b)这个联合索引的。
同样的道理,对于(a, b, c)联合索引来说,查询 (a, b) 可以用到这个联合索引,但是查询 (b, c) 就没办法使用这个联合索引,因为 b 和 c 列的有序性都是依托于 a 列的存在的。
这就是联合索引的最左前缀原则,只要查询的是联合索引的最左N个字段,就可以利用该联合索引来加速查询。
基于上面对最左前缀索引的说明以及用户表的例子,我们来讨论一个问题:在建立联合索引的时候,如何安排索引内的字段顺序?
有两点原则。
首先,第一原则,如果通过调整顺序,可以少维护一个索引,那么这个字段顺序往往就是需要优先考虑采用的
很好理解,当已经有了(a,b)这个联合索引后,一般就不需要单独在 a 上建立索引了。
那么,再思考一个问题:如果既有联合查询(a,b),又有基于a、b各自的查询呢?
显然,如果查询条件里面只有b的语句,是无法使用(a,b)这个联合索引的,这时候你不得不维护另外一个b列的索引,也就是说你需要同时维护 (a,b)、(b) 这两个索引。
举个用户表例子,有这样三个高频查询需求:
-- 根据name查询id
select id from user where name = xxx;
-- 根据age查询id
select id from user where age= xxx;
-- 根据name和age查询 id
select id from user where name = xxx and age = xxx;
这个时候,我们有两种索引建立的选择:
怎么选?这种场景下,我们要考虑的原则就是空间。
显然,name字段是要比age字段大的,所以,第二种选择占用的空间要小于第一种选择,推荐使用第二种选择:联合索引 (name, age) + 单字段索引 (age)。
最左前缀可以用于在索引中定位记录,那么,那些不符合最左前缀的部分,会怎么样呢?
以用户表的联合索引 (name, age) 为例,假设现在有一个需求,找出所有姓 “张” 并且20岁的男性:
select * from user where name like '张%' and age = 20 and gender = 'male';
对于联合索引,如果查询中有某个列的范围查询,则其右边所有列都无法使用索引进行快速定位。
所以对于这条语句来说,其实并不能完全踩中 (name, age) 这个联合索引,他只能踩到name索引。
具体来说,这个语句在搜索 (name, age) 的联合索引树的时候,并不会去看age的值,只是按顺序把 “name第一个字是张” 的记录一条条取出来,然后开始回表,到主键索引上找出数据行,再一个一个判断其他条件是否满足。下图展示了回表过程,可以看出来需要回表3次。
这是MySQL 5.6之前的做法,简单总结,当进行索引查询时,首先根据索引来查找记录,然后再根据where条件来过滤记录。而MySQL 5.6开始,数据库在取出索引的同时,会根据where条件直接过滤掉不满足条件的记录,减少回表次数。这就是索引下推 (Index Condition Pushdown,ICP) ,一种根据索引进行查询的优化方式。
从图中可以看出来,InnoDB在 (name,age) 索引内部就判断了age是否等于20,对于不等于20的记录,直接判断并跳过,所以只需要对ID1这条记录进行回表判断就可以了。
最左前缀原则就是只要查询的是联合索引的最左N个字段,就可以利用该联合索引来加速查询。
不按照最左匹配来为什么失效呢,其原因就在于联合索引的B+树中的键值是排好序的。不过,这里指的排好序,其实是相对的,举个例子,有(a, b, c)联合索引,a首先是排序好的,而b列是在a列排序的基础上做的排序,同样的c是在(a,b)两列有序的基础上做的排序。所以说,如果有where a = xxx order by b这种请求的话,是可以直接在这颗联合索引树上查出来的,不用对b列进行额外的排序;而如果是where a = xxx order by c这种请求的话,还需要额外对c列进行一次排序才行,没法用上联合索引最左前缀的部分。
举个例子:如果有对 (a,b,c)的联合条件查询的话,并且a是模糊匹配或者说是范围查询的话,其实并不能完全踩中联合索引 (a,b,c),a列右边的所有列都无法使用索引进行快速定位了。所以这个时候就需要进行回表判断,也就是说数据库会首先根据联合索引树中的结果去主键索引树中查找记录,然后再根据where条件来过滤记录。
不过在MySQL 5.6中支持了索引下推ICP,数据库在取出索引的同时,会根据where条件直接过滤掉不满足条件的记录,减少回表次数。