
1. 数据查询的整体思路先搞懂SELECT的底层逻辑1.1 SQL书写顺序与执行顺序的差异先抛个问题下面这段SQL看起来很简单但你真能说清楚它每一步是怎么执行的吗SELECT dept_id, COUNT(*) AS cnt FROM employee WHERE age 22 GROUP BY dept_id HAVING cnt 10 ORDER BY cnt DESC LIMIT 5;这是我带新人时常用来考他们的第一道题。大多数人都能写出来但追问“执行顺序是什么”能答对的不超过三成。虽然我们最终只关心查询结果但搞不清楚执行顺序后面所有的调优、建索引、排查慢查询都会变成瞎猜。MySQL这里指以InnoDB为存储引擎的常规场景8.0及以前版本差异不大的执行顺序是FROM确定数据源加载表JOIN ... ON根据关联条件合并多张表生成中间结果集WHERE对中间结果集做行级过滤GROUP BY按字段分组HAVING对分组后的结果做过滤SELECT计算并投影需要的列ORDER BY对结果进行排序LIMIT截取指定行数注意书写顺序是SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT和执行顺序是反过来的。这条顺序线几乎解释了所有SQL新手踩过的坑。举个常见的场景很多人以为WHERE里可以用SELECT里定义的别名结果报错“Unknown column”就是因为WHERE先于SELECT执行别名在那个阶段还没生成自然引用不了。ORDER BY为什么能用别名因为它在SELECT之后执行所以没问题。1.2 查询设计的三个前置问题前阵子有个同事接了个需求要统计“各部门在2024年1月入职的员工平均薪资”。他第一版SQL写了大概四十行用了三个子查询嵌套实际跑起来要两秒多。我帮他把思路理清后压到了六行耗时降到几十毫秒。差别就在于他拿到需求就动手写SQL而没有先回答三个前置问题数据源在哪里单表还是多表是否需要关联关联的字段是否有索引先过滤还是先聚合尽量先通过WHERE把不用的行滤掉再做分组与聚合中间结果集越小后面成本越低。结果集口径是什么有些列是跨表笛卡尔积产生的有些列是带NULL的聚合结果先想清楚才不会写出逻辑正确但结果错误的SQL。说句大实话多数查询慢和结果不对问题不在SQL写法本身而是没想清楚这三件事就急着敲键盘。你花两分钟想清楚往往能省下两小时的排错时间。2. 单表查询的核心操作过滤、排序、去重2.1 WHERE条件过滤的细节单表查询是使用频率最高的操作大部分业务查询都停在“一张表 几个WHERE条件”这个级别。WHERE的写法看起来简单但有几个细节值得单独拿出来说。第一NULL的匹配必须用IS NULL或IS NOT NULL不能用等号或不等号去比对。原因很朴素SQL里NULL代表“未知”对一个未知值做相等判断结果也是未知最终被当作false过滤掉。我见过不少新手写WHERE name ! 张三然后抱怨“明明张三就在表里怎么没查出来”这类乌龙其实是其他行里有NULL值这些NULL行也被过滤了。第二日期范围的过滤要小心。如果日期列是DATETIME类型而你只传了一个日期比如WHERE create_time 2024-01-15这条SQL不会报错但结果集大概率是空的因为create_time里存的是2024-01-15 09:23:41这样的完整时间值。正确写法是WHERE create_time 2024-01-15 AND create_time 2024-01-16用半开区间把当天数据完整包住。这是一个极常见但极容易被忽略的边界问题。第三LIKE模糊匹配的索引失效问题。WHERE name LIKE 张%能走索引但WHERE name LIKE %张或WHERE name LIKE %张%因为前导通配符存在优化器无法用B树的顺序查找能力只能全表扫描。业务中确实需要后模糊匹配的话可以考虑在搜索引擎或者ES里解决MySQL这边硬扛对性能伤害很大。排序方向也存在类似盲区。如果业务高频使用ORDER BY create_time DESC按倒序取最新数据但索引建的是(create_time)正序查询时InnoDB一样能通过倒序扫描来满足需求8.0开始优化器对倒序索引的利用更加成熟但如果你有多个字段的排序需求建索引时就要把方向也考虑进去。2.2 ORDER BY排序的实战写法排序是单表查询里最能体现经验差距的环节之一。ORDER BY支持多字段排序排序优先级从左到右依次递减例如ORDER BY dept_id ASC, salary DESC意味着先按部门升序同一个部门内部按薪资从高到低排。这个顺序是有讲究的如果你把方向写反了数据的组织形态会完全不一样。关于排序还有一个隐含的坑当排序字段区分度很低时比如只有“男”“女”两个值MySQL会额外根据主键或者行位置做一次稳定化排序保证同样的排序值多次查询返回顺序一致。如果业务依赖行顺序做分页一定要在ORDER BY里加一个唯一性字段比如主键ID作为最终兜底避免分页数据出现重复或跳变。LIMIT配合排序使用是常见分页方式。但LIMIT的偏移量不是越大越稳。LIMIT 100000, 20这种写法MySQL仍然会先把前100020行数据查出来再丢弃深翻页时性能会急剧下降。更优的做法是用“上一页最大ID”这种方式来做键集分页WHERE id 上一页最大id ORDER BY id LIMIT 20。两种写法的性能差异在百万级数据表上会非常明显。排序还有一类场景容易被忽视就是排序字段的字符集或排序规则不一致导致的性能损耗。两表关联时如果JOIN字段使用了不同字符集比如一个utf8mb4一个gbkMySQL无法直接使用索引比较需要先把一侧转换后再对比排序也是同理。我遇到过几次“SQL逻辑没问题但慢得出奇”的案例最后排查下来都是字符集不统一引起的隐式转换。2.3 DISTINCT去重的适用场景DISTINCT是单表查询里很简单但也很容易被误用的功能。它的语义是对结果集整行做去重而不是对某个字段去重。比如SELECT DISTINCT dept_id, salary FROM employee它会将dept_id和salary组合起来做去重这样你得到的是“各个部门的所有薪资值”而不是“所有部门ID”。如果业务真正需要的是“有哪些部门”写SELECT dept_id FROM employee GROUP BY dept_id更合理。GROUP BY的性能表现通常也优于DISTINCT因为分组操作可以利用索引而且优化器对分组的处理路径更成熟。还有一点需要注意DISTINCT会把NULL值合并为一组也就是说多条NULL值记录去重后只保留一个NULL。LIMIT和DISTINCT搭配使用还有一个容易被忽略的现象SELECT DISTINCT dept_id FROM employee LIMIT 10MySQL会先去重再取前10行如果你只想看“前10条原始记录的去重结果”这个写法可能达不到预期因为去重发生在LIMIT之前。3. 聚合统计与分组别再用SELECT *硬数了3.1 聚合函数与NULL处理的规则数据查询操作当然绕不开统计。先记住一个基本原则除了COUNT(*)之外其他聚合函数都会忽略NULL值。这个特性在实际场景里很容易被看出问题比如统计平均薪资SELECT AVG(salary) FROM employee WHERE dept_id 3;假如部门3有10个人其中2人的salary是NULL那么分母是按8计算的而不是10。这在口头汇报“部门平均薪资”时可能会导致口径不一。如果业务希望把缺薪资的人当作0来计算就得先COALESCE把NULL转成0再聚合AVG(COALESCE(salary, 0))。另一个常见的坑在于SUM。对一个空分组执行SUM不会返回NULL而是返回NULL——注意是NULL而不是0。如果在程序代码里直接读取这个值并参与数值运算可能出现意想不到的连锁错误。通常我会建议对结果做兜底处理写成SELECT COALESCE(SUM(salary), 0) FROM ...。COUNT()与COUNT(列)的区别也是面试高频题。COUNT()统计的是行数不会遗漏任何一行COUNT(列)统计的是该列非NULL值的个数。当列存在大量NULL时两者的结果会差很多。3.2 GROUP BY分组与HAVING过滤的分工分组是聚合查询里最容易被写乱的环节。GROUP BY的核心规则是分组后SELECT子句里能出现的列只有两类分组键本身以及聚合函数的结果。除此之外的普通列不允许出现。如果非要单独取出某个非分组字段那意味着你的分组粒度选粗了需要换一种查询思路。MySQL有一个特殊的地方在关闭ONLY_FULL_GROUP_BY模式时5.7之前尤其常见上面的规则不会强制生效SELECT里写非分组字段也不报错但返回的那一行是从该分组里“随手挑出来”的结果可能完全随机。我建议在开发和测试环境一律开着ONLY_FULL_GROUP_BY宁可让SQL报错也不能让NIU头不对马嘴的隐含逻辑混进代码里。HAVING和WHERE的分工也需要理顺WHERE在分组前对原始行进行过滤HAVING在分组后对分组结果进行条件判断。一条SQL里同时出现WHERE和HAVING很常见正确理解它们的分工能避免重复过滤。比如筛选“人数大于3的部门里年龄大于22的员工数量”应该先用WHERE把年龄大于22的行滤掉再GROUP BY再用HAVING cnt 3过滤分组。SELECT dept_id, COUNT(*) AS cnt FROM employee WHERE age 22 GROUP BY dept_id HAVING COUNT(*) 3;这里如果把age 22放到HAVING里完全行不通因为HAVING判断的是分组后的整体条件age在分组后已经不是可直接引用的行粒度字段了。判断逻辑放错位置会直接报错或得到完全意外的结果。3.3 分组统计的实战示例随手搭一个非常简单的员工表来演示分组统计的完整思路CREATE TABLE employee ( id INT PRIMARY KEY, name VARCHAR(50), dept_id INT, salary DECIMAL(10,2), age INT );需求一查每个部门的人数、最高薪资、最低薪资、平均薪资。SELECT dept_id, COUNT(*) AS emp_count, MAX(salary) AS max_salary, MIN(salary) AS min_salary, ROUND(AVG(salary), 2) AS avg_salary FROM employee GROUP BY dept_id ORDER BY avg_salary DESC;需求二统计各部门中年龄小于30的员工人数只保留人数不少于3人的部门。用我们前面铺好的逻辑先WHERE过滤年龄再分组再HAVING过滤分组数量。SELECT dept_id, COUNT(*) AS cnt FROM employee WHERE age 30 GROUP BY dept_id HAVING cnt 3;写完这两条SQL你会发现所有单表统计类查询的骨架基本就是确定WHERE的过滤范围确定GROUP BY的分组字段确定SELECT要展示的聚合列最后根据分组数量约束选择HAVING或ORDER BYLIMIT。另外补充一句CUBE和WITH ROLLUP这类高级分组扩展8.0时代用得相对少且语义容易让结果集多出“总计行”如果业务有比较严格的报表校验机制不建议随意使用。常规需求用GROUP BY加上两端聚合的方式就够了。4. 多表查询JOIN的选择与关键避坑4.1 几种常用JOIN的语义对比单表翻来翻去总有不够用的一天生产环境百分之八十的查询都在跟多张表打交道。JOIN类型可简单归纳成一张对比表JOIN类型结果集语义使用频率INNER JOIN只保留两边都匹配成功的行最高LEFT JOIN保留左表全部行右表无匹配则为NULL高RIGHT JOIN保留右表全部行左表无匹配则为NULL低CROSS JOIN两表的笛卡尔积一般不直接使用极低LEFT JOIN在实际使用中最大但出错率也最高。它最典型的问题就是“行数变多”如果左表的一行在右表里匹配到了多行返回结果也会变成多行看起来左表数据被人为“复制”了一遍。这通常不是想让业务看到的样子反而会给数据统计带来污染。有一个经典的业务场景统计每个部门的人数。如果这么写SELECT d.dept_name, COUNT(*) FROM dept d LEFT JOIN employee e ON e.dept_id d.dept_id GROUP BY d.dept_name;当employee表里有一个人属于两个表时比如通过员工部门关联表间接关联这个人的id会重复出现在多个结果里COUNT(*)会把重复行也算进去。解决思路是让计数不依赖JOIN产生的行数比如改成COUNT(e.id)但即使这样重复问题也可能依然存在因为JOIN后行还是没有去重。更稳妥的办法是先对employee表按部门去重统计得到每个部门的员工数再与部门表关联而不是让部门表直接JOIN明细表。这一点在多对多关系场景下尤其重要。4.2 ON与WHERE的差异一个能改结果语义的细节LEFT JOIN的精髓在于ON和WHERE的分工。先看这样一段对比-- 写法A过滤条件放在WHERE里 SELECT d.dept_name, e.name FROM dept d LEFT JOIN employee e ON e.dept_id d.dept_id WHERE e.age 22; -- 写法B过滤条件放在ON里 SELECT d.dept_name, e.name FROM dept d LEFT JOIN employee e ON e.dept_id d.dept_id AND e.age 22;看起来区别不大但执行结果可能完全不同。在写法A中WHERE e.age 22会把右表NULL的行和小于等于22的行全部滤掉相当于把LEFT JOIN变成了INNER JOIN那些没有员工的部门全部消失了。写法B中连接条件保留了“左表全保留”的特性没有员工或员工age不满足条件的部门依然会出现右表字段显示为NULL。选错写法报表就会无声无息地少几行数据而且不是随机少是刚好漏掉空部门或全空部门这种很容易让人事后找补的数据。一个简单有效的自检方法写LEFT JOIN时反问一遍“我到底是想过滤左表还是过滤右表”。过滤左表条件放WHERE过滤右表条件放ON。有人觉得放ON麻烦但数据完整性问题往往比多写几行麻烦更致命得多。4.3 JOIN字段隐式转换与索引失效多表关联还有一个高频问题关联字段的类型或字符集不一致导致MySQL做隐式类型转换。比如左表dept_id是INT类型右表dept_code是VARCHAR类型JOIN时MySQL会把一边转换成另一边再比较。转换的结论通常是字符型转数值型比较时如果对索引列使用函数或转换索引会失效查询变成全表扫描。而更隐蔽的是字符集不同导致的关联性能损耗。比如A库表用了utf8mb4B库表用了gbkJOIN时优化器无法直接对两棵B树做二分匹配需要先转换一边才能比较。最开始时表结构设计阶段就应统一字符集和排序规则至少保证关联字段完全一致。环境已经存在字符集不一致的情况也可以选择重建相关字段来统一类型而不是长期压着一条又慢又容易出错的SQL扛着跑。还有一种情况是数字类型的边界差异关联字段一边是INT一边是BIGINT或者一边是DECIMAL一边是FLOAT。浮点数做关联匹配本身就容易出现精度差异可能导致部分行匹配不上建议关联字段统一用整数或高精度DECIMAL。5. 子查询与派生表的应用技巧5.1 子查询的三种位置与使用场景子查询本质上就是把一个查询结果当作另一个查询的输入常见出现位置有三处WHERE子句中、FROM子句中、SELECT子句中。每一处的写法思路完全不同。WHERE处子查询通常配合IN、EXISTS、比较运算符使用用来动态缩小主查询范围。一个日常场景是“找出薪资高于部门平均值的员工”或者“找出没有下过订单的用户”这些需求因为有“集合比较”语义非常适合子查询。FROM处子查询也可以叫派生表。它会把一个子查询的结果当作一张临时表来使用例如SELECT dept_id, avg_salary FROM ( SELECT dept_id, AVG(salary) AS avg_salary FROM employee GROUP BY dept_id ) t WHERE avg_salary 5000;注意MySQL的派生表是必须有别名的这里的t不能省略不然直接报错。如果你在做二次查询时发现要把子查询的结果再JOIN一层派生表往往是最直观的解法。SELECT处子查询用来在结果集的每一行上补充一个标量值。比如在主查询里附加“每个部门的人数”字段SELECT e.name, e.salary, (SELECT COUNT(*) FROM employee e2 WHERE e2.dept_id e.dept_id) AS dept_cnt FROM employee e;这种写法通俗直观但性能要特别留意因为子查询对外层查询的每一行都可能执行一次如果外层有十万行这个子查询就可能被执行十万次。如果确认某张业务表的量级很小比如部门表只有几十条记录那无所谓一旦主查询量大建议改成LEFT JOIN聚合结果来实现。5.2 IN与EXISTS的选择经典的IN与EXISTS之争几乎每个MySQL技术帖都会聊。直观理解IN适合子查询结果集很小、主表很大的情况EXISTS适合外层表数据量适中、子查询能用到索引的情况。MySQL优化器会比较两张表的数据量和索引有时候“内部重写”会让两者底层执行计划几乎一样。所以与其背结论不如养成下意识用EXPLAIN看一眼的习惯。从表达语义上看EXISTS更侧重“是否存在”一旦子查询能匹配到一条记录就停止扫描。实际使用中EXISTS与关联条件往往绑定在一起SELECT d.dept_id, d.dept_name FROM dept d WHERE EXISTS ( SELECT 1 FROM employee e WHERE e.dept_id d.dept_id );这种写法常用于“存在性判断”。注意子查询里写的SELECT 1而不是SELECT *或SELECT e.id因为EXISTS只关心是否存在匹配行不关心具体返回什么值。写SELECT 1让意图更清晰也让优化器更容易展开处理。5.3 关联子查询的性能陷阱关联子查询是指子查询中引用了外层查询的字段比如上面那个部门人数例子。它的代码看起来不大但语义上每输出一行都要重新计算一次子查询如果没有合适的索引支撑性能会非常难看。应对策略主要有三个方向一是改成JOINGROUP BY大多数关联子查询都可以如此改写二是增加覆盖索引让子查询里对关联字段和聚合字段的访问全部用索引完成不回表三是在确实无法改造的情况下把外层结果集先行缩小减少子查询执行次数。我在实操中见过最高效的一个案例原本一个双层关联子查询的统计接口跑了800毫秒在底层加上联合索引后掉到了15毫秒。这说明很多子查询的“慢”其实不是查询本身复杂而是缺索引。排查时先用EXPLAIN看子查询执行计划里type是不是index、rows是否异常偏大再做对应优化。6. 查询性能分析与报错排查实录6.1 常见查询报错与解决办法查数据碰到报错是家常便饭我这里整理几个高频问题每一个都是实际踩过坑的报错一Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column这就是ONLY_FULL_GROUP_BY模式在起作用。遇到它先不要慌检查SELECT的列列表里有没有混入“非分组键、非聚合函数”的普通列。如果确实需要返回某个列要么把它加入GROUP BY要么用聚合函数包装比如MIN、MAX要么干脆调整业务需求换一种查询方案。把sql_mode改成宽松模式的解法不推荐那是把问题从报错变成了“隐藏的数据错误”。报错二Unknown column xxx in where clause前面我们讲过WHERE阶段SELECT别名还不存在所以不能直接引别名。解决办法是重复写一遍原生字段或在子查询/派生表里先计算好别名再在外层引用。报错三Illegal mix of collations通常发生在表与表或字段与字段之间的字符集不一致。比如此表是utf8mb4_general_ci彼表是utf8mb4_unicode_ci关联比较时直接把MySQL干懵了。对策就是统一字符集和排序规则。6.2 用EXPLAIN快速定位慢查询问题写查询不难难的是写出不慢的查询。在MySQL里排查查询性能的第一动作永远是EXPLAIN。我请新人排查问题第一步就让他们跑EXPLAIN把type、key和rows三列看一眼再说话。type从好到坏大致是const、eq_ref、ref、range、index、ALL。看到ALL基本可以判定全表扫描除非表很小否则一定有问题。key显示实际用到的索引名。如果为NULL说明没走索引要从WHERE与JOIN条件字段上再检查。rows预估需要扫描的行数数值越大越危险。举一个直观的例子。执行计划里出现type: ALL且rows: 5000000意味着这条SQL要把整张五百万行的表全部扫一遍。这时候检查一下WHERE条件字段是否被函数包裹、是否做隐式类型转换、是否为NULL这三类问题是让索引失效的三大元凶。依次排查修正绝大多数慢查询能立竿见影地改善。另外EXPLAIN的Extra列也值得一看出现Using filesort说明排序没有用到索引需要在Order By字段上加合适的索引出现Using temporary说明用了临时表往往由GROUP BY或DISTINCT导致也要引起注意。6.3 慢查询日志与实战排查思路EXPLAIN能定位单条SQL的问题但线上往往不知道是哪条SQL在捣乱。这时就得看MySQL的慢查询日志。默认情况下它是关闭的可以临时开启SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 2;超过2秒的查询会记录到慢查询日志然后通过mysqldumpslow或直接查看日志文件把耗时最长的几条SQL捞出来逐一EXPLAIN分析索引与执行计划走向。如果你用的数据库是云上托管版控制台一般也有现成的慢查询报表省去了手动开日志的麻烦。做查询优化还要注意到一个基本现实同样一条SQL在数据量只有一千行时可能毫秒级返回到一千万行时直接秒级超时。所以上线前做一次合理量级的数据摸底很重要别拿测试环境的小数据量去预估生产环境的真实表现。我在实际工作中会建议团队在开发规范里写死几条铁律业务查询禁止SELECT *必须按需取列禁止对索引列做函数运算或隐式类型转换所有分页查询必须有稳定的ORDER BY多表JOIN先审视关联字段是否都有索引。这些规矩看起来简单却能规避掉绝大多数查询层面的严重事故。把查询基本功磨扎实比背一百个优化技巧都务实。7. 写在最后的一点经验做数据查询这几年我最深的体会是SQL写得好不好不在一时手速而在你能不能把“数据到底是怎么被取出来的”这个过程想明白。很多人卡在“会写但不会优化”症结往往是没搞懂WHERE和GROUP BY的分工没搞懂JOIN的驱动方向没搞懂子查询的执行次数。这些内容不是靠背语法就能解决的多跑EXPLAIN、多看执行计划慢慢就会建立直觉。另外分享一个非常实用的小习惯每次写完一个稍复杂的查询不要急着上线先在数据量大一倍的环境里跑一遍用EXPLAIN看一眼执行计划。哪怕只是多花几分钟也可能帮你躲过一个几天后才会暴露的深坑。数据查询这件事慢工出细活是真的。