优化的道路永无止境——Mysql的ICP及MRR
在讲ICP和MRR之前,我们先了解一下MySQL的架构。于本文的重点并不在架构细节上,所以让我们直接看关键部分,存储引擎作为单独的一层,是连接底层存储系统和上层server其他部分的桥梁,而MySQL对存储引擎的抽象也极大地丰富了其可扩展性。
ICP
我们平时需要查询和写入的数据最终的存储介质都是底层的文件系统,而数据的传输必然要经过上面说到的两个部分。就好像我们将单体服务切分成微服务一样,如果我们需要从另一个服务取数据,需要由对方服务查询DB,然后通过网络再传输给自己。查询DB的过程也是一种网络传输,所以这里的数据其实经过了两次网络传输,相比直接查询查询DB增加了一倍的网络消耗。这是微服务切分后带来的代价,那有没有其他的途径可以减少这种消耗呢?
如果我实际需要10条数据,但每次都获取100条数据后自己执行过滤,那两次网络传输的都是100条;如果由对方服务进行过滤,则其中一次网络只需传输10条;更甚者,如果对方查DB直接只查10条,那两次网络传输都是10条。
ICP(Index Condition Pushdown)就是利用了类似的原理来达到性能优化的。举个🌰
有这么一张表employees,其中first_name和last_name建立了一个联合索引。
CREATE TABLE `employees` (
`emp_no` int(11) NOT NULL,
`birth_date` date NOT NULL,
`first_name` varchar(14) NOT NULL,
`last_name` varchar(16) NOT NULL,
`gender` enum('M','F') NOT NULL,
`hire_date` date NOT NULL,
PRIMARY KEY (`emp_no`),
KEY `birth` (`birth_date`),
KEY `first_last` (`first_name`,`last_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
执行以下语句,MySQL会怎么处理呢?
EXPLAIN SELECT * FROM employees WHERE first_name = "Georgi" AND last_name LIKE "Maliniak%"
在5.6版本以前,MySQL会使用first_last这个联合索引,然后存储引擎根据first_name = "Georgi"这个过滤条件查询相应的记录,返回上层的server后再根据last_name LIKE "Maliniak%"这个条件做过滤,最后将符合条件的数据返回给用户。也就是说,last_name LIKE "Maliniak%"这个条件是经历了两次数据传输后才起的作用,查询的扫描行数就是first_name = "Georgi"的所有记录,跟我们一开始举的例子很像,是存在优化空间的。
5.6版本及以后呢,MySQL不这么干了。走索引的时候虽然拿不到完整的数据文件,但是last_name就是索引中的字段,这个信息其实在first_last这个联合索引文件中是存在的,所以提前过滤完全可以做到。MySQL将where条件下推到存储引擎,存储引擎尽可能的根据条件中的信息查询记录,最终存储引擎获取到的就是符合first_name = "Georgi" AND last_name LIKE "Maliniak%"的记录,这样在最底层就避免了额外的数据传输。
ICP是可以通过开关控制的,而且它有特别的适用场景,并不是无脑能用的:
- ICP适用于连接类型为range,ref,eq_ref和ref_or_null的全表扫描,连接类型就explain输出中的type字段;
- InnoDB和MyISAM都支持ICP;
- ICP适用于二级索引,不适用于聚簇索引(一般就是主键),因为聚簇索引的叶子节点就是数据记录,在存在缓冲区的情况下,ICP起不到啥作用;
- 二级索引如果含有虚拟列的话不支持ICP;
- 条件中如果用到了子查询,那不会被下推;
默认情况下ICP是打开的,如果你愿意(除了测试应该没人会这么做的吧),可以通过SELECT @@optimizer_switch查看,通过SET optimizer_switch = 'index_condition_pushdown=off'关闭这个功能。
MRR
MRR(Multi-Range Read)则是从硬件层面切入,但目标和ICP是一致的,就是优化查询效率。我们都知道对于机械硬盘来说,由于其物理结构的限制,随机读的效率远低于顺序读。大致结构长下面这样,凑合看吧。
因为转速有上限,所以单位时间内磁头的移动距离也有限制,最坏的情况下,7200 RPM的机械硬盘每秒寻道120次,1次寻道读取一条记录,那1秒内只能返回120条记录,完全是凉凉的节奏。而如果单次寻道能够匹配多条记录,那效率就能成倍上升,这就是顺序读的优势,更何况还有page cache预读等黑科技加持,顺序读和随机读简直就像跑车和自行车。
说了这么多好像跟MRR没太大关系,我们想象一种场景:通过二级索引查询数据库记录,此时不管是MyISAM还是InnoDB的存储引擎(假设无法用到覆盖索引),都需要一个回表操作,而二级索引的顺序和数据记录的顺序可能完全不一致,这次查询的流程就像下面这张图一样(来源于MariaDB官网,MariaDB是MySQL的一个分支,好多功能类似)
图中的红线是查询过程,而蓝色则是磁盘的运动轨迹,可以看到上上下下,很明显磁盘在做随机读。MRR就是一种将这样的随机读转变为顺序读的骚操作。我们看一下加入了MRR后整个流程会有哪些变化:
中间多了一步排序,拿InnoDB举例,聚簇索引(一般就是主键索引,除非你没设置主键)的叶子节点存放的就是数据记录本身,也就是说,聚簇索引和数据在磁盘的存储顺序其实是一致的。当我们通过二级索引拿到聚簇索引的值后,先对其进行排序,然后再去磁盘查询,这样原来的随机读就被转化为了顺序读。除了将随机读转换为顺序读以外,MRR还有索引批量访问的优点,比如对索引进行范围访问或者将索引列作为连接属性进行连接查询,MRR能够累积一定数量的查询key然后进行批量查询。
当我们用EXPLAIN输出某条SQL的执行计划的时候,如果MySQL判断会用到MRR,则会在Extra这一列说明Using MRR。
虽然MRR看上去是一个很牛逼的优化,但也并不能做到一招鲜吃遍天。最简单的,如果我只查询一条数据,哪需要什么排序呢?而中间的排序过程其实需要将第一步的结果放入一个临时的缓冲区,可以将MRR看成一种空间换时间的方法。既然MRR有时候没有优势,所以机智的MySQL增加了好几个配置来处理这些情况:
- mrr——如果值为off,mrr永远不会被使用,on则有可能会被使用
- mrr_cost_based——智能决策,如果发现mrr划算则使用,否则不使用
- read_rnd_buffer_size——控制排序步骤缓冲区的大小,如果满了就会先执行一次查询
这三个属性值前两个可以通过SELECT @@optimizer_switch查看,第3个则可以通过SHOW VARIABLES查看。我的测试版本是5.7.29,这三个属性的默认值分别是on,on,262144(也就是256KB)。