2026/10/22 10:33:04

一条MySQL查询的完整旅程:从连接到执行计划与慢查询排查

一条MySQL查询的完整旅程:从连接到执行计划与慢查询排查 很多后端的同学每天都在跟MySQL打交道写SQL、调接口、看慢日志但有一次线上突然出现大量连接超时排查了半天发现是认证插件版本不兼容才意识到自己从来没认真理解过一条查询从连接建立到结果返回到底经历了什么。这篇文章就用一次完整的查询来做解剖从TCP握手讲到执行器拿到数据把每一层的关键机制、常见问题和排查方法串起来。不管你是刚开始接触MySQL的新手还是已经写过不少SQL但没系统梳理过内部机制的开发者按着这条链路走一遍以后再遇到连接失败、查询慢、索引不生效这类问题心里会更有底。1. 先搞清楚连接是怎么回事1.1 一条连接是怎么建立的很多人把“连接数据库”理解成输入密码回车就完事了实际上建立连接是一个分层的动作。以最常见的MySQL 8.0为例当你执行下面这条命令时mysql -h 192.168.1.10 -u app_user -p底层发生的事远比你看到的要多。先是TCP三次握手建立网络通道客户端和服务端确认网络通不通。这一步如果网络不通、防火墙拦了3306端口、服务器本身挂了后面所有事情都不会发生你只会看到类似“Cant connect to MySQL server”的报错。TCP通道建立后MySQL协议层才开始干活。服务端会主动发一个握手包里面包含协议版本号、服务端版本号、连接ID、默认字符集、认证插件类型和一系列能力标志位。客户端拿到这个包之后会结合自己支持的协议和能力协商出一个双方都能接受的连接参数集合然后才把你的用户名和密码按协商好的认证插件格式发过去。这里有个细节很多人没注意密码在网络上是加密的不是明文传输。MySQL 5.7以前默认用mysql_native_password就是那个经典的41位哈希算法MySQL 8.0默认改成了caching_sha2_password安全性更高但如果你是老版本的客户端连接新版本服务端可能会直接报Authentication plugin caching_sha2_password cannot be loaded。这是我在实际工作中遇到过的最典型的连接层兼容问题。鉴权通过后服务端才会真正创建一个会话session分配这个连接的状态信息用来记录当前用户、当前数据库、字符集、事务隔离级别等。后续这个连接上的所有查询都会基于这个会话上下文执行。1.2 连接配置里那些容易忽视的参数连接层是很多问题的重灾区而且大部分问题都不是网络故障是参数和账号配置的问题。下面几个参数我建议你背下来max_connections最大连接数默认151。生产环境很容易被怼满一旦满了新连接直接报Too many connections。常见做法是调到500或1000但别盲目调每个连接都要占内存太大了内存会先崩。wait_timeout非交互连接空闲超时默认8小时。也就是说一个连接闲着不动8小时后服务端会主动断开。很多用连接池的同学遇到过“连接被断开”的报错很可能就是这个参数在作怪。配合interactive_timeout一起看。back_logTCP连接在握手队列里的排队长度。短时间连接暴增时这个值决定有多少连接可以先排队等待默认大概50高并发场景可以适当加大。thread_cache_size线程缓存。连接断开后处理这个连接的线程不一定要立即销毁可以缓存复用减少线程频繁创建销毁的开销。如果你是直接写代码连数据库建议不要每次查询都新建连接而是用连接池。连接池的核心作用是复用连接、控制连接数量上限、检测失效连接。很多框架默认配置都够用但你要知道连接池还有个坑如果服务端wait_timeout把连接断开了而连接池还没发现下一次请求就会在“借出连接”时拿到一个死连接。解决方法是给连接池配置心跳检测或者合适的空闲回收时间比如每隔30秒发一次SELECT 1。1.3 连不上时先看这四件事我排查过很多连接问题总结下来百分之八十都出在下面几个小地方端口能不能通。先telnet或者nc一下3306端口网络不通就去查防火墙和安全组这是排查顺序的第一步。bind-address配置。MySQL默认可能只监听了本地地址127.0.0.1或者只监听某个内网网卡。远程连不上时检查my.cnf里有没有设置bind-address。账号授权。MySQL的账号权限是同时受host限制的比如app_userlocalhost只能在本地登录远程肯定连不上。要通过Host字段允许指定网段或者用通配符%。密码插件不兼容。这个前面提到过老客户端连新服务端或者反过来都可能因为默认认证插件不匹配导致认证失败。注意不要为了图省事把bind-address设成0.0.0.0还放公网数据库暴露在公网上是高危行为。如果确实有远程连接需求建议走内网IP或者通过安全通道。2. 查询请求进入MySQL后第一步2.1 一段SQL是怎么被拆解的连接建立完成你发来一条查询比如SELECT name, age FROM user WHERE city 杭州 ORDER BY age DESC LIMIT 10;MySQL内部有一个命令分发器会先判断这条语句是什么类型。SELECT走查询路径INSERT/UPDATE/DELETE走写入路径还有一些管理命令走专门的通道。我们今天只看查询。接下来进入解析器Parser这里要做两件事词法分析和语法分析。词法分析就是把SQL字符串拆成一个个token例如上面这段SQL会被拆成SELECT、name、age、FROM、user、WHERE、city、杭州这些基本单元。语法分析则检查这些token的组合符不符合MySQL的SQL语法规则如果不符合你会在客户端看到一条语法错误比如ERROR 1064。这个阶段单纯只做语法检查完全不知道表存不存在、字段对不对。解析完成后会生成一棵语法树Parse Tree这棵树体现了SQL结构的层级关系。比如SELECT子句、FROM子句、WHERE子句各自是一棵子树。后面所有环节都基于这棵语法树进行。这里可以类比一下你给一个翻译软件一段中文翻译软件先得断句、分词、分析主谓宾搞清楚这句话的逻辑结构然后才能进一步处理。MySQL解析SQL也是同样的思路。2.2 对象校验和权限检查语法树生成后MySQL需要确认你这棵语法树里的对象是不是真实存在的。这一步叫预处理器Preprocessor或者叫语义检查。具体来说检查表名存在FROM子句里的user这张表在数据库里存不存在。检查列名存在SELECT的name、ageWHERE的cityORDER BY的age这些列在user表里存不存在。检查列名是否有歧义比如多表JOIN时两个表都有id字段你要是不写清楚table.id这里就会报Column id is ambiguous。对象检查通过后MySQL还要做权限验证。如果说你当前登录的这个账号对user表没有SELECT权限预处理阶段就直接拦截了报一个权限错误。对MySQL来说权限本质上是一组缓存的校验结果频繁的权限变更可能导致需要刷新权限缓存这也是为什么改权限后要FLUSH PRIVILEGES才保险。这个阶段做完了MySQL才知道你这句SQL要操作哪些对象它可以开始认真思考怎么执行了。2.3 为什么没有任何缓存很多从其他数据库转过来的朋友会问MySQL有没有查询缓存为什么我执行同样的语句速度没有明显提升这里要澄清旧版本的MySQL确实有Query Cache它的设计是把SELECT语句的文本作为key把查询结果集作为value缓存起来。第一次查询把结果存内存第二次查询如果SQL文本完全一样就直接返回结果。听起来很美好但实际线上效果很差。原因在于只要相关的表有任何一丁点数据变化缓存就要失效写多读少的业务里这个缓存几乎没有命中率。MySQL 5.7里默认已经关闭到了8.0版本直接把查询缓存整个移除了。所以现在的MySQL里不存在“整个查询结果级”的缓存。你可能觉得第一次跑了100ms第二次跑了1ms那是InnoDB的缓冲池Buffer Pool在起作用数据页被读进内存了后面直接从内存读这属于存储引擎层的行为在文章后面会详细讲。3. 优化器——决定查询快慢的核心环节3.1 优化器在做什么预处理做完MySQL并不会直接去执行你的SQL而是把执行方案的决策交给优化器Optimizer。优化器的职责是根据你的SQL语义分析所有可能的执行路径挑一条它认为代价最低的路径最终生成一个执行计划Execution Plan。注意这里的关键词是“代价最低”。MySQL用一个代价模型来评估不同方案的成本。成本主要由两部分组成IO成本从磁盘或内存读数据页的花销和CPU成本比较、排序、计算的花销。优化器会估算每种执行方式需要读多少行、做多少次比较再乘上对应的权重因子得出一个总代价选最小值。这就好比导航软件你从A到B导航会基于距离、红绿灯、拥堵情况计算多种路线推荐一条“理论最优”路线。但导航推荐的未必是实际最快的因为路况一直在变它的估算数据本身有延迟。MySQL的代价估算同样存在误差因为优化器依赖的是统计信息和采样数据。统计信息不准或者索引数据分布发生变化就会导致选错索引甚至选错执行计划。这是优化器最难搞的部分。3.2 一个可复现的完整案例光讲理论不容易消化我们直接构造一个可复现的场景。假设你有两张表一张用户表一张订单表CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL, city varchar(50) NOT NULL, age int DEFAULT NULL, PRIMARY KEY (id), KEY idx_city (city), KEY idx_age (age) ) ENGINEInnoDB; CREATE TABLE order ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, amount decimal(10,2) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB;执行下面这条查询SELECT u.name, o.amount FROM user u JOIN order o ON u.id o.user_id WHERE u.city 杭州 ORDER BY o.amount DESC LIMIT 10;注意order是MySQL的保留字实际使用里需要反引号这里写出来是提醒你别踩坑。然后在命令行执行EXPLAIN SELECT u.name, o.amount FROM user u JOIN order o ON u.id o.user_id WHERE u.city 杭州 ORDER BY o.amount DESC LIMIT 10;EXPLAIN的输出就是优化器给出的执行计划。最关键的几个列id执行步骤编号不是执行顺序。id相同从上往下执行。select_type查询类型SIMPLE表示简单查询PRIMARY表示最外层查询SUBQUERY表示子查询。table这一步读哪张表。type访问类型从好到差依次是system、const、eq_ref、ref、range、index、ALL。ALL就是全表扫描这是我们要避免的。possible_keys这一步可能用到哪些索引。key实际选中的索引。rows优化器估算需要扫描的行数。Extra额外信息包含了非常多重要线索。这个例子里优化器大概率会先从user表走idx_city定位出杭州的用户估算出有多少行然后拿这些user_id去order表走idx_user_id查对应的订单最后做一次排序返回前10条。3.3 索引选择不总是最优的几个坑优化器不是万能的我总结几个常见的“选择失误”场景统计信息过期。MySQL的索引统计信息不是实时的当表数据快速变化时优化器手里的统计信息可能还停留在老状态导致它选了一个并不划算的索引。解决办法是执行ANALYZE TABLE强制更新统计信息。数据分布不均衡。比如city这个字段大部分行都是“杭州”那优化器会认为走city索引没有意义返回行数太多不如直接全表扫。这时即使你建了索引也可能看到type还是ALL。这种属于优化器的合理判断问题的根源是索引设计不适合这个数据分布。隐式类型转换。如果user表的id是int你写WHERE id 123字符串会被转换成数字MySQL大多还能使用索引。但反过来如果字段本身是varchar你写WHERE phone 13800001111MySQL默认会把条件里的数字转成字符串经常会导致索引失效。对索引字段做函数运算。比如WHERE YEAR(created_at) 2024这种写法会让索引失效因为索引树里存的是原始值不是YEAR运算后的结果。正确的写法是改成范围查询WHERE created_at 2024-01-01 AND created_at 2025-01-01。这些坑单独看都不难但组合起来往往让人头疼。我给自己的排查原则是看到一条慢SQL先EXPLAIN不要凭感觉加索引。3.4 用optimizer_trace看优化器的内心戏EXPLAIN只能看到最终决策看不到决策过程。想搞清楚优化器为什么选了这个索引、为什么没选另一个我用的是optimizer_trace这是MySQL提供的一个跟踪优化器决策过程的工具。在会话里执行SET optimizer_traceenabledon; SELECT * FROM user WHERE city 杭州 ORDER BY age DESC; SELECT * FROM information_schema.OPTIMIZER_TRACE; SET optimizer_traceenabledoff;查询结果里有个JSON结构详细记录了优化器每一步的cost计算过程。其中有几块信息最有价值table_scan全表扫描的估算成本。range_scan_alternatives每个可能用到的索引范围扫描成本。chosen标记最终选择的是哪个方案。cause没选某个方案时的原因描述。通过trace你能看到为什么优化器宁可全表扫也不走索引是rows估算太大还是cost计算下来确实全表更便宜。这一步对排查“明明有索引却不走索引”的问题特别有用。我在处理线上慢SQL时第一步EXPLAIN第二步optimizer_trace基本能把百分之九十的索引选择问题定位清楚。3.5 慎用Hints但关键时刻能救命当优化器选错执行计划时你可以在SQL里加Hints强制干预比如FORCE INDEXSELECT u.name, o.amount FROM user u FORCE INDEX (idx_city) JOIN order o ON u.id o.user_id WHERE u.city 杭州;或者用STRAIGHT_JOIN控制JOIN顺序SELECT u.name, o.amount FROM user u STRAIGHT_JOIN order o ON u.id o.user_id;但我建议把Hints当成最后的应急手段不要一开始就用。原因是Hints写死之后一旦表数据量和分布发生大的变化Hints指定的方案可能反而不如优化器自己选的。我一般只在确认优化器误判、且短期内无法通过调整统计信息或者重写SQL解决问题时才用而且要留下注释说明原因方便后续维护。4. 执行器与存储引擎——真正干活的部分4.1 执行计划怎么生成真实结果优化器产出了执行计划接下来的工作交给执行器Executor。执行器会按照执行计划一步一步调用存储引擎的API把数据找出来再做必要的处理比如过滤、排序、聚合最后把结果集返回给客户端。执行器是整个查询链路里离数据最近的那一层。它跟存储引擎的交互是逐行或者逐批进行的不是一次性把所有数据都捞到内存。具体来说InnoDB会向执行器的迭代器接口吐出一行行记录执行器根据计划串起这些记录。这里有个很重要的概念叫下推Pushdown。MySQL 5.6之后加入了索引条件下推ICPIndex Condition Pushdown原本可以在Server层做的WHERE过滤被推到了存储引擎层在读取索引的时候就先做过滤。这样做的好处是减少回表次数。判断有没有走ICP看EXPLAIN的Extra列里有没有Using index condition。执行器这个阶段也是锁发生的地方。比如UPDATE语句执行器在读取一行数据后会对这行加锁读取下一行前会判断条件是否满足不满足就释放锁这就是半一致性读。理解了执行器的工作方式你才能理解为什么某些长事务会导致大量锁等待。4.2 InnoDB的Buffer Pool是性能的核心执行器向InnoDB要数据InnoDB并不是每次都去磁盘读。InnoDB内部有一个非常重要的内存区域叫Buffer Pool默认大小通常是128MB生产环境建议根据物理内存设置为50%到75%。Buffer Pool缓存的是数据页一个页默认16KB。读数据时InnoDB先看目标数据页在不在Buffer Pool里如果在就直接从内存返回如果不在就需要从磁盘读取数据页到Buffer Pool这个过程叫随机的IO也是查询慢的主要来源。到这里你就能理解一个现象一条SQL第一次执行耗时100ms第二次执行只要1ms差别就在缓存命中。查看Buffer Pool命中率SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read_requests; SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_reads;前者是逻辑读次数后者是物理读次数命中率基本等于两者之差除以逻辑读次数。长期低于95%甚至低于90%说明Buffer Pool可能太小或者查询真的是在大量扫描无用数据。Buffer Pool还有一个容易被忽略的副作用如果一次查询要扫描几十万行即使最终结果只有10行这些扫描过的数据页也会被拉进Buffer Pool把原本热数据挤出去。这就是为什么一条糟糕的全表扫描SQL会让整个数据库实例性能雪崩。所以在生产环境监控慢查询不只是为了优化单条语句而是为了保护整个实例的缓存命中率。4.3 回表、深分页和索引下推InnoDB的数据按主键聚簇存放主键索引的叶子节点就是完整的行数据二级索引的叶子节点保存的是主键值。所以当你用city索引查数据时索引只告诉你符合条件的城市在哪个主键上要继续取其他列还得拿主键去主键索引再查一次这个过程叫回表。回表是有成本的。假设city杭州命中了1万行就要回表1万次。如果查询所需的列正好都包含在二级索引里那就没必要回表了这叫覆盖索引。比如SELECT city, age FROM user WHERE city 杭州;如果有一个联合索引(city, age)那么city和age两个字段在二级索引里都有直接扫索引就能返回结果Extra列会显示Using index。在设计索引时优先考虑覆盖查询字段是减少回表的第一个手段。深分页也是一个经典问题。LIMIT 100000, 20看着只是取20行但InnoDB需要先扫描前100000行然后扔掉再往后取20行。扫描成本非常大。我处理过很多电商后台的分页接口数据量到了几十万之后后面几页越来越慢原因就在这。一个常用的优化方案是延迟关联SELECT u.id, u.name, u.age FROM user u JOIN (SELECT id FROM user WHERE city 杭州 ORDER BY age DESC LIMIT 100000, 20) tmp ON u.id tmp.id;子查询临时表只在二级索引上做排序和分页先拿回20个主键再通过主键回表拿完整数据。这样扫描的行数大幅减少。4.4 数据返回的最后一公里MySQL返回结果集不是全部算完再一次性发给你而是边执行边发送。这是MySQL采用的一种流式传输机制。执行器每产生一批结果就通过网络缓冲区发给客户端。所以如果你在客户端执行一条超大结果集的查询你能看到数据是一批批慢慢出来的。这个机制也解释了一个现象一条查询执行时间很长但客户端迟迟没收到数据可能不是查询真的在跑而是结果集在网络上传输缓慢或者客户端自己处理太慢。排查的时候要分清是服务端在计算阶段耗时还是传输阶段耗时。可以通过观察执行时间和实际返回数据的速度来判断。如果你的PHP、Python写代码时用了默认的客户端库去执行一条大查询很多库的默认行为是把整个结果集全部读到内存后才返回给业务代码这会导致内存暴涨。遇到这种情况要开启游标式查询或者流式读取。比如在Python的mysql-connector里可以设置prepared语句和cursor来流式读取不要把海量结果一次性fetch进来。5. 查询变慢从哪里开始排查5.1 先确认卡在哪一层遇到“查询慢”的问题我从来不会一上来就看SQL。先跑一下SHOW PROCESSLIST看看这条SQL当前的状态是什么这一步能帮你快速定位它到底卡在哪个环节。PROCESSLIST里的State字段很有价值如果状态是Sending data说明执行器和存储引擎正在读数据如果状态是Waiting for table metadata lock说明被DDL操作锁住了如果状态是Waiting for handler commit说明在等事务提交如果状态是Statistics说明正在计算统计信息。状态不同排查方向完全不同。举个例子之前有个朋友反馈某条查询平时50ms某天突然变成20秒。SHOW PROCESSLIST发现状态是Sending data但EXPLAIN显示走了主键理论上不可能扫那么多行。最后定位到是Buffer Pool被其他大量扫描SQL打满了缓存命中率骤降导致本来在内存里的数据也要从磁盘读。这就是典型的“SQL本身没问题但共享资源被破坏”的情况。5.2 开慢查询日志和必要的监控慢查询日志是最基础的排查工具。开启方式SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; SET GLOBAL slow_query_log_file /var/log/mysql/slow.log;long_query_time设为1秒也就是超过1秒的SQL都会记录到慢日志。注意线上环境如果并发很大慢日志本身也会带来一点IO开销建议只在排查阶段临时打开排查完及时关闭。也可以用性能分析工具定期扫描慢日志比如把慢日志导出来按平均耗时排序优先优化高频且耗时的SQL。在慢日志里每一行会记录执行时间、锁等待时间、扫描行数、返回行数。注意“扫描行数”和“返回行数”的比值非常关键。如果扫描了10万行才返回10行说明这个查询严重低效问题通常出在索引选择或过滤条件下推上。5.3 常见慢查询场景和对应的解法我整理一个自己常用的排查对照表缓存的排查经验都在里面。慢的场景可能的根因对症手段单表查询慢EXPLAIN显示ALL没索引或索引设计不合理结合WHERE、ORDER BY、GROUP BY字段设计联合索引能走索引但仍然慢Extra显示Using filesort排序字段不在索引里把排序字段加入联合索引消除filesort多表JOIN慢驱动表过大、缺索引小表驱动大表JOIN关联字段建索引LIMIT翻页越来越慢深分页扫描太多行延迟关联或基于主键游标分页常见并发查询但偶发卡顿Buffer Pool命中率低调大Buffer Pool排查垃圾扫描SQL慢日志里大量同款SQL应用层缓存失效导致重复查询在应用层加本地缓存协调缓存刷新策略插入慢但查询也受影响索引过多写入维护成本高清理冗余索引这里特别展开说一下排序导致的问题。MySQL排序有两种实现一种是直接通过索引顺序读取不需要额外排序另一种是先读取数据再排序叫filesort。如果filesort的数据量不大排序在内存中进行如果超过sort_buffer_size就要在磁盘上创建临时文件分块排序性能会断崖式下降。查看慢SQL的EXPLAINExtra列出现Using filesort时就要警惕优先考虑把排序字段加入到索引里。5.4 别忽略服务器和数据库层面的硬限制有时候SQL本身已经优化得很好了但性能还是上不去这时候问题可能在全局限流上CPU打满。多核机器上单条SQL也只能用单核长时间高负载的SQL会直接把CPU吃满。IO打满。如果数据规模远超内存查询大量依赖磁盘读磁盘IO会变成瓶颈。SSD和HDD的差距在这种场景下非常明显。网络带宽。结果集特别大时网络传输占用的时间也会成为瓶颈这时候需要减少返回字段。连接数打满。连接池配置不合理或者存在慢查询占用连接不释放会导致其他请求拿不到连接表现为接口超时。锁等待。行锁、表锁、元数据锁、间隙锁只要有一个长事务持有锁不释放后面所有相关查询都在排队。我在实际排查中发现上面的第五种情况最隐蔽。因为SHOW PROCESSLIST里大量连接都显示在“等待锁”但每一条等待的SQL都运行不到几秒你觉得它们好像“没慢”。真正慢的是持有锁的上游长事务。所以排查问题不要只看单条SQL的耗时还要看整个会话的事务状态和锁等待情况。6. 写在最后的一些实际操作体会把这条链路从连接、解析、优化、执行到返回完整走了一遍之后我有一个很深的体会MySQL本身并不神秘它像一条流水线每个环节都有明确的分工也有自己的瓶颈。以前我调优时习惯“哪里慢就改哪里”现在我会先问自己一句它现在卡在哪一层连接问题去看认证插件和连接参数解析问题去看SQL写法优化器问题去看统计信息和执行计划执行层问题去看Buffer Pool和锁状态资源问题去看CPU、内存、IO。每一层都有对应的排查工具EXPLAIN、optimizer_trace、SHOW PROCESSLIST、慢日志用熟了之后绝大多数性能问题的定位也就是十几分钟的事。最后再分享一个小技巧随手把生产环境的表结构和索引DDL连同EXPLAIN的执行计划一起保存下来遇到版本升级或者数据量增长后再拿出来跑一遍对比一下。很多问题不是突然出现的而是随着数据量增长慢慢逼近临界点。提前对比执行计划的变化比线上出故障再排查要轻松得多。