SQL面试复盘:WHERE与HAVING区别及MySQL索引优化实战
前两天刚面完一轮技术面试复盘的时候发现面试官问的两个问题看着像“送分题”实际每个都往下追了两三层。一个是老生常谈的WHERE和HAVING的区别另一个是顺着一条慢SQL把MySQL索引的知识母题整个拆了一遍。这两个问题本身不难难的是你有没有把SQL执行顺序、分组逻辑、B树存储结构、回表和覆盖索引这些底层原理串成一条线。这篇文章就把我当时怎么答的、哪里被追问了、事后怎么纠错的完整过程记录一下顺便把这些年工作中踩过的索引坑也整理出来给准备面试的朋友做个参考。1. 面试场景复盘两个“基础题”背后的连环追问1.1 第一个问题WHERE和HAVING有什么区别面试官没有让我干巴巴地“背区别”而是直接甩了一张简化版的订单表过来。CREATE TABLE order ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, KEY idx_user (user_id), KEY idx_status_time (status, created_at) );题目是查出所有“已支付”状态的订单中每个用户的订单数量并且只要订单数大于等于3的用户。我写出来的答案是SELECT user_id, COUNT(*) AS cnt FROM order WHERE status 已支付 GROUP BY user_id HAVING cnt 3;面试官点点头然后开始连环问。第一个追问是两个过滤条件为什么一个放WHERE一个放HAVING换过来可不可以这个问题看着简单实际上考察的是对SQL逻辑执行顺序的理解。我当时回答得还可以但后来复盘发现细节处还是少讲了一个关键点就是HAVING的执行位置在GROUP BY之后、SELECT之后的部分规则这在后面小节里我会展开。第二个追问是如果这张表数据量是千万级这个查询怎么优化这就从“会写SQL”跳到了“懂索引”。面试官关心的是你能不能在WHERE条件上建索引、能不能让GROUP BY走索引分组、能不能避免临时表和文件排序。从这一题开始整个面试就进入了索引知识母题的范畴。1.2 第二个问题从一条慢SQL引出索引连招第二个问题依然是围绕这张订单表场景是运营要查“2024年1月1日之后每个用户的累计下单金额”SQL写出来是这样的SELECT user_id, SUM(amount) AS total_amount FROM order WHERE created_at 2024-01-01 GROUP BY user_id;这条SQL很慢面试官问了两件事你觉得慢在哪里如果要优化从哪里入手我当时的思路是先看执行计划再决定怎么建索引。如果表上只有idx_user和idx_status_time两个索引那么WHERE条件只有created_at字段无法有效利用idx_status_time它需要先匹配status所以大概率是全表扫描或者扫很大一段数据。GROUP BY user_id也没有现成的索引可以配合MySQL会走临时表或者文件排序。到这里索引就绕不开几个概念了B树的存储结构、联合索引的最左前缀原则、回表和覆盖索引。面试官一个一个往下问基本就是按这条线走的。这套追问链条非常典型我建议准备面试的朋友把下面这一整套逻辑吃透而不是去背零散的结论。2. 把WHERE和HAVING彻底讲透执行时机与分组逻辑2.1 执行时机差异行过滤与分组过滤先说最核心的区别一句话就能讲清楚WHERE是在GROUP BY分组之前过滤“行”的HAVING是在GROUP BY分组之后过滤“组”的。用生活里的例子来类比如果把GROUP BY比作一场演出的分区入场WHERE就是入场前的检票票不合格的观众直接被拦在门口根本进不了场而HAVING是演出结束后统计每个分区的人数发现人数不达标的分区被标记出来。一个发生在“进场前”一个发生在“进场后按区统计时”对象完全不同。SQL的逻辑执行顺序注意是逻辑顺序不是MySQL内部实际优化顺序是这样的FROM - WHERE - GROUP BY - HAVING - SELECT - ORDER BY - LIMIT我把这个过程用一组数字演示一下更容易理解。假设order表原始数据有100万行WHEREstatus 已支付过滤掉40万行剩下60万行进入分组GROUP BYuser_id60万行被分成了15万个用户分组HAVINGCOUNT(*) 3再过滤掉不符合条件的用户最后剩2万组。如果反过来把status 已支付这个条件挪到HAVING里写SQL在逻辑上会先把100万行全部分组分组之后再按status 已支付去过滤组。这里就会出现一个语义陷阱status是行的属性不是分组的属性除非整个分组里的status都一致否则把行级条件放到HAVING里本身就是错误的设计。所以WHERE负责行级过滤HAVING负责组级过滤。这个区分不是语法层面的洁癖而是执行成本和语义正确性的双重需要。2.1 聚合函数、别名与SQL执行顺序细节决定成败第二个关键区别是聚合函数的使用。WHERE子句中不能使用聚合函数比如WHERE COUNT(*) 3这种写法会直接报错。原因就是聚合函数是在分组阶段才计算的WHERE执行的时候连组都没有形成怎么可能算出COUNTHAVING则刚好相反它天然就是为聚合结果过滤设计的HAVING COUNT(*) 3是最常见不过的写法。但这里有一个非常容易被忽略的细节如果HAVING后面写的是别名比如我前面写的HAVING cnt 3它依赖于MySQL对SELECT别名的一个扩展支持。在标准SQL里HAVING并不能引用SELECT子句中定义的别名但MySQL允许因为它把HAVING的解析放在了SELECT别名解析之后。这算是MySQL的“方言行为”换到其他数据库比如某些版本的SQL Server或Oracle里这样写就可能报错。面试的时候如果主动把这个差异讲出来会是个小加分项。同样WHERE中绝对不能使用SELECT别名因为WHERE在逻辑执行顺序上排在SELECT之前执行的时候别名还不存在。比如WHERE total_amount 100搭配SELECT SUM(amount) AS total_amount就是标准的错误写法MySQL会直接报“Unknown column”错误。还有一个小众问题面试偶尔会问到HAVING一定要配合GROUP BY使用吗答案是不一定。在MySQL里HAVING可以单独使用此时会把整个结果集当成一个分组来过滤例如SELECT COUNT(*) FROM order HAVING COUNT(*) 1000;这其实等价于用WHERE做一次聚合前的过滤但语义上有点别扭实际工作中不建议这么写。单独存在的HAVING容易让人误解直接写成WHERE或子查询更清晰。2.3 性能取舍为什么说“能WHERE就不要HAVING”关于WHERE和HAVING的性能问题我见很多博客喜欢写成“WHERE性能比HAVING高所以能用WHERE就用WHERE”这个说法不严谨但方向上没错。为什么方向上没错核心原因是过滤时机导致的数据处理量差异。WHERE在进入分组之前就把行过滤掉了进入GROUP BY的数据量更小分组计算的总量也随之减小。HAVING则不同它是在分组完成之后再做过滤前期所有行都必须参与分组和聚合计算内存和CPU的消耗自然更高。我见过一条真实的生产慢SQL之前同事统计各渠道的活跃用户数写法是这样的SELECT channel, COUNT(DISTINCT user_id) FROM user_login_log GROUP BY channel HAVING log_date 2024-01-01;这条SQL从语义上是错的把日期条件放在HAVING里导致这一年的登录日志全部参与分组扫描了上亿行。后来改成了SELECT channel, COUNT(DISTINCT user_id) FROM user_login_log WHERE log_date 2024-01-01 GROUP BY channel;两个版本拿到的数据其实是同一份因为HAVING里的条件刚好是行级条件但执行时间从分钟级降到了秒级。这个案例非常典型行级条件放在HAVING里等于让无数不必要的行参加了分组运算白白浪费算力。但也要注意如果条件本身是组级条件比如“订单数大于3”“累计金额超过1万”那就只能用HAVING没有选择余地。所谓“能WHERE就不要HAVING”的前提是这个条件既可以用WHERE过滤也可以用HAVING过滤比如行级字段上的等值或范围条件。这时候才推荐优先放到WHERE里。3. MySQL索引从B树到回表、覆盖索引的完整知识链3.1 B树为什么能扛住千万级数据面试官从慢SQL切入后第一个深挖的问题是索引底层到底是什么结构MySQL InnoDB引擎用的索引结构是B树。B树是一种多路平衡搜索树和二叉树最大的区别是每个节点可以存储多个子节点指针和多个键值这让整棵树变得又矮又宽。这里可以做一个估算让你直观理解为什么它能扛住千万级数据。InnoDB默认的页大小是16KB也就是每次磁盘IO至少读取16KB数据。B树的非叶子节点不存完整数据只存索引键值和指向子节点的指针。假设一条索引项占16字节一个16KB的页大约能存放1000个索引项也就是一个非叶子节点可以分叉出1000个子节点。那么两层B树能存多少数据1000乘以1000等于100万个索引项。如果加上叶子节点假设每个叶子页能存16条完整记录按行较宽的情况估算三层B树就能存1000乘以1000乘以16也就是1600万行左右。这就是为什么千万级的表用B树查一条记录只需要3次左右磁盘IO查询时间还在毫秒级。树的高度低意味着IO次数稳定这是B树最大的优势。面试如果追问到这里可以顺手对比一下其他数据结构。哈希索引的等值查询确实更快O(1)复杂度但它不支持范围查询也不支持排序普通二叉树的层高随数据量快速增加在千万级数据下几十层就出来了每层都是一次磁盘IO完全扛不住。B树就是靠“矮”和“叶子节点有序”两个特性同时保证了等值查询、范围查询和排序的高效。3.2 聚簇索引与二级索引回表到底浪费了什么很多新人会天然觉得“我给某个字段建了索引查询就一定会走索引”这话对了一半。要理解另一半必须知道InnoDB的表结构。InnoDB的表本身就是按B树组织的。每个表必须有一个聚簇索引默认就是用主键建的。聚簇索引的叶子节点存储的是整行数据也就是说通过主键查数据直接在索引里就能拿到完整记录。除了聚簇索引其他索引都叫二级索引也叫辅助索引。二级索引的叶子节点并不存整行数据只存索引列的值和主键值。为什么会这样设计考虑到数据一致性和存储成本如果每个索引的叶子节点都存一份完整行数据那每建一个索引整张表的数据就要复制一份既浪费空间又容易数据不一致。所以二级索引只存“定位数据”的钥匙这把钥匙就是主键。于是就有了回表这个概念。当查询走的是二级索引但SELECT需要的字段在二级索引里找不到时MySQL必须先通过二级索引拿到主键值再用主键回到聚簇索引里查找完整行这个过程就叫回表。举个例子SELECT id, name, age FROM student WHERE name 张三;如果name上建了普通索引而age字段不在二级索引里那么执行流程是先在name索引的B树里找到张三对应的叶子节点拿到主键id再用这个id去聚簇索引里查整行最后从行中取出name和age返回。多了一次聚簇索引的随机IO。回表是业绩杀手吗是但也要看情况。数据量小的时候回表代价可以忽略但千万级大表里一次回表就是一次随机磁盘IO如果涉及大量行性能就会明显下降。所以优化的方向之一就是想办法避免回表。3.3 复合索引、最左前缀与覆盖索引面试官最爱的连环问面试官问到这里基本就会拿一条SQL让你分析怎么建索引了。这里有个绕不开的原则最左前缀原则。假设我们建了一个复合索引idx(a, b, c)组合顺序是a、b、c。这个索引的B树会先按a排序a相同的记录再按b排序b相同再按c排序。查询条件如果从a开始连续匹配就能利用索引如果跳过了a直接查b或者c索引就无法高效定位因为整个索引的结构是按a为第一关键字排列的。我用一个表格总结一下查询条件能否使用索引说明WHERE a 1完整使用最左列WHERE a 1 AND b 2完整使用连续匹配两列WHERE a 1 AND b 2 AND c 3完整使用连续匹配三列WHERE b 2 AND c 3无法使用跳过了aWHERE a 1 AND c 3部分使用a能走索引c可能走索引下推但无法完全定位WHERE a 1 AND b 2 AND c 3部分使用范围查询b之后的c失效特别注意最后一行范围查询之后的列无法走索引。这是被问烂了的一个知识点但实际工作里还是经常有人踩。还有一个高频概念叫覆盖索引。所谓覆盖索引指的是查询涉及的所有字段都在同一个二级索引中可以找到这样MySQL就不需要回表了。判断是否走了覆盖索引看执行计划Extra列是不是出现了Using index。举个例子还是student表我们建了联合索引idx_name_age(name, age)SELECT age FROM student WHERE name 张三;因为age就在idx_name_age的二级索引里查询直接在索引里返回结果不用回表Extra列会显示Using index。反过来SELECT class_id FROM student WHERE name 张三;class_id不在索引中必须回表取Extra列就不会出现Using index。理解了这个很多优化思路就出来了。比如高频查询固定只查几个字段可以设计一个复合索引把所有需要的字段覆盖进去把回表省掉。这比一味堆单列索引要聪明得多。4. 实战避坑慢SQL排查与索引设计踩坑记录4.1 索引失效常见场景汇总面试和工作中常见的索引失效场景我列一个表每一个都是我实际踩过或者帮别人排查过的失效场景示例原因对索引列使用函数WHERE DATE(created_at) 2024-01-01索引列被函数加工后B树无法直接匹配隐式类型转换WHERE phone 13800138000phone是varchar字段值被转成数值索引列发生隐式转换模糊匹配以通配符开头WHERE name LIKE %张三%B树需要有序前缀才能定位OR连接非索引条件WHERE status 1 OR category Acategory无索引优化器评估后可能全表扫描负向查询WHERE status ! 1、WHERE id NOT IN (...)不满足B树有序定位条件优化器权衡后放弃违反最左前缀WHERE b 2 AND c 3联合索引为a,b,c索引序列被跳过需要说明的是这几种场景不是“绝对失效”MySQL优化器是基于成本来决策的。有时候负向查询的数据量很小优化器计算后发现全表扫描成本更低也会选择不走索引有时候条件写的函数表达式MySQL8.0还能尝试做一些转换优化。所以准确的说法是这些写法大概率导致索引失效或效率下降不能把“可能”当“必然”。4.2 用EXPLAIN定位慢SQL实战排查慢SQL第一步永远是看执行计划而不是凭感觉加索引。EXPLAIN SELECT user_id, COUNT(*) AS cnt FROM order WHERE status 已支付 AND created_at 2024-01-01 GROUP BY user_id HAVING cnt 3;这是我在面试复盘里用的一条模拟慢SQL。执行计划重点看几列type连接类型从好到坏大致是system const eq_ref ref range index ALL看到ALL基本就是全表扫描了key实际使用的索引没值就是没走索引rows预估扫描行数数值越大越危险Extra会出现Using index覆盖索引、Using where、Using temporary、Using filesort等。如果这条SQL在原始表结构下跑大概率会看到type为ALL或indexExtra里有Using temporary; Using filesort。Using temporary说明GROUP BY让MySQL建了内部临时表Using filesort说明排序没法走索引这两项都是性能杀手。优化思路要分两个方向看如果主要瓶颈在WHERE过滤可以建复合索引(status, created_at, user_id)让where条件走range扫描并且user_id在索引里覆盖了GROUP BY字段有机会避免回表。如果主要瓶颈在GROUP BY分组排序单独建(user_id, status, created_at)这样的索引会让GROUP BY user_id变成有序扫描减少临时表和文件排序但WHERE条件的过滤可能又不够理想。这两者之间往往需要权衡没有一个固定的“万能索引”可以适用所有SQL。实际工作里正确的做法是结合业务查询频率找到最常用的那几条SQL分别分析它们的执行计划再决定建什么索引。4.3 索引设计的几条实操建议最后整理几条我做DBA优化和业务研发时沉淀下来的经验。第一条索引的选择性要足够高。选择性 去重后的值个数 / 总行数。如果某个字段只有男女两种取值选择性就是2除以总行数非常低这种字段单独建索引基本没有意义优化器往往会忽略它。反过来主键、订单号、手机号这类高基数字段建索引效果明显。第二条联合索引的字段顺序有讲究。经验法则是等值条件字段放前面范围条件字段放后面。因为等值条件能精确定位范围条件定位后会破坏后续列的有序性。这个和前面说的最左前缀原则是一致的。第三条不要为了“每个字段都能走索引”就猛建单列索引。每建一个索引写入数据时就要多维护一棵B树插入、更新、删除的性能都会下降。正确的思路是尽量用联合索引覆盖多个常用查询而不是一张表建七八个单列索引。第四条爱护索引统计信息。有时候索引明明建了执行计划还是不走可能是因为索引统计信息过期优化器不知道这个索引有多好。跑一下ANALYZE TABLE重新统计往往问题就解决了。第五条如果某条SQL是线上最高频查询宁可牺牲一点写入性能也要把索引建到位。每一个业务系统都有自己的核心路径索引设计要为核心路径服务而不是平均用力。写在最后面试复盘最怕的是“当时答对了下来就忘了”。我习惯把每次面试的问题当成一棵知识树的根节点顺着它把一个知识面完整补齐。WHERE和HAVING的区别看起来只是两条语法规则但顺着它可以延伸到SQL执行顺序、分组逻辑、聚合函数、别名解析索引母题也是一样从B树可以延伸到聚簇索引、二级索引、回表、覆盖索引、最左前缀再到执行计划和实际的索引设计。我个人在实际排查慢SQL时最大的体会是很多问题不是不会写SQL而是写完SQL之后没有养成看一眼执行计划的习惯。如果你最近也在准备面试或者处理线上慢查询不妨把这条链路上的知识点自己动手画一遍、写几条SQL验证一遍效果比反复刷题要好得多。