2026/10/19 10:43:27

SQL执行顺序与索引优化:从WHERE和HAVING的区别到慢查询排查

SQL执行顺序与索引优化:从WHERE和HAVING的区别到慢查询排查 那次面试复盘我记了整整三页笔记。面试官从一条看似基础的“WHERE和HAVING有什么区别”切入我按教科书答完“一个过滤行、一个过滤分组、HAVING能用聚合函数”之后他点了点头接着抛出一串连环追问那这条语句为什么报错这条为什么慢你explain过没有索引在哪个环节起的作用我这才意识到这道“送分题”背后连着的不是一句口诀而是SQL执行顺序、聚合语义、B树索引原理一整条知识链。事后我把整场问答重新过了一遍把WHERE、HAVING和索引的关系彻底梳理干净这篇文章就是我的复盘记录。如果你也在准备数据库相关的面试或者平时写SQL只停留在“能跑就行”这篇内容应该能帮你少走一段弯路。1. 面试现场回顾一个“送分题”引发的连锁追问1.1 问题最早是从一条报错SQL开始的面试官先在共享文档里给我贴了一段SQLSELECT department_id, COUNT(*) AS cnt FROM employees WHERE COUNT(*) 5 GROUP BY department_id;他问我“这条SQL能不能执行如果能结果是什么如果不能报错信息是什么为什么”我第一反应是愣住因为我在日常开发里确实很少会把聚合函数写进WHERE但真要我说清楚“为什么不行”我当时的语言组织非常混乱。事实上这条SQL在MySQL中直接报错Invalid use of group function。问题在于WHERE子句的执行时机在GROUP BY分组之前而COUNT()这类聚合函数只有在分组之后才有完整的意义。MySQL在解析阶段就会发现WHERE中使用了聚合函数但此时分组根本还没发生自然无法判定“COUNT() 5”这个条件该对哪一行生效于是直接拒绝执行。1.2 面试官趁势追问的三连击看到我答出了“执行顺序”这个关键词面试官紧接着问了三个问题既然WHERE不能过滤分组那HAVING为什么可以把WHERE换成HAVING后性能一定更好吗一条SQL里同时出现WHERE和HAVING各自的职责边界在哪里这三个问题才是真正让我冒冷汗的地方。第一个问题我能答上来HAVING在GROUP BY之后执行这时候每组已经形成COUNT(*)等聚合函数的计算结果已经存在用它做过滤当然没问题。但第二个问题我开始含糊了因为很多人包括当时的我会有一种潜意识的误解既然HAVING能做聚合过滤那我干脆把所有条件都扔进HAVING好了反正结果一样。事实并非如此。第三个问题更是需要结合一个具体业务场景才能讲清楚而不只是背诵定义。1.3 为什么这道题值得专门复盘后来我想明白一个道理WHERE和HAVING的区别之所以被高频面试是因为它精准地考察了一个开发者的SQL执行心智模型是否建立。你背下来的“WHERE过滤行、HAVING过滤组”只是表层结论真正的分水岭是你是否理解数据从FROM到SELECT的流动顺序是否清楚每一步会发生什么、哪些操作依赖前一步的结果。而索引之所以被牵扯进来是因为面试官下一句往往是“既然WHERE能提前过滤那HAVING能不能用上索引为什么”这就把两件事串成了一条线。复盘的时候我把这条线完整地拉了出来后面几节就是我梳理后的完整思路。2. 从执行顺序理解WHERE与HAVING的本质差异2.1 数据流动的顺序决定了过滤的边界要理解WHERE和HAVING的区别最可靠的方式不是背口诀而是画出SQL逻辑执行顺序。一条典型的聚合查询在关系型数据库里大致按下面这个顺序处理FROM - WHERE - GROUP BY - HAVING - SELECT - ORDER BY - LIMITFROM先确定数据源WHERE立即对每一行做条件判断不合格的行直接丢弃这个阶段还没有任何“组”的概念随后GROUP BY把剩余的行划分成若干分组每个分组对应一行聚合结果HAVING在这个聚合结果集上继续过滤条件里可以使用COUNT、SUM、AVG等聚合函数的计算值最后SELECT才投影出你需要的列再排序、分页。正因为WHERE在分组前执行它能接触到的只有原始行记录比如salary 10000、status active、create_time 2024-06-01这类“行级条件”。而HAVING在分组后执行它能接触的已经是一个“组”及其聚合指标所以COUNT(*) 5、SUM(amount) 10000、AVG(score) 90这类条件只能放在HAVING里。这个边界不是MySQL一拍脑袋想出来的规则而是数据处理流水线本身的自然结果。2.2 聚合函数的使用边界把COUNT(*)写进WHERE之所以报错核心原因可以用一句话概括聚合函数的计算依赖分组而分组在WHERE之后才发生。反过来聚合函数在HAVING中理所当然可用因为HAVING拿到的输入就是“分组后的聚合结果”。有一个容易被忽略的细节是HAVING条件里也不一定非要出现聚合函数。你完全可以在一个已经GROUP BY的查询中写HAVING department_id 10MySQL其实是允许的。但这样做通常是个坏味道因为department_id 10明明可以在WHERE阶段就把行过滤掉根本不需要参与分组。换句话说HAVING不是“带聚合函数专用”而是“必须处理分组后的信息”时才需要它。如果条件里既没有聚合函数、也没有依赖分组结果那它90%的情况应该挪到WHERE里。2.3 别名引用的隐藏差异这是一个很多人栽过的坑SELECT子句里定义的别名能不能在WHERE或HAVING中直接引用标准SQL的规定是WHERE在SELECT之前执行所以WHERE中不能引用SELECT别名HAVING在SELECT之后严谨地说在MySQL的逻辑层SELECT位于HAVING之后但MySQL对HAVING提供了扩展支持允许HAVING引用SELECT中的别名。举个例子SELECT department_id, AVG(salary) AS avg_salary FROM employees WHERE avg_salary 10000 GROUP BY department_id;这条在MySQL中会报错Unknown column avg_salary in where clause。因为WHERE阶段连SELECT都没执行别名自然不存在。但换成HAVINGSELECT department_id, AVG(salary) AS avg_salary FROM employees GROUP BY department_id HAVING avg_salary 10000;这条在MySQL中可以正常执行因为MySQL对HAVING做了别名解析的扩展。这个差异本质上还是执行顺序的又一次体现但它很容易被当作“MySQL语法特性”背下来而不是当作顺序问题理解。面试官如果追问一句“标准SQL允许吗”你能答出“标准SQL里HAVING也不能引用别名这是MySQL的扩展”印象分会完全不一样。2.4 性能层面的第一性思考WHERE和HAVING在性能上的差异不止是“HAVING慢”这么简单而是“HAVING被迫处理了更多数据”。WHERE提前过滤掉大量行进入GROUP BY的数据量小分组计算的开销随之降低HAVING则必须等所有符合FROM条件的数据完成分组后才能在聚合结果上一层层过滤。如果一张表有100万行WHERE把条件过滤后只剩1万行参与分组和100万行全部参与分组运行时间可能相差一个数量级。我用一个模拟场景验证过。某表200万行数据SELECT status, COUNT(*) FROM orders GROUP BY status HAVING status PAID跑出来的耗时是HAVING status PAID过滤时机太晚所有行都参与了分组而同一条语句写成SELECT status, COUNT(*) FROM orders WHERE status PAID GROUP BY status因为WHERE提前过滤掉绝大多数行耗时明显下降。这个对比就是面试官想听到的“性能意识”的来源。为了便于记忆我把WHERE与HAVING的核心差异整理成一个对照表维度WHEREHAVING执行阶段GROUP BY 之前GROUP BY 之后过滤对象原始行记录分组后的聚合结果是否允许聚合函数不允许允许是否允许SELECT别名标准SQL不允许MySQL不允许标准SQL不允许MySQL扩展允许数据量影响过滤越早进入分组的行越少必须等全量分组完成典型场景行级条件如 status、time、金额组级条件如 COUNT、SUM、AVG 的结果3. 实际SQL中三类“翻车现场”的完整分析3.1 翻车场景一把聚合条件硬塞进WHERE这种情况初学者经常遇到。业务需求是“找出订单数超过100的客户”妄图一条WHERE搞定-- 错误写法 SELECT customer_id FROM orders WHERE COUNT(*) 100 GROUP BY customer_id;MySQL报错原因前面已经说过。正确写法是把条件放到HAVING-- 正确写法 SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(*) 100;这里的含义完全不同HAVING是对每个customer_id分组后的行数进行判断筛选出大于100的组。这个改动表面上只是把关键字换了一下但背后对应着“先分组、再按组过滤”的完整逻辑。3.2 翻车场景二非聚合条件误放HAVING另一种相反的错误是“把所有条件都扔给HAVING”。看这段-- 低效写法 SELECT customer_id, COUNT(*) AS cnt FROM orders GROUP BY customer_id HAVING customer_id 1000 AND create_time 2024-01-01;这条能跑但create_time 2024-01-01是行级条件根本不需要等分组之后才知道。它被放在HAVING里意味着所有历史订单可能几百万行都进入了GROUP BY分组阶段整个性能被白白拖垮。改写后-- 高效写法 SELECT customer_id, COUNT(*) AS cnt FROM orders WHERE create_time 2024-01-01 AND customer_id 1000 GROUP BY customer_id;WHERE阶段就把日期条件过滤掉后续参与分组的行数大幅减少。如果这时create_time上有索引还能直接减少数据扫描范围此优化在HAVING写法里完全享受不到。这条准则是写SQL时的第一直觉能用WHERE过滤的绝不留到HAVING。3.3 翻车场景三同时出现WHERE和HAVING时的职责混用还有一种情况是二者同时出现这时候尤其考验你能不能把职责拆清楚。看这个业务“统计2024年每个区域的成交订单量筛选出成交单量超过500的区域”。SELECT region, COUNT(*) AS order_cnt FROM orders WHERE create_time 2024-01-01 AND create_time 2025-01-01 GROUP BY region HAVING order_cnt 500;这里WHERE负责时间区间的行级过滤HAVING负责聚合后的数量过滤职责非常清晰。如果你反过来把时间条件也写进HAVING逻辑上也能得到同样结果但代价是让全年订单都进了分组流水线——这就是典型的“能过但很慢”。我在实际业务里见过线上慢查询最后定位出来就是用HAVING包了本该属于WHERE的字段条件改成WHERE后查询时间从秒级降到毫秒级。3.4 一个容易误判的语法细节没有GROUP BY的HAVING面试官可能还会扩展问一句“如果查询没有GROUP BYHAVING还能用吗”答案是能。MySQL会把整个查询结果视为一个“大组”此时HAVING和WHERE看起来过滤效果类似但语义上HAVING依然是在“聚合结果上过滤”。在这种场景下SELECT COUNT(*) AS total_cnt FROM orders HAVING total_cnt 10000;如果表里只有一条聚合结果HAVING可以直接拿到聚合值。实际开发中这种用法偶尔会出现在“只有满足聚合条件才返回结果”的写法里但要注意它和WHERE在语义、执行阶段上是完全不同的两码事。如果面试里碰到这道扩展题扯清楚“没有GROUP BY时HAVING作用于单组聚合结果而WHERE作用于全表行记录”就够了。4. 索引视野下的WHERE与HAVING为什么一个能加速一个不能4.1 先建立B树索引的”行定位“心智模型这一节是整场面试里最硬核的部分。面试官问我“你说WHERE过滤有性能优势那索引是怎么帮上忙的HAVING阶段索引为什么帮不上同样的忙”我的复盘从B树的结构开始。MySQL的InnoDB主索引就是一棵B树叶子节点按索引键值顺序排列每个叶子节点存储一行完整数据聚簇索引。二级索引的叶子节点存储的是索引列和主键值查询时要通过主键回表拿到完整行。索引的价值在于它把“无序的扫描”变成了“有序的查找”。等值查询可以像二分查找一样快速定位范围查询可以沿着叶子节点链表顺序扫一小段联合索引还能同时利用多个列的排序信息。理解了这一点再看WHERE能走索引的原因就非常清晰WHERE过滤的是原始行而索引恰恰按行的某个或某几个字段建立。比如WHERE create_time 2024-01-01如果create_time上有索引MySQL可以直接定位到2024年1月1日在B树中的位置然后顺着链表扫一小段叶子节点完全不需要全表扫描。4.2 HAVING过滤的是“组”索引无法直接定位“组”那么HAVING的条件为什么通常帮不上索引的忙关键在于HAVING过滤的是分组后的聚合结果而不是原始行。比如HAVING COUNT(*) 5这个COUN值需要把分组内所有行数一遍才能得到HAVING SUM(amount) 10000需要把组内金额累加完毕才知道结果。B树索引只能帮你更快地“找到某一批行”但它不能告诉你“这批行累加完是多少”。在聚合值算出来之前索引根本没有可以定位的目标。用一句大白话来说索引描述的是“行在哪里”而HAVING问的是“组够不够格”。行存在物理位置可以索引组的聚合值是计算出来的不是物理存在的自然不能像定位行一样去定位组。这就是WHERE能大幅受益于索引而HAVING通常只能老老实实等待分组完成的根本原因。4.3 索引也并非完全缺席聚合查询覆盖索引与松散扫描不过如果因此说“聚合查询跟索引没关系”也过于极端。面试官很可能会接着问“那GROUP BY总能用索引吧”这就延展到几个重要机制。第一覆盖索引。如果SELECT和WHERE里涉及的列都在同一个二级索引中查询可以只扫索引而不回表。SELECT COUNT(*) FROM orders WHERE status PAID如果有一个(status)索引直接扫描索引即可得到行数速度远快于扫全表。第二松散索引扫描。在一些特定聚和条件下MySQL可以利用GROUP BY字段的索引顺序跳过无关的分组值。例如SELECT COUNT(DISTINCT status) FROM orders配合(status)索引可以沿着索引的连续不同值统计而不用扫描每一行。第三ORDER BY和GROUP BY如果与索引顺序一致可以避免临时表和filesort。比如GROUP BY dept_id且dept_id上有索引数据按dept_id排好序分组时相邻记录直接累加即可无需额外的排序步骤。但要注意这些“索引帮上忙”的场景几乎都集中在减少回表、利用有序性加速分组排序上并没有改变“HAVING过滤的是聚合值”这一本质。即使Group By用上了索引分组结果算出来后HAVING条件依然是在聚合值上做判断无法像WHERE那样用B树的键值比较来裁剪数据。理解这一点面试时面对“HAVING能走索引吗”这类问题就可以分层回答分组阶段可能借助索引加速聚合条件过滤阶段基本不能。4.4 一个对比实验同样条件的执行差异为了验证我建了一张模拟订单表在status字段上建索引分别跑了三条SQL并看执行计划SQL执行计划关键信息扫描行数SELECT * FROM orders WHERE status PAIDtyperefkeyidx_status约2000行SELECT status, COUNT(*) FROM orders GROUP BY statustypeindexkeyidx_statusUsing index全索引扫描约50万行但无回表SELECT status, COUNT(*) FROM orders GROUP BY status HAVING status PAIDtypeindexkeyidx_statusUsing temporary全索引扫描且出现临时表从结果能清楚看到WHERE条件让索引充分发挥了快速定位能力GROUP BY能借助索引扫描但无法跳过大段无关数据除非松散扫描生效HAVING过滤并没有让扫描量减少反而在分组阶段还需要额外的临时表处理。这个实验也解释了为什么优化SQL时优先思考“WHERE能挡掉多少数据”而不是“HAVING怎么写得好看”。5. MySQL索引使用中的七个高频失效场景与explain验证5.1 先学会看执行计划再谈索引优化面试复盘里有一块是绕不开的索引失效。面试官给我出了几张SQL问我哪些能走索引、哪些不能。如果你不会用explain这种题基本全靠猜。我的建议是先掌握explain输出里的几个关键列列名含义良好信号type访问类型const eq_ref ref range index ALLkey实际使用的索引有索引名rows预估扫描行数越小越好Extra附加信息Using index比Using filesort好Using temporary要警惕判断一条SQL是否走索引最直接的指标就是type和key。type为ALL代表全表扫描为index代表扫描全索引为range代表范围扫描为ref代表等值引用查找。下面七个场景基本是面试和线上排查中出现频率最高的。5.2 七类失效场景逐个拆解场景一对索引列使用函数或运算-- 失效因为对create_time用了DATE函数 SELECT * FROM orders WHERE DATE(create_time) 2024-06-01; -- 生效直接使用范围条件 SELECT * FROM orders WHERE create_time 2024-06-01 AND create_time 2024-06-02;函数导致失效的本质是索引里存的是原始的create_time值不是DATE(create_time)的结果。MySQL无法在B树中直接查找函数计算后的值只能把列值取出来计算后再比较。规避方法很朴素不要对索引列做函数或表达式运算要把运算转换到条件一侧。场景二隐式类型转换-- 失效phone列为varchar却用数字比较 SELECT * FROM users WHERE phone 13800138000; -- 生效使用字符串比较 SELECT * FROM users WHERE phone 13800138000;MySQL在比较时会尝试把字段转换为数值类型这相当于对索引列做了一次隐式函数转换导致索引失效。排查这类问题时要格外注意因为肉眼很难发现字段类型和传入参数不一致。场景三LIKE前缀通配符-- 失效左模糊 SELECT * FROM articles WHERE title LIKE %MySQL索引%; -- 部分生效右模糊 SELECT * FROM articles WHERE title LIKE MySQL%;B树的索引是有序排列的左模糊意味着要匹配任意前缀有序性完全派不上用场只能全部扫一遍。工作中如果确实需要左模糊搜索通常要另想方案比如引入全文索引或搜索引擎而不是纠结要不要建索引。场景四OR连接导致索引失效-- 失效status有索引但 title 无索引 SELECT * FROM orders WHERE status PAID OR title test;OR条件中只要有一个字段没有索引优化器很可能直接放弃使用status索引因为即使status索引筛选出结果还得去全表找title匹配的行两者取并集不如直接全表扫描。优化方向通常是把OR拆成UNION ALL或者确保OR两侧字段都有索引。场景五联合索引违背最左前缀原则假设有联合索引(dept_id, salary)-- 生效使用dept_id SELECT * FROM employees WHERE dept_id 10 AND salary 10000; -- 部分失效从salary开始跳过了dept_id SELECT * FROM employees WHERE salary 10000;联合索引的B树先按第一列排序再按第二列排序。查询条件如果从第二列开始索引的有序性无法直接利用只能扫全索引。最左前缀原则不是MySQL的“规则限制”而是B树排序结构的必然结果。场景六范围查询导致右侧索引列失效-- 联合索引(dept_id, salary, hire_date) SELECT * FROM employees WHERE dept_id 10 AND salary 10000 AND hire_date 2020-01-01;这条的dept_id等值查找能定位salary范围之后的 hire_date 无法继续利用索引做精确定位。因为一旦salary进入范围查询hire_date的排序只能在salary这个范围内局部排序无法再用B树快速跳转。优化时把等值条件的列放前面、范围条件的列放后面是联合索引设计的基本功。场景七NOT IN / NOT EXISTS 与优化器选择SELECT * FROM orders WHERE status NOT IN (CANCELLED);严格来说NOT IN不一定“失效”但优化器常常评估后发现排除的数据量太大全表扫描可能更快于是放弃索引。这是一种成本选择而不是语法层面的失效。面试时答这个场景最好加一句“优化器基于成本决定”反而更能体现深度。5.3 联合索引设计的一个核心心法通过上面七个场景能提炼出一个联合索引设计的核心心法把等值条件放前面把范围条件放后面。更完整一点说优先根据查询中的等值列确定联合索引的前缀然后按选择性从大到小排列最后放范围列。这个顺序不是拍脑袋而是为了让B树的每一层都能最大化使用索引定位。如果查询里还有排序字段索引列顺序如果能和ORDER BY一致还能顺便省掉filesort这部分属于更高阶的设计思路面试中如果主动提出来会显得比“背八股”高一个层次。6. 复盘后的几条实用建议怎么写SQL、怎么建索引、怎么答题6.1 日常写SQL时给自己立三条规矩复盘完这轮面试我给自己定了三条强制规矩也分享给正在看这篇文章的人。第一条所有过滤条件先问自己一句“这个条件针对行还是针对组”。针对行放WHERE针对组放HAVING。这条看似简单但能拦住80%的慢查询。第二条能用WHERE提前过滤绝不拖到HAVING。哪怕只是在一个很小的数据集上这个习惯也能让SQL保持可读性和可维护性。我见过太多“二三十个字段、十几个条件全塞HAVING”的老代码维护起来简直灾难。第三条写完SQL顺手explain一眼。不是所有查询都要逐字分析但涉及多表联查、聚合、大表的语句explain能直接告诉你有没有在走全表扫描、有没有产生临时表和filesort。这个习惯比背任何八股都管用。6.2 索引设计避开想当然新手常犯的错是“查询里有哪个字段就建哪个索引”。复盘中我特别强调一个反直觉的点一条SQL只能选择一个索引路径不考虑index merge的特殊情况所以索引设计永远是为“最频繁、最重要的查询模式”服务的而不是为所有字段建一堆单列索引。比如一个表经常按dept_id hire_date筛选就建联合索引(dept_id, hire_date)两个单列索引在这种场景下大概率只会用其中一个另一个白占空间和写入开销。我还会建议每半年用SHOW INDEX FROM table过一遍现有索引找出那些重复的和几乎不被使用的索引。索引多了写入变慢、占用磁盘空间真正的高效系统不是索引越多越好而是每条索引都在刀刃上。6.3 面试答题的建议框架如果回到面试现场我会把这个问题的回答组织成三层递进。第一层给定义。WHERE在分组前过滤原始行HAVING在分组后过滤聚合结果。然后用一个“能报错”和“不能报错”的典型SQL把差异钉死。第二层讲原理。把SQL执行顺序背出来FROM、WHERE、GROUP BY、HAVING、SELECT、ORDER BY、LIMIT。指出WHERE阶段聚合函数还不存在HAVING阶段聚合结果已经生成。第三层谈优化和索引。主动把数据量差异讲出来WHERE能提前裁剪行、减少分组规模、有机会走索引HAVING要等分组结束条件通常作用于聚合值难以直接借助B树索引。如果面试官追问索引再把联合索引设计、最左前缀、覆盖索引这些内容顺势抛出来。这样答下来既证明基础扎实又显示出实战思维比记住一句“WHERE过滤行、HAVING过滤组”要有说服力得多。6.4 一次复盘带来的长期收益那次复盘后我做的第一件事是把部门里几个核心报表SQL重新review了一遍。原本有一条统计每日订单金额的定时任务跑了两年多都没人动它里面就放着好几个本该进WHERE却被写进HAVING的日期条件。改成WHERE后任务运行时间从将近20分钟压缩到30秒左右。这个收益不是靠“优化技巧”只是靠“理解过滤时机”这一个朴素认知。技术面试的意义往往不只是帮你拿offer而是逼着你去补那些平时习以为常却没真正想通过的知识盲区。这次复盘于我最大的收获就是把“会写SQL”升级成了“懂SQL”。