这周收到一个 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)用的却是全表扫描


我们知道 MySQL 是基于成本来选择是基于全表扫描还是选择某个索引来执行最终的执行计划的,所以看起来是全表扫描的成本小于基于 idx_uid_stat 索引执行的成本,不过我的第一感觉很奇怪,这条 SQL 虽然是回表,但它的 limit 是 1,也就是说只选择了满足 uid = 5837661 中的其中一条语句,就算回表也只回一条记录,这种成本几乎可以忽略不计,优化器怎么会选择全表扫描呢。

当然怀疑归怀疑,为了查看 MySQL 优化器为啥选择了全表扫描,我打开了 optimizer_trace 来一探究竟

画外音:在MySQL 5.6 及之后的版本中,我们可以使用 optimizer trace 功能查看优化器生成执行计划的整个过程

使用 optimizer_trace 的具体过程如下

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 都还没解决,所以在官方修复前我们要尽量避免这种写法,那么怎么避免呢,主要有两种方案

  1. 使用 force index 来强制使用指定的索引,如下:
select * from order_info force index(idx_uid_stat) where uid = 5837661 order by id asc limit 1

这种写法虽然可以,但不够优雅,如果这个索引被废弃了咋办?于是有了第二种比较优雅的方案

  1. 使用 order by (id+0) 方案,如下
select * from order_info where uid = 5837661 order by (id+0) asc limit 1

这种方案也可以让优化器选择正确的索引,更推荐!

巨人的肩膀

  • mysql 优化器 bug http://4zsw5.cn/L1zEi

最后,求关注,原创不易,需要一些正反馈,欢迎大家关注我的公众号「码海」,一起进阶,一起牛逼


这个 MySQL bug 让我大开眼界的更多相关文章

  1. MySQL Bug导致异常宕机的分析流程

    原文链接:http://click.aliyun.com/m/42521/ 摘要: 本文主要通过一个bug来记录一下如何分析一个MySQL bug的崩溃信息. 版本:Percona 5.7.17-11 ...

  2. MySQL bug:server-id默认被自己主动置为1

    昨天同事在做主从时,从库报例如以下错误: Got fatal error 1236 from master when reading data from binary log: 'Misconfigu ...

  3. 从MySQL Bug#67718浅谈B+树索引的分裂优化(转)

    原文链接:http://hedengcheng.com/?p=525 问题背景 今天,看到Twitter的DBA团队发布了其最新的MySQL分支:Changes in Twitter MySQL 5. ...

  4. MySQL Bug剖析之Slave节点并行复制死锁

    此文已由作者温正湖授权网易云社区发布. 欢迎访问网易云社区,了解更多网易技术产品运营经验. 有天一早,DBA同学就找上来了,说有个DDB集群下的RDS实例Slave节点(从库)死锁了,请求支援.说实话 ...

  5. MySQL Bug#67718 浅谈B+树索引的分裂优化

    原文链接:http://hedengcheng.com/?p=525 问题背景 今天,看到Twitter的DBA团队发布了其最新的MySQL分支:Changes in Twitter MySQL 5. ...

  6. 如何从头到脚彻底解决一个MySQL Bug

    摘要:为了保障华为云GaussDB产品的可靠性,每一款产品发布前都要通过多轮严苛的测试用例. 说明:本文中的MySQL,如果不做特殊说明,指的是开源社区版MySQL. 华为云数据库新版本在发布之前,会 ...

  7. MySQL · BUG分析 · Rename table 死锁分析

    http://mysql.taobao.org/monthly/2016/03/06/ 背景 InnoDB buffer pool中的page管理牵涉到两个链表,一个是lru链表,一个是flush 脏 ...

  8. Mysql bug: The server time zone value '�й���׼ʱ��' is unrecognized or represents more than one time zone.

    在 MySQL 中执行命令试下: set global time_zone='+8:00': 解释:在访问数据库时出现时区无法识别问题,在通过在数据库连接URL后,加上?serverTimezone= ...

  9. MySQL 5.6查询优化器新特性的“BUG” eq_range_index_dive_limit

    本文转自 http://www.imysql.cn 最近碰到一个慢SQL问题,解决过程有点小曲折,和大家分享下. SQL本身不复杂,表结构.索引也比较简单,不过个别字段存在于多个索引中. CREATE ...

随机推荐

  1. 007 GMII、SGMII和SerDes的区别和联系

    一.GMII和SGMII的区别和联系 GMII和SGMII区别,上一篇已经介绍了,这一篇重点介绍SGMII和SerDes区别. GMII和SGMII GMII 在MII接口基础上提升了数据位宽和Clo ...

  2. 004 Ethernet(以太网)详解

    一.以太网 以太网是一种计算机局域网技术.IEEE组织的IEEE 802.3标准制定了以太网的技术标准,它规定了包括物理层的连线.电子信号和介质访问层协议的内容. 以太网有两类:第一类是经典以太网,第 ...

  3. MySQL临时表与内存表

    在MySQL中有三种虚拟表:临时表.内存表.视图.下面简单介绍一下临时表和内存表的使用. 1.临时表 MySQL临时表在我们需要保存一些临时数据时是非常有用的.临时表在MySQL 3.23版本中添加. ...

  4. WPF 显示3D密集场景,堆场管理系统

    又好久好久没写博客了,这次接着上文https://www.cnblogs.com/CSSZBB/p/12785380.html,上文用WPF 的绘图功能,制作了一个伪3D的2.5D控件ThreeDBo ...

  5. return 和 return false 的区别

    return返回null,起到中断方法执行的效果,只要不return false事件处理函数将会继续执行,表单将提交. return false,事件处理函数会取消事件,不再继续向下执行.比如表单将终 ...

  6. IDEA 创建Maven Web工程

    一.Maven环境搭建 二.Maven常用命令 mvn clean 清除生成的target文件 mvn install 生成target文件 mvn clean install 相当于先删除targe ...

  7. 分布式协调组件Zookeeper之 选举机制与ZAB协议

    Zookeeper简介: Zookeeper是什么: Zookeeper 是⼀个分布式协调服务的开源框架. 主要⽤来解决分布式集群中应⽤系统的⼀致性问题, 例如怎样避免同时操作同⼀数据造成脏读的问题. ...

  8. 简单明了的Java线程池

    线程池 线程池从功能上来看,就是一个任务管理器.在Java中,Executor接口是线程池的根接口,其中只包含一个方法: Executor void execute(Runnable command) ...

  9. 20210816 你相信引力吗,marshland,party?,半夜

    考场 第一眼都不可做 T1 长得就像单调栈/单调队列,推了推性质发现优弧.劣弧都合法的点对很好处理,其他情况只在一种情况合法,那么开两个单调队列分别统计距离 \(\le\frac2n,>\fra ...

  10. openswan协商流程之(六):main_inI3_outR3()

    主模式第六包:main_inI3_outR3 1. 序言 main_inI3_outR3()函数是ISAKMP协商过程中第六包的核心处理函数的入口,第五六包主要用来验证对方的身份信息,同时此报文也是加 ...