ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL查询语法核心:从执行顺序到SQL优化实战

MySQL查询语法核心:从执行顺序到SQL优化实战 做后端开发这几年我反复遇到同一个场景新同事拿着写好的SQL来问“为什么这么慢”“为什么结果不对”问题十有八九出在查询语法理解得不够透。MySQL查询语法看着简单无非SELECT、FROM、WHERE这些关键字但真要写出高效、正确、可维护的查询里面门道不少。这篇内容我打算不按教科书顺序讲而是从实际排查问题的角度把查询语法的核心骨架、执行逻辑、常见误区和优化思路串一遍适合刚学完MySQL基础、开始写业务查询的同学也适合写了一阵子SQL但总被性能问题困扰的开发者。1. 先搞懂SELECT到底在做什么很多教程一上来就列关键字SELECT、FROM、WHERE、GROUP BY、HAVING、ORDER BY、LIMIT然后逐个解释含义。这么学没错但容易忽略一个关键问题这些关键字的执行顺序和书写顺序完全不一样。不理解执行顺序你就很难看懂为什么某些写法会报错为什么某些别名不能在WHERE里用为什么GROUP BY之后SELECT的列莫名受限。1.1 查询语句的执行顺序才是理解一切的基础SQL写出来是给人看的但数据库引擎执行时有自己的一套逻辑顺序。以最常见的分组查询为例SELECT department_id, COUNT(*) AS emp_count FROM employees WHERE hire_date 2023-01-01 GROUP BY department_id HAVING COUNT(*) 5 ORDER BY emp_count DESC LIMIT 10;这段SQL的语法顺序是先SELECT再FROM再WHERE……但MySQL实际执行顺序是这样的FROM确定从哪张表取数据包括JOIN操作也在这一步完成。WHERE对FROM阶段产生的行做逐行过滤把不满足条件的行直接扔掉。GROUP BY按指定列把行分组。HAVING对分组之后的结果做过滤。SELECT计算要返回的列包括聚合函数、表达式、别名。ORDER BY对最终结果排序。LIMIT截取指定行数。这个顺序解释了非常多实际开发中遇到的问题。比如很多初学者在WHERE里使用SELECT中定义的别名-- 这样写会报错Unknown column emp_count SELECT department_id, COUNT(*) AS emp_count FROM employees WHERE emp_count 10 GROUP BY department_id;原因很清楚WHERE在SELECT之前执行此时列别名还不存在MySQL自然找不到emp_count。想过滤分组后的统计值应该用HAVING因为HAVING在分组之后、SELECT阶段附近执行。再比如为什么GROUP BY之后SELECT后面的列那么受限因为在分组阶段每个分组被压缩成一行如果你SELECT了非分组列且没有聚合函数包裹MySQL 8.0默认开启ONLY_FULL_GROUP_BY模式会直接报错。这是因为那一列在多行里有不同值数据库不知道取哪一个干脆拒绝这种不严谨的写法。1.2 真正写给开发者的“最小可用”查询模板理解了执行顺序我建议新手把下面这个模板当成起点来写查询可以避免大部分语法错误和逻辑混乱SELECT 需要的列或聚合结果 FROM 数据来源 WHERE 原始行过滤条件 GROUP BY 分组依据 HAVING 分组后过滤条件 ORDER BY 最终排序规则 LIMIT 分页或截断;写的时候按这个物理顺序从下往上写也行但心里要装着执行顺序。我的习惯是先理清业务逻辑先确定要查哪张表再想清楚过滤条件放在WHERE还是HAVING然后才动手写SELECT列。这样写出来的SQL逻辑清晰得多后续加索引、调性能也有据可循。2. 条件过滤WHERE子句的进阶玩法与索引陷阱WHERE是查询里使用频率最高、踩坑也最多的部分。业务上80%的查询性能问题根源往往就是WHERE条件的写法导致索引失效。我在排查慢查询时第一个动作永远是看WHERE条件怎么写的。2.1 常用条件操作符与索引失效场景WHERE子句里的条件MySQL支持的操作符无非、、、、、、!、BETWEEN、LIKE、IN、IS NULL等。从索引利用角度看这些操作符表现差异很大。等值比较和IN通常能很好地利用索引范围查询BETWEEN、、也能用索引但要注意范围查询右边的边界。真正容易让索引失效的是下面这几种写法对索引列做了函数运算WHERE YEAR(hire_date) 2023即使hire_date上有索引也完全用不上因为索引存储的是原始值不是函数计算结果。应该改写成WHERE hire_date 2023-01-01 AND hire_date 2024-01-01。对索引列做了隐式类型转换如果phone列是字符串类型索引也是按照字符串排序的但你写WHERE phone 13812345678数字字面量会被转换成字符串再比较这个转换过程可能导致索引失效。更严重的是即使索引没失效也可能因为转换规则选出错误数据比如13812345678和13812345678在特定情况下匹配逻辑诡异。前导通配符的LIKEWHERE name LIKE %张%因为通配符在最前面MySQL无法利用B树的顺序查找特性只能全表扫描。但如果写成WHERE name LIKE 张%前缀匹配是可以走索引的。OR连接非索引列WHERE id 1 OR name 张三如果name上没有索引MySQL可能放弃索引合并策略选择全表扫描。遇到这种情况可以拆成UNION或者给两边都加上索引。这些坑看着不起眼一旦数据量上来性能差距就是几十倍甚至上百倍。我在一次排查中碰到过一个订单表几百万行数据就因为查询条件里对时间列用了DATE_FORMAT函数做格式化后比较导致每次查询全表扫描接口超时率飙升。改成范围查询后查询时间从2.8秒降到了30毫秒。2.2 NULL处理一个容易踩坑的老问题NULL在SQL里的语义是“未知”而不是“空字符串”或“0”。这个语义差异导致很多逻辑错误。比如统计某列不为空的数量新手可能写SELECT COUNT(*) FROM users WHERE phone ! ;但正确的业务逻辑通常是“手机号没有填写”对应的是phone IS NULL而不是空字符串。如果数据库里phone列默认值是NULL你写WHERE phone 根本查不到数据。NULL参与比较也有特殊的规则任何与NULL的比较结果都是NULL也就是未知在WHERE里会被当成不成立。所以WHERE phone NULL永远查不到任何行必须写IS NULL。很多人以为 NULL是判断为空其实是把判断写错了。另外要注意聚合函数会忽略NULL值。AVG(score)只统计非NULL的分数如果一行score是NULL既不参与分子也不参与分母。如果业务上需要“缺考按0分算”就得用COALESCE(score, 0)先做转换。这类细节遇到统计口径对不上时最容易发现。提示设计表结构时能用NOT NULL DEFAULT的尽量不要允许NULL。MySQL处理NULL的索引和统计成本比普通值高而且业务判断容易出错。很多时候“空值”用空字符串或者0就够了这也让后续SQL写起来更干净。3. 多表连接JOIN的选型与NULL的边界多表连接是查询语法里最需要“想清楚”的部分。很多人写JOIN只凭感觉结果写出了笛卡尔积或者因为连接条件漏了导致数据膨胀一个普通报表查询跑十几分钟。JOIN的核心理解其实就一句话把两张表按连接条件“拼”成一张临时大表然后再做过滤和分组。3.1 四种JOIN的差异对比MySQL支持的内连接和外连接在实际业务里最常用的是这三种CROSS JOIN先不展开连接类型语义返回结果实际场景INNER JOIN只取两表匹配上的行匹配行订单与订单明细只查有效关联LEFT JOIN左表全部保留右表匹配不上补NULL左表全量 右表匹配字段用户列表携带最新订单信息无订单也要显示用户RIGHT JOIN右表全部保留左表匹配不上补NULL右表全量 左表匹配字段少见因为可以翻转表顺序用LEFT JOIN实现容易记混的是LEFT JOIN的“NULL补位”行为。当左表某一行在右表中找不到匹配时右表所有列在结果里都是NULL。这导致一个经典问题在LEFT JOIN的ON条件后面追加过滤条件和在WHERE里追加过滤条件结果完全不同。3.2 连接条件放哪里JOIN ON与WHERE的微妙区别看这个例子users表和orders表想查所有用户的订单数同时只要已支付订单-- 写法A过滤条件放ON SELECT u.id, u.name, o.order_id FROM users u LEFT JOIN orders o ON o.user_id u.id AND o.status paid; -- 写法B过滤条件放WHERE SELECT u.id, u.name, o.order_id FROM users u LEFT JOIN orders o ON o.user_id u.id WHERE o.status paid;这两种写法结果差异非常大。写法A中AND o.statuspaid是连接条件的一部分对左表users没有任何过滤作用只是决定右表哪些行能匹配上。没有已支付订单的用户依然会出现在结果中右表列显示NULL。写法B中WHERE o.statuspaid是在连接完成后的最终结果集上过滤过滤条件把右表为NULL的行全扔掉了效果等同于INNER JOIN没有订单的用户直接消失。实际开发里这个差异经常导致报表数据对不上。我的习惯是**LEFT JOIN的过滤条件绝大部分都放在WHERE里并且心里清楚这会把LEFT JOIN变成INNER JOIN的语义。**如果你确实想保留左表全量就写在ON条件里。写之前先问自己一句这个过滤条件要不要影响左表的行数要就放WHERE不要就放ON。另外多表JOIN时连接的字段最好类型一致字符集一致。否则MySQL可能需要做隐式转换索引用不上是小事数据量大时直接拖垮性能。跨表连接时养成习惯用EXPLAIN看一眼有没有Using join buffer或Using where有这些标记就要小心了。4. 子查询与集合判断IN、EXISTS、ANY、ALL子查询是查询语法里让新手最头疼的部分之一。MySQL处理子查询的方式经历过几次版本变化不同写法在性能上差异明显。但抛开优化细节先从逻辑上把几种集合判断搞清楚写出来的查询才不容易出错。4.1 IN与EXISTS的性能之争IN和EXISTS都能做“存在性判断”但语义略有不同-- 查询下过订单的用户 SELECT * FROM users WHERE id IN (SELECT user_id FROM orders); -- 同样的查询用EXISTS写 SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id u.id);传统说法是外层表小、子查询大用IN外层表大、子查询小用EXISTS。这个经验在MySQL 5.x时代基本成立因为那时候EXISTS被实现为相关子查询对每一行外层记录都会执行一次子查询而IN会先物化子查询结果。但MySQL 5.6之后做了优化IN子查询会被改写成半连接EXISTS在特定条件下也能被优化。到了MySQL 8.0优化器已经足够聪明大多数场景下两者性能差距可以忽略。真正需要注意的反而是写法本身有没有语义错误。比如IN的子查询结果中包含NULL时NOT IN会返回空结果这个坑很隐蔽。假设orders.user_id有NULL值下面的查询不会返回任何用户SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);原因是id NOT IN (某个NULL)的结果是NULL不是TRUENULL在WHERE里不成立行被过滤掉。遇到这种场景要么在子查询里加WHERE user_id IS NOT NULL要么干脆改写成NOT EXISTSSELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id u.id);NOT EXISTS不会出现NULL陷阱在处理“不存在”类业务逻辑时更安全。这是我个人的强烈建议能写EXISTS就不写NOT IN。4.2 子查询的几种形态与注意点子查询按照返回结果可以分为三种标量子查询返回单个值常用于SELECT列或WHERE比较例如SELECT name, (SELECT MAX(score) FROM exam WHERE exam.user_id users.id) AS max_score。行子查询返回一行多列例如WHERE (col1, col2) (SELECT col1, col2 FROM ...)。表子查询返回多行多列常配合IN、EXISTS、FROM使用。标量子查询和FROM子句里的派生表都有一个共同问题如果处理不好会产生大量临时表扫描。比如从FROM子查询里查数据SELECT * FROM ( SELECT user_id, COUNT(*) cnt FROM orders GROUP BY user_id ) t WHERE t.cnt 3;MySQL必须先把子查询结果物化成临时表然后外层再扫临时表。如果内层结果集很大这个临时表可能落盘性能很差。能改成JOIN就尽量改写成JOIN改不了的时候就要确认内层子查询有没有用到合适的索引。MySQL 8.0支持了公用表表达式和窗口函数很多原本需要写复杂子查询的场景用它们更清晰。比如“查每个部门工资最高的员工”旧写法是关联子查询新写法用窗口函数ROW_NUMBER()逻辑和性能都好很多。这个我放到后面一节展开。5. 分组、聚合、排序与分页查询的最后几公里WHERE过滤完原表行JOIN拼好大表接下来就是收尾阶段。这个阶段看似简单却是报表统计和分页查询里最容易出“业务口径”问题的地方。5.1 聚合函数与GROUP BY的配合逻辑聚合函数包括COUNT、SUM、AVG、MAX、MIN等它们把多行压成一行。写GROUP BY时SELECT后面每一个非聚合列都必须出现在GROUP BY里这是SQL标准要求的。MySQL 8.0默认开启ONLY_FULL_GROUP_BY违反就会报错。这里要特别提醒COUNT(*)和COUNT(column)的区别。COUNT(*)统计行数不会忽略NULLCOUNT(column)统计该列非NULL的数量。这个差异在做统计报表时非常关键。比如统计一个班级的总人数和填了手机号的人数SELECT COUNT(*) AS total_students, COUNT(phone) AS students_with_phone FROM students;如果phone列有NULL这俩结果就不一样恰好可以反映数据完整度。但如果业务表design上把没有值的情况存成了空字符串COUNT(phone)会把空字符串也算进去统计口径就变了。所以统计之前先确认数据里“没有值”到底是以什么形式存在的。5.2 HAVING与WHERE的分工HAVING和WHERE都能过滤但作用阶段完全不同。WHERE在分组之前过滤原始记录HAVING在分组之后过滤聚合结果。这个顺序决定了它们不能互相替代。举例查“2023年入职、平均工资超过1万的部门”SELECT department_id, AVG(salary) AS avg_salary FROM employees WHERE hire_date 2023-01-01 GROUP BY department_id HAVING AVG(salary) 10000;hire_date是原始列过滤条件放在WHERE里在分组前先把2023年入职的员工筛出来。平均工资超过1万是聚合后的判断只能放HAVING。如果把hire_date条件放HAVING逻辑上也能写但性能会很差因为所有年份的数据都要先分组聚合再被HAVING过滤掉白白浪费大量计算。把能提前过滤的条件尽量提前这是SQL优化的基本盘。5.3 排序与分页的性能细节ORDER BY的排序操作如果作用于没有索引的列MySQL会对结果集做文件排序。数据量小感觉不到几万行以上延迟就开始明显了。优化方案是在排序列上建索引或者让排序字段与WHERE条件字段组成联合索引让MySQL直接使用索引顺序返回结果省掉排序这一步。分页查询LIMIT offset, size的问题更隐蔽。很多人写LIMIT 100000, 20MySQL需要先扫描前100000行再扔掉再返回20行越到后面的页越慢。常见的优化思路是延迟关联先查主键再用主键去关联原表取完整数据-- 传统写法深分页时慢 SELECT * FROM orders ORDER BY create_time DESC LIMIT 100000, 20; -- 延迟关联写法先取主键和排序列 SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON o.id tmp.id;子查询里只查主键走索引排序和扫描临时表体积小很多再用主键回表取完整行效率提升明显。6. 窗口函数与进阶扩展MySQL 8.0引入窗口函数是查询语法学习的一个重要分水岭。它解决的问题是需要在分组内做排序、排名、累计等操作但不想真正把行压成一个分组的场景。6.1 窗口函数能解决的问题先看一个常见需求“查每个部门工资排名前三的员工”。用传统语法写要么用关联子查询要么用变量代码绕且难懂。用窗口函数就很直观SELECT department_id, employee_name, salary, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rk FROM employees;OVER (PARTITION BY department_id ORDER BY salary DESC)是窗口的定义意思是按department_id分区分区内部按salary降序排名。RANK()是排名函数它会为每个分区内的行生成一个排名值。接下来想取前三名在外层包一层子查询过滤rk 3即可。窗口函数最大的好处是不改变行的粒度。分组聚合会把多行合成一行丢失明细数据窗口函数则保留每一行原始数据只是附加一个计算列。这在计算“同比环比”“累计求和”“移动平均”等场景下非常有用。6.2 常用窗口函数速览排名类RANK()、DENSE_RANK()、ROW_NUMBER()。RANK在并列时会跳跃比如两个第一下一个从第三开始DENSE_RANK不跳跃两个第一后下一个是第二ROW_NUMBER保证每行一个唯一序号不理会并列。聚合类SUM() OVER (...)、AVG() OVER (...)、COUNT() OVER (...)。聚合函数加OVER就变成窗口聚合可以计算分组内累计值。偏移类LAG(column, n)取分区内前n行的值LEAD(column, n)取后n行的值。比如算用户连续登录天数、与上一次订单的时间间隔全靠这两个函数。取值类FIRST_VALUE()、LAST_VALUE()取分区内第一个、最后一个值配合ORDER BY能实现“取每个分组最新一条记录”的需求。窗口函数的执行发生在ORDER BY之前、SELECT计算列归属的阶段所以它能使用SELECT阶段的别名但WHERE、GROUP BY这些阶段还没结束窗口函数里不能直接引用这些阶段的列。这也是个容易踩的坑自己写一遍就会记住。7. 常见问题与排查技巧实录讲完了语法核心最后写点实战排查的方法。SQL报错和异常的排查很多时候比写SQL本身更考验经验。我把自己日常排查查询问题的一套流程整理一下供你参考。7.1 慢查询排查的基本方法当一条查询变得很慢第一步不是改SQL而是先看执行计划EXPLAIN SELECT u.id, u.name, COUNT(o.order_id) FROM users u LEFT JOIN orders o ON o.user_id u.id GROUP BY u.id;执行计划里最需要关注的几个字段type从好到差依次是system、const、eq_ref、ref、range、index、ALL。看到ALL说明是全表扫描优先优化。key实际用到的索引。如果为NULL说明没走索引。rows预估扫描的行数。这个数字比实际值参考意义更大扫描行数越多查询越慢。Extra出现Using temporary通常是GROUP BY或DISTINCT导致临时表出现Using filesort表示有额外的排序操作。这两项都值得警惕。看到问题后常规优化路径是检查WHERE和JOIN条件列上有没有索引没有就加有索引但没走检查是不是写了函数或隐式转换GROUP BY和ORDER BY的列尽量和索引顺序保持一致避免SELECT *只取需要的列减少回表成本。7.2 几条实战建议第一MySQL的数据字典信息也能帮你定位问题执行SHOW INDEX FROM table_name可以查看表的索引情况确认新建索引是否生效、是否有冗余索引。经常有同事加了索引却没生效排查下来发现加的是重复索引白白占空间还影响写入性能。第二查询条件里的日期范围写和比BETWEEN更安全。比如“查8月数据”BETWEEN 2024-08-01 AND 2024-08-31在不同日期时间类型下可能漏掉8月31日当天的部分数据。改成 2024-08-01 AND 2024-09-01语义清晰也不容易出边界问题。第三永远不要在循环里逐条执行查询。我在代码评审里见过太多类似写法查出100个用户然后在循环里挨个查订单。改成一条LEFT JOIN或者WHERE user_id IN (...)数据库压力能降一个量级。这个是查询语法之外的“查询习惯”但影响比语法本身还大。第四写复杂查询前先在数据量最大的表上看一眼索引。有些需求逻辑复杂但真正耗时的是驱动表的选择。调整一下JOIN顺序、改一下子查询写法往往能稳定提升性能。我在实际工作里最深的一点体会是MySQL查询语法并不难背难的是建立“执行顺序”和“索引利用”这两个底层直觉。只要是写查询都问自己一句——这条SQL的执行顺序是怎么走的每一步会处理多少行有没有可能让扫描范围更小这套思维方式养成了随便拿到一条慢SQL都能快速找到优化方向。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进