ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

那次面试复盘,我记了整整三页笔记。面试官从一条看似基础的“WHERE和HAVING有什么区别”切入,我按教科书答完“一个过滤行、一个过滤分组、HAVING能用聚合函数”之后,他点了点头,接着抛出一串连环追问:那这条语句为什么报错?这条为什么慢?你explain过没有?索引在哪个环节起的作用?我这才意识到,这道“送分题”背后连着的不是一句口诀,而是SQL执行顺序、聚合语义、B+树索引原理一整条知识链。事后我把整场问答重新过了一遍,把WHERE、HAVING和索引的关系彻底梳理干净,这篇文章就是我的复盘记录。如果你也在准备数据库相关的面试,或者平时写SQL只停留在“能跑就行”,这篇内容应该能帮你少走一段弯路。

1. 面试现场回顾:一个“送分题”引发的连锁追问

1.1 问题最早是从一条报错SQL开始的

面试官先在共享文档里给我贴了一段SQL:

SELECT 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 面试官趁势追问的三连击

看到我答出了“执行顺序”这个关键词,面试官紧接着问了三个问题:

  1. 既然WHERE不能过滤分组,那HAVING为什么可以?
  2. 把WHERE换成HAVING后,性能一定更好吗?
  3. 一条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 -> LIMIT

FROM先确定数据源;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 = 10,MySQL其实是允许的。但这样做通常是个坏味道,因为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都没执行,别名自然不存在。但换成HAVING:

SELECT 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 BY,HAVING还能用吗?”答案是能。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 = 'PAID'type=ref,key=idx_status约2000行
SELECT status, COUNT(*) FROM orders GROUP BY statustype=index,key=idx_status,Using index全索引扫描约50万行,但无回表
SELECT status, COUNT(*) FROM orders GROUP BY status HAVING status = 'PAID'type=index,key=idx_status,Using temporary全索引扫描,且出现临时表

从结果能清楚看到:WHERE条件让索引充分发挥了快速定位能力;GROUP BY能借助索引扫描但无法跳过大段无关数据(除非松散扫描生效);HAVING过滤并没有让扫描量减少,反而在分组阶段还需要额外的临时表处理。这个实验也解释了为什么优化SQL时优先思考“WHERE能挡掉多少数据”,而不是“HAVING怎么写得好看”。

5. MySQL索引使用中的七个高频失效场景与explain验证

5.1 先学会看执行计划,再谈索引优化

面试复盘里有一块是绕不开的:索引失效。面试官给我出了几张SQL,问我哪些能走索引、哪些不能。如果你不会用explain,这种题基本全靠猜。我的建议是先掌握explain输出里的几个关键列:

列名含义良好信号
type访问类型const > eq_ref > ref > range > index > ALL
key实际使用的索引有索引名
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”。

RELATED READING

延伸阅读

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