一条 SQL 从客户端发过去到结果返回给客户端中间到底经过了哪些模块、每个模块负责干什么、先后顺序能不能跳过这是我面试应届生时必问的一道题也是我自己排查线上慢 SQL 时脑子里最先过的一张图。很多人对“MySQL执行每个模块的顺序”背得滚瓜烂熟连接器、查询缓存、分析器、优化器、执行器、存储引擎。可一旦让他结合一条真实的 SQL 讲讲每一步具体发生了什么、在哪个环节被卡住、为什么更新语句和查询语句走的路不一样就答不清楚了。这篇文章我就把这套顺序掰开揉碎不讲云里雾里的理论直接拿一条实际 SQL 从头跟到尾再把查询和更新两条执行路径放在一起对照最后教你怎么用日志和命令把这条链路“可视化”出来。无论你是准备面试还是正在被一条慢 SQL 折磨这篇都值得读完。1. 先看清整体一条SQL在MySQL里到底要过几道门1.1 六大模块的完整调用顺序MySQL 的逻辑架构拆开来看基本是这样一个顺序客户端先和 MySQL 建立连接这一步由连接器负责。连接建立之后对于查询语句MySQL 会先看查询缓存这个模块在 8.0 已经被彻底移除但 5.7 之前用得很多。然后进入分析器做词法分析和语法分析确认你的 SQL 没写错。分析通过之后优化器登场决定这条 SQL 具体怎么执行走哪个索引、表连接顺序怎么安排。生成执行计划后执行器开始真正干活它调用存储引擎的接口一行一行地把数据读出来最终把结果返回给客户端。所以完整链条是连接器 → 查询缓存8.0 前→ 分析器 → 优化器 → 执行器 → 存储引擎。注意最后一步不是执行器自己把数据从磁盘读出来存储引擎才是真正和磁盘打交道的那个人执行器只是发号施令的总指挥。1.2 为什么这个顺序固定不能乱有人可能会想为什么不能先优化再分析如果 SQL 语法都是错的优化器根本没有内容可以处理所以分析器必须排在优化器前面。为什么连接器在最前面因为 MySQL 要拿到你的身份信息、权限信息才知道你有没有资格执行后续的操作如果连权限都没有后面所有步骤都谈不上。这个顺序背后其实是一种非常清晰的模块化设计哲学每一层只负责自己那一件事层与层之间通过清晰的接口对接。连接器管“你是谁”分析器管“你说的是什么”优化器管“怎么干最快”执行器管“按计划去干”存储引擎管“数据到底放哪、怎么读”。任何一个环节想跳过去都会导致信息缺失所以顺序是硬约束不是写代码的人随便排的。1.3 查询和更新两条路径的差异别踩同一个坑一个特别容易被忽略的点以上说的顺序主要针对查询语句。对于更新语句INSERT、UPDATE、DELETE整体链路的前半段是一样的也要经过连接器、分析器、优化器、执行器但后半段完全不同。更新语句涉及数据修改必须保证崩溃后数据不丢所以执行器在调用存储引擎修改数据之后还会追加一个“写日志”的环节包含 redo log 的两阶段提交和 binlog 的写入。我在实际工作中见过很多人把这两条路径混为一谈排查一条 UPDATE 慢的时候还在傻傻地看查询缓存结果查了半天方向完全错了。查询和更新的执行顺序差异是 MySQL 执行链路里最值得记清楚的一个分水岭后面我会专门用一节展开讲更新语句的两阶段提交。2. 前面三道门连接器、查询缓存、分析器2.1 连接器权限校验比你想的更早客户端发起一条 SQL第一步永远是和 MySQL 建立连接。连接器负责的事情包括校验用户名和密码、获取该用户的权限列表、维护连接的状态和超时时间。很多人以为权限校验是在执行 SQL 时才做的其实错了权限在连接建立的那一刻就被查出来并缓存起来了。这意味着什么意味着一个连接一旦建立即使 DBA 在另一边把这个用户的权限改了这个连接依然持有旧的权限直到连接断开重新建立。这个机制在实际运维中非常容易踩坑。我遇到过某公司线上一个账号权限被回收后已经存在的连接仍然能继续查询DBA 以为权限已经生效了结果数据泄露窗口又多了一个小时。排查了很久才发现是连接复用导致权限缓存没有刷新。另外连接器还负责管理空闲连接的超时断开这个参数由 wait_timeout 和 interactive_timeout 控制。如果连接池里的连接长期空闲MySQL 会在超时后把它断开而应用层可能还不知道继续往一个“死连接”上发 SQL就会出现间歇性的连接异常。2.2 查询缓存MySQL 8.0 为什么要拆掉它查询缓存这个模块的位置在分析器之前逻辑也很简单如果一个 SELECT 语句之前执行过并且结果没有失效MySQL 直接返回缓存结果不再走后面的分析、优化、执行流程。这个模块的设计初衷非常朴素——很多业务下热门查询会被反复执行缓存能省掉大量重复计算。但它有个致命问题只要涉及的表有任何数据变更这张表的所有查询缓存全部失效。哪怕你只 UPDATE 了一行数据这张表相关的整片 Query Cache 都被清空。在高并发写入场景下缓存失效的维护成本甚至超过了缓存本身带来的收益还引入了一个全局锁来保护缓存操作导致其他查询被阻塞。这也是 MySQL 8.0 直接把这个模块删掉的原因。如果你还在维护 5.7 或更早版本的 MySQL我建议把 query_cache_type 设置为 OFF不要因为看着有缓存就觉得心理踏实在写多读少的场景下它会成为性能杀手。如果确实需要缓存去用应用层的 Redis 或者 MySQL 8.0 之外的内存表方案都比 Query Cache 可控得多。2.3 分析器词法分析、语法分析以及一条错SQL在哪一步被抓分析器要做两件事词法分析和语法分析。词法分析就是把 SQL 字符串拆成一个个“单词”识别出哪些是关键字、哪些是表名、哪些是列名。语法分析则是根据 MySQL 定义的语法规则判断这些单词组合在一起的句子是否合法。这个阶段如果出问题你会收到类似 ERROR 1064 (42000): You have an error in your SQL syntax 这样的错误。需要注意的是MySQL 在这个阶段还不会去检查表名、列名是否存在它只知道语法有没有问题。表名和列名的检查要等到优化器阶段那里会通过数据字典来校验。所以你在分析器阶段看到报错“语法错误”说明你写的 SQL 连“人话”都没说利索如果你收到的是“Unknown column”说明语法是通的但字段名不对是优化器阶段才发现的。理解这两步之间的区别对排查问题很有用。我遇到过一种情况明明 SQL 在测试环境跑得好好的上了生产环境就报语法错误最后发现是两套环境 MySQL 版本不同新版本对某些语法更严格。这种问题如果不是对分析器的工作方式有概念很容易浪费很多时间去翻业务代码。3. 核心两道门优化器与执行器3.1 优化器改写了什么索引选择、连接顺序、子查询改写分析器把 SQL 变成一棵解析树之后优化器开始发挥作用。它是整个执行链路里最复杂的模块目标是找到一种“代价最小”的执行方式。它做的事包括但不限于决定是否使用索引、使用哪个索引、多表 JOIN 时谁先谁后、子查询怎么改写、条件能不能下推、排序能不能用索引完成。我举个例子你写的是SELECT * FROM orders WHERE user_id 123 AND status 1user_id 和 status 上都有索引优化器会根据表的统计信息估算每个条件能过滤掉多少行然后选择它认为更优的索引。如果统计信息过期了优化器完全可能选错索引这就是为什么我们在线上偶尔会遇到“明明有索引却走了全表扫描”的情况。另外优化器还会改变 JOIN 的驱动顺序。你以为你写的是 A JOIN B实际执行计划可能是先读 B 再读 A优化器会根据表的数据量、连接字段的索引情况重新排列。这个顺序直接影响查询性能差一个量级都是正常的。所以千万别把 SQL 的书写顺序等同于真正的执行顺序这是初学者最容易犯的错误。3.2 执行器最后一个“总指挥”优化器输出执行计划之后执行器正式开始执行。它拿到计划然后逐步调用存储引擎提供的接口。以全表扫描为例执行器的行为大致是调用存储引擎取第一行数据判断这行是否满足 WHERE 条件满足就放入结果集不满足就跳过然后调用存储引擎取下一行重复判断直到最后一行。注意一个细节执行器判断过滤条件是“读取到一行之后”做的而不是在存储引擎内部做。除非优化器把 WHERE 条件下推给了存储引擎比如 InnoDB 对主键范围查询有特殊优化否则过滤逻辑都在执行器这层完成。这就是为什么 MySQL 的 EXPLAIN 里会出现 Using where 这样的标记它表示执行器还需要对引擎返回的记录做一遍条件判断。执行器还会在返回结果的同时把记录写入慢查询日志的统计里。一条 SQL 如果执行器阶段耗时很长说明它从存储引擎那里读了大量的行并且做了大量无效过滤。这时候优化方向应该是让存储引擎少返回一些行也就是增加或优化索引而不是去调整执行器的什么参数因为执行器没有可以调的旋钮。3.3 更新语句的执行顺序为什么必须有两阶段提交写操作的前半段和查询一样也要经过连接器、分析器、优化器、执行器。但执行器修改完数据之后还需要保证即使数据库突然宕机数据也不会丢。这里涉及两个日志binlog归档日志和 redo log重做日志。如果先写 binlog 再写 redo log或者反过来都会出现数据不一致的问题。所以 MySQL 用了一个经典的“两阶段提交”方案。具体顺序是这样的执行器先找到目标行修改内存中的数据并把这个更新写入 redo log 的 prepare 状态然后执行器写 binlog最后把 redo log 从 prepare 状态改为 commit 状态。这个顺序保证了无论哪个环节崩溃恢复后都能让两个日志达到一致。我在排查主从复制延迟的时候经常要回到这个顺序上来思考。如果 binlog 写入慢或者每次都强制刷盘整个 UPDATE 的提交时间会被明显拉长。两阶段提交是 MySQL 高可用链条里的基石不把这个顺序理解清楚就没办法真正理解主从同步的原理更没法应对复制异常这类线上事故。4. 顺带讲清楚模块顺序和SQL子句执行顺序别混了4.1 SQL书写顺序不等于逻辑执行顺序这是我在面试里最常纠正的一个误区。你写一条 SQL总是 SELECT 在最前面FROM 在中间WHERE 在最后但 MySQL 真正处理各子句的顺序并不是你写的这个先后。很多人在聊“MySQL执行每个模块的顺序”时把模块调用顺序和 SQL 子句内部的逻辑执行顺序混在一起。其实这是两条完全不同的线一条是 MySQL 服务端模块的调用顺序另一条是 SQL 各子句的语义逻辑执行顺序。如果你把它们混为一谈会出现一个很搞笑的问题你会试图解释为什么 SELECT 在最前面执行却还要用到 WHERE 里过滤后的数据。正确理解应该是SQL 语句只是描述“我要什么结果”至于怎么一步步算出这个结果是优化器和执行器的事和书写顺序没有对应关系。4.2 从FROM到LIMIT一条查询的子句执行流程SQL 子句的逻辑执行顺序大致是先执行 FROM确定数据来源再执行 JOIN把需要的表连接起来然后执行 WHERE过滤掉不符合条件的数据接着执行 GROUP BY对剩余数据分组再执行 HAVING过滤分组后的结果之后进入 SELECT 子句计算要输出的列再到 DISTINCT 去重然后是 ORDER BY 排序最后是 LIMIT 限制返回行数。这个顺序解释了为什么 WHERE 里不能使用 SELECT 子句中定义的别名因为 WHERE 执行时SELECT 还没有被计算。也解释了为什么 HAVING 可以使用聚合函数因为它发生在 GROUP BY 之后。我第一次理解这个顺序时有种豁然开朗的感觉以前不少想不明白的 SQL 报错一下就通了。尤其要注意 GROUP BY 和 HAVING 的关系。很多人以为 HAVING 就是加强版 WHERE其实 HAVING 执行在分组之后所以它能过滤聚合结果性能上往往比 WHERE 差一些因为 HAVING 要等数据都分组完才能过滤而 WHERE 在分组之前就把数据削减了。能用 WHERE 过滤的绝不要放到 HAVING 里这条建议能帮你省下不少查询时间。4.3 同一个SQL的不同写法竟然会改变优化器的处理顺序SQL 的写法虽然不会改变逻辑执行顺序但会影响优化器生成的执行计划。举个例子一条 SQL 用了子查询优化器可能会把它改写成 JOIN也可能不会改写取决于版本和优化器的代价估算结果。某些写法写得很绕优化器需要花更长时间才能生成合理的执行计划表现为 SQL 在优化器阶段耗时明显偏高。我之前遇到一个慢查询SQL 本身执行只要几十毫秒但总延迟超过一秒。用 profiling 工具一查发现时间几乎都耗在了优化器阶段。原因就是 SQL 里嵌套了多层子查询优化器选择了一个极其昂贵的连接顺序。后来我把子查询改写成 JOIN 方式优化器瞬间就找到了最优计划。这个案例说明模块顺序不仅是“数据怎么流动”也可能是“时间在哪里消耗”排查慢 SQL 时需要把这条链路每一站的耗时都看一遍。5. 实战怎么观测模块顺序和定位瓶颈5.1 打开通用日志和慢查询日志看执行链路通用日志general log可以记录每一条到达 MySQL 的 SQL它能帮你确认一条语句到底有没有进入 MySQL、什么时候进入的。不过它比较重生产环境一般不长期打开只在短时间内用于排查问题。慢查询日志则记录执行时间超过阈值的语句通过 slow_query_log 和 long_query_time 两个参数控制。我会把慢查询日志的时间阈值设成 1 秒配合 log_queries_not_using_indexesON可以把那些没有走索引的 SQL 也记录下来。拿到一条慢 SQL 之后先看它的 Query_time再看它的 Rows_examined如果 Rows_examined 特别大说明执行器从存储引擎那里读了很多行问题大概率出在索引或者执行计划上而不是 MySQL 其他模块的问题。5.2 用EXPLAIN看优化器给出的执行计划EXPLAIN 是观察优化器输出最直接的工具。你只需要在 SQL 前面加 EXPLAIN 三个字母MySQL 就会返回执行计划而不会真的去执行这条 SQL。里面最重要的几个字段type 表示访问类型从 system、const、eq_ref、ref、range、index 到 ALL从左到右性能越来越差key 表示最终选中的索引rows 是优化器估算需要扫描的行数Extra 里的 Using filesort、Using temporary 往往暗示排序或分组没有用到索引需要优化。我一般会先看 type如果看到 ALL 或者 index说明这条 SQL 在做全表扫描或者全索引扫描通常就是性能瓶颈所在。然后对照 key 看是不是用上了预期的索引如果预期索引没有出现在 key 里就要去检查表统计信息是否过期或者是不是 SQL 的写法导致索引失效比如在索引列上做了函数运算。5.3 一种快速定位模块耗时的方法EXPLAIN 只能看到优化器给出的计划看不到每个模块到底花了多长时间。想按模块顺序定位耗时分布可以借助 performance_schema 里的事件统计。比如你可以开启 events_statements_history_long然后执行一次慢查询再去查这张表里记录的各个阶段耗时。具体操作不复杂先确认 performance_schema 处于开启状态执行目标 SQL然后查询慢查询对应的事件 ID在 events_stages_history_long 里就能看到这条 SQL 在 parsing、optimizing、executing、end 等各个阶段的耗时。我经常用这个方法来回答“SQL 到底慢在优化器还是执行器”这种问题。之前有个同事调优一条 JOIN 语句劈里啪啦改索引结构改了一下午没效果我用这个方式一查发现耗时全在 optimizing 阶段改 SQL 写法立刻解决了问题和索引一点关系都没有。6. 常见问题速查与避坑清单6.1 查询缓存已经没了就别再配query_cache了如果你从 5.7 迁移到 8.0第一个感觉就是配置文件里那堆 query_cache 参数全部失效了。启动的时候会直接报错提示这些参数不存在。这不是配置写错了是 MySQL 8.0 把这个模块整个删掉了。如果你还在 5.7 上我也建议直接关闭 query_cache宁可把热点查询结果放到应用层 Redis也不要依赖 MySQL 自带的这个缓存。它在新版本里已经变成了历史包袱留着只会增加无谓的锁开销。6.2 明明有索引却走全表扫描到底卡在哪个模块这个问题其实是优化器阶段决定的不是分析器更不是执行器。优化器选择全表扫描的原因可能有几种表数据量太小优化器认为走索引还不如全表扫描快统计信息过期优化器对索引区分度的估算严重失真SQL 写法导致索引列无法参与比较比如在索引列上使用了函数或者类型转换。排查顺序应该是先用 EXPLAIN 看 key 字段再检查 SQL 写法最后用 ANALYZE TABLE 更新统计信息。不要把精力浪费在分析器上这一阶段只负责语法。6.3 锁等待、长事务执行顺序卡在执行器还是引擎一条 UPDATE 或者 DELETE 卡住不动大概率不是优化器的问题而是执行器调用存储引擎接口时被锁阻塞了。InnoDB 的行锁在引擎层执行器发出修改指令后引擎需要申请锁。如果这个行被另一个事务锁住引擎就会等待。你可以通过 information_schema.innodb_trx 查看当前事务列表通过 sys.innodb_lock_waits 查看锁等待关系。碰到这种问题要看事务的开始时间、是否长时间未提交而不是去调执行器参数因为执行器本身没有锁管理能力。最后再分享一个我自己的排查习惯。接到任何一个 SQL 性能问题我先在心里默背一遍“MySQL执行每个模块的顺序”然后把问题归类连接失败找连接器语法报错找分析器执行计划不对找优化器读大量行或者锁等待找执行器和引擎。这样归完类再动手查日志、开 profiling、看锁信息几乎不会跑偏方向。这个习惯看起来简单但在紧急故障时能帮你少做很多无用功。