一次生产慢查询,了解MySQL优化器

问题引入

在一个平常到不能在平常午夜,本来准备睡觉的我,突然收到了一个告警电话。当时我的心情如同经过了一万匹草泥马一般。


虽然已经非常困了,但作为即将冉冉升起的职场新星(自封),还是提起精神去排查问题了。


经过一番排查,发现是一个定时任务中的慢sql导致的异常告警。在发现的第一时间,我们首先停止了定时任务的触发与执行,及时的阻止了该告警的再次发生。但回过头来,我们当天并没有什么发布和配置变更,而且该定时任务已经平稳的运行了一年多的时间了,为什么会突然出现慢sql呢?

而后,我们针对慢sql进行的针对性的分析。通过explain发现原本查询语句是可以通过push_status索引进行查询的,避免全表扫描提高查询效率的。然而,发生慢sql时,该查询语句并没有如预期一般通过push_status索引进行数据查询,而是通过primary主键索引进行数据查询,进而导致了慢sql。

那么,问题就更加扑朔迷离了,编写的查询语句明明可以使用索引进行数据查询,MySQL为什么如此傲娇不使用该索引进行数据查询呢?


在日常开发中,对MySQL的数据查询是再常见不过的了。因此也产生了各种查询优化的手段,如:添加索引、避免回表、分页优化等操作。其中最常用的查询优化手段就是使用索引。当我们对数据表建立的了索引,并且编写的查询语句可以使用索引,但MySQL在进行查询操作时并不一定会使用我们预期的索引进行数据查询。这也就导致,查询语句使用的索引不及预期、相同的查询语句执行索引情况不同等问题。

上述查询语句的查询数据表对push_status添加了索引,编写查询语句时预期该语句会通过push_status的索引加速查询效率。然而生产环境实际运行中并没有完全符合预期,该查询语句有时会通过push_status索引进行查询;有时并不会通过push_status索引进行数据查询。进而导致了慢SQL的产生。那么问题来了,为什么相同的查询语句为什么会通过不同的索引进行查询?

在查阅相关资料后得知,我们在编写完成查询语句后,MySQL会对该查询语句进行一系列的操作(如上图所示)。

  • 客户端发送一条查询语句给MySQL服务器
  • MySQL服务器检查查询缓存,命中缓存直接返回缓存中的结果,否则进入下一阶段
  • MySQL服务器进行SQL解析、预处理生成语法树
  • 根据语句生成对应的执行计划
  • MySQL根据生成的执行计划调用存储引擎API执行查询
  • 将结果返回给客户端

通过上述内容,我们知道了一条查询语句会经历诸多过程最终返回查询结果。此处,我们需要关注的是MySQL在执行查询语句前,会先通过查询优化器对查询语句进行优化,选择出MySQL认为对最优查询计划,而后通过查询计划进行数据查询。那么MySQL查询优化器是如何进行查询优化的呢?

优化器原理

数据准备

在分析查询优化器原理前,生成一些测试数据来直观的展示查询优化的效果。

sql 复制代码
create table `users` (
  `uid` int(11) not null default '0',
  `id` int(11) not null auto_increment,
  `name` varchar(255) not null,
  `age` int(11) not null,
  primary key (`id`),
  key `index_uid` (`uid`) using btree,
  key `index_name` (`name`) using btree,
  key `index_age` (`age`) using btree
) engine=InnoDB auto_increment=356 default charset=utf8mb4 collate=utf8mb4_0900_ai_ci; ​

DELIMITER $$  -- 临时修改分隔符

CREATE PROCEDURE initData()
BEGIN
    DECLARE count INT DEFAULT 1;
    WHILE count <= 100 DO  -- 修正循环语法
        INSERT INTO users (uid, `name`, age) 
        VALUES (count, CONCAT('学生', CAST(count AS CHAR)), count); 
        SET count = count + 1;
    END WHILE;
END$$

DELIMITER ;  -- 恢复默认分隔符

-- 调用存储过程
CALL initData();

在MySQL中查询优化器属于服务器层对查询语句进行查询优化。查询优化器对查询语句的优化大致分为三个阶段:优化准备联合优化联合执行

优化准备

在逻辑转换阶段,MySQL优化器会在不影响查询结果的前提下对查询语句进行转换。其转换的内容包括:否定消除等值传递常量传递常量表达式求值外连接转为内连接子查询转换视图合并等。转换的目的是消除某些操作以提高查询效率。 以以下查询语句为例

sql 复制代码
select * from users where age >= 20 and 1 = 1;

以上查询语句的过滤条件中1=1是恒成立的,所以该过滤条件的存在与否对查询结果是没有任何影响的。因此,MySQL优化器在逻辑转换阶段会将该过滤条件舍弃。可以通过optimizer_trace进行验证,语句如下:

sql 复制代码
set optimizer_trace="enabled=on"; -- 开启optimizer_trace

select * from users where age>=20 and 1=1; -- 执行查询语句

select * from information_schema.optimizer_trace; -- 查看优化器追踪内容

查询优化器追踪内容如下

在优化准备阶段,MySQL查询优化器将1=1过滤条件舍弃了。

联合优化

在该阶段MySQL优化器会找出所有可以用来执行该查询语句的方案。该阶段也是MySQL查询优化器最重要的优化部分也是分析optimizer_trace的最重要部分。在该阶段主要的优化内容如下:

  • 连接顺序的确定: 优化器会在所有可能的连接顺序中选择成本最低的顺序执行多表连接(不同的连接顺序可能导致不同的I/O操作和数据扫描次数)。
  • 连接算法的选择: 优化器会根据表的大小、索引的存在以及数据分布来选择最合适的连接算法(如:嵌套循环连接(Nested Loop Join)、归并连接(Merge Join)、哈希连接(Hash Join)等)。
  • 物理访问路径的优化: 优化器会决定如何访问每个表,如是否使用索引扫描。
  • 索引优化: 对于连接操作,优化器会考虑使用或合并索引来减少数据扫描的范围(特别是在多where条件中,可能采取索引合并技术)。
  • 连接条件的重写: 优化器可能会重写JOIN条件,以利用更有效的连接策略。
  • 成本估算: 在整个成本优化阶段都会基于成本模型进行决策,成本估算维度包括CPU、I/O、内存等资源估算,以确保生成的计划在实际执行时能获得最佳性能。
  • 临时表的建立: 在一些复杂查询中,优化器会决定使用临时表来存储中间结果,以便后续步骤中更快的访问数据。
  • 并行查询优化: 对于支持并行查询环境中,优化器会进行并行执行,以提高查询速度。

该阶段会经过9个优化过程:

condition_processing

该过程的核心作用是重构和简化查询条件,该阶段关键处理操作(包括:等值传播常量传播简化条件表达式等)。主要针对where和having条件。下面以下图查询语句为例:

图中该过程对condition(where)进行了优化,original_condition(原始条件)优化后resulting_condition(优化后结果)。图中存在3个优化分别为:equality_propagation(等值传播)、constant_propagation(常量传播)、trivial_condition_removal(无效条件移除)。

substitute_generated_columns

该过程的核心作用是处理表中的生成列(生成列是MySQL5.7版本引入的新特性)。该阶段会将设计生成列的条件或表达式替换为其底层表达式定义,以便优化器能基于原始列进行更高效的优化。

由于在日常工作中生成列使用较少,不在此赘述该阶段优化内容。

在MySQL中生成列的种类分为存储生成列、虚拟生成列

存储生成列:存储生成列的值会在插入或更新行数据时计算并实际存储到表中。 虚拟生成列:虚拟生成列的值不会存储在表中,每当读取该列时,MySQL会动态计算该值。 以下是虚拟生成列及存储生成列的示例数据。

sql 复制代码
CREATE TABLE product (
	id INT PRIMARY KEY AUTO_INCREMENT,
	NAME VARCHAR ( 100 ) NOT NULL,
	price DECIMAL ( 10, 2 ) NOT NULL,-- 基础价格
	quantity INT NOT NULL,-- 数量
-- 虚拟生成列(查询时实时计算)
	total_price_virtual DECIMAL ( 10, 2 ) GENERATED ALWAYS AS ( price * quantity ) VIRTUAL,-- 存储生成列(写入时计算并存储)
	total_price_stored DECIMAL ( 10, 2 ) GENERATED ALWAYS AS ( price * quantity ) STORED,-- 虚拟列可结合函数使用
	name_upper VARCHAR ( 100 ) GENERATED ALWAYS AS (
	UPPER( NAME )) VIRTUAL 
);

table_dependencies

在该阶段MySQL优化器会分析查询语句中表之间的依赖关系。

该阶段会与其它优化阶段协同优化

ref_optimizer_key_uses

该阶段会列举出所有可用的ref类型的索引。以以下查询语句为例:

sql 复制代码
select * from users where age=20;

MySQL优化器在该阶段对其优化结果如下:

上图所示,MySQL优化器选择了age字段上的索引来加速查询效率。

rows_estimation

在该阶段MySQL优化器会估算各种执行计划需要扫描的数据数量和成本,该阶段是MySQL优化器选择最佳执行计划的数据依据。以以下查询语句为例

sql 复制代码
select * from users where age > 15;

该阶段优化结果如下:

json 复制代码
{
    "rows_estimation": [
        {
            "table": "`users`", # 表名
            "range_analysis": {
                "table_scan": {
                    "rows": 100, # 扫描数据量
                    "cost": 12.35 # 扫描成本
                },
                "potential_range_indexes": [ # 潜在索引
                    {
                        "index": "PRIMARY",
                        "usable": false,
                        "cause": "not_applicable"
                    },
                    {
                        "index": "index_uid",
                        "usable": false,
                        "cause": "not_applicable"
                    },
                    {
                        "index": "index_name",
                        "usable": false,
                        "cause": "not_applicable"
                    },
                    {
                        "index": "index_age",
                        "usable": true,
                        "key_parts": [
                            "age",
                            "id"
                        ]
                    }
                ],
                "setup_range_conditions": [

                ],
                "group_index_range": { # 分组索引分析
                    "chosen": false,
                    "cause": "not_group_by_or_distinct"
                },
                "skip_scan_range": { 
                    "potential_skip_scan_indexes": [
                        {
                            "index": "index_age",
                            "usable": false,
                            "cause": "query_references_nonkey_column"
                        }
                    ]
                },
                "analyzing_range_alternatives": {
                    "range_scan_alternatives": [ # 索引扫描分析
                        {
                            "index": "index_age", # 索引名
                            "ranges": [ # 扫描条件范围
                                "15 < age"
                            ],
                            "index_dives_for_eq_ranges": true, # 是否使用了索引
                            "rowid_ordered": false, # 是否根据PK值排序
                            "using_mrr": false,
                            "index_only": false, # 是否使用了覆盖索引
                            "in_memory": 1,
                            "rows": 85, # 扫描行数
                            "cost": 30.01, # 索引使用成本
                            "chosen": false, # 是否使用了该索引
                            "cause": "cost" # 没有使用该索引的原因
                        }
                    ],
                    "analyzing_roworder_intersect": { # 是否使用索引合并
                        "usable": false,
                        "cause": "too_few_roworder_scans"
                    }
                }
            }
        }
    ]
}

considered_execution_plans

综合考虑各个计划,并选择出最终执行计划。

sql 复制代码
select * from users where age>15;

以上查询语句MySQL优化器对其优化结果如下:

其中各个字段含义

json 复制代码
{
    "considered_execution_plans": [
        {
            "plan_prefix": [ # 当前计划的前置执行计划

            ],
            "table": "`users`", # 涉及的表名(别名也会列出)
            "best_access_path": {
                "considered_access_paths": [ # 当前考虑的访问路径
                    {
                        "rows_to_scan": 100, # 扫描行数
                        "access_type": "scan", #使用索引方式
                        "resulting_rows": 100, # 行数
                        "cost": 10.25, # 成本
                        "chosen": true # 是否选择当前执行路径
                    }
                ]
            },
            "condition_filtering_pct": 100,
            "rows_for_plan": 100,
            "cost_for_plan": 10.25,
            "chosen": true
        }
    ]
}

attaching_conditions_to_tables

该阶段会根据上一阶段(considered_execution_plans)选出的最佳执行计划的执行情况添加一些附加条件。该阶段主要赋值将查询中的过滤条件(where、on等子句)正确的分配到对应表和索引上,尽可能将条件下推到解决访问数据的地方(为索引下推ICP做准备)。

finalizing_table_conditions

该阶段主要是对上一阶段(attaching_conditions_to_tables)添加的附加条件进行简化,使得生成的执行计划更精简。

refine_plan

该阶段主要是对执行计划进行最终微调

联合执行

该阶段的核心作用是依据优化器生成的最终执行计划,通过调用存储引擎API,实际读取和处理数据。

优化器中的成本模型

在上面分析优化器如何优化时提到优化器是基于成本进行优化的。那么,优化器考虑的成本有哪些呢?在MySQL优化器中主要考虑的成本有CPU成本I/O成本

  • CPU成本: MySQL在读取或检测数据是否满足过滤条件,对结果集进行排序、聚合等操作所消耗的时间。
  • I/O成本: MySQL在查询表数据时,需要先将数据(或索引)从磁盘加载到内存中。此过程消耗的时间称为I/O成本。

MySQL查询成本 = CPU成本 + I/O成本

MySQL中对每种操作都赋予了对应的成本常量系数(用于成本计算),可以通过server_cost、engine_cost表进行查询或设置。

sql 复制代码
select * from mysql.server_cost;
select * from mysql.engine_cost;
成本类型 成本常量名称 默认值 描述
CPU disk_temptable_create_cost 20 内部创建的临时表存储在基于磁盘的存储引擎(InnoDB或MyISAM)中的成本估算。
CPU disk_temptable_row_cost 0.5 增加这些值会增加使用内部临时表的成本估计,并使优化器更喜欢使用较少的查询计。
CPU key_compare_cost 0.05 比较记录键的开销。增加这个值会导致比较多个键的查询计划变得更昂贵。例如,与使用索引避免排序的查询计划相比,执行文件排序的查询计划会相对昂贵一些。
CPU memory_temptable_create_cost 1 存储在MEMORY存储引擎中的内部创建的临时表的成本估算。增加这些值会增加使用内部临时表的成本估计,并使优化器更喜欢使用较少的查询计划。
CPU memory_temptable_row_cost 0.1 存储在MEMORY存储引擎中的内部创建的临时表的成本估算。增加这些值会增加使用内部临时表的成本估计,并使优化器更喜欢使用较少的查询计划。
CPU row_evaluate_cost 0.1 评估记录条件的成本。与检查行数较少的查询计划相比,增加该值将导致检查多行的查询计划的开销增加。例如,与读取更少行的范围扫描相比,表扫描的开销相对更高。
CPU io_block_read_cost 1 从磁盘读取索引或数据块的开销。增加该值将导致读取许多磁盘块的查询计划比读取较少磁盘块的查询计划开销更大。例如,与读取更少块的范围扫描相比,表扫描的开销相对更大。
CPU memory_block_read_cost 0.25 类似于io_block_read_cost,但表示从内存中的数据库缓冲区读取索引或数据块的开销。

计算成本

在了解MySQL成本模型后,对于某个具体查询语句,其查询成本是如何计算的呢?下面以以下查询语句为例:

sql 复制代码
select * from users where age > 20;

其优化结果如下

可以看出优化器最终的执行计划全表扫描、扫描行数100行、执行计划查询成本10.25。那么问题是查询成本10.25是如何计算得来的呢?我们可以通过如下手段进行成本的计算。

sql 复制代码
show table status like 'users';

以上查询结果,在计算成本时关心的数据是Rows(数据条数)、Data_length(表实际存储空间字节数,Data_length=数据页数量*数据页大小)。

该执行计划为全表扫描,成本=CPU成本 + I/O成本

CPU成本 = 读取数据数量 * 读取单条数据成本 = 100 * 0.1 = 10

I/O成本 = 访问数据页的页面数量 * 访问单个数据页成本 = (16384/(16*1024)) * 0.25 =0.25

成本 = CPU成本 + I/O成本 = 10 + 0.25 = 10.25

计算I/O成本中,MySQL默认数据页大小为16k,因此一个数据页所占字节数=16*1024

问题解决

在了解MySQL优化器原理后,分析开篇的问题。发现相同的查询语句在不同的数据环境下,选择的索引并不相同,因为push_status字段区分度不高,且push_status=0状态位的数据大量存在,进而导致MySQL优化器在进行成本优化指定查询计划过程中,并没有选择push_status作为查询索引,而是进行全表扫描,从而导致了慢查询。

该问题后续通过优化查询语句得以解决,使得MySQL优化器在进行成本分析选择执行计划时,能够选择push_status索引进行数据查询。

此处,查询语句优化并没有使用FORCE INDEX强制指定索引等手段。

相关推荐
光溯星河4 分钟前
【实践手记】Git重写已提交代码历史信息
后端·github
九分源码10 分钟前
基于PHP+MySQL组合开发开源问答网站平台源码系统 源码开源可二次开发 含完整的搭建指南
mysql·开源·php
PetterHillWater22 分钟前
Trae中实现OOP原则工程重构
后端·aigc
圆滚滚肉肉25 分钟前
后端MVC(控制器与动作方法的关系)
后端·c#·asp.net·mvc
SimonKing26 分钟前
拯救大文件上传:一文彻底彻底搞懂秒传、断点续传以及分片上传
java·后端·架构
深栈解码26 分钟前
JUC并发编程 内存布局和对象头
java·后端
37手游后端团队28 分钟前
巧妙利用装饰器模式给WebSocket连接新增持久化
后端
编程乐趣31 分钟前
C#版本LINQ增强开源库
后端
tonydf31 分钟前
记一次近6万多个文件的备份过程
windows·后端
前端付豪32 分钟前
13、你还在 print 调试🧾?教你写出自己的日志系统
后端·python