Python3实战Spark大数据分析及调度
这个 MySQL bug 让我大开眼界!
这周收到一个 sentry 报警,如下 SQL 查询超时了。
select * from order_info where uid = 5837661 order by id asc limit 1
复制代码
执行show create table order_info 发现这个表其实是有加索引的
CREATE TABLE order_info
(id
bigint(20) unsigned NOT NULL AUTO_INCREMENT,uid
int(11) unsigned,order_status
tinyint(3) DEFAULT NULL,
... 省略其它字段和索引
PRIMARY KEY (id
),
KEY idx_uid_stat
(uid
,order_status
),
) ENGINE=InnoDB DEFAULT CHARSET=utf8
复制代码
理论上执行上述 SQL 会命中 idx_uid_stat 这个索引,但实践执行 explain 查看
explain select * from order_info where uid = 5837661 order by id asc limit 1
复制代码
能够看到它的 possible_keys(此 SQL 可能触及到的索引) 是 idx_uid_stat,但实践上(key)用的却是全表扫描
download
我们晓得 MySQL 是基于本钱来选择是基于全表扫描还是选择某个索引来执行最终的执行方案的,所以看起来是全表扫描的本钱小于基于 idx_uid_stat 索引执行的本钱,不过我的第一觉得很奇异,这条 SQL 固然是回表,但它的 limit 是 1,也就是说只选择了满足 uid = 5837661 中的其中一条语句,就算回表也只回一条记载,这种本钱简直能够疏忽不计,优化器怎样会选择全表扫描呢。
当然疑心归疑心,为了查看 MySQL 优化器为啥选择了全表扫描,我翻开了 optimizer_trace 来一探求竟
画外音:在MySQL 5.6 及之后的版本中,我们能够运用 optimizer trace 功用查看优化器生成执行方案的整个过程
运用 optimizer_trace 的详细过程如下
download
SET optimizer_trace="enabled=on"; // 翻开 optimizer_trace
SELECT * FROM order_info where uid = 5837661 order by id asc limit 1
SELECT * FROM information_schema.OPTIMIZER_TRACE; // 查看执行方案表
SET optimizer_trace="enabled=off"; // 关闭 optimizer_trace
复制代码
MySQL 优化器首先会计算出全表扫描的本钱,然后选出该 SQL 可能触及到到的一切索引并且计算索引的本钱,然后选出一切本钱最小的那个来执行,来看下 optimizer trace 给出的关键信息
{
"rows_estimation": [
{
"table": "`rebate_order_info`",
"range_analysis": {
"table_scan": {
"rows": 21155996,
"cost": 4.45e6 // 全表扫描本钱
}
},
...
"analyzing_range_alternatives": {
"range_scan_alternatives": [
{
"index": "idx_uid_stat",
"ranges": [
"5837661 <= uid <= 5837661"
],
"index_dives_for_eq_ranges": true,
"rowid_ordered": false,
"using_mrr": false,
"index_only": false,
"rows": 255918,
"cost": 307103, // 运用idx_uid_stat索引的本钱
"chosen": true
}
],
"chosen_range_access_summary": { // 经过上面的各个本钱比拟后选择的最终结果
"range_access_plan": {
"type": "range_scan",
"index": "idx_uid_stat", // 能够看到最终选择了idx_uid_stat这个索引来执行
"rows": 255918,
"ranges": [
"58376617 <= uid <= 58376617"
]
},
"rows_for_plan": 255918,
"cost_for_plan": 307103,
"chosen": true
}
}
...
复制代码
能够看到全表扫描的本钱是 4.45e6,而选择索引 idx_uid_stat 的本钱是 307103,远小于全表扫描的本钱,而且从最终的选择结果(chosen_range_access_summary)来看,的确也是选择了 idx_uid_stat 这个索引,但为啥从 explain 看到的选择是执行 PRIMARY 也就是全表扫描呢,难道这个执行方案有误?
认真再看了一下这个执行方案,果真发现了猫腻,执行方案中有一个 reconsidering_access_paths_for_index_ordering 选择惹起了我的留意
{
"reconsidering_access_paths_for_index_ordering": {
"clause": "ORDER BY",
"index_order_summary": {
"table": "`rebate_order_info`",
"index_provides_order": true,
"order_direction": "asc",
"index": "PRIMARY", // 能够看到选择了主键索引
"plan_changed": true,
"access_type": "index_scan"
}
}
}
复制代码
这个选择表示由于排序的缘由再停止了一次索引选择优化,由于我们的 SQL 运用了 id 排序(order by id asc limit 1),优化器最终选择了 PRIMARY 也就是全表扫描来执行,也就是说这个选择会忽视之前的基于索引本钱的选择,为什么会有这样的一个选项呢,主要缘由如下:
The short explanation is that the optimizer thinks — or should I say hopes — that scanning the whole table (which is already sorted by the id field) will find the limited rows quick enough, and that this will avoid a sort operation. So by trying to avoid a sort, the optimizer ends-up losing time scanning the table.
从这段解释能够看出主要缘由是由于我们运用了 order by id asc 这种基于 id 的排序写法,优化器以为排序是个昂贵的操作,所以为了防止排序,并且它以为 limit n 的 n 假如很小的话即便运用全表扫描也能很快执行完,这样运用全表扫描也就防止了 id 的排序(全表扫描其实也就是基于 id 主键的聚簇索引的扫描,自身就是基于 id 排好序的)
假如这个选择是对的那也而已,但是实践上这个优化却是有 bug 的!实践选择 idx_uid_stat 执行会快得多(只需 28 ms)!网上有不少人反应这个问题,而且呈现这个问题根本只与 SQL 中呈现 order by id asc limit n这种写法有关,假如 n 比拟小很大约率会走全表扫描,假如 n 比拟大则会选择正确的索引。
这个 bug 最早追溯到 2014 年,不少人都呼吁官方及时修正这个bug,可能是完成比拟艰难,直到 MySQL 5.7,8.0 都还没处理,所以在官方修复前我们要尽量防止这种写法,那么怎样防止呢,主要有两种计划
运用 force index 来强迫运用指定的索引,如下:
select * from order_info force index(idx_uid_stat) where uid = 5837661 order by id asc limit 1
复制代码
这种写法固然能够,但不够文雅,假如这个索引被废弃了咋办?于是有了第二种比拟文雅的计划
运用 order by (id+0) 计划,如下
select * from order_info where uid = 5837661 order by (id+0) asc limit 1
download