优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。
思考
假设有表结构:
-- T表结构:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `b` (`b`)
) ENGINE=InnoDB;
往表t中插入10万行记录, 取值按整数递增, 即:(1,1,1), (2,2,2), (3,3,3) 直到 (100000,100000,100000)。
插入数据的存储过程如下:
-- 插入数据存储过程:
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=100000)do
insert into t values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
-- 调用存储过程
call idata();
接下来, 我们分析一条SQL语句:
mysql> select * from t where a between 10000 and 20000;
单独执行上述语句时,查询执行计划如下:
从执行计划来看,走了索引a,符合预期。
接着我们再做如下操作:
这里, session A的操作你已经很熟悉了, 它就是开启了一个事务。 随后, session B把数据都删除后, 又调用了 idata这个存储过程, 插入了10万行数据。
这时候, session B的查询语句select * from t where a between 10000 and 20000就不会再选择索引a了。可以通过慢查询日志(slow log) 来查看一下具体的执行情况。
为了说明优化器选择的结果是否正确, 我增加了一个对照, 即: 使用force index(a)来让优化器强制使用索引a。
在Session B中执行如下三条SQL语句:
set long_query_time=0;
select * from t where a between 10000 and 20000; /*Q1*/
select * from t force index(a) where a between 10000 and 20000;/*Q2*/
- 第一句, 是将慢查询日志的阈值设置为0, 表示这个线程接下来的语句都会被记录入慢查询日志中;
- 第二句, Q1是session B原来的查询;
- 第三句, Q2是加了force index(a)来和session B原来的查询语句执行情况对比。
这三条SQL语句执行完成后的慢查询日志结果如下:
可以看到, Q1扫描了10万行, 显然是走了全表扫描, 执行时间是40毫秒。 Q2扫描了10001行,执行了21毫秒。 也就是说, 我们在没有使用force index的时候, MySQL用错了索引, 导致了更长的执行时间。
这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。 这时, MySQL竟然会选错索引, 是不是有点奇怪呢?下面我们就来看一下优化器是如何选择索引的。
问:begin 和 start transaction with consistent snapshot 的区别是什么?
创建一致性视图的时机不同。
1)begin 是在第一次执行 SELECT 时创建一致性视图。
2)start transaction with consistent snapshot 是在该语句执行后,立即创建一致性视图。
优化器的逻辑
优化器选择索引的目的, 是找到一个最优的执行方案, 并用最小的代价去执行语句。
问1:扫描行数在优化器选择索引时发挥着怎样作用?
在数据库里面, 扫描行数是影响执行代价的因素之一。 扫描的行数越少, 意味着访问磁盘数据的次数越少, 消耗的CPU资源越少。当然, 扫描行数并不是唯一的判断标准, 优化器还会结合是否使用临时表、 是否排序等因素进行综合判断。
问2:扫描行数是怎么判断的?
MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。
这个统计信息就是索引的“区分度”。 显然, 一个索引上不同的值越多, 这个索引的区分度就越好。 而一个索引上不同的值的个数, 我们称之为“基数”(cardinality) 。 也就是说, 这个基数越大, 索引的区分度越好。
可以使用 show index from xxx 方法, 看到一个索引的基数。 如下图所示, 就是表t的show index的结果。 虽然这个表的每一行的三个字段都是一样的, 但是在统计信息中, 这三个索引的基数值并不同, 而且其实都不准确。
问3:MySQL是怎样得到索引的基数的呢?
下面简单介绍一下MySQL采样统计的方法。
- 采样统计的时候, InnoDB默认会选择N个数据页, 统计这些页面上的不同值, 得到一个平均值, 然后乘以这个索引的页面数, 就得到了这个索引的基数。
- 而数据表是会持续更新的, 索引统计信息也不会固定不变。 所以, 当变更的数据行数超过1/M的时候, 会自动触发重新做一次索引统计。
问4:在MySQL中,存储索引统计的方式(N、M值的选择)是如何设置的?
可以通过设置参数innodb_stats_persistent的值来选择:
- 设置为on的时候, 表示统计信息会持久化存储。 这时, 默认的N是20, M是10。
- 设置为off的时候, 表示统计信息只存储在内存中。 这时, 默认的N是8, M是16。
由于是采样统计, 所以不管N是20还是8, 这个基数都是很容易不准的。从上图可知,索引统计值(cardinality列,即扫描行数估计值)虽然不够准确,但大体上还是差不多的,那就说明选错索引还有别的原因。
注1:扫描行数不同于select count,是一个统计值,因而并不是非常精确的;
主2:主键索引的扫描行数直接通过show table status中表的行数(Rows)来估计,并不是通过上述采样方式获取的;
问5:为什么要采样统计呢?
因为把整张表取出来一行行统计, 虽然可以得到精确的结果, 但是代价太高了, 所以只能选择“采样统计”。
接下来, 我们再一起看看,在Session B中优化器预估的这两个语句的扫描行数是多少。
注:rows这个字段表示的是预计扫描行数。
其中, Q1的结果还是符合预期的, rows的值是104620(和采样统计的扫描行数接近); 但是Q2的rows值是37116, 偏差就大了。 而上图中我们用explain命令和慢日志记录看到的实际扫描rows是只有10001行, 是这个偏差误导了优化器的判断。
问6:上述优化器预估的两个语句的扫描行数分别为104620、37116,优化器为什么放着扫描37000行的执行计划不用, 却选择了扫描行数是100000的执行计划呢?
这是因为, 如果使用索引a, 每次从索引a上拿到一个值, 都要回到主键索引上查出整行数据(即考虑了回表的代价),这个代价优化器也要算进去的。而如果选择扫描10万行, 是直接在主键索引上扫描的, 没有额外的代价。
优化器会估算这两个选择的代价, 从结果看来, 优化器认为直接扫描主键索引更快。 当然, 从执行时间看来, 这个选择并不是最优的。
问7:对于Q2的执行计划,查询1w行数据,查询计划得出的扫描行数为什么是3.7w?
session B中先delete 10w行数据,再通过call idata()插入了10万行数据;由于session A开启了一致性读,且事务并未提交,则之前插入的10万行数据不能删除。所以之前的10w行数据每一行都有两个版本,旧版本是delete之前的数据,新版本是标记为deleted的数据。一共要扫描3w行数据;再加上到主键索引回表的数据,接近4w行;
索引选择异常和处理
其实大多数时候优化器都能找到正确的索引, 但偶尔你还是会碰到我们上面举例的这两种情况:原本可以执行得很快的SQL语句, 执行速度却比你预期的慢很多, 你应该怎么办呢?
如果索引选择异常,可通过如下几种策略更正:
1)采用 force index 强行选择一个索引。该策略有如下缺点:
- 格式不优美;
- 如果索引改了名字,这个语句也得改比较麻烦;
- 如果以后迁移到别的数据库,这个语法还可能不兼容;
2)修改SQL语句,引导MySQL使用我们期望的索引。一般不建议使用,使用条件比较苛刻。
3)新建一个更合适的索引,来提供给优化器做选择或删掉误用的索引。
4)执行analyze table xxx,更新统计值,重新分析表,使得统计值更加准确。(由于索引统计信息不准确导致选错索引,可以使用该方法解决)