SQL执行顺序详解:FROM到LIMIT七步原理与性能优化

📅 2026/8/26 5:24:02
SQL执行顺序详解:FROM到LIMIT七步原理与性能优化
1. 为什么搞懂 SQL 执行顺序比背一百条语法还管用你有没有遇到过这种场景明明写的 WHERE 条件逻辑很清晰结果查出来的数据却少了一半CASE WHEN 写在 SELECT 里但 NULL 值没被过滤掉最后 GROUP BY 出来一堆空组或者用子查询套了三层一加 HAVING 就报错“列不在 GROUP BY 中”——可你明明在 SELECT 里写了这个字段。这些不是数据库 bug也不是你手抖写错了而是 SQL 的执行顺序和你“以为”的书写顺序根本不是一回事。我带过几十个刚转行的数据岗新人90% 都卡在这个认知断层上。他们能熟练写出 JOIN、GROUP BY、ORDER BY但一到复杂查询就靠“试错法”改一句跑一次再改一句再跑像在黑盒里摸开关。这不是能力问题是底层执行模型没建立起来。SQL 是声明式语言你只告诉数据库“要什么”不告诉它“怎么拿”。而数据库内部有一套严格、不可跳过的执行流水线——就像工厂的装配线螺丝必须先拧紧才能装外壳顺序错了整条线就停摆。这个标题里的“最全最详细图解”不是指画一堆箭头框图糊弄人而是要把每一步到底干了什么、哪些字段可见、哪些别名生效、哪些计算真正落地全部掰开揉碎讲清楚。比如WHERE 为什么看不到 SELECT 里定义的别名HAVING 为什么能用聚合函数而 WHERE 不能ORDER BY 为什么能用窗口函数结果但 LIMIT 却不能引用它这些都不是“规定如此”而是执行引擎在内存中真实走过的路径决定的。尤其在实际业务中这个顺序直接决定性能生死。一个本该在 WHERE 提前过滤掉 95% 数据的条件如果误写进 HAVING就会让数据库先把几百万行全分组、全聚合最后才筛出你要的那几百条——CPU 和内存瞬间拉满DBA 的电话马上打过来。所以这不是理论题是每天都在发生的线上事故预警图。无论你是写报表的 BI 工程师、调优慢 SQL 的后端开发还是备考面试的应届生这张执行顺序图就是你 SQL 能力的“地基钢筋”。地基没焊牢楼盖得再高风一吹就晃。2. SQL 执行顺序全景拆解从词法解析到结果返回的七步硬核流程2.1 第一步FROM —— 数据源的“入场券”与连接编排执行链条的第一站永远是FROM。注意这里不是“先读表”而是“确定数据源集合及其关系”。数据库首先要解析FROM后面的所有表、视图、子查询包括JOIN并生成一个初始的“笛卡尔积候选集”。哪怕你只写了一个表这一步也必不可少——它要确认这张表是否存在、用户是否有权限、是否需要加锁如FOR UPDATE。关键细节在于JOIN的处理。INNER JOIN、LEFT JOIN、RIGHT JOIN的语义差异在这一步就已固化。以A LEFT JOIN B ON A.id B.a_id为例数据库会先扫描 A 表所有行对每一行再去 B 表中查找匹配的记录。如果 B 表没有匹配项这一行依然保留在结果集中B 表的字段全部置为NULL。这个过程是逐行驱动的不是先算完 A 再算 B而是 A 的每一行都触发一次 B 的探查。这也是为什么LEFT JOIN的右表条件如果写在WHERE里如WHERE B.status active会把原本该保留的 NULL 行给过滤掉——因为WHERE发生在JOIN之后此时 B 的字段已经是 NULL自然不满足active。实操中常踩的坑多表JOIN时ON条件的书写位置至关重要。ON只负责定义两张表之间的关联逻辑它属于JOIN操作的一部分而WHERE是对整个FROM结果集的全局过滤。比如SELECT * FROM orders o LEFT JOIN users u ON o.user_id u.id WHERE u.is_vip 1这个WHERE实际上把LEFT JOIN变成了INNER JOIN效果因为u.is_vip 1这个条件会让所有u为 NULL 的订单行被剔除。正确写法应该是... ON o.user_id u.id AND u.is_vip 1把筛选逻辑下推到JOIN关联时这样LEFT JOIN的语义才得以保留。提示FROM阶段还会处理表别名如FROM sales AS s这个别名从这一步起就生效了后续所有地方都可以用s代替sales。但注意AS关键字在大多数数据库中可省略FROM sales s是完全合法的。2.2 第二步WHERE —— 粗粒度过滤的“安检门”WHERE是执行链上第一个真正的“过滤器”但它过滤的对象是FROM阶段产出的原始行集。这里的关键词是“原始”——它能看到的只有FROM中各表的原始列以及ON条件中定义的关联字段。你在这个阶段绝对无法使用SELECT中定义的别名、GROUP BY的分组结果、或者任何聚合函数如COUNT(*),SUM(amount)。因为这些都还没诞生。为什么WHERE不能用聚合函数举个例子SELECT department, AVG(salary) FROM employees WHERE AVG(salary) 5000 GROUP BY department。这条语句会直接报错。原因很简单WHERE执行时数据库连“每个部门有多少人”、“每个人的工资是多少”都还没开始算更别说算出平均值了。AVG(salary)这个值要等到GROUP BY分好组、SELECT开始计算时才真正产生。WHERE的另一个核心价值是性能优化。它是最早能大幅削减数据量的环节。比如一张千万级的订单表你想查“2023 年的订单”正确的做法是WHERE order_date 2023-01-01 AND order_date 2024-01-01。这个条件如果能命中order_date字段上的索引数据库可能只需要扫描几万行而不是全表扫描一千万行。但如果把这个条件错误地写成HAVING YEAR(order_date) 2023由于YEAR()是函数索引失效就必须全表扫描后再计算年份性能差距可能是百倍。注意WHERE中的AND/OR优先级是明确的AND优先级高于OR。所以WHERE status paid OR status shipped AND amount 1000等价于WHERE status paid OR (status shipped AND amount 1000)而不是(status paid OR status shipped) AND amount 1000。不确定时务必用括号显式声明逻辑这是避免线上事故的铁律。2.3 第三步GROUP BY —— 数据的“物理分堆”GROUP BY不是一个过滤操作而是一个数据重组织操作。它把WHERE过滤后的行集按照指定的一个或多个列或表达式的值划分成若干个“桶”。每个桶里包含所有该列值相同的行。这一步本身不产生新数据只是为后续的聚合计算准备好了物理结构。理解GROUP BY的关键是SELECT 列表中所有非聚合函数的字段必须出现在 GROUP BY 子句中。这是 SQL 标准的强制要求MySQL 5.7 严格模式下也是如此。比如SELECT department, COUNT(*), AVG(salary) FROM employees GROUP BY department是合法的因为department在GROUP BY中出现了。但SELECT department, name, AVG(salary) FROM employees GROUP BY department就会报错因为name字段没有被分组数据库无法确定“每个部门里该选哪个员工的 name 来代表这个组”。这里有个经典误区有人认为GROUP BY是按“唯一值”分组所以SELECT department, name FROM employees GROUP BY department应该返回每个部门的第一条记录。这是完全错误的。标准 SQL 下这句根本无法执行即使某些数据库如旧版 MySQL允许返回的name也是随机的、不可预测的。真正想取每个部门的某个特定员工如薪资最高的必须用窗口函数或子查询而不是依赖GROUP BY的“默认行为”。GROUP BY阶段还会处理GROUP BY中的表达式。比如GROUP BY YEAR(order_date), product_category数据库会先为每一行计算YEAR(order_date)的值再根据这个值和product_category一起分组。这意味着GROUP BY的计算发生在SELECT的聚合计算之前但它本身不计算聚合值。2.4 第四步HAVING —— 对分组结果的“二次筛选”如果说WHERE是对“单行”的筛选那么HAVING就是对“一组行”的筛选。它作用的对象是GROUP BY产生的各个分组。正因为如此HAVING是整个执行链中第一个可以合法使用聚合函数的地方。COUNT(*) 10、SUM(revenue) 100000、MAX(created_at) 2023-01-01这些条件只能写在HAVING里。HAVING的存在意义是解决WHERE无法完成的任务。继续用订单的例子你想找出“订单总数超过 100 的城市”WHERE无能为力因为你不知道每个城市的订单数是多少。但HAVING可以SELECT city, COUNT(*) as order_count FROM orders GROUP BY city HAVING COUNT(*) 100。这里GROUP BY city先把订单按城市分堆COUNT(*)计算每个堆的大小HAVING再把这些堆的大小拿来比较只留下大于 100 的堆。性能上HAVING是一个昂贵的操作。因为它必须等GROUP BY完成、所有聚合计算做完之后才能开始筛选。所以能前置到WHERE的条件绝不要拖到HAVING。比如上面的例子如果你只想看“2023 年的订单”一定要写成WHERE order_date 2023-01-01而不是HAVING YEAR(order_date) 2023。前者在分组前就筛掉了 90% 的数据后者却要先分组、再算年份、再筛选效率天壤之别。实操心得HAVING子句中的字段既可以是GROUP BY中的列如city也可以是聚合函数如COUNT(*)。但不能是未分组、未聚合的普通列比如HAVING name Alice在GROUP BY city的上下文中是非法的因为一个城市里可能有多个 Alice数据库不知道你指的是哪一个。2.5 第五步SELECT —— “结果画布”的最终绘制SELECT是大家最熟悉却最容易误解的一步。它看起来是“选择要显示的列”但实际上它是整个执行链中最晚进行字段计算和别名定义的环节。SELECT阶段会做三件事计算所有表达式包括聚合函数、CASE WHEN、算术运算、为结果列赋予别名、决定最终输出的列顺序。正因为SELECT发生在GROUP BY和HAVING之后它才能安全地使用聚合函数。SELECT department, AVG(salary) as avg_salary中的AVG(salary)就是在GROUP BY department分好组后对每个组内的salary字段求平均然后把结果赋给avg_salary这个别名。SELECT阶段定义的别名在此刻才正式生效。这意味着在SELECT本身内部你不能引用自己刚定义的别名。例如SELECT salary * 1.1 as new_salary, new_salary 100 as final_salary是非法的因为new_salary这个别名在SELECT执行过程中还不存在。你必须写成SELECT salary * 1.1 as new_salary, salary * 1.1 100 as final_salary或者用子查询/CTE 来复用。SELECT还是DISTINCT的执行点。SELECT DISTINCT会在这个阶段对最终结果集进行去重。注意DISTINCT是对整行去重不是对单个字段。SELECT DISTINCT department, city FROM employees会返回所有唯一的(department, city)组合而不是分别对department和city去重。提示SELECT中的CASE WHEN表达式其WHEN条件的判断是基于WHERE和GROUP BY之后的数据状态。比如CASE WHEN COUNT(*) 10 THEN Large ELSE Small END这个判断是在分组后针对每个组的COUNT(*)值进行的非常直观。2.6 第六步ORDER BY —— 结果集的“最终排序”ORDER BY是执行链的倒数第二步它对SELECT阶段产出的最终结果集进行排序。它的强大之处在于它可以引用SELECT中定义的列别名和位置序号。比如SELECT name, salary * 12 as annual_salary FROM employees ORDER BY annual_salary DESC是完全合法的因为annual_salary这个别名已经在SELECT阶段创建好了。ORDER BY还能使用SELECT中的表达式甚至窗口函数的结果。SELECT name, salary, RANK() OVER (ORDER BY salary DESC) as rank FROM employees ORDER BY rank这里ORDER BY rank引用的是窗口函数计算出的排名这在WHERE或HAVING中是绝对做不到的。但ORDER BY有一个重要限制它不能引用未在 SELECT 列表中出现的列除非该列出现在GROUP BY中。比如SELECT department, COUNT(*) FROM employees GROUP BY department ORDER BY hire_date就会报错因为hire_date没有出现在SELECT列表里也不在GROUP BY中数据库无法确定“每个部门的 hire_date”是什么。ORDER BY的性能影响巨大。如果排序字段没有索引数据库必须将所有结果行加载到内存或磁盘临时文件中进行排序。对于百万级结果集这会消耗大量内存和时间。因此ORDER BY的字段尤其是和LIMIT配合使用时如分页查询强烈建议建立索引。2.7 第七步LIMIT/TOP/OFFSET —— 结果的“最终裁剪”LIMITMySQL/PostgreSQL、TOPSQL Server、ROWNUMOracle或FETCH FIRST标准 SQL是执行链的终点。它对ORDER BY排序后的结果集进行截取只返回指定数量的行。LIMIT 10返回前 10 行LIMIT 10 OFFSET 20跳过前 20 行返回第 21 到 30 行。LIMIT的关键特性是它发生在所有其他操作之后。这意味着ORDER BY必须先完成数据库才能知道哪一行是“第一行”哪一行是“第十行”。所以SELECT * FROM orders LIMIT 10数据库依然要扫描、过滤、排序如果没ORDER BY则按存储顺序然后再取前 10 条。它不是“只查 10 条”而是“查完所有再挑 10 条”。这也是分页查询性能陷阱的根源。OFFSET 10000 LIMIT 10数据库必须先找到前 10000 行再取接下来的 10 行前面的 10000 行其实都被丢弃了。对于大数据量分页应该用“游标分页”Cursor-based Pagination即用上一页最后一条记录的主键值作为下一页的查询条件避免OFFSET的线性扫描。注意LIMIT/OFFSET的语法在不同数据库中差异很大。SQL Server 用TOP 10和OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLYOracle 12c 用OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLYMySQL 用LIMIT 10 OFFSET 20或LIMIT 20, 10。跨数据库迁移时这部分是高频修改点。3. 核心难点深度剖析那些让你抓狂的“为什么”背后真相3.1 为什么 WHERE 看不到 SELECT 的别名—— 执行时序的铁律这个问题几乎是 SQL 学习者的第一道坎。SELECT name AS full_name FROM users WHERE full_name LIKE %John%报错提示full_name未知。原因直白得令人发指WHERE执行时SELECT还没开始工作full_name这个别名根本就不存在于数据库的符号表中。我们可以用一个生活类比想象你在厨房做菜。FROM是把所有食材肉、菜、调料从冰箱里拿出来摆在操作台上。WHERE是在切菜前先检查一下“肉是不是新鲜的”、“菜是不是烂的”把不合格的食材挑出去。SELECT是最后装盘你把切好的肉和菜摆好再撒上芝麻给这道菜起个名字叫“黄金炒饭”。那么在“检查食材新鲜度”WHERE这个环节你当然不可能去检查“黄金炒饭”新不新鲜因为这道菜还没做出来呢。技术上SQL 解析器会为每个执行阶段维护一个独立的“作用域”Scope。FROM阶段的作用域里只有原始表的列名。WHERE阶段的作用域继承自FROM所以它能看到users.name、users.email等原始列。SELECT阶段的作用域则是在WHERE和GROUP BY之后新建的它包含了所有计算出的表达式和别名。这两个作用域是隔离的WHERE无法访问SELECT作用域里的东西。解决方案只有两个一是把逻辑下推到WHERE用原始列写如WHERE name LIKE %John%二是用子查询或 CTE把SELECT的结果变成一个“新表”再在外部查询中用WHERE过滤这个新表。例如SELECT full_name FROM ( SELECT name AS full_name FROM users ) t WHERE full_name LIKE %John%;这里内层查询的SELECT先执行生成一个临时结果集t外层查询的WHERE就能合法地引用t.full_name了。3.2 为什么 HAVING 能用聚合函数WHERE 却不能—— 数据粒度的根本差异WHERE和HAVING的区别本质是“行粒度”和“组粒度”的战争。WHERE面对的是一张张孤立的“单兵”它只能问“这个士兵的军衔是不是上校”、“他的入伍年份是不是 2020”——这些问题的答案对每个士兵都是独立的。HAVING面对的则是一个个“连队”它的问题是“这个连队的平均军衔是不是上校”、“这个连队的总服役年限是不是超过 100 年”——这些问题的答案必须等整个连队集结完毕GROUP BY完成然后对连队内部所有士兵的数据进行汇总计算聚合函数才能得出。技术实现上数据库引擎在WHERE阶段只会为每一行单独评估布尔表达式不涉及任何跨行计算。而到了HAVING阶段引擎已经完成了分组每个分组都有一个对应的“聚合上下文”里面存着COUNT(*)、SUM()等函数的中间结果。HAVING的条件就是在这个上下文中进行评估的。一个典型的反模式是SELECT department, AVG(salary) FROM employees WHERE AVG(salary) 5000 GROUP BY department。这句试图在WHERE里用AVG但WHERE连“哪个部门”都不知道怎么可能算出平均值正确的逻辑是先GROUP BY department确定连队再HAVING AVG(salary) 5000评估每个连队。3.3 为什么 ORDER BY 能用别名但 LIMIT 不能—— 最终结果集的边界ORDER BY和LIMIT都作用于最终结果集但它们的“视野”不同。ORDER BY是在SELECT之后、LIMIT之前它看到的是SELECT完全渲染好的结果集包括所有别名和计算列。所以ORDER BY引用别名是顺理成章的。LIMIT则不同。它虽然也在最后但它是一个纯粹的“截断”操作不参与任何计算或引用。它只认“行号”。LIMIT 10的意思是“给我结果集的前 10 行”它不关心这 10 行的name是什么salary是多少它只数数。因此LIMIT语法本身不支持引用列名或别名。但这并不意味着你不能“按条件取前 N 条”。你可以把ORDER BY和LIMIT组合起来达到相同效果SELECT * FROM products ORDER BY price DESC LIMIT 10先按价格降序排好再取前 10 条。ORDER BY负责“排序逻辑”LIMIT负责“取数动作”分工明确。3.4 多层嵌套子查询的执行顺序一层一层剥洋葱复杂的 SQL 往往包含多层子查询。执行顺序遵循一个简单原则从最内层开始由内向外执行。可以把子查询想象成一个函数调用外层查询需要参数就先去执行内层查询拿到结果再把它当作一个“临时表”或“标量值”喂给外层。例如SELECT name, (SELECT COUNT(*) FROM orders WHERE orders.user_id users.id) as order_count FROM users WHERE id IN (SELECT id FROM vip_users WHERE level 5);执行步骤是最内层SELECT id FROM vip_users WHERE level 5。先执行这个得到一个 VIP 用户 ID 列表。中间层WHERE id IN (...)。用上一步的结果过滤users表得到目标用户列表。最外层对每一个筛选出的用户执行相关子查询SELECT COUNT(*) FROM orders WHERE orders.user_id users.id计算其订单数最后拼成最终结果。注意相关子查询Correlated Subquery如上面的orders.user_id users.id是“为外层每一行执行一次”性能开销很大。而非相关子查询Uncorrelated Subquery如SELECT id FROM vip_users ...只执行一次结果被缓存复用。优化时应尽量将相关子查询改写为JOIN例如SELECT u.name, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id o.user_id WHERE u.id IN (SELECT id FROM vip_users WHERE level 5) GROUP BY u.id, u.name;这样数据库可以一次性完成关联和聚合效率远高于循环执行子查询。4. 实战场景还原从慢查询日志到执行计划的完整诊断链4.1 场景一报表查询突然变慢DBA 找上门现象一个日常运行 2 秒的销售报表今天跑了 3 分钟EXPLAIN显示type: ALL全表扫描rows: 5000000。诊断过程看执行计划EXPLAIN SELECT region, SUM(amount) FROM sales WHERE created_at DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY region;type: ALL表明sales表没有用到索引。key: NULL确认了这一点。查 WHERE 条件created_at ...这是一个范围查询理论上应该能用created_at索引。深挖表结构发现created_at字段类型是VARCHAR开发为了兼容历史数据把日期存成了字符串2023-10-01 12:34:56。WHERE条件created_at 2023-09-01变成了字符串比较索引失效。修复方案短期WHERE DATE(created_at) 2023-09-01但函数导致索引失效仍慢。长期新增created_dateDATE类型字段建立索引并用WHERE created_date 2023-09-01。根因WHERE阶段的条件类型与索引字段类型不匹配违反了索引最左前缀原则导致执行引擎放弃索引退化为全表扫描。这再次印证WHERE是性能的生命线。4.2 场景二GROUP BY 报错 “column not in GROUP BY”但逻辑明明正确现象SELECT user_id, MAX(created_at) as last_login, COUNT(*) as login_count FROM logins GROUP BY user_id;在 MySQL 5.7 严格模式下报错提示last_login不在GROUP BY。诊断过程确认 SQL 模式SELECT sql_mode;返回STRICT_TRANS_TABLES,ONLY_FULL_GROUP_BYONLY_FULL_GROUP_BY是罪魁祸首。理解标准该模式强制要求SELECT列表中的所有非聚合列必须在GROUP BY中出现。MAX(created_at)是聚合函数没问题user_id在GROUP BY中也没问题。但last_login是别名不是原始列。修复方案推荐SELECT user_id, MAX(created_at) as last_login, COUNT(*) as login_count FROM logins GROUP BY user_id;—— 直接去掉别名用原始表达式。或SELECT user_id, MAX(created_at) as last_login, COUNT(*) as login_count FROM logins GROUP BY user_id, MAX(created_at);—— 但MAX(created_at)不能放在GROUP BY语法错误。终极关闭ONLY_FULL_GROUP_BY不推荐掩盖问题。根因GROUP BY的语义是“按这些列的值分组”SELECT中的非聚合列必须是分组依据的列否则结果不唯一。别名只是显示用的不改变底层逻辑。4.3 场景三分页查询 OFFSET 越大越慢老板催着上线现象SELECT * FROM products ORDER BY id DESC LIMIT 20 OFFSET 100000查第 10001 页耗时 8 秒。诊断过程EXPLAIN分析key: PRIMARY,rows: 100020表明数据库要扫描前 100020 行只为取后面的 20 行。瓶颈定位OFFSET的线性扫描是硬伤无法通过索引优化。重构方案——游标分页-- 第一页 SELECT id, name, price FROM products ORDER BY id DESC LIMIT 20; -- 假设最后一条的 id 是 500000 -- 第二页用上一页最后的 id 作为游标 SELECT id, name, price FROM products WHERE id 500000 ORDER BY id DESC LIMIT 20;这样每次查询都利用id索引进行范围扫描rows从 100020 降到 20速度从秒级变为毫秒级。根因LIMIT/OFFSET的执行逻辑决定了它必须“走过”前面所有的行。而WHEREORDER BYLIMIT的组合可以利用索引的有序性直接定位到起点。5. 常见问题速查表与独家避坑指南问题现象错误写法正确写法根本原因我的实操心得Unknown column alias_name in where clauseSELECT name AS n FROM t WHERE n JohnSELECT name AS n FROM t WHERE name John或用子查询WHERE在SELECT之前执行别名尚未创建别名只在SELECT及之后生效。写WHERE时脑子里要切换回“原始列名”模式。Invalid use of group functionSELECT name FROM t WHERE COUNT(*) 1SELECT name FROM t GROUP BY name HAVING COUNT(*) 1WHERE作用于单行无法使用聚合函数脑中默念口诀“单行用WHERE分组用HAVING”。看到COUNT、SUM立刻想到GROUP BYHAVING。Expression not in GROUP BY or aggregateSELECT dept, name, AVG(salary) FROM emp GROUP BY deptSELECT dept, AVG(salary) FROM emp GROUP BY dept或SELECT dept, MAX(name), AVG(salary) FROM emp GROUP BY deptSELECT中的非聚合列name未在GROUP BY中结果不唯一GROUP BY后SELECT列表要么是GROUP BY的列要么是聚合函数。MAX(name)是合法的它表示“该组中名字最大的那个”而非随机一个。ORDER BY clause is not in SELECT listSELECT dept FROM emp GROUP BY dept ORDER BY salarySELECT dept, AVG(salary) as avg_sal FROM emp GROUP BY dept ORDER BY avg_salORDER BY不能引用未出现在SELECT列表中的原始列除非在GROUP BY中ORDER BY的字段必须是SELECT输出的列或是GROUP BY的列。养成习惯写完SELECT立刻检查ORDER BY是否与之匹配。LIMIT与ORDER BY一起用结果不稳定SELECT * FROM t LIMIT 10无ORDER BYSELECT * FROM t ORDER BY id LIMIT 10没有ORDER BY数据库返回顺序是任意的LIMIT取的“前 10 条”每次可能不同铁律只要用了LIMIT前面必须有ORDER BY。没有排序的分页就是定时炸弹。独家避坑技巧“执行顺序草稿纸”法写复杂 SQL 前先在纸上按FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT的顺序挨个写下每一步你期望看到的数据形态。比如WHERE后数据应该只剩 1000 行GROUP BY后应该变成 50 个组SELECT后应该有 3 列……这样能提前发现逻辑断层。EXPLAIN必查三要素每次上线新 SQL必看EXPLAIN的type是否用索引、key用了哪个索引、rows扫描行数。rows超过表总行数的 10%就要警惕。SELECT *是毒药在GROUP BY查询中SELECT *几乎必然报错。在JOIN查询中它会导致列名冲突如两个表都有id。永远明确写出你需要的列。NULL是隐形杀手WHERE col value会自动过滤掉col IS NULL的行WHERE col ! value也会过滤NULL行因为NULL ! value的结果是UNKNOWN不是TRUE。需要NULL必须显式写WHERE col IS NULL或WHERE col IS NOT NULL。6. 进阶思考执行顺序如何影响窗口函数与 CTE6.1 窗口函数的“时空定位”它在哪一步执行窗口函数ROW_NUMBER(),RANK(),SUM() OVER (...)是一个特例。它在SELECT阶段执行但它所依赖的“窗口框架”其定义PARTITION BY,ORDER BY是在SELECT内部完成的。这意味着窗口函数能看到WHERE过滤后的行也能看到GROUP BY分组后的行如果GROUP BY存在但它的计算是基于SELECT当前作用域的。例如