1.1.1 SQL语句在MySQL服务层的执行过程
很多图书和文章中都会介绍,SQL语句在MySQL服务层的执行过程主要经过了三个最主要的核心组件,即分析器(Parser)、优化器(Optimizer)和执行器(Executor)。几乎所有的关系型数据库都是按照这个访问路径去设计实现的。在本节的“说明”中,我们还会结合代码讲解如何通过调试方法来阅读MySQL的代码逻辑。
MySQL的连接器和查询缓存也是服务层的重要组件,只不过连接器主要负责建立连接和鉴权的工作,而查询缓存在实际生产中用得比较少,因此,我们从分析器开始SQL旅程的介绍。
分析器做了很多事情,比如词法分析、语法分析等,通俗地讲,就是检查输入的SQL语句是否合法。其中分析器提供了两类重要信息,分别是MySQL的open table方法和生成的结构体THD,它们和我们平时的使用息息相关。
分析器会把SQL语句拆解成一棵语法树,也就是我们常说的SQL Lex。这棵语法树包含了select后面的部分,即item,from、join等逻辑关系;表的别名,以及where条件,即谓词(Predicate)。最后会得到一棵主语法树和若干子语法树,加上表列表(Table List)。这就是分析器真正处理后的结果。
THD是贯穿于整个SQL语句生命周期的重要的结构体,比如query_string,就是用来记录SQL语句的。此外,Session级别的环境变量,具体的锁的地址、MDL地址,语句的开始时间、等锁时间,语句的逻辑读数量等,都被记录在这个结构体中,这个结构体非常大。general log、slowlog都是从这里取到的结果。RDS的SQL洞察,也是在这里实现记录采集的。
有了分析器提供的这两类重要的信息,接下来优化器就要开始工作了。虽然优化器的代码是独立封装的,但实际上,MySQL优化器的调度是混合在执行器里的,执行器开始执行SQL语句时会调度优化器进行调优,然后根据调优后的结果再执行。
MySQL优化器的三个核心步骤如下。
(1)预处理:主要是去除子查询不必要的条件,同时改写子查询,想办法将其变形成Semi-join(半连接方式)。实际上,很多时候改写并不容易,所以MySQL的子查询变形效率并不高。
(2)逻辑优化:计算谓词、group by等条件,尝试利用index dive来获取统计信息,并按照SQL语句的编写顺序连接多表。
(3)物理优化:尝试利用贪婪算法(Greedy Search),并转换代数关系,把I/O、CPU考量全部计算上,选择最优计划。
总体来看,因为单表访问手段并不是特别复杂,MySQL优化器主要是在连接时对多表连接进行优化的。并且它不进行多线程考量,因此在阈值上设计得很少,这一点和后续介绍的数据库有些不同。
执行器得到优化后的计划,真正执行的过程会调用Handler接口。所谓的Handler接口,指的是打通服务层和存储引擎层的接口。由于历史发展原因,我们知道MySQL最早使用的并不是InnoDB引擎,而是插件式引擎,因此执行器真正调度的是各个插件式引擎的接口,而由存储引擎来决定具体怎么执行。
具体来说,执行器可能执行的是对某一行的列A值加1,这个信息通过Handler接口传递后,会变成对应的存储引擎的操作,最典型的就是InnoDB增加了事务和刷脏设计。
执行完成后,dispatch_command一定会检查是否需要写入slowlog,即检查slowlog的相关参数配置,如果开启了slowlog,则会根据逻辑调用slowlog的判断条件,比如是否大于long_query_time。
从某种意义上说,在主处理线程中处理slowlog时,也会存在性能瓶颈。比如在极端情况下,slowlog遇到阻塞,就会出现processlist大量会话堆积的情况。关于slowlog的内容,我们将在1.1.3节中再讨论。
说明
slowlog相关代码逻辑执行结束后,还有一些清理工作要做,在processlist的展示中会看到“freeing item”的状态。但在调试过程中,你甚至会发现,select线程已经能返回结果了,然后这个线程就进入了休眠状态。
上面主要讲解了在单线程情况下,一条SQL语句在服务层的执行过程。实际上,MySQL并不是单线程的数据库,平时总是有并发的线程在运行,因此还会有多线程争抢的问题。
MySQL还存在一个常见的瓶颈:ut_delay。
有经验的DBA可能在各种各样的文档或者perf、pstack堆栈中看见过一个热点函数——ut_delay。下面我们就来讲解ut_delay函数(如图1-3所示)。
图1-3 ut_delay函数
在高并发的场景下,因为自旋锁(Spin Lock)的存在,MySQL会频繁地消耗Kernel CPU,这是非常常见的开销。
另外,有时候自旋(Spin)的源头和自适应哈希索引(Adaptive Hash Index,AHI)也有关系,比如图1-4所示的这个调用链。
图1-4 AHI与ut_delay
可以看到,btr_search_guess_on_hash就是典型的Hash Index的堆栈。
其实在“innodb engine status”中也可以看到Mutex争抢带来的CPU开销,如图1-5所示。
图1-5 Mutex Spin记录
在MySQL中大量使用了自旋的设计,因此在多线程争抢时会出现重复申请某个页的过程。从代码上说,就是某个线程申请rw_lock(一种Latch)或某个Mutex来确保自己持有页,然后才能进行操作。这个申请操作对应的函数是rw_lock_s_lock_spin。如果这个线程没有申请到互斥变量,这时候就会调用ut_delay函数进行休息,这个函数实际上封装了一个循环来执行UT_RELAX_CPU()。本质上,我们观测到的热点函数实际上是一个休息再调用的休息函数,是一种结果,而非原因。
说明
如下代码正是MySQL封装的ut_delay函数,实际上就是一个休息函数。
MySQL使用innodb_spin_wait_delay控制自旋锁的等待时间,等待时间是innodb_spin_wait_delay乘50个中断(Pause)。
这里CPU的型号不同,每次中断的圈数(Circle)也不同,比如版本1的CPU是10 Circle,版本2的CPU是140 Circle(如Skylark)。假设将innodb_spin_wait_delay设置为30,如果是版本1的CPU,实际上MySQL的自旋休息周期等于中断了30×50×10=15 000 Cycle,对于2.5GHz的CPU,等待时间约为6μs;如果是版本2的CPU,MySQL的自旋休息周期等于中断了30×50×140=210 000 Cycle,若CPU主频也是2.5GHz,则等待时间为84μs。这时候等待时间的差异就非常大了,所以合理设置innodb_spin_wait_delay和CPU型号息息相关。而RDS则没有这个烦恼,因为我们已经对这个参数进行了调校。我们还对AHI进行了分片,可以看到RDS的AHI是有分区(Partition)的。
说明
前文为了行文连贯,主要讲述了服务层的原理。为了方便大家深入理解,接下来我们会引入真实的MySQL代码来讲解SQL语句在服务层的执行过程,也方便对比后续几个关系型数据库的区别。我们选用MySQL 8.0.18这个版本的社区代码来讲解。
我们先选择了入口函数dispatch_command,它在sql_parser.cc文件中完整的名字是
这里有一个非常重要的变量——THD *thd,这是一个非常大的结构体,贯穿于整个SQL语句的执行过程。如果在GDB中使用ptype命令,比如“ptype *thd”查看它的定义,你会发现定义非常长,要翻好几页才能浏览全部结果集。它的代码在sql_class.h中,有class THD,有3000行代码定义,非常大。所以query_string、SQL语句执行的起始时间和结束时间、锁、MDL锁等,都可以在这个结构体中找到。
那么如何捕获这个函数呢?为了方便演示,我们直接使用Attach方式,附加到对应的mysqld进程上。关于GDB的安装和使用,请参考其官方文档。
然后使用PID Attach方式附加到对应的进程上。
这里打一个断点。
我们在另外一个窗口中执行一条简单的SQL语句。
此时就会发现这条语句被卡住了,正是被我们所打的断点给卡住了。
在GDB窗口中会返回命中的断点。
1471行,就是对应的dispatch_command第一行代码。
在这里,我们可以使用s或n命令进行调试,s相当于调试器的Step Into,即单步调试并进入,如果函数内有循环,则每次都要执行一遍;n相当于调试器的Step Over,直接执行完成这个函数,到达函数返回的地方。
如果你熟悉Windows,则可以把GDB的命令轻松地对应上Visual Studio的Step下拉菜单。在WinDbg中,命令p相当于Step Into;tc相当于Step Over,叫作Trace to Next Call;gu相当于Step Out,叫作Go Up。
随着调试的进行,你会看见dispatch_command在不断地往下执行,有时候会调用其他函数。
解析器最重要的函数调用关系是mysql_parse()→parse_sql(),这个函数链是真正解析SQL语句的,所以这里打一个断点
这样就可以看清这个调用关系了:dispatch_command()→mysql_parse()→parse_sql()。parse_sql()有多个变量,如thd、parser_state等,这些变量可以通过p或者ptype来查看。
随着调用的深入,我们会发现分析器调用了新的函数MYSQLparse(),它来自sql_yacc.cc文件。这个文件和一个依赖组件有关,编译安装MySQL的读者一定有印象,有一个名为Bison的组件,就是用来进行词法和语法解析的。第一次编译时很有可能会因为没有Bison组件而失败。
下面两个核心的对象,分别对应着语句解析和语法树。
还有大量的相关函数,在sql_yacc.yy中处理各种链表;Yacc自带的很多关键词,配合词法解析。以select为例,处理过程大致如下:
① 处理select的item。
② 处理from、join等逻辑关系,表的别名等。
③ 处理where条件,处理标识(Identify)和谓词(Predicate)。
处理完成后,它们最终会被写到THD结构体中,语法树被写到main_lex中,select被写到多个st_select_lex中,会区分item和谓词等;而表列表(Table List)也会被单独存储。
关于具体如何进行词法解析,这里就不展开介绍了。
MYSQLparse()执行完成以后,parse_sql()函数还会判断是否开启了general log,所以general log也是从解析器开始就记录的。RDS特有的SQL洞察功能也有异曲同工之妙,在解析的时候就挂了一个钩子在THD结构体上,所以SQL语句的文本(Text)、执行时间等信息都可以知道。
至此,解析器的工作基本就讲完了。
接下来会发现一个很重要的函数,就是mysql_execute_command(thd, true)。它是一个超级大函数,有2000多行代码。虽然这个函数的名字里有“execute command”字样,但实际上,优化器的代码绝大部分都是在这个函数中调用的。
这里打一个断点来展示一下,优化器的代码是如何在执行代码中调用的。在优化器的入口函数JOIN::optimize中,当断点命中时,通过堆栈可以看到,优化器的函数是在mysql_execute_command()执行时调用的,通过Lex的操纵和优化器进行互动。
这里要说明的是,MySQL 8.0有一个新设计,对应着MySQL社区的worklog WL#5094。在MySQL 8.0中构建了一个新类,叫作sql_cmd_dml,所有的SQL语句都继承这个类。而sql_cmd_dml则继承了sql_cmd,这和MySQL 5.7的代码结构有点不一样,不过都是做了两层判断,先判断执行(Execute)的分类,然后根据不同类型进行预处理(Prepare)、优化(Optimize),通过lex->m_sql_cmd->execute(thd)来完成调用。
下面简单总结一下优化器都做了哪些事情。我们看下面的代码结构。
执行完成后,会看到两个和slowlog有关的函数,其中一个是thd->update_slow_query_status();另一个是紧随其后的log_slow_statement(thd, query_start_status_ptr)。这两个函数主要是处理slowlog的记录,我们会在1.1.3节中具体介绍它们的代码实现。