15.1 全局理解 MySQL 优化
15.1.1 性能调优金字塔
三种调优方法是按照金字塔的调优顺序排列的。一般来说,自底向上调优的效果是成反比的,而越往下层调优效果越好,但是难度也越大。

在进行优化时,首先需要关注和优化的应该是架构,如果架构不合理,那么 DBA 能做的事情其实是比较有限的。对于架构调优,在系统设计时首先需要充分考虑业务的实际情况,是否可以把不适合数据库做的事情放到数据仓库、搜索引擎或者缓存中去做;然后考虑写的并发量有多大,是否需要采用分布式;最后考虑读的压力是否很大,是否需要读写分离。对于核心应用或者金融类的应用,需要额外考虑数据安全因素,数据是否不允许丢失。
对于 MySQL 调优,需要确认业务表结构设计是否合理,SQL 语句优化是否足够,该添加的索引是否都添加了,是否可以剔除多余的索引,数据库的参数优化是否足够。最后确定系统、硬件有哪些地方需要优化,系统瓶颈在哪里,哪些系统参数需要调整优化,进程资源限制是否提到足够高;在硬件方面是否需要更换为具有更高 I/O 性能的存储硬件,是否需要升级内存、CPU、网络等。如果在设计之初架构就不合理,比如没有进行读写分离,那么后期的 MySQL 和硬件、系统优化的成本就会很高,并且还不一定能最终解决问题。如果业务性能的瓶颈是由于索引等 MySQL 层的优化不够导致的,那么即使配置再高性能的 I/O 存储硬件或者 CPU 也无法支撑业务。
- 架构调优
金字塔的底部是架构调优,采用更适合业务场景的架构能最大程度地提升系统的扩展性和可用性。在设计中进行垂直拆分能尽量解耦应用的依赖,对读压力比较大的业务进行读写分离能保证读性能线性扩展,而对于读写并发压力比较大的业务在 MySQL 上也有采用读写分离。作为金字塔的底部,在底层硬件系统、SQL 语句和参数都基本定型的情况下,单个 MySQL 数据库能提供的性能、扩展性等就基本定型了。但是通过架构设计和优化,却能承载几倍、几十倍甚至百倍于单个 MySQL 数据库能力的业务请求能力。
- MySQL 调优
- 参数调优
参数调优的目的就在于如何适配硬件和系统,在 MySQL 的服务器层和 InnoDB 层最大程度地发挥底层的性能,保证业务系统高效。
- SQL、索引调优
SQL、索引调优要求 DBA 对业务和数据流非常清楚。从业务需求讨论到表结构审核、SQL 语句审核、上线、索引更新、版本迭代升级,甚至哪些数据应该放到非关系型数据库中,哪些数据放到数据仓库、搜索引擎或者缓存中,都需要 DBA 跟踪和复审。DBA 熟悉每个表、每个字段的含义,他们跟踪业务模块关系、更新迭代的缘由、业务高峰/低谷时哪里最耗资源、是否还有优化空间等。如果这些数据模型都在的话,就很方便对业务逻辑和代码诊断和修改了。
- 硬件及系统调优
- 硬件优化
更高频率的 CPU 能让复杂的 SQL 语句在 MySQL 上运行的速度更快;更大的内存能让更多的热点数据缓存在内存中,使得并发效率更高;更快的存储系统能让 MySQL 及时存取数据,提升客户端的响应效率;更高的网络带宽和更低的网络延迟能让 MySQL 提供更大的吞吐率。硬件优化对数据库效率的提升非常关键。
- 系统优化
由于硬件资源的限制,也为了让系统中运行的各个组件能均衡地使用硬件资源,Linux 系统设计和实现了各种资源使用策略。数据库的操作系统优化从某种程度来说就是理解操作系统的资源使用策略,充分让数据库使用更多的硬件资源,发挥硬件性能。
15.1.2 SQL 优化方法论
- 优化查询方法论
查询性能低下最基本的原因是访问的数据太多。大部分性能低下的查询都可以通过减少访问的数据量的方式进行优化。对于低效的查询,一般通过下面两个步骤来分析:
- 确认应用程序是否在检索大量超过需要的数据
- 业务层-是否请求了不需要的数据(多余的行和列)
- 确认 MySQL 服务器层是否在分析大量超过需要的数据行
- 执行层-是否扫描了额外的记录(扫描的行数与返回的行数的比率过大)
- 重构查询方法论
在优化有问题的查询时,目标应该是找到一个更优的方法获得实际需要的结果,而不一定总是需要从 MySQL 获取一模一样的结果集。有时候,可以将查询转换一种写法让其返回一样的结果,但是性能更好。但也可以通过修改应用代码,用另一种方式完成查询,最终达到一样的目的。
- 一个复杂查询 OR 多个简单查询
设计查询的时候一个需要考虑的重要问题是,是否需要将一个复杂的查询分成多个简单的查询。有时候将一个复杂的大查询分解为多个小查询会效率更高。不过,在应用设计的时候,如果一个查询能够胜任时还写成多个独立查询是不明智的。
- 切分操作
有时候对于一个大操作我们需要"分而治之",将大操作切分成小操作,每个操作功能完全一样,只完成一小部分,每次只返回一小部分结果。例如:删除旧的数据,定期地清除大量数据时,如果用一个大的语句一次性完成的话,则可能需要一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。将一个大的 DELETE 语句切分成多个较小的查询可以尽可能小地影响 MySQL 性能,同时还可以减少 MySQL 复制的延迟。
- 分解关联查询
对关联查询进行分解,简单地,可以对每一个表进行一次单表查询,然后将结果在应用程序中进行关联。
15.1.3 全流程考虑 SQL 优化
- 为什么查询速度会慢
如果把查询看作是一个任务,那么它由一系列子任务组成,每个子任务都会消耗一定的时间。如果要优化查询,实际上要优化其子任务,要么消除其中一些子任务,要么减少子任务的执行次数,要么让子任务运行得更快。在完成这些子任务的时候,查询需要在不同的地方花费时间,包括网络,CPU 计算,生成统计信息和执行计划、锁等待等操作,尤其是向底层存储引擎检索数据的调用操作,这些调用需要在内存操作,CPU 操作和内存不足时导致的 I/O 操作上消耗时间。根据存储引擎不同,可能还会产生大量的上下文切换以及系统调用。优化查询的目的就是减少和消除这些操作所花费的时间。
- SQL 执行流程
- 客户端发送一条查询 SQL 给服务器。
- 服务器先查询缓存,如果命中了缓存,则立刻返回存储在缓存中的结果。否则进入下一阶段。
- 服务器端进行 SQL 解析、预处理,再由优化器生成对应的执行计划。
- MySQL 根据优化器生成的执行计划,调用存储引擎的 API 来执行查询。
- 将结果返回给客户端。
所以 MySQL 查询的生命周期大致可以分为:客户端与服务端通信,生成执行计划、执行、返回结果给客户端。其中"执行"可以认为是整个生命周期中最重要的阶段,这其中包括了大量为了检索数据到存储引擎的调用以及调用后的数据处理,包括排序、分组等。
- 客户端服务端通信
MySQL 客户端和服务器之间的通信协议是"半双工"的,这意味着,在任何一个时刻,要么是由服务器向客户端发送数据,要么是由客户端向服务器发送数据,这两个动作不能同时发生。所以,我们无法也无须将一个消息切成小块独立来发送。这种协议让 MySQL 通信简单快速,但是也从很多地方限制了MySQL。一个明显的限制是,没法进行流量控制。一旦一端开始发生消息,另一端要接收完整个消息才能响应它。一旦客户端发送了请求,它能做的事情就只是等待结果了。
当客户端从服务器取数据时,看起来是一个拉数据的过程,但实际上是 MySQL 在向客户端推送数据的过程。客户端不断地接收从服务器推送的数据,客户端也没法让服务器停下来。MySQL 通常需要等所有的数据都已经发送给客户端才能释放这条查询所占用的资源。一般服务器响应给用户的数据通常很多,由多个数据包组成。当服务器开始响应客户端请求时,客户端必须完整地接收整个返回结果,而不能简单地只取前面几条结果,然后让服务器停止发送数据。这也是在必要的时候一定要在查询中加上 LIMIT 限制的原因。多数连接 MySQL 的库函数都可以获得全部结果集并缓存到内存里,还可以逐行获取需要的数据。默认一般是获得全部结果集并缓存到内存中。
当使用库函数从 MySQL 获取数据时,其结果看起来都像是从 MySQL 服务器获取数据,而实际上都是从这个库函数的缓存获取数据。多数情况下这没什么问题,但是如果需要返回一个很大的结果集的时候,库函数会花很多时间和内存来存储所有的结果集。对于 Java 程序来说,很有可能发生 JVM OOM,所以 MySQL 的 JDBC 里提供了 setFetchSize() 之类的功能,来解决这个问题,此时 JDBC 采用的是流数据接收方式,每次只从服务器接收部份数据,直到所有数据处理完毕,不会发生 JVM OOM。
- 生成执行计划
查询的生命周期的下一步是将一个 SQL 转换成一个执行计划,MySQL 再依照这个执行计划和存储引擎进行交互。这包括多个子阶段:解析 SQL、预处理、优化 SQL 执行计划等。这个过程中任何错误都可能终止查询。在实际执行中,这几部分可能一起执行也可能单独执行。
MySQL 的查询优化器是一个非常复杂的部件,它使用了很多优化策略来生成一个最优的执行计划。优化策略可以简单地分为两种,一种是静态优化,一种是动态优化。静态优化可以直接对解析树进行分析,并完成优化。例如,优化器可以通过一些简单的代数变换将 WHERE 条件转换成另一种等价形式。静态优化不依赖于特别的数值,如 WHERE 条件中带入的一些常数等。静态优化在第一次完成后就一直有效,即使使用不同的参数重复执行查询也不会发生变化。可以认为这是一种"编译时优化"。相反,动态优化则和查询的上下文有关,也可能和很多其他因素有关,例如 WHERE 条件中的取值、索引中条目对应的数据行数等。这需要在每次查询的时候都重新评估,可以认为这是"运行时优化"。
优化器是相当复杂性和智能的。如果没有必要,不要去干扰优化器的工作,让优化器按照它的方式工作。尽量按照优化器的提示去优化我们的表、索引和 SQL 语句。当然,虽然优化器已经很智能了,但是有时候也无法给出最优的结果。
- 执行
在解析和优化阶段,MySQL 将生成查询对应的执行计划,MySQL 的查询执行引擎则根据这个执行计划来完成整个查询。相对于查询优化阶段,查询执行阶段不是那么复杂:MySQL 只是简单地根据执行计划给出的指令逐步执行。
- 返回结果集
查询执行的最后一个阶段是将结果返回给客户端。即使查询不需要返回结果集给客户端,MySQL 仍然会返回这个查询的一些信息,如该查询影响到的行数。如果查询可以被缓存,那么 MySQL 在这个阶段也会将结果存放到查询缓存中。MySQL 将结果集返回客户端是一个增量、逐步返回的过程。一旦服务器开始生成第一条结果时, MySQL 就可以开始向客户端逐步返回结果集了。
这一点从 MySQL 的源码 sql_union.cc 中其实可以看得很清楚,这样处理有两个好处:服务器端无须存储太多的结果,也就不会因为要返回太多结果而消耗太多内存。另外,这样的处理也让 MySQL 客户端第一时间获得返回的结果。

结果集中的每一行都会以一个满足 MySQL 客户端/服务器通信协议的封包发送,再通过 TCP 协议进行传输,在 TCP 传输的过程中,可能对 MySQL 的封包进行缓存然后批量传输。
15.1.4 SQL 查询状态
对于一个 MySQL 连接,或者说一个线程,任何时刻都有一个状态,该状态表示了 MySQL 当前正在做什么。在一个查询的生命周期中,状态会变化很多次。通过 show processlist 查看线程状态可以让我们很快地了解当前 MySQL 在进行的线程,包括线程的状态、是否锁表等,可以实时地查看 SQL 的执行情况,同时对一些锁表操作进行优化。在一个繁忙的服务器上,可能会看到大量的不正常的状态,例如 statistics 正占用大量的时间。这通常表示,某个地方有异常了。

通过 show profile 语句能够看到每个线程执行过程中消耗的时间

通过 show profile query #{id} 语句能够看到执行过程中线程的每个状态和消耗的时间

在获取到最消耗时间的线程状态后, MySQL 支持进一步选择 all、cpu、block io、contextswitch 等明细类型来查看 MySQL 在使用什么资源上耗费了过高的时间,示例:show profile #{type} for query #{id}
15.2 MySQL 索引合并
MySQL 在一般情况下执行一个查询时最多只会用到单个二级索引,但也存在特殊情况,在这些特殊情况下也可能在一个查询中使用到多个二级索引,MySQL 中这种使用到多个索引来完成一次查询的执行方法称之为:索引合并(index merge)
15.2.1 Intersection 合并
index merge intersection access algorithm(索引合并交集访问算法),他指的是对于每一个用到的二级索引进行查询,然后获取主键集合,将这些集合合并取交集。得到的集合就是满足条件记录的主键集合,然后再回表查询具体的数据。针对SELECT * FROM T WHERE a = 1 AND b = 'A';Intersection 索引合并查询过程如下:
- 利用 idx_a 索引将获取到的 id 集合记作 id_setA。
- 利用 idx_b 索引将获取到的 id 集合记作 id_setB。
- 将 id_setA 和 id_setB 取交集,记作 id_set。
- 对 id_set 回表查询,将结果返回给客户端。
需要注意的是,使用 Intersection 索引合并是有条件的。这些条件都是为了满足:如果使用到的索引都是二级索引,则要求通过二级索引取出的记录是按照主键排好序的。
- 等值匹配
如果具有 idx_a、idx_b 两个二级索引索引,执行查询select * from table where a>1 and b=2; 那么是无法进行交集索引合并的,因为 a>1 得到的主键并不是有序的;
- 联合索引中的每个列都必须等值匹配,不能只匹配部分列
联合索引 idx_q_w_e 、二级索引 idx_r 两个索引,执行查询select * from table where q = 1 and w = 2 and r = 3;也不可以使用交集索引合并,因为联合索引是依次根据 q、w、e 排序的,满足 q = 1 and w = 2 的数据主键并不是有序的。
- 主键列可以是范围匹配
二级索引 idx_a、聚簇索引两个索引,执行查询select * from a = 1 and id > 100这样的查询理论上是主键有序可与使用的,但是 MySQL 会找到满足 a = 1 且 id > 100 的第一条记录,然后向右直到不符合条件的数据出现,这种情况也不需要使用交集索引合并。
为什么会有这些要求呢?主要是有以下两个好处:
- 对两个有序集合取交集更简单。
- 主键有序的情况下,回表将不再是单纯的随机 I/O,回表的效率更高。按照有序的主键值去回表取记录有个专有名词,叫:Rowid Ordered Retrieval,简称 ROR
当然,以上条件只是发生 Intersection 索引合并的必要条件,不是充分条件。也就是说即使满足条件,也不一定发生 Intersection 索引合并,这得看优化器。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数太多,导致回表开销太大,而通过 Intersection 索引合并后需要回表的记录数大大减少时才会使用 Intersection 索引合并。
:::success
建议使用联合索引替代 Intersection 索引合并
:::
15.2.2 Union 合并
index merge union access algorithm(索引合并并集访问算法),他指的是对于每一个用到的二级索引进行查询,然后获取主键集合,将这些集合合并取并集。得到的集合就是满足条件记录的主键集合,然后再回表查询具体的数据。针对SELECT * FROM T WHERE a = 1 AND b = 'A';Union 索引合并查询过程如下:
- 利用 idx_a 索引将获取到的 id 集合记作 id_setA。
- 利用 idx_b 索引将获取到的 id 集合记作 id_setB。
- 将 id_setA 和 id_setB 取并集,记作 id_set。
- 对 id_set 回表查询,将结果返回给客户端。
这个过程和 Intersection 其实很像,只是交集换成了并集而已,所以也需要满足:如果使用到的索引都是二级索引,则要求通过二级索引取出的记录是按照主键排好序的。此外,当搜索条件的某些部分满足使用 Intersection 索引合并时,可以使用 Intersection 索引合并得到一部分主键集合然后和其他方式得到的主键集合取并集。
同理,查询条件符合了这些情况也不一定就会采用 Union 索引合并,也得看优化器。优化器只有在单独根据搜索条件从某个二级索引中获取的记录数比较少,通过 Union 索引合并后进行访问的代价比全表扫描更小时才会使用 Union 索引合并。
15.2.3 Sort-Union 合并
index merge sort sort-union access algorithm(索引合并排序并集访问算法),union 索引合并的要求太过苛刻,所以出现了二级索引不必等值查询,联合索引也不必匹配所有的索引项的 sort-union 索引合并。针对SELECT * FROM T WHERE a = 1 OR b >= 'Z';是不能使用 Union 索引合并的,因为它从 idx_b 二级索引取出的记录并非是按照主键排序的。但是这两个条件 a = 1 和 b >= 'Z' 很大概率能过滤掉大部分记录,是可以提升查询效率的,MySQL 很想利用这两个索引,于是想了个办法。既然二级索引自然取出来的主键不是排好序的,那我就先放到内存里自己排好序再使用 Union 的方式去查询。整个过程如下:
- 利用 idx_a 索引将获取到的 id 集合,在内存中排序,记作 id_setA。
- 利用 idx_b 索引将获取到的 id 集合,在内存中排序,记作 id_setB。
- 将 id_setA 和 id_setB 取并集,记作 id_set。
- 对 id_set 回表查询,将结果返回给客户端。
15.2.4 Index Merge 导致的死锁

在不同事务对示例表 test_table 执行UPDATE test_table SET status = 1 WHERE trans_id = '38' AND status = 0 ;MySQL 会根据 trans_id = 38 这个条件,利用 uniq_trans_id 聚簇索引找到叶子节点中保存的 id 值;同时会根据 status = 0 这个条件,利用 idx_status 二级索引找到叶子节点中保存的 id 值;然后将找到的两组 id 值取交集,最终通过交集后的 id 回表。
当遇到以下时序时,就会出现死锁:

两个 update 事务加锁的过程,如下图所示,在 idx_status 二级索引和 聚簇索引 上都存在重合交叉的部分,这样就为死锁造成了条件。

MySQL 检测到死锁后,会自动回滚代价更低的那个事务,如上边的时序图中,事务 1 持有的锁比事务 2 少,则MySQL 就将事务 1 进行了回滚。【一般会从业务层面处理该死锁问题】
15.3 MySQL 内核优化
15.3.1 条件化简
- 移除不必要的括号
有时候表达式里有许多无用的括号,比如:((a = 5 AND b = c) OR ((a > c) AND (c < 5))优化器会把那些用不到的括号给移除,优化为:(a = 5 and b = c) OR (a > c AND c < 5)。
- 常量传递
有时候某个表达式是某个列和某个常量做等值匹配。当a = 5这个表达式和其他涉及列 a 的表达式使用 AND 连接起来时,可以将其他表达式中的 a 的值替换为 5,比如:a = 5 AND b > a就可以被转换为:a = 5 AND b > 5。
- 等值传递
有时候多个列之间存在等值匹配的关系,比如:a = b and b = c and c = 5 这个表达式可以优化为:a = 5 and b = 5 and c = 5。
- 移除无用条件
对于一些明显永远为 true 或者 false 的表达式,优化器会移除掉它们,比如:(a < 1 and b = b) OR (a = 6 OR 5 != 5)很明显,b = b 这个表达式永远为 true,5 != 5 这个表达式永远为 false,可以化简为:(a < 1 and TRUE) OR (a = 6 OR FALSE)可以继续被简化为a < 1 OR a = 6
- 表达式计算
在查询开始执行之前,如果表达式中只包含常量的话,它的值会被先计算出来,比如:a = 5 + 1因为 5 + 1 这个表达式只包含常量,所以就会被化简成:a = 6但是这里需要注意的是,如果某个列并不是以单独的形式作为表达式的操作数时,比如出现在函数中,出现在某个更复杂表达式中,就像这样:ABS(a) > 5或者:-a < -8优化器是不会尝试对这些表达式进行化简的。而搜索条件中索引列和常数使用某些运算符连接起来才可能使用到索引,所以最好让索引列以单独的形式出现在表达式中。
- 常量表检测
MySQL 觉得使用主键等值匹配或者唯一二级索引列等值匹配作为搜索条件来查询某个表时,查询运行的特别快,花费的时间特别少,少到可以忽略,所以也把通过这两种方式查询的表称之为常量表(constant tables)。优化器在分析一个查询语句时,先首先执行常量表查询,然后把查询中涉及到该表的条件全部替换成常数,最后再分析其余表的查询成本,比如:SELECT * FROM table1 INNER JOIN table2 ON table1.column1 = table2.column2 WHERE table1.primary_key = 1;很明显,这个查询可以使用主键和常量值的等值匹配来查询 table1 表,也就是在这个查询中 table1 表相当于常量表,在分析对 table2 表的查询成本之前,就会执行对 table1 表的查询,并把查询中涉及 table1 表的条件都替换掉,优化为:SELECT table1.*(常量值), table2.* FROM table1 INNER JOIN table2 ON table1.column1(常量值) = table2.column2;
15.3.2 外连接消除
内连接的驱动表和被驱动表的位置可以相互转换,而左连接和右连接的驱动表和被驱动表是固定的。这就导致内连接可能通过优化表的连接顺序来降低整体的查询成本,而外连接却无法优化表的连接顺序。外连接和内连接的本质区别就是:对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配 ON 子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用 NULL 填充;而内连接的驱动表的记录如果无法在被驱动表中找到匹配 ON 子句中的过滤条件的记录,那么该记录会被舍弃。
因为凡是不符合 WHERE 子句中条件的记录都不会参与连接。所以只要我们在搜索条件中指定被驱动表相关列的值不为 NULL,那么外连接中在被驱动表中找不到符合 ON 子句条件的记录就不会使用 NULL 填充了,也就是说,在这种情况下:外连接和内连接也就没有什么区别了!当然,我们也可以不用显式的指定被驱动表的某个列 IS NOT NULL,只要隐含的有这个意思就行了,比方说等于一个常量。
我们把这种在外连接查询中,指定的 WHERE 子句中包含被驱动表中的列不为 NULL 值的条件称之为空值拒绝(reject-NULL)。在被驱动表的 WHERE 子句符合空值拒绝的条件后,外连接和内连接可以相互转换。这种转换带来的好处就是查询优化器可以通过评估表的不同连接顺序的成本,选出成本最低的那种连接顺序来执行查询。
15.3.3 子查询优化
- 子查询分类
- 按结果集区分
- 标量子查询
返回一个单一值的子查询称之为标量子查询
- <font style="color:#614700;">行子查询</font>
返回一条记录的子查询,不过这条记录需要包含多个列(只包含一个列就成了标量子查询)
- <font style="color:#614700;">列子查询</font>
返回一个列的数据,不过这个列的数据需要包含多条记录(只包含一条记录就成了标量子查询)
- <font style="color:#614700;">表子查询</font>
返回一个表的数据,既包含很多条记录,又包含很多个列
- 按查询关系区分
- 不相关子查询
如果子查询可以单独运行出结果,而不依赖于外层查询的值,就可以把这个子查询称之为不相关子查询
- <font style="color:#614700;">相关子查询 </font>
如果子查询的执行需要依赖于外层查询的值,就可以把这个子查询称之为相关子查询
:::color1
- 在 SELECT 子句中的子查询必须是标量子查询,如果子查询结果集中有多个列或者多个行,都不允许放在 SELECT 子句中,在想要得到标量子查询或者行子查询,但又不能保证子查询的结果集只有一条记录时,应该使用 LIMIT 1 语句来限制记录数量。
- 对于 [NOT] IN/ANY/SOME/ALL 子查询来说,子查询中不允许有 LIMIT 语句,而且这类子查询中 ORDER BY 子句、DISTINCT 语句、没有聚集函数以及 HAVING 子句的 GROUP BY 子句没有什么意义。因为子查询的结果其实就相当于一个集合,集合里的值排不排序等一点儿都不重要。
- 不允许在一条语句中增删改某个表的记录时同时还对该表进行子查询。
:::
- 子查询的执行
- 标量子查询、行子查询执行方式
- 不相关子查询
对于不相关标量子查询、不相关行子查询来说,它们的执行方式很简单,比方:SELECT * FROM s1 WHERE order_note = (SELECT order_note FROM s2 WHERE key3 = 'a' LIMIT 1);它的执行方式为:先单独执行(SELECT order_note FROM s2 WHERE key3 = 'a' LIMIT 1)这个子查询。然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询 SELECT * FROM s1 WHERE order_note= xx;也就是说,对于包含不相关的标量子查询或者行子查询的查询语句来说,MySQL 会分别独立的执行外层查询和子查询,相当于两个单表查询。
- <font style="color:#614700;">相关子查询</font>
对于相关的标量子查询|相关的行子查询来说,比如:SELECT * FROM s1 WHERE order_note = (SELECT order_note FROM s2 WHERE s1.order_no= s2.order_no LIMIT 1);它的执行方式为:先从外层查询中获取一条记录(本例中也就是先从 s1 表中获取一条记录)。然后从上一步骤中获取的那条记录中找出子查询中涉及到的值(本例中就是从 s1 表中获取的那条记录中找出 s1.order_no 列的值),然后执行子查询。最后根据子查询的查询结果来检测外层查询 WHERE 子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。再次执行第一步,获取第二条外层查询中的记录,依次类推。
- IN 子查询的优化
- 物化表
对于不相关的 IN 子查询,比如:SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE order_no = 'a');我们最开始的感觉就是这种不相关的 IN 子查询和不相关的标量子查询或者行子查询是一样一样的,都是把外层查询和子查询当作两个独立的单表查询来对待。但是 MySQL 为了优化 IN 子查询下了很大力气,所以整个执行过程并不像我们想象的那么简单。对于不相关的 IN 子查询来说,如果子查询的结果集中的记录条数很少,那么把子查询和外层查询分别看成两个单独的单表查询效率很高,但是如果单独执行子查询后的结果集太多的话,就会导致以下问题:
* 结果集太多,可能内存中都放不下。
* 对于外层查询来说,如果子查询的结果集太多,那就意味着 IN 子句中的参数特别多,这就导致:无法有效的使用索引,只能对外层查询进行全表扫描。
在对外层查询执行全表扫描时,由于 IN 子句中的参数太多,这会导致检测一条记录是否符合和 IN 子句中的参数匹配花费的时间太长。MySQL 的改进是不直接将不相关子查询的结果集当作外层查询的参数,而是将该结果集写入一个临时表里。写入临时表的过程是这样的:
* 该临时表的列就是子查询结果集中的列。
* 写入临时表的记录会被去重,临时表也是个表,只要为表中记录的所有列建立主键/唯一索引。
一般情况下子查询结果集不会大的离谱,所以会为它建立基于内存的使用 Memory 存储引擎的临时表,而且会为该表建立哈希索引。如果子查询的结果集非常大,超过了系统变量 tmp_table_size 或者 max_heap_table_size,临时表会转而使用基于磁盘的存储引擎来保存结果集中的记录,索引类型也对应转变为 B+Tree 索引。
MySQL 把这个将子查询结果集中的记录保存到临时表的过程称之为物化(Materialize)。为了方便起见,我们就把那个存储子查询结果集的临时表称之为物化表。正因为物化表中的记录都建立了索引,通过索引执行 IN 语句判断某个操作数在不在子查询结果集中变得非常快,从而提升了子查询语句的性能。
- <font style="color:#614700;">物化表转连接</font>
重新审视一下查询语句:SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE order_no = 'a');当我们把子查询进行物化之后,假设子查询物化表的名称为 materialized_table,该物化表存储的子查询结果集的列为 m_val,那么这个查询就相当于表 s1 和子查询物化表 materialized_table 进行内连接:SELECT s1.* FROM s1 INNER JOIN materialized_table ON order_note = m_val;
转化成内连接之后,查询优化器可以评估不同连接顺序需要的成本是多少,选取成本最低的那种查询方式执行查询。我们分析一下上述查询中使用外层查询的表 s1 和物化表 materialized_table 进行内连接的成本都是由哪几部分组成的:
* 如果使用s1表作为驱动表的话,总查询成本由下边几个部分组成:
+ 物化子查询时需要的成本
+ 扫描s1表时的成本
s1 表中的记录数量 × 通过 m_val = xxx 对 materialized_table 表进行单表访问的成本
* 如果使用 materialized_table 表作为驱动表的话,总查询成本由下边几个部分组成:
+ 物化子查询时需要的成本
+ 扫描物化表时的成本
物化表中的记录数量 × 通过 order_note= xxx 对 s1 表进行单表访问的成本
MySQL 查询优化器会通过运算来选择上述成本更低的方案来执行查询。
- <font style="color:#614700;">子查询转 semi-join</font>
重新审视一下上查询语句:SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE order_no = 'a');我们可以把这个查询理解成:对于s1表中的某条记录,如果我们能在 s2 表(准确的说是执行完 WHERE s2.order_no= 'a' 之后的结果集)中找到一条或多条记录,这些记录的 order_note 的值等于 s1 表记录的 order_note 列的值,那么该条 s1 表的记录就会被加入到最终的结果集。这个过程其实和把 s1 和 s2 两个表连接起来的效果很像:SELECT s1.* FROM s1 INNER JOIN s2 ON s1.order_note = s2.order_note WHERE s2.order_no= 'a';只不过我们不能保证对于 s1 表的某条记录来说,在 s2 表(准确的说是执行完 WHERE s2.order_no= 'a' 之后的结果集)中有多少条记录满足 s1.order_no = s2.order_no 这个条件,可以分三种情况讨论:
* 对于 s1 表的某条记录来说,s2 表中没有任何记录满足 s1.order_note = s2.order_note 这个条件,那么该记录自然也不会加入到最后的结果集。
* 对于 s1 表的某条记录来说,s2 表中有且只有 1 条记录满足 s1.order_note = s2.order_note 这个条件,那么该记录会被加入最终的结果集。
* 对于 s1 表的某条记录来说,s2 表中至少有 2 条记录满足 s1.order_note = s2.order_note 这个条件,那么该记录会被多次加入最终的结果集。
对于 s1 表的某条记录来说,由于我们只关心 s2 表中是否存在记录满足 s1.order_no = s2.order_note 这个条件,而不关心具体有多少条记录与之匹配,又因为有情况三的存在,我们上边所说的 IN 子查询和两表连接之间并不完全等价。但是将子查询转换为连接又真的可以充分发挥优化器的作用,所以 MySQL 在这里提出了一个新概念 ------ 半连接(semi-join)。
将 s1 表和 s2 表进行半连接的意思就是:对于 s1 表的某条记录来说,我们只关心在 s2 表中是否存在与之匹配的记录,而不关心具体有多少条记录与之匹配,最终的结果集中只保留 s1 表的记录。为了让大家有更直观的感受,可以理解为 MySQL 内部是这么改写上边的子查询的:SELECT s1.* FROM s1 SEMI JOIN s2 ON s1.order_note = s2.order_note WHERE order_no= 'a';注意: semi-join 只是在 MySQL 内部采用的一种执行子查询的方式,MySQL 并没有提供面向用户的 semi-join 语法。
:::warning
++半连接的实现方法++
MySQL 准备了好几种办法,比如 Table pullout(子查询中的表上拉)、DuplicateWeedout execution strategy (重复值消除)、LooseScan execution strategy(松散扫描)、Semi-join Materializationa(半连接物化)、FirstMatch execution strategy (首次匹配)等等。
:::
- <font style="color:#614700;">不能转为 semi-join</font>
并不是所有包含 IN 子查询的查询语句都可以转换为 semi-join,对于不能转换的,MySQL 有其他方案:
* 对于不相关子查询来说,会尝试把它们物化之后再参与查询
* 不管子查询是相关的还是不相关的,都可以把 IN 子查询尝试转为 EXISTS 子查询
需要注意的是,如果 IN 子查询不满足转换为 semi-join 的条件,又不能转换为物化表或者转换为物化表的成本太大,那么它就会被转换为 EXISTS 查询。在 MySQL 5.5 以及之前的版本没有引进 semi-join 和物化的方式优化子查询时,优化器都会把 IN 子查询转换为 EXISTS 子查询,所以很多技术书籍或者博客都是建议大家把子查询转为连接,不过随着 MySQL 的发展,最近的版本中引入了非常多的子查询优化策略,内部的转换工作优化器会为大家自动实现。
如果 IN 子查询符合转换为 semi-join 的条件,查询优化器会优先把该子查询转换为 semi-join,然后从执行半连接的策略中选择成本最低的那种执行策略来执行子查询。如果 IN 子查询不符合转换为 semi-join 的条件,那么查询优化器会从下边两种策略中找出一种成本更低的方式执行子查询:
- 先将子查询物化之后再执行查询
- 执行 IN to EXISTS 转换。
- ANY/ALL 子查询优化
如果 ANY/ALL 子查询是不相关子查询的话,它们在很多场合都能转换成我们熟悉的方式去执行
- `< ANY (SELECT inner_expr FROM table)`转化为`< (SELECT MAX(inner_expr) FROM table)`
- `< ALL (SELECT inner_expr FROM table)`转化为`< (SELECT MIN(inner_expr) FROM table)`
- [NOT] EXISTS 子查询的执行
如果 [NOT] EXISTS 子查询是不相关子查询,可以先执行子查询,得出该 [NOT] EXISTS 子查询的结果是 true 还是 false,并重写原先的查询语句,比如对这个查询来说:SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE expire_time= 'a') OR order_no> '2021-03-22 18:28:28';因为这个语句里的子查询是不相关子查询,所以优化器会首先执行该子查询,假设该 EXISTS 子查询的结果为 true,那么接着优化器会重写查询为:SELECT * FROM s1 WHERE true OR order_no > '2021-03-22 18:28:28'进一步简化后就变成了:SELECT * FROM s1 WHERE TRUE;
对于相关的 [NOT] EXISTS 子查询来说,比如:SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE s1.order_note = s2.order_note);很不幸,这个查询并不能进行优化。不过如果 [NOT] EXISTS 子查询中如果可以使用索引的话,那查询速度也会加快不少,比如:SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 WHERE s1.order_note = s2.order_no);这个 EXISTS 子查询中可以使用 idx_order_no 来加快查询速度。