ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL SELECT执行顺序:从书写到执行的完整拆解与优化实践

MySQL SELECT执行顺序:从书写到执行的完整拆解与优化实践 做开发这些年我面试过不少人也帮同事排查过不少慢查询。每次被问到“MySQL SELECT语句执行顺序”很多人的第一反应是“不就是先select再from吗”因为SQL写出来就是 select ... from ... where ...。但真实情况恰恰相反MySQL执行一条查询时最先处理的往往不是select而是from后面的表。这个认知差轻则让你在写复杂查询时总被“列不存在”的报错卡住重则会让一条SQL的性能差出几个数量级。今天我就把MySQL SELECT语句从书写顺序到内部执行流程完整拆开讲清楚每一步到底在做什么以及怎么利用这个顺序去优化查询。如果你正在学SQL或者被复杂统计报表折腾得头疼这篇内容应该能帮你把思路理顺。1. 先看书写顺序和执行顺序到底差多少1.1 你手写的SQL其实是一种“倒装句”我们在工单、报表、后台管理页面里最常用的SQL语法顺序是这样写的SELECT col1, col2, ... FROM table_name JOIN other_table ON join_condition WHERE filter_condition GROUP BY column_name HAVING group_filter_condition ORDER BY column_name LIMIT offset, count;这个顺序是写给人看的先想好我要哪些列再从哪张表取接着过滤、分组、排序。但MySQL拿到这条SQL后并不会按这个顺序执行。它真正的逻辑执行顺序是FROM - ON - JOIN - WHERE - GROUP BY - HAVING - SELECT - DISTINCT - ORDER BY - LIMIT很多人第一次看到这个顺序会愣住SELECT不是写在最前面吗为什么最后才执行因为SQL是一种描述性语言你声明“我要什么”数据库自己规划“怎么拿”。SELECT负责最终结果的投影当然要等前面的数据都准备好了才轮到它。用生活里的例子类比一下就像你点外卖你写的订单是“一份牛肉面不要香菜加个蛋”这个顺序是描述需求但后厨实际流程可能是先从锅里捞面再挑掉香菜最后装碗加蛋。需求描述顺序和实际流水线顺序从来不是一回事。1.2 理解执行顺序到底有什么实际价值有人可能会想我写了这么多年SQL不知道执行顺序不也照样跑吗确实简单的单表查询不知道这些也能跑通。但一旦涉及GROUP BY、HAVING、别名、子查询、分页优化不懂执行顺序就会掉进各种莫名其妙的坑。举几个很典型的场景为什么WHERE条件里引用SELECT别名会直接报“Unknown column”为什么ORDER BY却能使用别名为什么同一个过滤条件写在WHERE和HAVING里性能差很多为什么分页查询越到后面越慢这些问题背后全都是执行顺序在起作用。搞懂了执行顺序你写SQL时不仅知道自己每一步在做什么排查慢查询和执行计划时也能更快地定位问题所在。这条知识不是用来背的是用来推的。2. 标准执行顺序的每一环是怎么工作的2.1 FROM和ON一切从数据源开始执行一条SELECT时MySQL要做的第一件事是从FROM子句中确定数据源。这个阶段会做几件事找到物理表读取表的元数据确定需要扫描的全量数据集合。如果是多表关联还要在这一刻处理JOIN和ON条件。这里有一个特别值得关注的点ON的执行为什么发生在WHERE之前因为ON是用来构建JOIN结果的它会决定左表保留哪些行、右表如何匹配过去而WHERE是在JOIN结果已经生成之后对这个结果集再做行级过滤。举个实际例子假设有两张表SELECT * FROM users u LEFT JOIN orders o ON u.id o.user_id AND o.amount 100;这个SQL中o.amount 100是JOIN的匹配条件。它的含义是左表users的所有行都保留只有在orders表中找到金额大于100的订单才匹配上找不到就用NULL填充。但如果我把它挪到WHERE里SELECT * FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE o.amount 100;结果就完全变了WHERE会在LEFT JOIN生成的结果集上过滤把没有匹配订单的users行直接删除因为它们的o.amount是NULL。所以你以为用了LEFT JOIN就能保留左表全部数据但条件写错位置LEFT JOIN就被“降级”成了INNER JOIN。这就是FROM和ON阶段理解不到位最容易踩的坑。2.2 WHERE逐行过滤的关卡JOIN结果数据集构建完成后就进入WHERE阶段。WHERE会根据你给出的条件对结果集中的每一行进行判断符合条件的行保留不符合的丢弃。这一步发生在分组和聚合之前所以WHERE里一定不能使用聚合函数。例如你想筛选出2024年1月之后下单的用户这个条件应该在WHERE里写而不是在HAVING里写。WHERE还有一个底层原则不能使用SELECT别名。因为在这个执行阶段SELECT还没开始计算别名根本不存在。但奇怪的是很多人在WHERE里写别名会收到报错然后以为是自己字段名拼错了其实真正原因是执行顺序。举个例子SELECT amount * 0.9 AS discounted_amount FROM orders WHERE discounted_amount 100;这条SQL会直接报“Unknown column discounted_amount in where clause”。解决办法有两个一个是在WHERE里重写完整的表达式amount * 0.9 100另一个是用子查询或派生表包一层再在外部查询过滤。2.3 GROUP BY与聚合分组后才方便算总数WHERE过滤完行之后接下来是GROUP BY。这一步会把数据按照一个或多个列进行分组分组后每组只保留一条聚合结果。聚合函数如SUM、COUNT、AVG、MAX、MIN在这个阶段开始起作用因为聚合计算天然依赖于分组。这里要特别强调SELECT子句中出现的非聚合列在标准SQL里必须出现在GROUP BY中。MySQL早期版本这个检查不是很严格在ONLY_FULL_GROUP_BY模式关闭的情况下可以写出如下SQL而不报错SELECT user_id, order_amount FROM orders GROUP BY user_id;这时返回的order_amount是每组里的任意一条MySQL不保证它一定取自哪一行。这种“宽松模式”会带来严重的逻辑隐患尤其是你想取“每个用户的最近一笔订单金额”时直接这样写拿到的可能完全不是你期望的那条记录。所以在使用GROUP BY时遵循“SELECT中的列要么是分组列要么是聚合函数”这条铁律能省很多后续排查的麻烦。2.4 HAVING分组后的筛选器GROUP BY把数据分组并计算出聚合结果后还需要对分组结果进行过滤这就是HAVING的职责。HAVING和WHERE最大的区别在于WHERE过滤的是原始行HAVING过滤的是分组后的结果因此HAVING中可以使用聚合函数而WHERE不行。典型的场景是“查询订单数大于5的用户”SELECT user_id, COUNT(*) AS order_cnt FROM orders WHERE status 1 GROUP BY user_id HAVING COUNT(*) 5;这里WHEREstatus 1先过滤掉无效订单再按user_id分组最后用HAVING过滤出订单数超过5的用户。如果把status 1也放进HAVING虽然结果通常一样但执行过程会变成“先分组聚合再过滤status”不仅数据量暴增性能也会明显变差。所以经验是能用WHERE过滤的绝对不要放到HAVING里去干。2.5 SELECT终于开始投影和计算经过FROM、WHERE、GROUP BY、HAVING这些步骤后数据集的行数和分组结构都已经固定了。这时候才轮到SELECT真正执行。它会计算查询列表中的每一项普通列、表达式、函数、CASE WHEN分支甚至是标量子查询。也是从这个阶段开始你写的别名才正式生效可以被SELECT后方的步骤引用。SELECT阶段有一个容易被忽略的细节它确实是在分组聚合之后执行但这并不意味着它会从头到尾重新读取数据。MySQL优化器在执行计划中会尽量把“投影计算”下推到读取阶段比如从索引中只加载需要的列避免加载整行。但你从逻辑层面理解时还是要记住SELECT是取数和计算结果的最后一环不是第一环。2.6 DISTINCT、ORDER BY和LIMIT收尾三件套SELECT完成投影后如果查询中带了DISTINCTMySQL会对结果集做去重去重是作用和所有查询列组合之上的。随后进入ORDER BY阶段因为排序需要基于最终结果集进行。正因为ORDER BY在SELECT之后所以ORDER BY可以使用SELECT别名这个“特权”和WHERE、HAVING形成鲜明对比。LIMIT是在最后执行的它表示从完整结果集中截取指定行数。这条执行顺序对分页查询有深远影响MySQL不能先取前20条来排序它必须先完成排序再跳过offset条然后取接下来的rows条。所以当offset特别大时即使最后只返回20条前面那10万条数据的排序也躲不掉。这也是“深分页很慢”的根本原因。从FROM到LIMIT这个逻辑链条基本就是标准SQL的执行顺序。但MySQL内部的真实执行流程还远不止这一套逻辑顺序这么简单。3. MySQL内部是怎么从“手写SQL”走到“查询结果”的3.1 解析阶段词法、语法与语义检查MySQL收到客户端发来的SQL后首先进入解析阶段。这分为两步词法分析和语法分析。词法分析阶段会把SQL字符串拆成一个个token例如关键字、表名、列名、数字、字符串、操作符等。比如SELECT user_id, COUNT(*) FROM orders会被拆成SELECT、user_id、逗号、COUNT、*、FROM、orders。到了语法分析阶段MySQL会根据语法规则把这个token序列生成一棵解析树。这个阶段如果SQL语法有误比如多写了一个逗号、漏了结束分号、括号不匹配就会直接报语法错误。解析树生成后MySQL还要做语义检查表是否存在、列是否存在、字段权限是否足够。比如SELECT abc FROM orders中abc列不存在就是这个阶段抛出的“Unknown column”错误。解析和语义检查最直接的作用是保证SQL“合法可用”但还没有决定怎么做更高效。3.2 优化器选择最快的那条路解析完成、查询块就绪后MySQL的优化器正式登场。优化器的职责是生成一个它认为代价最低的执行计划通常简称为执行计划。这一步非常复杂它要做的事情包括决定表连接顺序多表JOIN时先连哪张表、再连哪张表。选择使用哪个索引单条件还是复合条件走哪个索引最优。选择数据访问方式全表扫描、范围扫描、索引扫描、ref还是eq_ref。决定使用临时表还是文件排序GROUP BY、ORDER BY、DISTINCT都可能触发临时表。对子查询进行重写把子查询改写成半连接、物化、或转换为派生表再优化。优化器的决策依据主要有三类表统计信息、索引的区分度、当前系统参数。另外它还会做基于规则的优化query rewrite比如把HAVING中的别名视为对应表达式、把IN子查询改成EXISTS。这里需要注意优化器并不是严格按照SQL书写的逻辑顺序去执行它可能调整表的读取顺序或者把条件下推到更早的阶段。所以你用EXPLAIN看到的执行计划往往是优化器重排之后的“实际路线”。3.3 执行器和存储引擎一层层要数据优化器生成执行计划后执行器负责真正去执行。MySQL是分层架构Server层处理逻辑相关的操作如WHERE判断、排序、分组、聚合存储引擎层负责物理数据读取如扫描索引、定位行、读取记录、加锁和事务控制。执行的过程中执行器会向存储引擎发起读取请求。如果计划中要用到索引它就告诉引擎从某个索引的某个位置开始扫描如果引擎返回的行还需要回表执行器再根据二级索引拿到的主键去聚簇索引查完整行数据。WHERE条件过滤的动作是在Server层完成的但InnoDB可以在索引扫描阶段提前跳过很多不满足条件的记录这个能力叫作“索引条件下推”ICP能显著减少Server层与引擎层之间的数据传递。从流程上看逻辑执行顺序是“标准答案”真正底层的执行是基于执行计划的“工程实现”。理解这一点你就不会因为看到某条SQL没有按照逻辑顺序去读数据而感到困惑。3.4 用EXPLAIN把内部流程拉到台面上讲再多概念不如直接看一次执行计划。假设有一张订单表CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB;现在执行一条统计类查询EXPLAIN SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM t_order WHERE status 1 GROUP BY user_id HAVING COUNT(*) 3 ORDER BY order_cnt DESC LIMIT 10;EXPLAIN的输出会显示一组列核心有用的几个是type、possible_keys、key、rows、Extra。type表示访问类型从好到坏大致是system、const、eq_ref、ref、range、index、ALL。ref和range通常说明走了索引ALL就是全表扫描。对于上述SQL如果表中status1的数据占比较高优化器可能放弃idx_status走全表扫描再分组。如果status区分度很高就会走idx_status的ref访问然后临时表分组。你还可以通过EXPLAIN ANALYZE查看每个步骤的耗时快速定位到底是在过滤、排序还是分组上耗费最多。执行计划是MySQL内部流程的可视化结果。看执行计划时不要只是在网上搜“type只代表什么”一定要结合你的数据和索引去推断优化器的每一步意图。这个思路养成后调优SQL就会快很多。4. 执行顺序引出的经典坑与排查心得4.1 WHERE里能不能用SELECT别名这个坑实在太常见了。几乎每个团队都有新人写过这样的SQLSELECT order_id, amount * 0.9 AS discount FROM orders WHERE discount 100;结果MySQL直接报错“Unknown column discount in where clause”。原因我们从执行顺序里已经看到了WHERE发生在SELECT之前别名还不存在。很多人的第一反应是“我明明刚定义了别名怎么就说不知到”然后去检查大小写、检查拼写折腾半天。正确的做法有两个一是把表达式复制一遍amount * 0.9 100缺点是如果表达式很长写起来冗余二是用子查询包一层SELECT * FROM ( SELECT order_id, amount * 0.9 AS discount FROM orders ) t WHERE discount 100;这样内部子查询先生成派生表外部WHERE再过滤就合法了。注意派生表是物化的内部SQL没有索引可用时性能可能下降。所以能用表达式重写就尽量重写不要把每处过滤都包成子查询。4.2 ORDER BY为什么可以引用别名但HAVING要谨慎既然WHERE不能用别名为什么ORDER BY可以因为ORDER BY在SELECT之后执行别名已经生效。我刚入行时也利用过这个特性写过不少很“简洁”的排序SQL比如上面那条ORDER BY order_cnt DESC。这种写法确实方便但它也有代价ORDER BY如果依赖SELECT别名MySQL在优化时很可能会额外维护一个临时表因为要先计算出表达式再基于计算结果排序。如果你把同样的表达式直接在ORDER BY里写一遍可能可以用上索引排序。HAVING使用别名这件事在MySQL是允许的但我不建议在关键业务里依赖它。原因有两个第一标准SQL中HAVING执行在SELECT之前别名按理说不可用MySQL的扩展兼容性很难保证在未来的版本里一直保持第二一旦别人把这条SQL移植到其他数据库比如PostgreSQL或SQL Server大概率会报错。稳妥的写法是在HAVING里重复聚合表达式例如HAVING COUNT(*) 3语义清晰也不用依赖别名。4.3 同一个过滤条件放在WHERE还是HAVING性能能差多少执行顺序决定了WHERE和HAVING的定位完全不同。前面提到WHERE是行级过滤它能大幅减少进入GROUP BY的行数HAVING是分组级过滤如果条件本身和聚合结果无关比如HAVING status 1那它会在分组之后才过滤白白让MySQL做了一堆无用的分组和聚合计算。我排查过一个真实案例某运营报表SQL在HAVING里写了两个过滤条件其中一个条件明显可以直接落到WHERE上但因为当时图省事全部塞在HAVING里导致全表数据先被分组生成几万组临时结果后才过滤掉大部分查询耗时从50毫秒涨到4秒。把这个条件挪到WHERE后耗时直接降回100毫秒以内。所以在写SQL时心里一定要有这杆秤能往前挪的过滤条件尽量往前挪。4.4 子查询和派生表的执行顺序陷阱提到执行顺序很多人会下意识认为FROM里的子查询一定先执行然后外部查询再基于子查询结果继续执行。这个说法在多数场景下成立但MySQL优化器并不总给你“先内后外”的保证。尤其是关联子查询correlated subquery它的执行顺序往往是外层表每读一行就执行一次内层子查询。这在EXPLAIN里会看到DEPENDENT SUBQUERY往往性能很差。另外MySQL还经常把IN (SELECT ...)优化成半连接把半连接优化成普通JOIN这时候你想象中“先执行子查询拿列表再外层IN判断”的流程就变成了“外层表先和子查询的表做JOIN再去重”顺序完全不同。所以遇到子查询性能问题时建议多看一眼EXPLAIN确认优化器到底走了哪条路再针对性改写。4.5 深分页查询为什么越翻越慢怎么改分页是最常见的场景也是最容易被执行顺序坑到的地方。常见写法如下SELECT * FROM t_order WHERE user_id 12345 ORDER BY created_at DESC LIMIT 100000, 20;如果表里只有idx_user_id没有(user_id, created_at)联合索引那MySQL会把user_id12345的所有订单先查出来再对这批订单按created_at做文件排序filesort排序完跳过前10万行最后返回20行。更糟的是如果user_id12345的订单量很大优化器甚至会放弃这个索引改成全表扫描后再排序因为用索引的成本可能更高。优化办法有两个。第一个是建立联合索引(user_id, created_at)这样索引已经按用户和时间排好序MySQL可以直接按索引顺序读取取够20条就停不需要额外的filesort和大量跳过。第二个是“书签分页”先记录上一页最后一条的created_at下一页用WHERE user_id 12345 AND created_at 上一页最后时间 ORDER BY created_at DESC LIMIT 20来实现。我更推荐后者因为它能稳定地在索引上扫描不会随着页数增大而变慢。5. 把执行顺序变成你的SQL优化武器5.1 让WHERE尽早缩小数据集合理解了执行顺序后你会发现过滤条件写得越早后续每一步处理的数据量就越小。所以写复杂SQL时要有一个习惯先看FROM和JOIN把数据扩成了多大然后看WHERE能过滤掉多少行。凡是能用普通字段条件过滤掉的绝不放后面凡是能用索引过滤的一定要建合适的索引。这里有个细节MySQL优化器可能会主动调整WHERE条件的判断顺序但它不会改变“过滤发生在分组和排序前”这个逻辑边界。所以对这个边界的把控完全取决于你写的SQL结构。如果你在子查询的结果上再做WHERE过滤那原始表扫描和过滤负担就会前移到子查询里外部WHERE只能过滤子查询产出的临时数据。5.2 GROUP BY列和索引的配合分组操作在MySQL里通常通过“分组列排序”或“哈希聚合”两种方式完成。如果分组列有索引MySQL可以沿着索引顺序扫描天然得到分组顺序避免额外的临时表。在很多统计查询里GROUP BY的状态甚至能直接被索引扫描取代。举个例子如果经常需要统计WHERE status 1 GROUP BY user_id那么建一个联合索引(status, user_id)就非常高效先由status定位到需要的数据范围再由user_id完成分组整个查询不需要临时表。如果单独给user_id建索引性能反而可能退化为再分组。这就是“让执行顺序顺着索引走”的典型思路。5.3 ORDER BY和LIMIT的联合优化因为LIMIT是最后执行的MySQL在取够LIMIT条数后就可以提前终止。这个“提前终止”的能力很值钱。如果排序字段可以利用索引排序MySQL就不需要对全部结果先排序再取数而是边扫描边累计行数一够LIMIT就停。因此给排序字段建立合适的索引是分页性能的重要保障。对于常见场景“WHERE user_id ? ORDER BY created_at DESC LIMIT 20”联合索引(user_id, created_at)几乎是最有效的解法。反过来说如果你对多字段排序且排序方向和索引顺序不一致优化器只能选择filesort。所以设计索引时要把WHERE条件列、GROUP BY列、ORDER BY列一起考虑进去。5.4 避免让优化器“猜”错你的意图最后提醒一个底层问题优化器是基于成本的不是基于直觉的。你写SQL时的“理所当然”优化器不一定认。常见的反例包括在WHERE中对索引列做运算如WHERE amount 1 100这会让索引失效在WHERE中对索引列使用函数如WHERE DATE(created_at) 2024-01-01在LIKE条件中以%开头如WHERE name LIKE %张对NULL的判断和负向条件可能让优化器放弃索引。这些写法从逻辑上讲当天都能成立但因为破坏了索引的有序性或查询的匹配条件优化器算完成本后可能全给你换成全表扫描。理解执行顺序和优化器的工作原理之后你就会明白写SQL不只是让结果正确还要让优化器在执行计划里选择那条最省力的路径。写SQL这么多年我自己最大的感触是执行顺序不是用来背的是用来推的。遇到一条复杂SQL我会先在脑子里按FROM - WHERE - GROUP BY - HAVING - SELECT - ORDER BY - LIMIT走一遍看看它每一步在数据上到底做了什么动作。很多莫名其妙的报错、令人困惑的回表、深分页越来越慢的问题只要顺着这条逻辑链条推回去原因基本都能浮出水面。建议你以后写统计报表、排查慢查询时也试着用这个顺序去思考你会发现MySQL其实挺讲道理的。
返回列表