目录
前言:
作为Java开发者,我们在日常开发中免不了与MySQL打交道。今天,本文将从一条简单的【SELECT * FROM user WHERE age > 18】查询
语句入手,探查一下MySQL内核级的精密协作,拆解其中MySQL查询语句的执行流程。
先来一个上帝视角图,下面就是 MySQL 执行一条 SQL 查询语句的流程,也从图中可以看到 MySQL 内部架构里的各个功能模块。
我们可以清楚看到,整个MySQL的架构总共分成了两层Server层和存储引擎层
- Server 层负责建立连接、分析和执行 SQL。MySQL 大多数的核心功能模块都在这实现,主要包括连接器,查询缓存、解析器、预处理器、优化器、执行器等。另外,所有的内置函数(如日期、时间、数学和加密函数等)和所有跨存储引擎的功能(如存储过程、触发器、视图等。)都在 Server 层实现。
- 存储引擎层负责数据的存储和提取。支持 InnoDB、MyISAM、Memory 等多个存储引擎,不同的存储引擎共用一个 Server 层。现在最常用的存储引擎是 InnoDB,从 MySQL 5.5 版本开始, InnoDB 成为了 MySQL 的默认存储引擎。我们常说的索引数据结构,就是由存储引擎层实现的,不同的存储引擎支持的索引类型也不相同,比如 InnoDB 支持索引类型是 B+树 ,且是默认使用,也就是说在数据表中创建的主键索引和二级索引默认使用的是 B+ 树索引。
好了,现在我们对 Server 层和存储引擎层有了一个简单认识,接下来,就详细说一条 SQL 查询语句的执行流程,依次看看每一个功能模块的作用。
一、连接器
在查询真正执行前,客户端需要先与服务端建立连接。MySQL的服务端架构采用分层设计,最外层是连接层,负责处理网络通信和认证,也就是下图所示的部分。
1.1 TCP连接建立
因为MySQL是基于TCP协议进行传输的,所以客户端(如Java程序中的JDBC)需要通过TCP协议连接MySQL服务端(默认端口3306)。这一步由操作系统的Socket机制完成,服务端会监听该端口,等待客户端请求。
1.2 连接认证与线程分配
- 认证阶段:客户端发送用户名、密码、数据库名等信息(格式如下),服务端通过
mysql.user
系统表验证权限(涉及加密算法,如旧版的mysql_native_password
和新版的caching_sha2_password
)。
mysql -h [主机地址] -P [端口号] -u [用户名] -p[密码]
/*参数说明:
*-h:目标主机地址(本地为 localhost 或 127.0.0.1,远程为 IP 或域名)。
*-P:MySQL 服务端口(默认 3306,若未修改可省略)。
*-u:登录用户名(如 root)。
*-p:提示输入密码(注意:-p 后不要加空格,若直接跟密码需写成 -p密码,但不推荐,不安全)。
*/
连接的过程需要先经过 TCP 三次握手,因为 MySQL 是基于 TCP 协议进行传输的,如果 MySQL 服务并没有启动(这里我手动将MySQL进程关闭了),则会收到如下的报错:
如果 MySQL 服务正常运行,完成 TCP 连接的建立后,连接器就要开始验证你的用户名和密码,如果用户名或密码不对,就收到一个"Access denied for user"的错误,然后客户端程序结束执行。
我们正常进入MySQL服务
- 线程管理:在TCP验证通过后,服务端会为该连接分配一个线程(或从线程池复用)。早期MySQL(5.7前)采用
one-connection-per-thread
模式,每个连接独占一个线程;5.7后引入线程池(thread_handling=pool-of-threads
),减少高并发下的线程创建开销。 - 连接器:当我们正常进入MySQL服务器之后,连接器就会获取该用户的权限,然后保存起来,后续该用户在此连接里的任何操作,都会基于连接开始时读到的权限进行权限逻辑的判断。所以,如果一个用户已经建立了连接,即使管理员中途修改了该用户的权限,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。
1.3 问题
-
如何查看MySQL服务被多少个客户端连接?
如果你想知道当前 MySQL 服务被多少个客户端连接了,你可以执行 show processlist 命令进行查看
这里我们对表中的各个字段先进行一个解释
- Id:线程唯一标识(就是我们上面线程管理部分所说的连接服务器之后,系统会分配一个线程,非连接 ID,与
CONNECTION_ID()
不同)。 - User:发起连接的用户名。
- Host:客户端连接的主机信息(IP:Port)。
- db:当前使用的数据库名(未使用时为
NULL
)。 - Command:线程执行的命令类型。
- Time:线程保持当前状态的持续时间(秒)。
- State:线程的具体状态(反映 SQL 执行阶段或等待事件)。
- Info:当前执行的 SQL 语句(截断至前 100 字符)。
如上图结果所示,有两个客户端连接着我们的MySQL服务器,一个是event_scheduler,event_scheduler是MySQL的一个内置事件调度器,用于定期执行指定的事件。它的命令是Daemon,这是一个系统守护进程,通常不需要用户干预。状态是Waiting on empty queue,说明它在等待队列中有事件需要执行,目前队列是空的,所以处于等待状态。时间是661秒,说明这个进程已经运行了一段时间,但因为没有事件,所以一直等待,这是正常现象,不需要担心。下表是对他的一个进一步分析:
字段 | 值 | 说明 |
---|---|---|
Id | 5 | 线程 ID。 |
User | event_scheduler | MySQL 内置的系统用户,专门用于执行定时事件(需开启 event_scheduler=ON )。 |
Host | localhost | 连接来自本地主机。 |
db | NULL | 无需指定数据库(事件调度器操作系统表)。 |
Command | Daemon | 守护进程(系统级线程,非用户连接)。 |
Time | 661 | 线程已运行 661 秒(因无事件需执行,持续等待)。 |
State | Waiting on empty queue | 当前状态:等待事件队列中有任务(正常现象,无事件时始终为此状态)。 |
Info | NULL | 无具体 SQL(守护进程不执行显式 SQL)。 |
另一个是管理员查询线程,来自本地主机的42961端口,当前正在执行show processlist命令,状态是init,时间是0秒,说明这个查询刚刚开始执行,还没有实际消耗时间。Info字段显示的就是当前执行的SQL语句,也就是show processlist本身,这属于正常的管理员操作,查看当前连接情况。
字段 | 值 | 说明 |
---|---|---|
Id | 9 | 线程 ID。 |
User | root | 连接用户为 MySQL 管理员 root。 |
Host | localhost:42961 | 连接来自本地主机的 42961 端口 |
db | NULL | 未使用任何数据库(show processlist 无需指定数据库)。 |
Command | Query | 执行 SQL 查询(非系统命令)。 |
Time | 0 | 查询刚启动,尚未消耗时间(Time 从查询开始计算,此处为初始值)。 |
State | init | 初始化状态(SQL 解析阶段,尚未进入执行阶段)。 |
Info | show processlist | 当前执行的 SQL 语句(即用户手动查看进程列表的操作)。 |
Command的状态除了上面提到的两种,我还需要介绍一种,Sleep。当处于Sleep状态的时候,意味着该用户连接完 MySQL 服务就没有再执行过任何命令,也就是说这是一个空闲的连接,那么,这里也就引出了我们的下一个问题:
- 空闲连接会一直占用着吗?
答案是否定的,MySQL 定义了空闲连接的最大空闲时长,由 wait_timeout 参数控制的,默认值是 8 小时(28880秒),如果空闲连接超过了这个时间,连接器就会自动将它断开。我们可以使用【show variables like 'wait_timeout';】这个命令进行查看。
当然,我们自己也可以手动断开空闲的连接,使用的是 kill connection + id 的命令。一个处于空闲状态的连接被服务端主动断开后,这个客户端并不会马上知道,等到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。
- MySQL的连接数会有限制吗?
MySQL 服务支持的最大连接数由 max_connections 参数控制,比如我的 MySQL 服务默认是 151 个,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。这里我们可以使用【show variables like 'max_connections';】来进行查看。
MySQL 的连接也跟 HTTP 一样,有短连接和长连接的概念,它们的区别如下:
//短连接
连接MySQL服务(TCP三次握手)
执行sql
断开MySQL服务(TCP四次挥手)
//长连接
连接MySQL服务(TCP三次握手)
执行sql
执行sql
执行sql
……
断开MySQL服务(TCP四次挥手)
我们用一个表格来了解一下这两个连接方式
维度 | 短连接 | 长连接 |
---|---|---|
连接生命周期 | 每次操作后关闭 | 长期复用(直到应用结束或主动关闭) |
连接建立开销 | 高(每次需 TCP+MySQL 认证) | 低(仅首次建立) |
资源占用 | 低(无空闲连接) | 高(需管理空闲连接) |
适用场景 | 低频率、非实时操作 | 高并发、实时操作 |
典型问题 | 高并发下性能瓶颈 | 连接泄漏、连接数耗尽 |
可以看到,使用长连接的好处就是可以减少建立连接和断开连接的过程,所以一般是推荐使用长连接。
但是,使用长连接后可能会占用内存增多,因为 MySQL 在执行查询过程中临时使用内存管理连接对象,这些连接对象资源只有在连接断开时才会释放。如果长连接累计很多,将导致 MySQL 服务占用内存太大,有可能会被系统强制杀掉,这样会发生 MySQL 服务异常重启的现象。
- 如何解决长连接长期占用内存的问题呢?
有两种解决方式。
第一种,定期断开长连接。既然断开连接后就会释放连接占用的内存资源,那么我们可以定期断开长连接。
第二种,客户端主动重置连接。MySQL 5.7 版本实现了 mysql_reset_connection() 函数的接口,注意这是接口函数不是命令,那么当客户端执行了一个很大的操作后,在代码里调用 mysql_reset_connection 函数来重置连接,达到释放内存的效果。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
第一部分到这里要分享的也差不多了,那么我们进入第二部分。
二,查询缓存
连接器得工作完成后,客户端就可以向 MySQL 服务发送 SQL 语句了,MySQL 服务收到 SQL 语句后,就会解析出 SQL 语句的第一个字段,看看是什么类型的语句。
如果 SQL 是查询语句(select 语句),MySQL 就会先去查询缓存( Query Cache )里查找缓存数据,看看之前有没有执行过这一条命令,这个查询缓存是以 key-value 形式保存在内存中的,key 为 SQL 查询语句,value 为 SQL 语句查询的结果。
如果查询的语句命中查询缓存,那么就会直接返回 value 给客户端。如果查询的语句没有命中查询缓存中,那么就要往下继续执行,等执行完后,查询的结果就会被存入查询缓存中。
这么看,查询缓存还挺有用,但是其实不然,我要说的是查询缓存很鸡肋,至于为什么,看看我下面的解释,你就会明白了。
第一:对于更新比较频繁的表,查询缓存的命中率很低的,因为只要一个表有更新操作,那么这个表的查询缓存就会被清空。如果刚缓存了一个查询结果很大的数据,还没被使用的时候,刚好这个表有更新操作,查询缓冲就被清空了,相当于缓存了个寂寞。
当然,仅仅这一条肯定是不能将他一棒子打死的,那么其二就是缓存的锁的力度比较大,而且对于动态sql的支持度不够。
缓存在数据进行更新的时候,是进行的表级锁,更新结束后,会把所有与更新内容相关的缓存全部删除。所以,如果表的写入比较多的话,缓存是比较浪费性能的。如果写入特别多,可能缓存反而会导致mysql变慢。
第三:缓存也不是万能的,这里总结一些查询不到缓存的情况:
1.查询条件有不确定数据:如now ,current_time等。
2.缓存对大小写敏感,如select * from test 和SELECT* FROM test 就不会解析为同一条sql
查询带来的额外开销:
- 开始前需要先检查缓存是否命中。
- 结果输出的时候,需要额外进行数据的缓存操作。
- 写入数据时,mysql会将对应表的所有缓存都设置为失效。当缓存内存较大的时候,会导致系统消耗较大。
所以,MySQL 8.0 版本直接将查询缓存删掉了,也就是说 MySQL 8.0 开始,执行一条 SQL 查询语句,不会再走到查询缓存这个阶段了。
对于 MySQL 8.0 之前的版本,如果想关闭查询缓存,我们可以通过将参数 query_cache_type 设置成 DEMAND。
三、解析与优化
连接建立后(缓存被移除,所以直接跳过),查询语句进入服务层处理。服务层是MySQL的核心模块,包含解析器、优化器、执行器三大组件,负责将SQL转化为可执行的物理计划。
3.1 解析器
解析器的工作分为两步:
- 词法分析:将SQL字符串拆分为有意义的“词法单元”(Token)。例如
SELECT id, name FROM user WHERE age > 18
会被拆分为SELECT
、id
、,
、name
、FROM
、user
、WHERE
、age
、>
、18
等Token。 - 语法分析:根据MySQL的语法规则(如SQL标准),将Token序列转换为抽象语法树(AST)。AST的结构直观反映SQL的逻辑:根节点是
SELECT
,子节点包含SELECT列表
、FROM表
、WHERE条件
等。
【SELECT id, name FROM user WHERE age > 18
】AST呈现为:
3.2 预处理器
在处理完语法解析和词法解析之后,我们需要进入到下一个阶段:执行sql。在执行sql的伊始,最开始的是预处理器。那么我们不禁好奇:这个预处理器是用来干什么的呢?
在回答这个问题之前,我再抛出一个问题:如果当你的表或者字段不存在,MySQL服务器会怎么处理呢?
我相信大家的第一反应应该是:那我加一个判断不就行了吗?
这个想法是对的,咱们就加一个判断,那我们知道了解决方法,那这个解决方法应该在哪里进行呢?通过前面的内容,我们知道了,我们在解析器里最终得到的结果是一个抽象语法树,他只是做了一个将sql语句解析出来,并没有进行判断;而在优化器里,我们提前透个短,他是默认你前面的表或者字段是已经存在的,他只是对你传下来的sql语句做优化,才不管你存不存在呢。所以,这个重任就交到了我们的预处理器手中。
我们知道,在解析器处理完sql语句之后生成的是一个抽象语法树,在预处理器中也是对生成的语法树做操作,他会通过查询 MySQL 的 数据字典(System Catalog)(即 information_schema
和 mysql
库的系统表),验证关键对象是否存在,并检查用户权限。即语义预校检。
-
表/视图存在性校验:
解析FROM
子句中的表名(如user
),通过information_schema.TABLES
表查询是否存在该表(需匹配当前数据库)。若不存在,抛出Table 'db.user' doesn't exist
错误。在MySQL的查询处理流水线中,预处理器阶段对应的就是我们的名称解析阶段,语义预校检也是里面的一部分。这个阶段在
Query_block::prepare
函数中实现。在这个预处理阶段中,setup_tables
函数负责处理表的存在性验证,这里给大家上上源码,感兴趣的也可以自己去官网翻一下https://github.com/mysql/mysql-server
//sql/sql_base.cc
if (table_list->open_strategy == Table_ref::OPEN_IF_EXISTS ||
table_list->open_strategy == Table_ref::OPEN_FOR_CREATE) {
bool exists;
if (check_if_table_exists(thd, table_list, &exists)) return true;
这里调用了 check_if_table_exists
函数来验证表是否存在:
static bool check_if_table_exists(THD *thd, Table_ref *table, bool *exists) {
DBUG_TRACE;
*exists = true;
assert(thd->mdl_context.owns_equal_or_stronger_lock(
MDL_key::TABLE, table->db, table->table_name, MDL_SHARED));
if (dd::table_exists(thd->dd_client(), table->db, table->table_name, exists))
return true; // Error is already reported.
if (*exists) goto end;
/* Table doesn't exist. Check if some engine can provide it. */
if (ha_check_if_table_exists(thd, table->db, table->table_name, exists)) {
my_printf_error(ER_OUT_OF_RESOURCES,
"Failed to open '%-.64s', error while "
"unpacking from engine",
MYF(0), table->table_name);
return true;
}
end:
return false;
}
-
列存在性校验:
对SELECT
/WHERE
/ORDER BY
等子句中的列(如age
),结合表的元数据(information_schema.COLUMNS
)验证是否属于该表。若user
表无age
列,抛出Unknown column 'age' in 'field list'
错误。列存在性校验主要在
Query_block::prepare
函数中进行,通过setup_fields
和相关的字段解析函数来完成。在预处理阶段,MySQL会调用setup_tables
来建立表结构,然后通过字段解析过程验证所有引用的列是否存在于对应的表中。
//sql/sql_resolver.cc
bool Query_block::prepare(THD *thd, mem_root_deque<Item *> *insert_field_list) {
DBUG_TRACE;
assert(this == thd->lex->current_query_block());
assert(join == nullptr);
assert(!thd->is_error());
// If this query block is a table value constructor, a lot of the preparation
// done in Query_block::prepare becomes irrelevant. Thus we call our own
// Query_block::prepare_values in this case.
if (is_table_value_constructor) return prepare_values(thd);
Query_expression *const unit = master_query_expression();
if (!m_table_nest.empty()) propagate_nullability(&m_table_nest, false);
/*
Determine whether it is suggested to merge immediate derived tables, based
on the placement of the query block:
- DTs belonging to outermost query block: always
- DTs belonging to first level subqueries: Yes if inside SELECT statement,
no otherwise (including UPDATE and DELETE).
This is required to support a workaround for allowing subqueries
containing the same table as is target for delete or update,
by forcing a materialization of the subquery.
- All other cases inherit status of parent query block.
*/
allow_merge_derived = outer_query_block() == nullptr ||
master_query_expression()->item == nullptr ||
(outer_query_block()->outer_query_block() == nullptr
? parent_lex->sql_command == SQLCOM_SELECT ||
parent_lex->sql_command == SQLCOM_SET_OPTION
: outer_query_block()->allow_merge_derived);
Opt_trace_context *const trace = &thd->opt_trace;
Opt_trace_object trace_wrapper_prepare(trace);
Opt_trace_object trace_prepare(trace, "join_preparation");
trace_prepare.add_select_number(select_number);
Opt_trace_array trace_steps(trace, "steps");
/*
Setup the expressions in the SELECT list.
For derived tables/views, wait with privilege checking of columns and
marking in read/write sets until we know how they are used (may be used in
UPDATE and INSERT). Exceptions:
- Always assume columns referenced in subqueries are selected.
- Always assume outer references are selected (marking is then done in
Item_outer_ref::fix_fields).
Expressions must be resolved here, before tables are set up, otherwise table
function's arguments are not resolved properly.
*/
const bool check_privs = !thd->derived_tables_processing ||
master_query_expression()->item != nullptr;
thd->mark_used_columns = check_privs ? MARK_COLUMNS_READ : MARK_COLUMNS_NONE;
Access_bitmask want_privilege_saved = thd->want_privilege;
thd->want_privilege = check_privs ? SELECT_ACL : 0;
/*
Expressions in lateral join can't refer to item list, thus item list lookup
shouldn't be allowed during table/table function setup.
*/
is_item_list_lookup = false;
/* Check that all tables, fields, conds and order are ok */
if (setup_tables(thd, get_table_list(), false)) return true;
-
函数/操作符合法性校验:
检查 SQL 中使用的函数(如SUM()
)或操作符(如->
JSON 提取符)是否被 MySQL 版本支持。例如,低版本 MySQL(<8.0)不支持JSON_TABLE
函数,使用会报错FUNCTION db.JSON_TABLE does not exist
。函数和操作符的校验通过
Item
类的fix_fields
方法进行。每个函数和操作符都有对应的Item
子类,在名称解析阶段会调用它们的fix_fields
方法。在setup_conds
函数中,可以看到对WHERE条件中的表达式进行解析和验证,包括函数调用和操作符的合法性检查。
//sql/sql_resolver.cc
/**
Resolve WHERE condition and join conditions
@param thd thread handler
@returns false if success, true if error
*/
bool Query_block::setup_conds(THD *thd) {
DBUG_TRACE;
/*
it_is_update set to true when tables of primary Query_block (Query_block
which belong to LEX, i.e. most up SELECT) will be updated by
INSERT/UPDATE/LOAD
NOTE: using this condition helps to prevent call of prepare_check_option()
from subquery of VIEW, because tables of subquery belongs to VIEW
(see condition before prepare_check_option() call)
*/
const bool it_is_update = (this == thd->lex->query_block) &&
thd->lex->which_check_option_applicable();
const bool save_is_item_list_lookup = is_item_list_lookup;
is_item_list_lookup = false;
DBUG_PRINT("info", ("thd->mark_used_columns: %d", thd->mark_used_columns));
if (m_where_cond) {
assert(m_where_cond->is_bool_func());
resolve_place = Query_block::RESOLVE_CONDITION;
thd->where = "where clause";
if ((!m_where_cond->fixed &&
m_where_cond->fix_fields(thd, &m_where_cond)) ||
m_where_cond->check_cols(1))
return true;
assert(m_where_cond->data_type() != MYSQL_TYPE_INVALID);
// Simplify the where condition if it's a const item
if (m_where_cond->const_item() && !thd->lex->is_view_context_analysis() &&
!m_where_cond->walk(&Item::is_non_const_over_literals,
enum_walk::POSTFIX, nullptr) &&
simplify_const_condition(thd, &m_where_cond))
return true;
resolve_place = Query_block::RESOLVE_NONE;
}
// Resolve all join condition clauses
if (!m_table_nest.empty() &&
setup_join_cond(thd, &m_table_nest, it_is_update))
return true;
is_item_list_lookup = save_is_item_list_lookup;
assert(thd->lex->current_query_block() == this);
assert(!thd->is_error());
return false;
}
这里我们对预处理器的校检流程做了一个总结:
- 表存在性验证 - 确认所有引用的表都存在
- 列存在性验证 - 验证所有列引用都指向有效的表列
- 函数和操作符验证 - 检查函数名称是否有效,参数类型是否匹配
- 权限检查 - 验证用户是否有访问相关对象的权限
- 表达式类型推导 - 确定表达式的结果类型
后面的就不做过多的解释了,在预处理器执行这些校验过后,他会生成一个新的解析树,然后交到我们的优化器手中。
3.3 优化器:生成最优执行计划
AST只是逻辑层面的描述,优化器的任务是根据表结构、索引、统计信息等,将其转化为物理执行计划(决定“如何执行”)。
优化器的核心决策点:
- 索引选择:判断是否使用索引(如
age
字段是否有索引)、选择哪个索引(若有多个索引)。 - 连接顺序(多表JOIN时):决定表的访问顺序(如
A JOIN B
是先扫A再扫B,还是反过来)。 - 算法选择:多表JOIN时选择嵌套循环(Nested Loop)、哈希连接(Hash Join)还是归并连接(Merge Join)。
- 扫描方式:选择全表扫描(Full Table Scan)还是索引扫描(Index Scan)。
关键优化:MySQL的优化器会基于
EXPLAIN
命令输出的统计信息(如rows
估算行数、type
访问类型)选择成本最低的计划。例如,若WHERE
条件字段有高选择性索引(如唯一索引),优化器会优先选择索引扫描。
那么我们还是以【SELECT id, name FROM user WHERE age > 18
】这条语句为例。
首先,他会通过进入优化器入口点,调用 unit->optimize()
方法开始优化过程。
//sql/sql_select.cc
if (unit->optimize(thd, /*materialize_destination=*/nullptr,
/*finalize_access_paths=*/true))
return true;
然后,进入查询优化:
- 表访问路径选择:优化器会分析
user
表,确定最佳的访问方式(全表扫描或索引扫描) - WHERE条件优化:对
age > 18
条件进行分析,如果age
字段有索引,会考虑使用索引范围扫描 - 投影优化:对
SELECT id, name
进行优化,确定是否可以使用覆盖索引
接着,进入成本计算环节:
//sql/sql_select.cc
void accumulate_statement_cost(const LEX *lex) {
Opt_trace_context *trace = &lex->thd->opt_trace;
Opt_trace_object trace_parent{trace};
Opt_trace_object trace_cost{trace, "current_query_cost"};
Opt_trace_array trace_query_blocks{trace, "query_blocks"};
double total_cost = 0.0;
for (const Query_block *query_block = lex->all_query_blocks_list;
query_block != nullptr;
query_block = query_block->next_select_in_list()) {
Opt_trace_object trace_query_block{trace};
trace_query_block.add_select_number(query_block->select_number);
if (query_block->join == nullptr) continue;
// Get the cost of this query block.
double query_block_cost = query_block->join->best_read;
trace_query_block.add("query_block_cost", query_block_cost);
// If it is a non-cacheable subquery, estimate how many times it
// needs to be executed, and adjust the cost accordingly.
const Item_subselect *item = query_block->master_query_expression()->item;
if (item != nullptr) {
trace_query_block.add("cacheable", query_block->is_cacheable());
if (!query_block->is_cacheable()) {
const double executions =
std::max(1.0, calculate_subquery_executions(item, trace));
trace_query_block.add("executions", executions);
query_block_cost *= executions;
trace_query_block.add("total_query_block_cost", query_block_cost);
}
}
total_cost += query_block_cost;
}
trace_query_blocks.end();
trace_cost.add("query_cost", total_cost);
lex->thd->m_current_query_cost = total_cost;
}
优化器会通过上面的 accumulate_statement_cost()
函数计算不同执行计划的成本:
- 计算全表扫描的成本
- 如果存在索引,计算索引扫描的成本
- 比较不同访问路径的总成本
后面,根据sql语句,系统会统计使用的优化器类型:
- 传统优化器:使用基于规则和成本的优化
- 超图优化器:对于复杂连接查询使用更先进的算法
if (!using_secondary_storage_engine() &&
is_explainable_query(sql_command_code())) {
if (lex->using_hypergraph_optimizer()) {
++option_tracker_hypergraph_optimizer_usage_count;
} else {
++option_tracker_traditional_optimizer_usage_count;
}
}
assert(!thd->is_error());
// Pop ignore / strict error handler
过了上面优化器的层层选择,终于生成了我们的执行计划。
3.4 执行器:协调存储引擎执行
优化器生成物理计划后,执行器负责调用存储引擎的接口,实际执行查询。执行器的核心逻辑是:
- 检查权限(确保用户对表有
SELECT
权限)。 - 调用存储引擎的API(如InnoDB的
handler
接口),逐行获取符合条件的数据。 - 对结果集进行过滤、排序、分组等操作(若SQL包含
ORDER BY
或GROUP BY
)。
这里通过三种方式执行的过程带大家一起看一下执行器和存储引擎的交互
- 主键索引查询
我们就以【select * from product where id = 1;】为例
这条查询语句的查询条件用到了主键索引,而且是等值查询,同时主键 id 是唯一,不会有 id 相同的记录,所以优化器决定选用访问类型为 const 进行查询,也就是使用主键索引查询一条记录,那么执行器与存储引擎的执行流程是这样的:
执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为 InnoDB 引擎索引查询的接口,把条件 id = 1 交给存储引擎,让存储引擎定位符合条件的第一条记录。
存储引擎通过主键索引的 B+ 树结构定位到 id = 1的第一条记录,如果记录是不存在的,就会向执行器上报记录找不到的错误,然后查询结束。如果记录是存在的,就会将记录返回给执行器;
执行器从存储引擎读到记录后,接着判断记录是否符合查询条件,如果符合则发送给客户端,如果不符合则跳过该记录。
执行器查询的过程是一个 while 循环,所以还会再查一次,但是这次因为不是第一次查询了,所以会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为一个永远返回 - 1 的函数,所以当调用该函数的时候,执行器就退出循环,也就是结束查询了。
至此,这个语句就执行完成了。
- 全表查询
这里,我们以【select * from product where name = 'iphone'】为例子
这条查询语句的查询条件没有用到索引,所以优化器决定选用访问类型为 ALL 进行查询,也就是全表扫描的方式查询,那么这时执行器与存储引擎的执行流程是这样的:
执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 all,这个函数指针被指向为 InnoDB 引擎全扫描的接口,让存储引擎读取表中的第一条记录;
执行器会判断读到的这条记录的 name 是不是 iphone,如果不是则跳过;如果是则将记录发给客户的(是的没错,Server 层每从存储引擎读到一条记录就会发送给客户端,之所以客户端显示的时候是直接显示所有记录的,是因为客户端是等查询语句查询完成后,才会显示出所有的记录)。
执行器查询的过程是一个 while 循环,所以还会再查一次,会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 all,read_record 函数指针指向的还是 InnoDB 引擎全扫描的接口,所以接着向存储引擎层要求继续读刚才那条记录的下一条记录,存储引擎把下一条记录取出后就将其返回给执行器(Server层),执行器继续判断条件,不符合查询条件即跳过该记录,否则发送到客户端;
一直重复上述过程,直到存储引擎把表中的所有记录读完,然后向执行器(Server层) 返回了读取完毕的信息;
执行器收到存储引擎报告的查询完毕的信息,退出循环,停止查询。
至此,这个语句也是执行完成了。
- 索引下推
索引下推(Index Condition Pushdown, ICP)是 MySQL 5.6 引入的优化技术,允许将部分过滤条件下推到存储引擎层,在索引扫描过程中提前过滤数据,减少回表次数和数据传输量。
1. 执行流程(以 age > 18
且 name LIKE '%张%'
为例)
假设查询语句为 SELECT id, name FROM user WHERE age > 18 AND name LIKE '%张%'
,且 age
有普通索引 idx_age
:
无 ICP 的传统执行方式:
- 执行器通过
idx_age
索引扫描,定位到所有age > 18
的行(假设返回 10 万行); - 将这 10 万行的
id
回表,从数据页中读取完整的行数据; - 在服务器层对回表后的数据执行
name LIKE '%张%'
过滤,最终得到结果。
有 ICP 的优化执行方式:
- 执行器通过
idx_age
索引扫描,定位到age > 18
的索引项(假设返回 10 万行); - 在存储引擎层(InnoDB)提前应用
name LIKE '%张%'
条件:利用索引中已有的name
列(若索引包含name
,或通过覆盖索引优化),过滤掉不符合LIKE
条件的索引项(假设过滤后剩 1 万行); - 仅将剩余 1 万行的
id
回表,读取完整数据; - 服务器层直接返回结果(无需二次过滤)。
2. 核心特点
- 减少回表次数:在存储引擎层提前过滤,避免将无效数据回表(减少 I/O)。
- 降低服务器层压力:过滤逻辑下沉到存储引擎,减少服务器层需要处理的数据量。
- 适用条件:
- 查询条件包含索引列和非索引列(如
age > 18 AND name LIKE '%张%'
); - 索引列在过滤条件的左侧(符合最左匹配原则);
- MySQL 版本 ≥ 5.6(ICP 默认开启,可通过
optimizer_switch='index_condition_pushdown=off'
关闭)。
- 查询条件包含索引列和非索引列(如
四、存储引擎层:数据读取与返回
MySQL的存储引擎是可插拔的(如InnoDB、MyISAM、Memory),不同引擎的查询处理逻辑差异较大。目前主流的InnoDB是我们关注的重点。
4.1 Buffer Pool:内存中的数据缓存
InnoDB的核心组件是Buffer Pool(缓冲池),用于缓存数据和索引页(Page,默认16KB)。当查询需要读取数据时:
- 若数据已在Buffer Pool中(命中缓存),直接从内存读取。
- 若未命中(缓存未命中),则从磁盘读取对应的数据页到Buffer Pool(可能触发预读策略,提前加载相邻页)。
数据页结构:InnoDB的数据按页存储,页内包含行记录、事务ID(Trx ID)、回滚指针(Rollback Pointer)等信息。B+树索引的叶子节点存储的是数据页的指针。
4.2 索引扫描:定位目标数据
假设查询条件是WHERE age > 18
,且age
字段有普通索引(非唯一索引):
- 索引查找:通过B+树索引快速定位到所有
age > 18
的记录(索引页在Buffer Pool中命中则直接读取,否则加载磁盘)。 - 回表操作:普通索引存储的是主键值(如
id
),因此需要根据主键值再次查找聚簇索引(主键索引),获取完整的行数据(这一步称为“回表”)。 - 覆盖索引优化:若查询仅需索引包含的字段(如
SELECT id, age FROM user WHERE age > 18
),则无需回表,直接通过索引获取数据(称为“覆盖索引”)。
4.3 结果集处理
存储引擎将符合条件的数据返回给执行器后,执行器会:
- 处理
ORDER BY
:若结果未排序,可能需要额外的排序操作(内存排序或磁盘临时文件排序)。 - 处理
LIMIT
:截断结果集到指定行数。 - 合并多表JOIN结果:若涉及多表查询,执行器会按优化器确定的顺序合并各表的结果。
五、关键细节:影响性能的隐藏因素
理解执行流程后,我们能更清晰地定位慢查询的根源:
5.1 索引失效场景
- 条件中使用函数(如
WHERE YEAR(create_time) = 2024
)会导致索引失效。 - 类型隐式转换(如
WHERE phone = 13812345678
,但phone
是字符串类型)。 - 左模糊查询(
LIKE '%keyword'
)无法利用索引前缀。
5.2 锁与事务的影响
InnoDB采用行级锁(共享锁S、排他锁X),查询时若遇到写锁会进入等待。例如:
- 若另一个事务对
user
表的某行加了写锁,当前查询会被阻塞(可通过SHOW PROCESSLIST
查看状态)。 - MVCC(多版本并发控制)通过
undo log
实现一致性读(快照读),避免了大部分读操作的锁竞争。
5.3 统计信息准确性
优化器依赖表的统计信息(如行数、索引基数)生成执行计划。若统计信息过时(如大量数据增删后未更新),可能导致优化器选择错误的索引。可通过ANALYZE TABLE
手动更新统计信息。
六、总结
一条查询语句的执行流程可简化为:
客户端连接 → 连接层认证 → 解析器生成AST → 优化器生成执行计划 → 执行器调用存储引擎 → 存储引擎通过Buffer Pool和索引读取数据 → 返回结果