从源码分析,MySQL 优化器如何估算 SQL 语句的访问行数
本文分享自华为云社区《【华为云MySQL技术专栏】MySQL优化器如何估算SQL语句的访问行数》,作者:GaussDB 数据库。
一、背景介绍
在数据库运维工作中,慢 SQL 是一个常见问题。导致慢 SQL 问题的原因很多,常见的包括资源瓶颈(CPU、磁盘、网络等资源打满)、不合理的参数配置、SQL 语句自身问题以及 SQL 代价估算不准确等。其中,SQL 代价估算不准确是慢 SQL 的 TOP 根因之一。
这类问题的复杂性通常与客户业务强相关,且往往需要详细查看执行计划才能确定错误原因。相比之下,其他慢 SQL 场景则通过资源监控、参数对比等手段,就可以较快速地定位到原因。在分析慢 SQL 的过程中,DBA 经常需要执行 EXPLAIN 命令来查看 SQL 访问每张表的路径和预估访问行数,来判断是否是最优的执行计划。
本文将从源码角度分析 SQL 优化器代价估算的基础——行数估算,并总结 MySQL 当前设计存在的局限性。最后用一个现网问题的定位过程作为例子,给出该类问题的解决方案。
二、原理介绍
MySQL 优化器使用的是基于代价的 CBO(Cost Based Optimizer)模型,为方便理解后续的原理,这里先介绍一些基础概念。
2.1 逻辑优化和物理优化
在 MySQL 中,一条 SQL 语句的执行要经过逻辑优化和物理优化两个步骤。逻辑优化的目的是优化语句结构,尽可能减少数据读取量,例如,常量折叠、谓词下推、JOIN 顺序选择等技术。物理优化则基于逻辑优化的结果,进一步确定访问路径(Access Path),例如选择哪一个索引来读取数据。
举个例子,假设有一张名为 employees 的薪资表,有 id(员工 id)、salary(工资收入)、allowance(津贴收入),当我们要查询总收入(工资+津贴)不少于 10000 且津贴为 1000 的员工的 SQL 语句为:
首先,逻辑优化过程会根据 allowance = 1000,将其传播到表达式 salary > 10000 – allowance 中,得到 salary > 10000 – 1000,并进一步计算出 salary > 9000,以此简化查询条件。
逻辑优化完成后,物理优化阶段会根据统计信息以及 SQL 的查询条件来计算不同执行路径的代价,并据此确定具体的 Access Path。例如,如果 salary 或者 allowance 字段上有索引,物理优化会分别计算访问索引的代价和全表扫描的代价,将两者相比较得出代价最小的 Access Path。
2.2 代价计算模型
一条 SQL 语句执行的代价维度可以有很多,例如 CPU、磁盘、内存、网络等资源。然而,MySQL 代价模型只保留 CPU 和 IO 两个维度,即评估一条 SQL 语句的代价时,只考虑 CPU 代价和 IO 代价。
由于 CPU 和 IO 是不同维度的评估方式,所以为了使总的代价可以比较,MySQL 使用加权的方法,将 CPU 代价和 IO 代价折算成一个总的数值。由于 CPU 和 IO 性能强依赖于具体的硬件情况,MySQL 提供了两张系统表 mysql.server_cost 和 mysql.engine_cost,允许用户查看并修改代价常数,表的具体字段含义客户可以查阅官方手册(参见引用[1])。不过,为了保证存量业务 SQL 语句的稳定性,一般不会修改该表来解决慢 SQL 问题。
2.3 统计信息
代价计算的输入有两个,一是表上的过滤条件(SQL 中的 WHERE/JOIN 条件),二是表的统计信息。优化器根据上述两个输入,来计算每个 Access Path 的代价。由于表上的过滤条件是确定的,所以统计信息的准确性直接影响代价的计算准确性,从而可能影响最终的执行计划选择。
三、源码阅读
下面将基于社区 MySQL 8.0.32 版本对统计信息采集的源码以及优化器实时下探(Index Dive)的源码进行解析。实时下探是指优化器不使用已有的统计信息,而是在优化阶段,实时访问索引 B+树来统计数据的分布情况。
3.1 统计信息采集
MySQL 中的统计信息涵盖表和索引两个维度,具体信息包括总行数、以数据页为单位的统计磁盘空间大小以及基数(Cardinality,每个索引/索引前缀组合不同值的个数)。为了保证执行计划的稳定性,MySQL 使用两张系统表持久化了上述统计信息,分别是 mysql.innodb_table_stats 和 mysql.innodb_index_stats。
通过参数 innodb_stats_persistent 可以控制是否启用持久化机制,其默认设置为启用持久化。当使用 ANALYZE TABLE 命令(手动方式)或者表中的数据修改行数超过 10%时(自动触发),系统会重新计算统计信息并将其持久化存储。
3.1.1 统计算法简介
InnoDB 通过对索引树的叶子节点进行采样的方式获取统计信息,innodb_stats_persistent_sample_pages 参数控制了采样的叶子节点个数,显然,该参数值越大,采样精度越高,相应地采样耗时会增加。采样算法总体逻辑为:随机选取一些叶子节点,读取其记录信息,并基于这些信息推算整个索引的统计信息。
在介绍具体的采样算法之前,先熟悉下 InnoDB 的索引存储结构。
在 InnoDB 中,每张表的底层数据通过一个聚簇索引(索引的叶子节点记录存放的是用户记录,而不是指针)来存放。
此外,用户可以根据业务需要创建多个二级索引来加速查询,这些二级索引的叶子节点存储的是索引键值和主键值(用于回表访问聚簇索引记录)。
无论是聚簇索引还是二级索引,InnoDB 都会使用 B+树来存放整个索引结构,其结构如图 1 所示。在 B+树中,叶子节点记录的是索引键值,而非叶子节点(中间节点)记录的是索引键值和指向下一层节点的指针。
了解了 B+树结构之后,下面开始详细介绍具体的算法。
首先,算法定义了一个“n-prefix-boring 记录”的概念。这个概念是说,n-prefix-boring 记录是指一条中间节点的用户记录,它的 n-prefix 列的值等于其紧邻的下一个记录(可跨索引页,忽略 infimum 和 supremum 记录)。之所以称这样的记录为”boring 记录“,是因为这些记录对应的子节点所有记录的 n-prefix 列的值都相等,没有采样意义。
例如,对于图 1 所示的记录,它的索引结构为 Index(col1, col2),那么,对于 1-prefix,即 col1 列来说,level 1 层的数据页[0,1|0,4|1,1]中的记录(0,1)以及记录(1,1)都是 boring 的,但是,记录(0,4)对 col1 列来说就不是 boring 的,因为它的下一个记录为(1,1),col1 列的值不同。
算法的流程如图 2 所示,针对索引的每个 prefix 组合都会使用该流程统计出数据分布信息:
这里解释下”每个 prefix 组合“是什么含义,由于 InnoDB 的联合索引使用最左匹配原则,例如对于一个联合索引 Index(col1, col2, col3),那么有效的匹配条件有(col1),(col1, col2),(col1, col2, col3)。所以,为了使所有的匹配条件都可以使用到统计信息,统计算法会计算每个“prefix”列组合的数据分布,来给优化器提供信息。
算法关键参数:
A = 采样页数(一般通过 innodb_stats_persistent_sample_pages 参数指定);
LA = 第 L 层 n-prefix 列不同值的个数;
D = A * 10,算法认为只有找到包含 A*10 个不同值的层级才可以进行后续叶节点下探。
3.1.2 统计算法源码分析
InnoDB 进行索引信息统计的核心函数是 dict_stats_analyze_index_low(),该函数简化版逻辑和源码如下所示:
首先,该函数会获取索引树的总大小和叶子节点大小(以数据页个数计算),下面的代码可以看到这两项也是统计信息的一部分。
遇到下面特殊情况:
整个 B+树只有 1 个根节点;
用户指定的采样页面数量很大(超过 B+树叶子节点的总个数),那么采样过程将退化为全表扫描,这样一来,获得的统计信息结果会更准确。
通过定义了一些辅助变量,用来实现 3.1.1 节的算法:
下面是 3.1.1 节的算法总体实现流程。另外,实现上对算法做了简化,详见注释:
在上述过程中,函数 dict_stats_analyze_index_level()被多次调用,用来获取指定层级的记录总数和不同值总数。该函数会填充 n_diff_on_level 和 n_diff_boundaries 两个数组。
而函数 dict_stats_analyze_index_for_n_prefix()用来估算索引前 n_prefix 列不同值的个数。该函数会选择 n_diff_data_t::n_leaf_pages_to_analyze 个本层(中间节点)的记录,并下探到对应的叶子节点,扫描叶子节点上的记录后,将统计值存入 n_diff_data_t::n_diff_all_analyzed_pages。
函数 dict_stats_analyze_index_below_cur()负责根据当前的中间节点记录,下探到对应的叶子节点,并计算叶子节点上 n-prefix 列不同值的个数。至此,所有采样操作已经完成。
函数 dict_stats_index_set_n_diff()会根据采样结果和 3.1.1 中的算法,根据每个页的统计结果,计算出整个索引 n-prefix 列的结果。另外,主键索引对应的统计信息会被作为表的统计信息。感兴趣的读者可以在源码中搜索函数名详细看一下实现逻辑。
3.2 行数估算方式
优化器对一张表的访问行数估算有以下 2 种方式:
使用统计信息进行估算。例如,对于非唯一索引的等值查询条件个数大于 eq_range_index_dive_limit 个时,优化器会使用统计信息中的 (记录总数/不同值个数)来估算平均访问行数;对于全表扫描,优化器会直接使用统计信息中的表总记录数进行估算。
优化器实时下探(Index Dive)。在 SQL 优化阶段,根据条件谓词进行 B+树的下探。例如,对于索引列的范围查询(index_column > x),优化器会使用 x 下探采样,估算大于 x 的记录个数;对于非唯一索引的等值查询,如果等值条件数小于 eq_range_index_dive_limit 个数,也会进行下探以获取更精确的估算结果。
3.2.1 实时下探算法源码分析
针对 SQL 查询中,对非唯一索引的查找或者对索引前缀的查找(这种场景可能返回多行结果),优化器会在优化阶段,利用范围条件对索引 B+树进行下探,来估算扫描行数。由于是实时下探,所以下探的代价不能太大。例如,对于一个查询条件(col1 >= A AND col1 <= B),如果范围[A,B]之间的记录数很多,那么,下探只能通过估算的方式来确定记录总数,而无法读取所有的叶子节点来计算记录总数。
在 InnoDB 存储引擎中,Index Dive 算法会从索引的根节点开始,每层读取并计算满足条件的行数,直到叶子节点。下面通过一个例子来理解下 Index Dive 的算法。
假设,表 t1 有一个联合索引 Index(col1, col2),这个索引的结构如图 3 所示。
如果这个时候优化器要对条件(col1 = 2 AND col2 >=1 AND col2 <= 7),即范围[(2,1),(2,7)]内的记录数进行估算,那么,算法将会按照下面流程进行:
使用值(2,1)和(2,7)定位到 B+树的叶子节点,并记录路径上的节点,这样可以在每个 B+树层级得到左右两个边界,如图 4 中的两条黄色边界。
从索引树的根节点开始,计算每层中在条件范围内的记录数(下文会解释为何需要这样做),记为 n_rec(level)。这里的关键问题是,如果范围边界的两个记录都在一个或者几个数据页(如图 4 中的根节点和 level1 的第二个节点),那么直接读取所有记录计算出总数即可。但如果范围很大,包含多个节点(代码中这个值是 10 个),则无法把每个节点的实际记录数都算出来,只能采用估算的方式。
对于节点数量庞大的层级,算法会读取该层级在条件范围内的前 10 个页面,并计算出这 10 个页面的平均记录数 avg_rec(level)。但要估算出本层的记录总数,还需要知道范围内的节点数,这里也不可能遍历所有的节点来计算,那么如何计算或者估算出本层的节点呢?
结合 3.1.1 节提到 B+树的特性,每个中间节点记录都对应一个子节点页面。故本层的节点数,就是上一层的记录数。由于我们是从根节点开始遍历的(可以通过遍历整个根节点获取范围内的记录数),通过这种方式,依次类推,可以算出每一层的节点数。以图 4 为例,level 1 中范围记录数为 3,那么,对应的 level 0 中的节点个数也是 3。
重复前面的流程,直到叶子节点,可以计算出叶子节点的记录总数 n_rec=avg_rec(level 0) * n_rec(level 1)。
以上是 Index Dive 算法的大致思想。
下面看一下代码实现。算法的实现函数为 btr_estimate_n_rows_in_range_low(),该函数的简化版代码如下所示:
定义一些辅助变量,用于记录路径信息:
使用边界值进行下探操作,注意有可能条件只有一个边界,例如,col > 100 就只有左边界:
下探完成后,path1 和 path2 内保存了左右边界信息,开始通过这个信息进行每层的记录数估算:
函数 btr_estimate_n_rows_in_range_on_level()用于估算某一层在条件区间内的记录数量,它会从左侧开始,最多向右读取 10 个页面(碰到右边界则停止),计算出每个页面的平均记录数后,则将平均记录数乘以该层的节点数,来估算出本层的记录总数。函数简化版的源码,如下所示:
指定读取的最大节点数为 10:
开始从左向右进行节点读取,并计算记录数:
根据已读取页面的平均记录数,来计算本层级条件范围内的记录总数:
3.3 限制总结
从上面的分析中可以看到,在设计上,MySQL 进行扫描行数估算的时候存在以下限制:
统计信息不准:当前 InnoDB 采样算法的假设是基于数据均匀分布。然而,InnoDB 在采样时只会随机采样 innodb_stats_persistent_sample_pages 个页面,这对于大表或者存在数据倾斜的表,是很难精确估计其统计信息。
统计信息更新延迟:统计信息只有在手动执行 ANALYZE TABLE 命令或者表的更新行数超过 10%时才会更新。例如,在瞬时大量更新的场景中,优化器可能使用过时的统计信息,从而导致选择非最优的执行计划。
四、现网案例
4.1 问题现象
客户某业务上的一条报表生成的 SQL 语句突然执行时间变长,期间并没有进行任何变更操作。另外,客户内部已经做了初步排查,发现该 SQL 语句在测试环境和生成环境上的执行计划不同,且测试环境中执行时间更短。
4.2 定位过程
由于已经有了执行计划,我们首先直接对比测试环境和生成环境的执行计划,两者如图 5 所示:
在排查过程中,由于其他表的 Access Path 没有变化,所以首先排查变化的两个表 TDO 和 TFC,图中已用红线标出对应的执行计划。接着,对比下访问行数,由于其他表访问行数都小于或等于 1,所以这里只对比 TDO 和 TFC 这两张表。
对比发现,生产实例的执行计划访问约为(23 * 100% * 1723 * 1.1% ≈ 438)行,而测试实例执行计划访问行数约为(71526 * 100% * 1 * 90.24% ≈ 64545)行。通过计算得出,生产实例的执行计划看起来确实更优,但是测试实例实际执行却更快。因此,首先怀疑是统计信息不准确,导致生产环境的执行计划行数估算出现偏差。
接下来确定 TDO 和 TFC 这两张表的实际数据情况。首先查看两张表的记录总数:
1、TFC 表的记录总数:
2、TDO 表的记录总数:
3、TDO 表中 Index_TT_DELIVERY_ORDER_01 索引的基数,由于该索引只有 F_COMPANY_ID 字段,所以使用直接使用 count(distinct(F_COMPANY_ID))计算:
可以看到,TFC 表的记录总数为 23 行,和 EXPLAIN 命令的结果一致。但是,对 TDO 表的实际访问数据和 EXPLAIN 结果差距很大。根据上述手工执行 SQL 所得到的结果,对于 ref 类型的索引访问方式,平均扫描行数应该为(7236062 / 21 ≈ 344526)行,远大于 EXPLAIN 所显示的 1733 行。
与客户沟通后得知,该字段的数据倾斜较严重,通过第 3 节的源码分析可知,采样算法无法很好的估算出行数信息。由此可以判断,该问题是由于 Index_TT_DELIVERY_ORDER_01 索引统计信息不准导致的。
4.3 解决方案
由于是业务原因导致的 F_COMPANY_ID 字段存在数据倾斜,即便重新执行 ANALYZE TABLE 操作,该问题依然存在。考虑到该索引选择性较低,最后决定把该索引删除掉,之后 MySQL 优化器可以选择正确的执行计划。
针对此类统计信息不准的问题,以下方案也可能有效:
增大 innodb_stats_persistent_sample_pages 参数的值:通过第 3 章的源码分析可知,该参数控制采样的叶子节点个数,增大该参数可以使采样更准确。
使用直方图:从 MySQL 8.0.3 版本开始,系统支持直方图。通过建立针对某个列的直方图,可以更准确地获取数据分布信息,从而使行数估算更加准确。
使用 Statement Outline 特性:华为云自主创新特性,可以给指定的 SQL 添加索引提示(index hints),实现给语句人工指定正确的索引而无需修改业务 SQL。
五、总结
本文解析了 MySQL 代价估算中行数估算的源码,并指出可能存在的问题,最后通过一个现网问题的定位过程,给出了该类问题的定位思路和解决方案,让大家再遇到此类问题时可以自己动手分析并解决问题。
六、引用
[1] MySQL 帮助文档:https://dev.mysql.com/doc/refman/8.0/en/cost-model.html
[2] GaussDB for MySQL Statement Outline 特性介绍:https://support.huaweicloud.com/kerneldesc-gaussdbformysql/gaussdbformysql_20_0018.html
华为开发者空间,汇聚鸿蒙、昇腾、鲲鹏、GaussDB、欧拉等各项根技术的开发资源及工具,致力于为每位开发者提供一台云主机、一套开发工具及云上存储空间,让开发者基于华为根生态创新。点击链接,免费领取您的专属云主机
评论