尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

深入理解SQL执行顺序:从声明式语言到查询优化实践

深入理解SQL执行顺序:从声明式语言到查询优化实践 1. 从“写”到“跑”为什么SQL执行顺序是理解数据库的钥匙如果你写过SQL尤其是稍微复杂一点的查询肯定有过这样的经历你写的查询语句从逻辑上看完全正确但执行结果却和预期大相径庭或者性能慢得让人怀疑人生。然后你开始疯狂地调整WHERE条件、JOIN顺序甚至重写整个查询试图让它“听话”。很多时候问题的根源不在于你的逻辑错了而在于你没有理解数据库引擎是如何“读”你的SQL的。SQLStructured Query Language是一种声明式语言。这意味着你告诉数据库“你想要什么”而不是“如何一步步去获取”。这就像你对厨师说“我要一份七分熟的菲力牛排配黑胡椒汁”而不是“先打开冰箱拿出牛排用厨房纸吸干血水然后热锅下油...”。声明式的优势是简洁、易读但这也带来了一个核心的认知偏差我们写的SQL语句顺序SELECT...FROM...WHERE...并不是数据库执行它的顺序。这个执行顺序就是数据库查询优化器Query Optimizer在幕后制定的“烹饪步骤”。理解这个顺序是你从“会写SQL”到“懂SQL”的关键一跃。它能帮你精准排错当查询结果不对时你能快速定位是哪个环节如ON条件、WHERE过滤、GROUP BY分组的理解出现了偏差。高效优化知道性能瓶颈可能出现在哪个阶段如大量的JOIN后再过滤还是先过滤再JOIN从而有针对性地改写SQL或调整索引。避免陷阱很多SQL的“坑”比如在WHERE中引用SELECT的别名、HAVING和WHERE的混淆都源于对执行顺序的误解。网上常见的“SQL执行顺序图”给出了一个标准流程但仅仅记住那个顺序是远远不够的。我们需要深入每个环节理解数据库在那一刻“看到”了什么数据能做什么操作不能做什么操作。接下来我们就抛开死记硬背从数据库引擎的视角完整拆解一条SELECT查询的“生命旅程”。2. 标准流程拆解一条SELECT语句的八步旅程我们先给出最经典的SQL SELECT语句执行顺序。请注意这是逻辑上的执行顺序而非物理执行计划。优化器可能会为了性能而调整某些步骤例如将WHERE条件下推但最终结果必须与遵循此逻辑顺序的执行结果一致。逻辑执行顺序如下FROMJOIN: 确定数据来源并连接多张表。WHERE: 对连接后的原始数据行进行过滤。GROUP BY: 将过滤后的数据行进行分组。HAVING: 对分组后的结果集进行过滤。SELECT: 计算选择列表中的表达式生成最终结果集的列。DISTINCT: 去除SELECT结果中的重复行。ORDER BY: 对最终结果集进行排序。LIMIT/OFFSET(或 TOP / FETCH): 从排序后的结果中取出指定行。这个顺序是理解一切的基础。一个常见的记忆口诀是“夫FROM姐JOIN为WHERE哥GROUP BY害HAVING喜SELECT弟DISTINCT偶ORDER BY累LIMIT”。虽然有点土但确实好用。下面我们结合具体场景一步步深入每个环节。2.1 第一步FROM与JOIN——搭建数据的舞台这是所有查询的起点。数据库引擎首先会定位并读取FROM子句中指定的表。如果只是一个简单的单表查询那么这一步就是加载整张表或通过索引快速定位部分数据到内存中形成一个临时的“中间结果集”。当涉及多表关联时JOIN就在这一步发生。这里有一个至关重要的细节ON子句的条件是在JOIN发生时进行判断的它是JOIN过程的一部分。例如SELECT * FROM orders o LEFT JOIN customers c ON o.customer_id c.id AND c.country US;在这个例子中数据库会先读取orders表的所有行然后尝试去customers表中寻找匹配的行。匹配的条件有两个o.customer_id c.id和c.country US。这意味着只有在customers表中country为US的行才会被关联上来。如果一个美国客户countryUS下了订单他会正常关联如果一个非美国客户下了订单由于不满足c.country US这次LEFT JOIN将无法从customers表找到匹配行但因为是LEFT JOINorders表的行仍然会保留对应的customers表字段全部为NULL。注意很多人会把AND c.country US写在WHERE子句这在INNER JOIN时结果等价但在OUTER JOINLEFT/RIGHT JOIN时会导致完全不同的结果。写在ON里是过滤被关联表的匹配资格写在WHERE里是过滤整个连接后的结果集这会导致那些因不满足WHERE条件而被过滤掉的、本应保留的主表行在LEFT JOIN中也被丢弃从而将OUTER JOIN退化为INNER JOIN的效果。这是执行顺序理解不到位导致的最常见错误之一。2.2 第二步WHERE——首轮数据筛选在FROM和JOIN构建了初始数据集包含所有可能组合的行之后WHERE子句开始工作。它基于指定的条件逐行过滤这个数据集。关键点WHERE作用于每一行数据它看不到分组也看不到聚合函数的结果。因此你不能在WHERE子句中直接使用SELECT列表中定义的列别名也不能使用聚合函数如SUM、AVG。因为此时SELECT阶段还没开始执行别名和聚合结果都还不存在。WHERE的过滤发生得非常早优秀的查询优化器会尽可能利用索引来加速WHERE过滤减少需要传递给后续步骤的数据量这被称为“谓词下推”。例如如果你在users表的age字段上有索引查询WHERE age 18可能会直接通过索引扫描来减少需要读取的数据行。错误示例-- 错误WHERE中不能使用SELECT的别名 SELECT order_id, unit_price * quantity AS total_amount FROM order_details WHERE total_amount 1000; -- 执行报错列total_amount不存在 -- 正确写法 SELECT order_id, unit_price * quantity AS total_amount FROM order_details WHERE unit_price * quantity 1000; -- 错误WHERE中不能直接使用聚合函数 SELECT customer_id, SUM(amount) FROM payments WHERE SUM(amount) 1000 -- 执行报错 GROUP BY customer_id; -- 正确写法使用HAVING SELECT customer_id, SUM(amount) FROM payments GROUP BY customer_id HAVING SUM(amount) 1000;2.3 第三步与第四步GROUP BY与HAVING——分组与组级过滤经过WHERE过滤后的数据如果查询中包含GROUP BY子句那么就会进入分组阶段。数据库会按照GROUP BY后面指定的列或表达式的值将行分成不同的组。每个组最终会聚合成结果集中的一行。GROUP BY的核心在分组之后SELECT列表中只能出现两种类型的列出现在GROUP BY子句中的列。使用聚合函数如SUM, COUNT, AVG, MAX, MIN包裹的列。因为非聚合列在同一个分组内可能有多个不同的值数据库无法确定该输出哪一个。HAVING子句是专门为GROUP BY设计的过滤器。它在分组和聚合计算之后执行。因此HAVING的条件可以引用聚合函数的结果也可以引用GROUP BY的列。WHERE vs HAVING 的本质区别WHERE在分组前过滤行。它决定哪些原始行有资格进入分组阶段。它不能使用聚合函数。HAVING在分组后过滤组。它决定哪些分组有资格进入最终结果集。它可以使用聚合函数和分组列。一个综合示例统计每个部门薪资超过10000元的员工的平均薪资且只显示平均薪资高于15000元的部门。SELECT department_id, AVG(salary) AS avg_salary FROM employees WHERE salary 10000 -- 第一步先过滤掉薪资10000的员工行 GROUP BY department_id -- 第二步按部门分组并计算每个组的平均薪资 HAVING AVG(salary) 15000; -- 第三步过滤掉平均薪资15000的部门组这个查询清晰地展示了执行顺序先WHERE行过滤再GROUP BY分组聚合最后HAVING组过滤。2.4 第五步与第六步SELECT与DISTINCT——塑造最终列与去重到了SELECT这一步数据库才开始计算你在SELECT列表中指定的表达式并为它们分配你定义的别名。这也是为什么之前WHERE和GROUP BY不能引用这些别名的原因——它们执行时这些别名对应的值还没被计算出来。SELECT阶段会生成一个包含所有指定列的中间结果集。如果使用了*则包含FROM/JOIN后所有可用的列。紧接着如果查询中包含了DISTINCT关键字数据库会在此刻移除结果集中所有重复的行。去重是一个成本相对较高的操作因为它通常需要对整个结果集进行排序或哈希计算来比较重复项。这也是为什么在可能的情况下应该先通过WHERE条件尽可能减少数据量再进行DISTINCT操作。实操心得谨慎使用SELECT DISTINCT。很多时候数据出现重复是因为JOIN条件不准确或表关系设计导致的多对多关联。盲目使用DISTINCT来掩盖数据重复问题会带来巨大的性能开销并且可能掩盖了更深层次的数据逻辑错误。正确的做法是检查JOIN条件和数据模型。2.5 第七步与第八步ORDER BY与LIMIT——最后的整理与交付ORDER BY是所有筛选、分组、计算都完成之后对最终结果集进行的排序操作。因为它处理的是最终数据所以它可以自由地使用SELECT列表中定义的列别名。一个重要的性能陷阱ORDER BY通常需要将整个结果集加载到内存中进行排序如果结果集很大这会消耗大量内存和CPU资源并可能使用临时磁盘空间导致性能急剧下降。为ORDER BY的列建立索引可以让数据库直接按索引顺序读取数据避免昂贵的排序操作。最后LIMIT或MySQL的LIMIT/OFFSET PostgreSQL的LIMIT/OFFSET SQL Server的TOP/FETCH粉墨登场。它从排序后的结果集中截取指定的行数。请注意LIMIT的执行顺序非常靠后这意味着即使你只想要前10行数据库也可能需要先处理、排序成千上万行数据然后再丢弃它们。这就是为什么LIMIT 1并不总是能带来性能提升的原因——如果前面有昂贵的JOIN和没有索引的ORDER BY性能依然会很差。3. 高级场景与常见误区执行顺序带来的“坑”理解了基本顺序我们来看几个高级场景和容易踩坑的地方。3.1 子查询的执行顺序它并非“从内到外”那么简单子查询分为关联子查询和非关联子查询它们的执行逻辑大不相同。非关联子查询子查询可以独立执行不依赖于外层查询。例如SELECT * FROM products WHERE category_id IN (SELECT id FROM categories WHERE name Electronics);数据库通常会先执行内层查询(SELECT id FROM categories WHERE name Electronics)得到一个结果集比如电子类目的ID列表然后将这个结果集作为常量应用到外层查询的WHERE条件中。这种执行顺序比较直观。关联子查询子查询引用了外层查询的列。例如查找比本部门平均工资高的员工SELECT e1.name, e1.salary, e1.department_id FROM employees e1 WHERE salary ( SELECT AVG(salary) FROM employees e2 WHERE e2.department_id e1.department_id -- 关联条件 );对于关联子查询数据库往往采用一种“嵌套循环”的方式执行对外层查询的每一行都执行一次内层子查询。以上面的例子来说数据库会从employees e1中取出一行。将这一行的department_id值代入内层子查询计算该部门的平均工资。比较这一行的salary与计算出的平均工资。重复1-3步直到处理完e1的所有行。显然如果外层表有N行内层子查询就要执行N次性能代价非常高。在这种情况下将其重写为使用JOIN和GROUP BY的查询通常是更好的优化手段。3.2 窗口函数的执行时机在SELECT之后ORDER BY之前窗口函数如ROW_NUMBER(),RANK(),SUM(...) OVER (...)是SQL中强大的工具。它们的执行顺序有一个特殊之处它们是在SELECT阶段被计算的但位于ORDER BY之前且在DISTINCT之后如果存在的话。更精确地说标准SQL的逻辑处理顺序中窗口函数是在SELECT列表中的其他普通表达式计算完毕之后但在最终应用DISTINCT之前进行的。这意味着窗口函数可以“看到”经过WHERE、GROUP BY、HAVING过滤和分组后的所有行并基于OVER子句定义的窗口进行计算。一个典型误区很多人试图在WHERE中过滤窗口函数的结果这是行不通的。-- 错误不能在WHERE中引用窗口函数别名 SELECT *, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employees WHERE rn 1; -- 错误 -- 正确做法使用派生表子查询或CTE SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employees ) AS ranked_emp WHERE rn 1;原因正在于执行顺序WHERE执行时SELECT包括窗口函数的计算还没开始所以rn这个列根本不存在。3.3 CTE公用表表达式与临时视图逻辑上的“预先定义”CTEWITH ... AS ...让查询结构更清晰。从逻辑执行顺序上看你可以认为CTE的定义部分优先于主查询执行。数据库会先计算CTE的结果集并将其物化或展开成一个临时视图供主查询引用。但这只是逻辑上的。实际上现代数据库优化器非常智能它可能会将CTE的定义与主查询合并进行整体优化而不是真的先执行CTE。CTE的一个重要特性——递归递归CTE有明确的执行顺序。它从“锚定成员”开始执行将其结果放入临时结果集然后用这个临时结果集作为输入反复执行“递归成员”将新结果不断添加到临时结果集中直到递归成员返回空集为止。这是一个有严格顺序的迭代过程。4. 从理解到优化利用执行顺序提升查询性能知道了执行顺序我们就可以有策略地优化SQL。4.1 核心原则减少早期阶段的数据量查询性能的黄金法则是尽可能早地过滤掉不需要的数据。数据越早被减少后续的JOIN、GROUP BY、排序等昂贵操作的压力就越小。WHERE条件要高效在WHERE子句中使用高选择性的条件能过滤掉大部分数据的条件并确保这些条件涉及的列上有合适的索引。避免在WHERE中对列进行函数操作如WHERE YEAR(create_time) 2023这会导致索引失效。应改为范围查询WHERE create_time 2023-01-01 AND create_time 2024-01-01。JOIN条件与WHERE下推优化器会尝试将WHERE条件“下推”到JOIN之前甚至下推到基表扫描时。但如果你写了复杂的表达式或使用了某些函数可能会阻碍优化器的下推行为。保持JOIN和WHERE条件的简洁和索引友好性。**慎用SELECT ***明确列出需要的列而不是使用SELECT *。这可以减少从磁盘读取的数据量特别是在有TEXT/BLOB等大字段时效果显著。网络传输的数据量也会减少。4.2 理解执行计划眼见为实理论再好也需要实践验证。EXPLAIN命令在MySQL/PostgreSQL中或执行计划图形在SQL Server Management Studio中是你的终极武器。它展示了优化器最终决定的物理执行计划。看执行计划时关注以下几点访问类型是全表扫描TABLE SCAN/SEQ SCAN还是索引查找INDEX SEEK我们追求后者。连接算法是Nested Loops Join Hash Join还是Merge Join对于大数据集Hash Join通常更高效对于有索引的小表驱动大表Nested Loops可能更好。操作成本关注估计的行数Estimate Rows和成本Cost。如果估计行数和实际行数相差巨大说明统计信息可能过时需要更新。昂贵操作查找是否有“Sort”排序、“Hash Aggregate”哈希聚合、“WindowAgg”窗口函数计算等成本较高的操作并思考能否通过索引或改写查询来避免它们。例如当你看到一个查询先对一个大表做了全表扫描然后才用WHERE条件过滤最后再做JOIN这通常意味着你需要为WHERE条件中的列建立索引或者调整查询写法。4.3 改写查询的实战技巧基于执行顺序我们可以主动改写查询来引导优化器。将HAVING条件移至WHERE如果过滤条件不依赖于聚合函数一定要放在WHERE里而不是HAVING里。WHERE在分组前过滤效率更高。-- 欠佳 SELECT department_id, COUNT(*) FROM employees GROUP BY department_id HAVING department_id IN (10, 20); -- 更优 SELECT department_id, COUNT(*) FROM employees WHERE department_id IN (10, 20) -- 先过滤减少分组的数据量 GROUP BY department_id;用JOINGROUP BY替代关联子查询如前所述关联子查询性能往往较差。将其改写为JOIN形式通常能利用更高效的连接算法。利用索引覆盖ORDER BY和WHERE设计一个复合索引使其列顺序能够同时满足WHERE的过滤条件和ORDER BY的排序需求。例如查询WHERE status active ORDER BY created_at DESC可以建立索引(status, created_at DESC)。这样数据库可以直接通过索引定位到statusactive的数据并且这些数据在索引中已经是按created_at降序排列的避免了额外的排序操作。理解SQL的执行顺序就像是拿到了数据库引擎的“施工图纸”。它不能替代对索引、统计信息、硬件资源等具体知识的掌握但它提供了一个正确且强大的心智模型。当你再面对一个运行缓慢或结果诡异的查询时不要急于盲目尝试而是静下心来按照FROM→WHERE→GROUP BY→...的逻辑顺序一步步推导数据库是如何处理你的SQL的。结合EXPLAIN执行计划进行分析你就能精准地定位问题所在并给出有效的优化方案。从“会写”到“懂为什么这么写”这一步跨越能让你在数据处理工作中更加游刃有余。
返回列表