面试复盘之WHERE和HAVING的区别以及MySQL的索引
最近和几个朋友聊面试,发现MySQL相关的问题几乎成了每场技术面试的“标配关卡”。不管是校招还是社招,面试官总喜欢从一条简单的查询语句切入,然后一环接一环地问到你怀疑人生。其中有两个点被问到的频率极高:一个是WHERE和HAVING到底有什么区别,另一个是MySQL的索引到底是怎么一回事。我把自己这段时间的面试复盘整理出来,结合真实场景把这两块内容掰开揉碎,希望能帮正在准备面试的同学少踩几个坑。
这篇文章适合正在准备技术面试的开发者,也适合那些写完SQL能跑、但说不清楚“为什么”的人。它不是那种概念背诵手册,而是把面试官想听的核心逻辑、常见追问方向、以及实操中容易翻车的细节都梳理清楚,让你在面试时不仅答得上,还能答得有深度。
1. 面试场景回顾
先还原一下我当时被问到的真实场景。面试官上来先给了一张订单表,结构大概是这样的:
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, KEY idx_user_id (user_id), KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;然后给出了一个需求:查询每个用户的订单总金额,要求只返回总金额大于1000的用户。
我当时写的是:
SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE total_amount > 1000 GROUP BY user_id;写完就知道坏了,脑子里瞬间闪过一个念头——WHERE里面压根不能放聚合结果。果然面试官笑了一下,问我:“你知道为什么这么写会报错吗?WHERE和HAVING有什么区别?”
就是这一问,引出了后面一整套关于SQL执行流程、索引结构和B+树的追问。后来我把整场面试的问题整理了一下,发现核心其实就是两块:
- WHERE和HAVING的执行逻辑差异
- MySQL索引的底层数据结构、分类、命中条件、失效场景
这两块看似独立,实际上在面试官的提问逻辑里是串联的。先考你SQL基本功,再从SQL的执行计划引出索引,最后落到索引的底层实现。所以这篇文章也按这个思路来复盘,一条线走到底。
2. WHERE和HAVING的区别详解
2.1 执行时机不同是关键
很多人只会背“WHERE是行过滤,HAVING是组过滤”,但面试官想听的是更本质的东西——执行时机。
WHERE是在数据分组之前进行过滤的,它作用于原始数据行。也就是说,WHERE执行的时候,GROUP BY还没有开始,聚合函数(比如SUM、COUNT、AVG)也还没有计算。
而HAVING是在数据分组完成之后、聚合函数计算完毕之后进行过滤的。它作用于分组后的结果集。
这个执行时机差异直接导致了一个硬性规则:WHERE子句中不能使用聚合函数,而HAVING可以使用。
-- 正确的写法:用HAVING过滤聚合结果 SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id HAVING total_amount > 1000; -- 错误的写法:WHERE中使用聚合函数 SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE SUM(amount) > 1000 GROUP BY user_id;第二种写法在MySQL中会直接报错,因为执行到WHERE的时候,SUM(amount)还没算出来呢,你怎么拿一个不存在的结果去做过滤条件?
我用一个生活化的类比理解这个这个:WHERE相当于你在水果摊挑水果,先把烂的、小的、不熟的都扔出去,剩下的才装进袋子;而HAVING相当于袋子装好之后,拎起来掂量一下重量,超过多少斤的袋子才留下。一个是挑的过程,一个是挑完之后的结果筛选。
2.2 聚合函数的处理差异
这条和上一条其实是同一件事的两种表述,但面试时我会分开来说,因为面试官会换个姿势再考一次。
比如他可能会问:“WHERE后面能不能跟COUNT(*)? HAVING后面能不能跟普通字段?为什么?”
WHERE后面不能跟COUNT(*)之类的聚合函数,理由上面说了——执行时机问题。但HAVING后面是能跟普通字段的,只要这个字段出现在SELECT列表中或者分组字段中。比如:
SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id HAVING user_id > 100;这个SQL是能正常执行的。因为分组之后,user_id是每个分组的一个属性,可以直接拿出来判断。不过这里有个小细节:如果HAVING后面的普通字段既不在SELECT列表里,也不在GROUP BY的字段里,虽然某些场景下MySQL不报错,但逻辑语义会变得模糊,不建议这么写。
另外还要注意一个容易混淆的点:COUNT(*)这种聚合函数在HAVING中是可以使用的,但它在WHERE中不行。有些面试者答到这里就开始背“聚合函数只能出现在HAVING中”,这是不准确的——聚合函数也可以出现在SELECT列表中,也可以用在HAVING的过滤条件里,只是不能用在WHERE中。要把“不能用在哪里”说准确,而不是笼统地背一条不严谨的结论。
2.3 与GROUP BY的配合关系
WHERE、GROUP BY、HAVING的执行顺序,是面试里另一个高频考点。完整的执行顺序如下:
- FROM确定数据源
- WHERE对原始数据做行级过滤
- GROUP BY进行分组
- 执行聚合函数计算
- HAVING对聚合结果做过滤
- SELECT选择输出列
- ORDER BY排序
- LIMIT限制行数
所以优化SQL时有个经典认知:能在WHERE中过滤掉的记录,就尽量不要用HAVING过滤。因为WHERE提前减少了参与分组的行数,分组和聚合的计算量都会变小,性能自然更好。
举个例子,你统计每个广东省用户的下单总金额,如果省过滤条件能写在WHERE里,就不要等分组完再在HAVING里过滤。数据量小的时候感觉不出来,但几百万行的表,这个差异就是秒级和毫秒级的区别。
-- 推荐:尽早过滤 SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE province = '广东' GROUP BY user_id HAVING total_amount > 1000; -- 不推荐:分组后再过滤省份 SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id, province HAVING province = '广东' AND total_amount > 1000;第二种写法看似结果相同,但分组的数据量和计算开销完全不同。这也是面试官考察“你是否理解原理”的一个经典切入点。
2.4 WHERE和HAVING的转换技巧
面试中还有一个延伸问题:如果需求是查出满足某种聚合条件的记录,能不能绕过HAVING用WHERE实现?
这个要看具体场景。如果你能提前用子查询算出聚合结果,再在外部查询里用WHERE过滤,是可行的,只是写法上绕了一些。
我第一次遇到这个问题时,用的是子查询:
SELECT * FROM ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) t WHERE total_amount > 1000;这里内层子查询已经完成了分组聚合,外层查询再对结果集执行WHERE过滤,逻辑上等价于HAVING做过滤。面试官会追问“这两种写法的性能差异”,我的回答是:MySQL优化器通常会把这种子查询改写成和HAVING类似的执行计划,但如果子查询的结果集很大,内层临时表会是物化操作,可能比直接HAVING慢。所以实际项目里更推荐直接使用HAVING,代码清晰度也更高,性能还更稳定。
3. MySQL索引的本质与分类
3.1 索引到底解决了什么问题
聊完WHERE和HAVING,面试官通常会顺势问一句:“那这个表的数据量大了以后怎么优化?怎么让SQL跑得快?”
这时候就到了索引这块。但很多人一上来就开始背“B+树索引、哈希索引”之类的名词,反而说不清楚一个最基础的问题:索引到底解决了什么?
索引的存在是为了减少磁盘IO次数。数据库的数据量远远大于内存容量,大部分数据都存在磁盘上。如果没有索引,查询一条数据可能要扫描整张表,也就是全表扫描;而有了索引,就能通过B+树的层级查找,把需要访问的磁盘块数量从“全表所有块”降到“树的深度”级别的次数。
我习惯用一个类比:新华字典的拼音检字表就是索引。你想查“abc”怎么读,不借助检字表就只能从头一页页翻,运气好翻到中后部,运气差整本都翻一遍。有了拼音索引,你能直接定位到对应页码,查字效率高好几个数量级。
索引的本质,就是一种用额外存储空间换取查询速度的数据结构。它不改变表里的数据,只是额外维护了一套快速定位数据的“路径信息”。
3.2 B+树为什么是MySQL的默认选择
这里有一个面试里一定会深挖的点:为什么MySQL InnoDB引擎的索引用B+树而不是B树,也不是二叉搜索树,更不是哈希表?
二叉搜索树的问题很直接:在极端情况下会退化成链表结构(比如插入顺序是有序的),查询复杂度从O(log n)恶化为O(n),性能不稳定。而且树高不受控,数据量大时磁盘IO次数会很多。
哈希表的问题是:它非常适合等值查询(比如WHERE id = 100),但没法支持范围查询(比如WHERE id > 100 AND id < 200),也没法支持前缀匹配。业务里的范围查询太常见了,所以哈希索引只能作为辅助结构。
B树和B+树的对比是重点。B树的每个节点都存储数据,且节点之间没有链表连接。这样带来的问题是:做范围查询时,需要在中序遍历的过程中不断回溯,访问多个分支节点;而且由于数据分散在各个节点中,查询路径不稳定,每次查找的磁盘IO次数可能不同。
B+树的优化点是:所有的数据都存放在叶子节点上,而且叶子节点之间通过双向链表连接。这意味着:
- 范围查询只需要找到起始叶子节点,然后沿着链表顺序向后遍历就行,不需要回溯父节点
- 非叶子节点只存索引键值,不存数据,所以同一个磁盘块可以放下更多的索引项,树的高度更低,查询时的磁盘IO次数更少
InnoDB页大小默认是16KB,非叶子节点里每条索引记录大概占十几到几十字节,一页能塞几百上千个索引项。三层B+树就能支撑上千万级别的数据量,这意味着查询一张千万行的大表,只需要访问3到4次磁盘就能定位到数据。这就是B+树被选择为默认索引结构的最核心理由——磁盘IO次数可控且足够少。
3.3 主键索引、二级索引与回表
索引在InnoDB中分成两大类:聚簇索引(也就是主键索引)和二级索引(辅助索引)。
聚簇索引的特殊之处在于:叶子节点上不仅存储了主键值,还直接存储了整行的数据。也就是说,表数据的物理存储顺序就是按照主键顺序排列的,主键索引本身和数据合二为一。这就是为什么InnoDB表必须要有主键——没有主键,它就没法组织数据存储。
二级索引(即非聚集索引)的叶子节点存的不是整行数据,而是索引列的值和对应的主键值。如果查询需要的数据列不在二级索引中,就只能根据主键值再回到聚簇索引中去查一次完整行,这个动作叫做回表。
举一个典型场景:
SELECT * FROM orders WHERE user_id = 100;如果user_id上有二级索引,查找过程是:先到user_id的二级索引中找到对应的叶子节点,拿到主键值(比如id = 500),然后拿着500回到主键索引中再查一次,取出完整行。这就是回表操作。
如果查询只关心user_id和主键id,可以直接写:
SELECT id, user_id FROM orders WHERE user_id = 100;因为二级索引的叶子节点本身就包含user_id和主键id,查询所需的所有列都能在二级索引中直接拿到,不需要回表,这叫覆盖索引。覆盖索引是实际SQL优化中性价比很高的一种手段。
4. 索引失效与命中规则
4.1 最左前缀原则
面试到这里,面试官差不多就要开始考索引失效的经典场景了。第一个绕不开的就是最左前缀原则。
联合索引(a, b, c),本质上是先把a排序,再在a相同的情况下把b排序,再把c排序。所以这个联合索引能够支持的查询模式是:
- a
- a, b
- a, b, c
这里我踩过太多次坑了。比如建了一个联合索引(user_id, status, created_at),结果写查询的时候条件是:
WHERE status = 1 AND created_at > '2024-01-01'看起来条件挺多,但实际上联合索引完全用不上,因为查询中没有出现联合索引的第一列user_id。这就是违反最左前缀原则的典型场景。
另一个常见的迷惑点是:条件顺序和索引列顺序不一致。比如:
WHERE status = 1 AND user_id = 100虽然条件的书写顺序和索引定义顺序不一致,但MySQL查询优化器会自动调整条件顺序,仍然能命中索引。这一点面试中经常有人答错,以为必须严格按照索引定义的列顺序去写条件,其实优化器会做等价转换,关键看查询条件中“有没有”前缀列,而不是“书写顺序前缀列”。
4.2 常见的索引失效场景
除了最左前缀之外,还有几个高频的索引失效问题,面试基本必带:
对索引列使用函数或者表达式。比如:
WHERE DATE(created_at) = '2024-01-01'这个写法会把索引列套进一个函数里,优化器没法直接利用索引做查找,只能对全表每行数据都调用DATE函数去计算再比较。正确的做法是对参数做函数处理,让索引列保持原样:
WHERE created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00'隐式类型转换也是一个容易翻车的点。比如索引列是varchar类型,但查询条件里用的是数字:
WHERE phone = 13800001111MySQL会把字符串列隐式转换成数字再比较,导致索引失效。建议是保持类型一致,或者用字符串写法:
WHERE phone = '13800001111'还有一个大家经常忽略的情况:模糊查询中,前缀使用了通配符。
WHERE name LIKE '%张'这种写法无法使用索引,因为索引是按照从左到右的字符顺序排列的,不知道最左侧是什么字符就没法走索引树查找。但是:
WHERE name LIKE '张%'这种前缀匹配的方式就可以命中索引。
4.3 优化器选择与执行计划
面试到这里,面试官可能会问:“是不是索引建了就一定会用?”
答案是否定的。MySQL中有一个查询优化器,它会根据表的统计信息、索引基数、数据分布等因素,来估算各种执行方案的成本,选择一个它认为最优的方案。如果优化器觉得走索引需要回表太多次,甚至还不如全表扫描快,它就会放弃索引,直接走全表扫描。
这里我用EXPLAIN能看到实际执行计划:
EXPLAIN SELECT * FROM orders WHERE user_id > 100;如果结果中的type列显示为ALL,意味着全表扫描;如果显示为range或者ref,说明走了索引范围扫描或普通非唯一索引查询。
有些时候你会发现明明索引没失效,但优化器就是不走索引,这时候可以用FORCE INDEX强制指定索引:
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id > 100;但提醒一句:生产环境不要动不动就FORCE INDEX,这属于偶然性手段。真正的问题往往是统计信息过期了,跑一下ANALYZE TABLE更新统计信息,优化器就会恢复“正常判断”。
5. 面试官追问:慢查询与索引优化
5.1 定位慢查询
面试中还有一个升级版的连环追问:线上有个SQL跑了三秒,怎么排查和优化?
先不要慌,拆解这个问题的关键其实是主次分明:
定位问题SQL,优先打开慢查询日志。MySQL的慢查询日志能记录执行时间超过阈值的SQL语句,通过以下方式查看:
SHOW VARIABLES LIKE 'slow_query_log%'; SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;定位到具体的SQL之后,用EXPLAIN去看执行计划,重点看几个指标:
- type列:如果看到ALL,基本可以确定是全表扫描,这是最差的情况
- key列:实际走了哪个索引,如果为NULL就是没走索引
- rows列:优化器预估扫描的行数,数值越大越危险
- Extra列:如果看到了Using filesort和Using temporary,说明SQL存在排序或临时表的开销,这也是一个优化信号
这里我再补充一个自己的实战体会:很多慢SQL的原因其实很简单,就是缺了一个合适的索引,或者SQL写法不规范导致索引没有生效。不要一开始就怀疑缓存、配置或者数据库CPU问题,先排除SQL本身,这是最基本的排查思路。
5.2 覆盖索引与索引下推
面试中的高阶题目往往会问到两个优化手段:覆盖索引和索引下推。
覆盖索引我在上面提到过,就是让查询所需的全部字段都包含在同一个二级索引中,从而避免回表操作。比如:
SELECT user_id, amount FROM orders WHERE user_id > 100;如果(user_id, amount)上有联合索引,那么这个查询的返回列和过滤条件都在索引里,完全可以避免回表,查询效率高非常多。
索引下推(Index Condition Pushdown,ICP)是MySQL 5.6引入的优化。它的核心思想是:在没有ICP的情况下,使用二级索引查询时,会先根据索引条件获取主键,然后回表取出完整行,再在服务器层判断剩余条件。而有了ICP之后,MySQL可以把一部分条件判断下推到存储引擎层,在索引遍历阶段就过滤掉不满足条件的记录,减少回表次数。
举例说明,有一个联合索引(user_id, status),执行:
SELECT * FROM orders WHERE user_id > 100 AND status = 1;存储引擎在读取二级索引时,可以先判断status = 1是否成立,不成立就直接跳过,不回表。没有ICP的话,所有user_id > 100的记录都要回表取到完整行之后才能过滤status条件,那回表成本会大很多。
这个点面试时容易忽略,但能答出来就有明显的加分效果,因为它说明你真的理解了MySQL执行引擎内部的工作方式,而不只是停留在“索引能加速查询”这种表面认知。
6. 常见问题与避坑清单
6.1 面试中的高频认知误区
结合我自己的面试复盘和与身边人的交流,发现大家对WHERE、HAVING和索引的理解普遍存在几个误区。
误区一是认为HAVING只能配合GROUP BY使用。实际上,即便是没有GROUP BY的查询,MySQL也允许使用HAVING来过滤聚合结果。例如:
SELECT SUM(amount) AS total FROM orders HAVING total > 100;这种写法在MySQL中不会报错,因为SELECT列表里有聚合结果,HAVING可以对该结果做过滤。只是实际业务中单独这样用的情况比较少,但面试时如果被问到,你要清楚这是合法的,不要武断地说“HAVING必须在GROUP BY之后”。
误区二是不区分WHERE和HAVING的性能影响。有人觉得两者都能过滤,效果一样,随便用哪个都行。实际上,能提前过滤的数据一定要在WHERE里过滤,不要拖到分组后。你想想,先过滤掉一万行再分组,和先分组再过滤一万行,计算量完全不同。
误区三是以为“索引建得越多越好”。这个在面试中经常作为压力测试出现。我自己的看法是:索引确实是越多越提高查询速度,但写入、更新、删除时维护索引的成本也会越大,而且存储空间占用也会增大。每个索引本质都是一棵额外的B+树,每次INSERT都要往每棵索引树里写数据,每次UPDATE可能还要改索引。索引过多对于一个写多读少的表来说是很伤的。正确思路是给高频查询场景建立合理的联合索引,而不是每个查询条件都单独建一个索引。
误区四是认为“NULL值一定不会走索引”。MySQL的索引对NULL值有自己的处理方式:如果索引列允许NULL,查询条件写成IS NULL是可以走索引的。这和部分人理解的“NULL值索引完全失效”并不一致。面试时如果被问到这个,可以答出“is not null时索引可能失效”会更好,因为优化器评估下来,遍历NULL值的成本可能比全表扫描更高。
6.2 实操排坑建议
最后说几条我实战中总结出来的排坑建议,对面试和日常工作都有直接帮助:
第一条,写完SELECT,先看WHERE和HAVING各自承担了什么职责。WHERE里面只写非聚合条件的过滤,HAVING里面只写聚合结果条件的过滤,保持清晰的边界,别人review你的代码时一眼就能看懂逻辑。
第二条,设计联合索引时的经验判断,优先考虑等值查询的列放在前面,范围查询的列放后面。但更重要的标准是“最常用的条件优先”。曾经我为一个高频查询建了(user_id, status, created_at)的联合索引,结果上了生产后看执行计划才发现,实际业务的查询条件和索引顺序明明是正确的,但优化器就是不那么满意——因为created_at的范围条件放在最后,过滤性最强但只能排在最后,导致扫描范围偏大。后来我把索引调整为(user_id, created_at, status),效果立竿见影。这个例子说明:联合索引的列顺序一定要结合真实业务里的等值条件和范围条件做取舍,不能想当然。
第三条,学会看EXPLAIN,不只是看有没有走索引,还要看具体走了哪个索引、预估扫描了多少行、有没有文件排序。在我早期的项目里,曾遇到过所有SQL都走了索引但整体仍然很慢的情况。用EXPLAIN排查之后才发现Extra列里全是Using filesort,很多SQL在查询完成后都要再排序,而排序往往是在内存或磁盘上额外进行的,代价很高。后来通过调整索引顺序把排序去掉,查询速度提升了几十倍。这个教训让我养成了“每一条新SQL上线前都必须过一遍EXPLAIN”的习惯。
第四条,翻车最容易的场景是“范围条件之后带等值条件”。前面也提过,联合索引的最左前缀原则决定了:一旦某列用了范围查询,后面的列就无法继续使用索引做等值定位。所以在设计联合索引时,要把范围条件涉及的列往后放,尽量让等值条件列都处于范围列之前。
第五条,不要过度相信“索引一定能提速”。当一张表数据量很小时(比如几千行),全表扫描的速度和走索引几乎没差别,甚至可能更快,因为走索引还需要额外的磁盘IO去读索引。这时候给表加索引,性能收益并不明显,反而增加写操作的开销。数据量到了一定规模(几十万行以上),索引的价值才会真正体现出来。所以别一上来就无脑建索引,先看数据量级再决定。
我自己复盘这一整场面试之后,有一个特别深的感受:很多基础问题看似简单,但面试官真正想考察的并不是你背了多少知识点,而是你有没有建立“从SQL到执行计划,再到底层存储结构”这条完整链路。WHERE和HAVING的区别本质上考验的是你对SQL执行流程的理解,索引相关的问题考验的是你对数据结构的理解。只有把这两条线打通,面试时才能做到无论怎么追问都不慌。
最后再分享一个面试小技巧:如果被问到索引问题时,主动画出InnoDB的B+树结构图,讲清楚叶子节点存储什么、非叶子节点存储什么、为什么这样做,面试官对你的评价通常会上升一个档次。这算是我的亲测经验,送给大家。