
做了十多年开发也当过不少场次的面试官我越来越觉得 SQL 面试题就像一面“照妖镜”。简历上写着“精通 SQL”的人不少可真到白板手写的时候经常在 JOIN、窗口函数、NULL 这些基础点上翻车。这篇文章不聊虚的直接把招聘里反复出现的 SQL 十大面试问题拉出来每个问题配上题目拆解、实战思路和避坑说明。不管你是准备校招的应届生还是想跳槽的资深开发又或者是带数据团队的负责人只要想在 SQL 上补齐短板这份清单都值得对着练一遍。我习惯把这十大问题分成三个梯队第一梯队是 JOIN、聚合、去重属于必拿分题第二梯队是窗口函数、执行顺序、自连接开始拉开差距第三梯队是索引、慢 SQL 优化和注入安全能答好的人通常已经具备一定的工程经验。下面我会按这个顺序逐题拆每一题都会给出可运行的 SQL 示例和我在实际面试中看到的典型错误。1. SQL 面试题的底层逻辑与准备路线1.1 面试官真正想从一道 SQL 题里看到什么很多候选人把 SQL 当成一种“英语作文”来准备背一背语法、记几个关键字然后期待面试时恰好碰到原题。但我可以直说面试官问 SQL 不是为了考你记忆力而是想看三件事第一是拆解问题的能力。面对“找出每个部门工资最高的员工”这种题你能不能先把问题翻译成一个明确的数据结构需要保留哪些字段、结果粒度是什么、是否需要处理并列情况。第二步才轮到写语法。我看到太多人上来就写写了半天才发现漏了一个部门或者把并列最高的人丢掉了。第二是对数据模型的理解。面试题通常会给出一个简化表结构但不会告诉你两个表之间是一对一还是一对多这恰恰是 JOIN 结果行数变多的根源。有经验的候选人会在动手前先确认关联键是否唯一。第三是工程意识和安全意识。比如问慢 SQL 优化很多人张嘴就说“加索引”但问你怎么确认走了索引、索引为什么会失效就答不出来了。再比如问 SQL 注入如果只会说“用预编译”但讲不清为什么预编译能挡住注入同样很难让面试官满意。所以我的建议是准备 SQL 面试题不要照抄答案而是沿着“问题定义 → 逻辑拆解 → 语法实现 → 边界测试 → 性能与安全考量”这条链走一遍。你只要能完整复述这个链条哪怕某个函数记不准面试官也愿意听你继续说。1.2 十大问题背后的知识版图在逐题拆解之前我先把十大问题对应的考察点和常见业务场景列出来方便你建立整体坐标。这张表也是我筛简历、出题时常用的框架。题号问题考察核心典型业务场景1JOIN 类型辨析与关联陷阱表关联逻辑、数据粒度订单表关联用户表统计活跃用户2GROUP BY 与聚合函数边界分组粒度、HAVING 与 WHERE 区别按部门统计平均薪资、人数3删除重复数据窗口函数、自连接、去重思路清理用户表里的重复注册记录4窗口函数三兄弟ROW_NUMBER / RANK / DENSE_RANK排行榜、分组 TopN5SQL 执行顺序逻辑执行流程、别名限制排查 WHERE 里为什么不能用别名6自连接与相关子查询层级关系、逐行关联员工-主管层级查找高于部门均薪的人7索引为什么失效B 树原理、EXPLAIN优化慢查询8SELECT * 的代价I/O、网络传输、兼容性线上接口误用 select * 导致慢查询9SQL 注入与参数化预编译原理、输入校验登录接口被万能密码绕过10慢 SQL 优化完整路径慢查询日志、执行计划、索引设计后台分页接口超时你会发现这十个点并非孤立存在JOIN 里带着分组去重里带着窗口函数慢 SQL 优化里带着索引和 SELECT *。面试官经常把几个点揉在一道题里所以你不只要“会写”还要能说出“为什么这么写”。1.3 我推荐的复习方法三遍刷题法很多人刷 SQL 题的方式是“看题 → 看答案 → 觉得自己会了”这是最典型的无效努力。因为面试不是判断题而是叙述题你看懂答案的瞬间不代表你能独立还原整个推导过程。我一般推荐三遍刷题法实测下来很稳。第一遍对照答案理解逻辑但不要只看要把 SQL 原样跑一遍。怎么搭环境这件事不过分强调随便用一个本地 MySQL 或 PostgreSQL 实例就行SQLite 也可以接受。关键是要亲眼看到结果集长什么样尤其是 LEFT JOIN 后右表为 NULL 的行、COUNT 计数为 0 的行只看不练很容易漏掉。第二遍关掉答案重新写同时处理边界情况。比如题目要求“找出最高薪资员工”你要主动追问一句如果有两个人并列最高怎么办如果某个部门没有员工怎么办这些边界不是刁难你而是工程师的本能。第三遍限时手写。模拟面试时白板编程的限制给自己三到五分钟不许查资料。写完之后大声解释一遍自己的思路这一步是为了训练你在压力下组织和表达能力。最关键的一点把答案里出现的表结构、样例数据、SQL 都存成一个 .sql 文件随时可以重建。这样你复习的时候不是对着 PDF 念而是真的在一个终端里跑查询。SQL 是实践学科眼会不等于手会。2. 第一梯队JOIN、聚合与去重2.1 问题一JOIN 类型辨析与关联陷阱JOIN 是 SQL 里最基础也最容易被问出细节的知识点。面试官通常会先问 INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL OUTER JOIN 的区别这一部分大多数人能答上来。真正把差距拉开的是“LEFT JOIN 之后数据量变多了你怎么排查”这个问题。我举个例子。假设有两个表用户表 usersuser_id 为主键、订单表 ordersorder_id 为主键user_id 为外键。执行SELECT u.user_id, u.name, o.order_id FROM users u LEFT JOIN orders o ON u.user_id o.user_id;如果用户表有 100 条记录但有一个用户下了 5 单那么结果集就不再是 100 行而是 100 加上这个用户多出来的 4 行也就是 104 行。这个膨胀的本质是JOIN 结果的行数是两个输入表按关联条件匹配的乘积之和。当右表关联键不唯一时左表一行会被复制成多行。面试时遇到这个题正确的回答思路是先定位关联字段的重复情况。你可以用一条临时查询验证SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id HAVING COUNT(*) 1;如果确实存在重复可以根据业务选择去重策略要么在关联前先对右表按 user_id 去重取最新一条要么接受一对多结果并在应用层做聚合。另一个容易忽略的坑是关联字段中存在 NULL。比如 LEFT JOIN 时如果右表关联键为 NULL它一定不会匹配上任何左表记录但你也不会在标准 LEFT JOIN 中看到“NULL 参与匹配”这件事因为 NULL 不参与等值比较。这一点我在问题二里还会再细说。2.2 问题二GROUP BY 与聚合函数的边界GROUP BY 的经典错误不是记不住语法而是搞不清楚“分组的粒度”。我给你一个高频面试题查每个部门的平均薪资和人数。SELECT department_id, AVG(salary) AS avg_salary, COUNT(*) AS emp_count FROM employee GROUP BY department_id;这道题基本属于送分题。但面试官紧接着会追问一句我能不能在 SELECT 里再加上 employee_name比如SELECT department_id, employee_name, AVG(salary) FROM employee GROUP BY department_id;标准 SQL 里这属于“语义非法”因为 employee_name 既没有出现在 GROUP BY 子句里也没有被聚合函数包裹。MySQL 的一些旧版本在默认 sql_mode 下不会报错会返回一个随机或不确定的 employee_name这让很多人误以为这是对的。实际上 Oracle、PostgreSQL、SQL Server 以及新版本的 MySQL 都会直接报错。会答正确用例还不够你要能解释说清楚背后的逻辑GROUP BY department_id 的意思是把 department_id 相同的一组行合并成一个结果行组内的 employee_name 可能有多个值数据库没办法确定你想看哪一个所以只能拒绝你的请求。与 GROUP BY 密不可分的还有 WHERE 和 HAVING 的区分。我常用一句话总结WHERE 是分组前过滤行HAVING 是分组后过滤组。比如“查平均薪资大于 8000 的部门且只统计在职员工”你会写成SELECT department_id, AVG(salary) AS avg_salary FROM employee WHERE status active GROUP BY department_id HAVING AVG(salary) 8000;如果写反了把员工状态过滤放到 HAVING 里不仅逻辑上别扭而且性能会更差因为需要在分组后再过滤很多无效行。2.3 问题三删除重复数据的三套方案“sql 语句去重”是一个出现频率极高的搜索词也是面试中的常客。常见的业务背景是由于接口重复提交或数据迁移失误用户表里出现了多条 user_id 相同、但 id 不同的记录现在要求只保留每个 user_id 中最早或最新的一条。我见过不少人第一反应是SELECT DISTINCT但这个解法在“保留一条完整记录”的需求面前基本是无效的因为 DISTINCT 只能帮你把查询结果去重并不能告诉你应该删除哪一行。真正的删除通常有三种方案。方案一使用窗口函数加 CTE这也是我最推荐的做法。以 PostgreSQL 为例先按 user_id 分组编号保留每组的第一条WITH ranked AS ( SELECT id, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at ASC) AS rn FROM users ) DELETE FROM users WHERE id IN ( SELECT id FROM ranked WHERE rn 1 );这段 SQL 的思路是先把所有重复的行标号rn1 的那条是保留行其余全部删掉。很多数据库如 MySQL 8.0、SQL Server、SQLite 都支持这种写法迁移成本很低。方案二使用自连接删除。比如保留每个 user_id 里 id 最小的那条DELETE u1 FROM users u1 INNER JOIN users u2 ON u1.user_id u2.user_id AND u1.id u2.id;这个思路也很有趣只要存在另一条 user_id 相同且 id 更小的记录当前这条就应该被删掉。不过缺点是不适合数据量特别大的表因为自连接会产生笛卡尔积性质的中间结果性能容易失控。方案三用 GROUP BY 加 MIN(id) 也可以但通常需要两步比较繁琐。这里必须给你一条最重要的经验生产环境删除前永远先把要删除的数据备份成一张临时表。哪怕你有百分之百的把握也先执行 SELECT 把影响行数看清楚再放进事务里执行出错立刻回滚。实际执行时我很建议先把 SELECT 语句改成SELECT * FROM ranked WHERE rn 1;确认阈值行数和你预期一致再改成 DELETE 执行。2.4 实战挑战经典订单表关联查询现在综合第一梯队三个考点给出一道典型实战题。现有两张表users(user_id, name)orders(order_id, user_id, amount)要求统计每个用户的累计下单金额和下单次数即使这个用户从未下单也要显示出来并按下单次数降序排列。这个题的考点非常密JOIN、GROUP BY、COUNT 与 COALESCE。标准解如下SELECT u.user_id, u.name, COUNT(o.order_id) AS order_cnt, COALESCE(SUM(o.amount), 0) AS total_amount FROM users u LEFT JOIN orders o ON u.user_id o.user_id GROUP BY u.user_id, u.name ORDER BY order_cnt DESC, total_amount DESC;你注意几个容易踩坑的细节。第一个细节为什么 COUNT(o.order_id) 而不是 COUNT()因为对于从未下单的用户LEFT JOIN 后右表字段全是 NULL。COUNT() 会把这个空行也数出来导致下单次数变成 1。而 COUNT(o.order_id) 只统计非 NULL 值符合业务语义。面试官经常用这个细节筛选候选人是否真的理解 COUNT(column) 和 COUNT(*) 的区别。第二个细节为什么 SUM 外面要套 COALESCE因为 SUM(o.amount) 在右表全为 NULL 时返回 NULL而不是 0。前端拿到 NULL 很容易渲染成空字符串引发后续判断混乱。做数据清洗的人应该深有体会。第三个细节GROUP BY 为什么要把 u.name 一起带进去因为 SELECT 里出现了 u.name而它不承担分组粒度。如果你的数据库比较严格不把 u.name 放进 GROUP BY 就会直接报错。从逻辑上讲user_id 已经是主键每个 user_id 对应固定 name所以这样分组不会破坏粒度。3. 第二梯队窗口函数、执行顺序与自连接3.1 问题四窗口函数三兄弟的差别窗口函数是面试中的分水岭会的人和不熟的人一眼就能看出来。最容易被问到的就是 ROW_NUMBER、RANK、DENSE_RANK 三者的区别。我用一个按工资排名的例子来说明。假设员工表里有两个部门工资分别是 5000、6000、6000、7000。执行SELECT employee_id, department_id, salary, ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) AS rn, RANK() OVER(PARTITION BY department_id ORDER BY salary DESC) AS rk, DENSE_RANK() OVER(PARTITION BY department_id ORDER BY salary DESC) AS dr FROM employee;结果里同一组内会有四种情况ROW_NUMBER() 给每一行一个连续的序号不关心是否并列6000 那两行会随机分配 2 和 3。RANK() 遇到并列时两人同名次但后续名次会跳跃比如两个人并列第二下一个人直接排第四。DENSE_RANK() 遇到并列时同名次但后续名次不跳跃两个人并列第二下一个人排第三。所以你回答问题时不要只说“RANK 是跳跃的DENSE_RANK 是不跳跃的”你更应该说明如果需求是“排行榜前 3 名并列名次算同榜允许第四名出现”用 RANK如果需求是“严格选出 3 个不同的人并列时按其他字段打破”用 ROW_NUMBER如果需求是“按工资分层让每一档都连续编号”用 DENSE_RANK。窗口函数还有一个常考扩展点OVER 子句里的 ORDER BY 结合 FRAME 使用可以计算累计求和。例如累计销售额SELECT sales_date, amount, SUM(amount) OVER(ORDER BY sales_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total FROM sales;我在面试时经常让候选人讲这个 SQL 的执行语义因为能考察他是否理解窗口函数是“先分组排序再对每行计算可移动的窗口范围”。3.2 问题五SQL 执行顺序为什么重要这道题表面上是背诵题的底子但实际非常考察理解。SQL 语句书写的顺序和逻辑执行顺序并不一致。标准逻辑顺序大致是FROM/JOIN - WHERE - GROUP BY - HAVING - SELECT - ORDER BY - LIMIT很多坑都源于这个顺序。最常见的一个问题能不能在 WHERE 子句里使用 SELECT 中定义的别名比如SELECT name AS employee_name FROM employee WHERE employee_name 张三;答案是不行。因为 WHERE 在逻辑执行上先于 SELECT当你写 WHERE employee_name 时数据库还不知道 employee_name 是什么。不同数据库的报错信息不同但本质上都是“列不存在”。那正确写法是什么要么直接写原始表达式要么把外层查询包一层子查询SELECT employee_name FROM ( SELECT name AS employee_name FROM employee ) t WHERE employee_name 张三;理解执行顺序还能帮你解释很多优化问题。比如WHERE 阶段能把很多行过滤掉所以尽早缩小数据范围很重要HAVING 是在分组之后执行所以能用 WHERE 过滤的字段就不要放到 HAVING 里因为前者的处理行数远小于后者。GROUP BY 里如果有函数表达式比如GROUP BY DATE(create_time)它更不可能用到常规索引优化器往往需要先做一次全表扫描。面试官问执行顺序其实是在确认你是否具备系统思维。不是让你背流程而是让你知道每个关键字“什么时候生效”这样你手写 SQL 时犯错概率更低排查问题也更有方向。3.3 问题六自连接与相关子查询的场景自连接算是一个“看一眼就会不点破就蒙”的考点。最常见的业务是组织架构里的员工-主管关系。假设员工表 employee(id, name, manager_id)manager_id 指向本表的另一位员工的 id。要查每位员工的直属主管姓名SELECT e1.name AS employee_name, e2.name AS manager_name FROM employee e1 LEFT JOIN employee e2 ON e1.manager_id e2.id;这里把同一张表当成两张逻辑表来关联一张代表“员工”另一张代表“主管”。很多候选人第一次见会愣一下但其实只要记住了一句表和它自己关联时必须给两个实例取不同的别名这个考点就破解了。与自连接经常并列出现的是相关子查询。比如“找出工资高于本部门平均工资的员工”这一题很多人的第一反应是先把部门平均工资 GROUP BY 出来再 JOIN 回去。这当然是一条路但相关子查询提供了一种更直观的写法SELECT e1.employee_id, e1.name, e1.salary FROM employee e1 WHERE e1.salary ( SELECT AVG(e2.salary) FROM employee e2 WHERE e2.department_id e1.department_id );这个 SQL 的意思是对外层每一行都执行一次内层子查询用当前行的部门 ID 去匹配平均值。优势是逻辑清晰缺点也很明显当数据量很大时这种“逐行子查询”可能引发严重的性能问题。所以我在实际生产里会更倾向 JOIN 写法但在面试中我反而希望候选人先能用相关子查询表达思路再说“如果数据量大我会改成 JOIN”。这体现的是你既理解解法又懂工程取舍。3.4 实战挑战每个分组取前 N 条记录“每个部门工资最高的前三名”属于窗口函数最经典的实战场景。笔试题通常会给你 employee 表要求输出部门 ID、员工名、工资并保证每个部门最多返回三行。多数人的第一版答案很可能是这样SELECT department_id, employee_id, salary FROM employee ORDER BY department_id, salary DESC;这个写法只能拿到全公司排序并不能只留前三名。要实现“分组 TopN”标准做法是先打上行号再在外面过滤WITH ranked AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employee ) SELECT department_id, employee_id, salary FROM ranked WHERE rn 3;我建议你写完之后主动确认一个边界如果两个员工工资相同前三条到底按什么规则给如果业务要求并列也要保留那就把 ROW_NUMBER() 换成 RANK() 或 DENSE_RANK()。这不是面试官刻意刁难而是“前 N 条记录”这个需求在真实业务里经常有歧义默认用 ROW_NUMBER 只是一种约定俗成不代表它是唯一答案。我在实际面试里还会加一问如果这张表没有主键你怎么保证 ROW_NUMBER 的结果是稳定的答案是在 ORDER BY 里补一个唯一列比如ORDER BY salary DESC, employee_id ASC。这一点能体现你对结果可复现性的重视。4. 第三梯队索引、慢 SQL 与注入安全4.1 问题七索引为什么能快又会失效索引相关问题几乎是所有“两年经验以上”岗位的必考题。面试官首先会问索引的原理你不需要把 B 树讲得多高深但至少要说清楚索引把无序的列值组织成有序结构让数据库不需要从第一行扫到最后一行而是通过树的高度快速定位到目标区间。索引之所以快本质是“把全表扫描变成树搜索”把时间复杂度从 O(n) 降到接近 O(log n)。紧跟着的问题就是“索引什么时候会失效”。所以你要把常见失效场景背扎实第一对索引列使用函数或表达式。例如WHERE YEAR(create_date) 2024即使 create_date 上有索引函数处理也会让索引失效因为数据库无法在索引存储的原始值上直接计算 YEAR。改写思路是WHERE create_date 2024-01-01 AND create_date 2025-01-01第二LIKE 前缀模糊查询。LIKE %abc的查询条件要求从头匹配索引结构无法帮你跳过前半部分所以基本只能全表扫描LIKE abc%则是可以用索引的。第三隐式类型转换。例如手机号字段是字符串类型你却写WHERE phone 13800000000数据库会把列上每个值都转换成数字去比较索引也就失效了。正确做法是写成字符串13800000000。第四联合索引不满足最左前缀原则。联合索引 (a, b, c) 能高效支持 a、ab、abc但如果条件里直接写 b 或者 c就没法从索引入口开始走。面试时最好主动补一句“怎么确认是否失效”这把问题的层次一下就拉高了。回答是用 EXPLAIN 看执行计划重点看 type 列和 key 列。如果 key 为 NULL说明全表扫描了。4.2 问题八SELECT * 的隐藏代价这个问题看着简单但背后关联着数据库性能和接口设计的多个层面。很多初级开发喜欢写 SELECT *因为省事。但面试官追问“它到底慢在哪”的时候答不全的人很多。首先SELECT * 会读取表中所有字段这意味更大的 I/O。如果一张表有 20 个字段而你只需要其中 2 个数据库也要把整行数据从磁盘读出来在线交易系统里这会放大几十倍扫描成本。其次它会产生无意义的网络传输。在分布式数据库或数据库与应用服务器分离的架构中数据要从数据库节点传到应用节点。你多 select 了 18 个用不到的字段网卡和内存都在做无用功TPS 掉下来往往就在这种“看起来无所谓”的地方。第三存在兼容性风险。如果你的应用代码依赖 SELECT * 返回固定列顺序当表结构新增字段时代码里的映射关系可能错乱尤其是在不够严谨的 ORM 配置下。更稳妥的做法是显式列出需要的字段让代码和数据的契约保持清晰。最后它还会影响优化器的选择。有些数据库支持覆盖索引也就是查询的字段全部包含在索引里时数据库可以直接扫描索引而不回表。SELECT * 则不可能走覆盖索引因为索引通常不会包含全部字段。面试中你最好能把“覆盖索引”这个概念带出来因为这是从“能不能写”上升到“会不会优化”的一个明显信号。4.3 问题九如何用参数化查询防住 SQL 注入最近很多热词里都有“sql注入”“sql注入万能密码绕过”这说明这个问题在实战中的存在感极强面试几乎必定会问。先理解攻击的根本原因SQL 注入的本质是“代码与数据没有分离”。当用户输入被直接拼接到 SQL 语句里时数据库无法区分哪一段是结构、哪一段是数据于是输入可以被解释成 SQL 语法的一部分。举一个经典的登录场景。假设后端代码写出这样一段sql SELECT * FROM users WHERE account account AND pwd pwd cur.execute(sql)如果用户在密码框里输入 OR 11 --拼接出来的 SQL 会变成SELECT * FROM users WHERE account admin AND pwd OR 11 --因为11恒为真注释符号--又把后面的内容全部注释掉攻击者不需要知道任何密码就能绕过登录。这就是所谓的“万能密码绕过”。正确的解决方案是使用参数化查询让输入永远作为“参数值”而非“可执行语法”传给数据库。以 Python 的 pymysql 为例cur.execute(SELECT * FROM users WHERE account %s AND pwd %s, (account, pwd))这里的%s是占位符数据库驱动会对参数做转义和类型绑定用户输入中的单引号、注释符号都只会被当作普通字符串处理不可能改变 SQL 语句的结构。回答这个题的时候有经验的候选人还会补充两条一是不要把数据库权限给得过大应用账号原则上只具备它需要的增删改查权限二是要做输入校验比如账号字段限制长度和字符集但输入校验是辅助真正的防线始终是参数化。4.4 问题十慢 SQL 优化的完整排查路径慢 SQL 优化是一个特别开放的问题面试官一般给一个场景看你有没有完整的方法论。我总结的路径是先找到慢查询再定位瓶颈最后针对性优化。不要一上来就谈加索引那样显得经验不足。第一步打开慢查询日志。MySQL 可以在配置文件里设置slow_query_log和long_query_time把执行时间超过阈值的 SQL 捞出来做全量排行。这一步解决的问题是你连哪些 SQL 慢都不知道优化无从谈起。第二步拿到具体 SQL 后执行 EXPLAIN。重点看几个关键信息typeall 表示全表扫描ref 或 range 表示走了索引范围扫描。key实际使用的索引。rows预估扫描行数。Extra是否有 Using filesort、Using temporary这些通常意味着排序或分组没有走索引性能会比较差。第三步审查 SQL 本身的写法。是不是 SELECT *是不是有隐式类型转换是不是子查询里嵌套了逐行执行的相关子查询是不是 OFFSET 过大的分页比如LIMIT 100000, 20会让数据库先扫描十万行再丢弃前面的数据这种场景可以改成“延迟 JOIN”或“基于游标的分页”-- 原始写法深分页 SELECT * FROM orders ORDER BY id LIMIT 100000, 20; -- 更优写法记住上一页最大 id SELECT * FROM orders WHERE id 100000 ORDER BY id LIMIT 20;第四步根据查询条件设计索引。你要先看清 WHERE 和 ORDER BY 里有哪些列再设计联合索引同时遵守最左前缀原则。加完索引之后回到第二步重新 EXPLAIN对比 rows 从多少降到多少。优化的闭环是前后对比而不是加完索引就跑。第五步如果数据量已经到了单表亿级索引可能也不够用这时候才考虑分区表、读写分离、汇总表这些重武器。要注意的是这些重武器都会带来运维复杂度面试时你只要点出“数据量到什么量级时该考虑”就会显得很有分寸感。4.5 实战挑战把一条慢查询改写掉现在给出一条典型的慢查询你可以在本地模拟优化过程。假设订单明细表 order_item 有 1000 万行表上有 create_date 和 status 两个字段查询需求是“查询 2024 年且状态为 PAID 的最近 20 条订单”。原始写法SELECT * FROM order_item WHERE YEAR(create_date) 2024 AND status PAID ORDER BY create_date DESC LIMIT 20;为什么慢第一条问题就是 YEAR(create_date) 这个函数包裹让 create_date 上的索引失效必须把所有行取出来逐个算年份。第二条问题用 SELECT *表里哪怕有 30 个字段也会全部读出来。第三条问题是在 WHERE 里同时出现了 create_date 和 status但没有一个联合索引能同时覆盖两者。改写要点有三个。首先把函数表达式改成范围查询SELECT id, order_id, status, amount, create_date FROM order_item WHERE create_date 2024-01-01 AND create_date 2025-01-01 AND status PAID ORDER BY create_date DESC LIMIT 20;然后给表设计一个联合索引建议顺序是 (status, create_date)。为什么不把 create_date 放前面因为你 WHERE 条件是“精确匹配 status 范围匹配 create_date”联合索引把等值条件的列放前面可以让索引定位更稳定后面的范围列负责排序还能避免文件排序。最后再加上延迟关联思路如果数据量真的大可以先从索引里取到 20 个主键再回表拿完整数据SELECT oi.* FROM ( SELECT id FROM order_item WHERE create_date 2024-01-01 AND create_date 2025-01-01 AND status PAID ORDER BY create_date DESC LIMIT 20 ) tmp JOIN order_item oi ON tmp.id oi.id ORDER BY oi.create_date DESC;这个改写过程体现的是完整的优化意识先消灭函数包裹再做索引设计最后用延迟关联减少回表。如果你在面试里能从第一版写到这个版本慢 SQL 这个话题基本上就稳了。5. 常见问题与排查技巧实录5.1 误区一别名引用顺序问题我在面试和代码审查中见过最多的问题就是候选人在 WHERE 里使用 SELECT 别名。每次出现这个错我都会反问他一句SQL 的执行顺序是什么如果你能立刻说出 WHERE 在 SELECT 之前你就会意识到这个错误不是语法问题而是逻辑模型问题。比如这段SELECT employee_id, salary / 1000 AS salary_k FROM employee WHERE salary_k 50;正确的写法是直接使用原始表达式或包一层子查询。这两种方案的区别在于前者对每一行计算一次 salary_k后者则要构建一个临时结果集数据量大时有额外开销。所以我的习惯是能直接写表达式就写表达式不要总是套子查询。这个错误的另一个变体出现在 ORDER BY 里。ORDER BY 在逻辑上确实可以在 SELECT 之后使用别名所以ORDER BY salary_k是合法的。这也是造成很多人混淆的原因——明明 ORDER BY 能用别名WHERE 为什么不能用因为两者的执行顺序不同理解顺序才是根治问题的关键。5.2 误区二NULL 的“三值逻辑”SQL 里的 NULL 是一个极其反直觉的存在。它既不等于空字符串也不等于 0更不等于它自己。在大多数数据库里NULL NULL的结果是 NULL 而不是 true所以你不能写WHERE column NULL必须写WHERE column IS NULL。NULL 带来的另一个大坑是 NOT IN 子查询。考虑下面这段 SQLSELECT id FROM users WHERE id NOT IN ( SELECT user_id FROM blacklist );如果 blacklist 表里哪怕只有一行 user_id 是 NULL这个查询的结果就可能为空。原因在于 NOT IN 的逻辑等价于“不等于子查询中的每一个值”一旦子查询中出现 NULLid ! NULL的结果是未知导致整行无法通过过滤。很多初级开发遇到“明明有数据却查不出来”的问题第二个念头才想到 NULL但其实这里的 NULL 是罪魁祸首。稳妥的做法是换成 NOT EXISTSSELECT id FROM users u WHERE NOT EXISTS ( SELECT 1 FROM blacklist b WHERE b.user_id u.id );NOT EXISTS 依赖子查询的“是否存在行”而不是逐值比较NULL 不会干扰结果。如果你在面试时能主动讲出这两种写法的差异面试官会非常满意因为这体现的是实打实的经验不是背出来的。5.3 误区三数据量大时子查询的替代方案子查询本身没有错但是在数据量大、索引设计不合理的时候一些子查询会被优化成逐行循环性能急剧下降。最典型的就是相关子查询比如“查每个用户最新订单”SELECT * FROM orders o1 WHERE o1.create_time ( SELECT MAX(o2.create_time) FROM orders o2 WHERE o2.user_id o1.user_id );这种写法逻辑正确但内层子查询对外层每一行都要执行一次数据量一旦上来就是灾难。更通用的替代方案是用窗口函数WITH ranked AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY create_time DESC) AS rn FROM orders ) SELECT * FROM ranked WHERE rn 1;这个写法的好处是只需要一次表扫描加一次排序数据库内部可以用哈希或排序实现窗口不用反复回表扫描。窗口函数或许在刚开始理解时要花点精力但它就是专门为了解决这类“分组内取极值”问题的。如果在 MySQL 5.7 这种不支持窗口函数的环境里也可以先查出每个用户的最大时间再 JOIN 原表SELECT o.* FROM orders o JOIN ( SELECT user_id, MAX(create_time) AS max_time FROM orders GROUP BY user_id ) t ON o.user_id t.user_id AND o.create_time t.max_time;这个方案至少把“逐行子查询”变成了“一次分组 一次 JOIN”性能通常更有保障。面试时你可以把这条演进路线讲出来从相关子查询到 JOIN再到窗口函数。这是很好的思路展示。5.4 高频面试题速查表我把上面遇到的问题浓缩成一张速查表面试前看一遍能快速唤醒记忆也是很多读者反馈比较实用的东西。问题场景关键结论常见错误LEFT JOIN 后行数变多右表关联键不唯一导致结果膨胀不做去重直接聚合GROUP BY 选非分组列必须保证列在分组或聚合中用 MySQL 宽松模式误导删除重复数据用 ROW_NUMBER CTE 或自连接直接 DISTINCTWHERE 使用别名WHERE 在 SELECT 前执行误以为语法兼容NULL 判断用 IS NULL / IS NOT NULL写 NULLNOT IN 与 NULL子查询含 NULL 时失效忽略 NULL 干扰分组 TopN窗口函数 外层过滤只排序不过滤索引失效函数、前模糊、类型转换、最左前缀无脑加索引SELECT *增加 I/O 与兼容风险忽略覆盖索引SQL 注入参数化查询拼接用户输入慢 SQL慢日志 EXPLAIN 索引 改写上来就加索引这张表没法替代练习但它能帮你快速检查知识框架里有没有漏洞。这些考点互相纠缠所以哪怕你只记住了表格关键词也能在面试现场顺着思路展开。5.5 说句实在话面试前最后一周怎么练很多人习惯在面试前一周疯狂背题我的建议是反过来少背题多照镜子式复习。你找一个晚上把十大问题分别写在小纸条上抽到一张就口述自己的解法边说边在白纸上画表结构和 SQL 语句。坚持三天后再去做模拟题你会发现思维比硬背答案清晰得多。我在实际面试中经常用“为什么”连续追问三遍。第一遍问“怎么解”第二遍问“为什么这么解”第三遍问“如果数据量翻一百倍你改哪里”。大多数人的卡点不在第一问而在第三问。这也是我把索引、慢 SQL、注入安全放在最后一部分的原因——它们代表的不是语法熟练度而是把你的 SQL 能力从“能跑”推向“能用、能扛、能守”。所以如果你的时间只够准备一件事我建议是把每个实战挑战的 SQL 亲手跑一遍之后主动给自己提几个“如果”。如果这里有个 NULL 会怎样如果有重复数据会怎样如果这个表有 1000 万行又会怎样能回答出这些“如果”你才真正把题目消化成了自己的东西。最后再分享一个很实用的小习惯建一个本地练习库专门用来看执行计划。EXPLAIN 的输出多看看就算一开始看不懂也比完全不看强。我第一次看懂执行计划的时候才意识到之前很多“为什么这么慢”的疑问全都找到了答案。SQL 面试题从来不是死记硬背的题库它是逼你去理解数据运行规律的敲门砖。