
作为一个常年跟MySQL打交道的人我一直觉得SQL是最不该纸上谈兵的技能。很多初学者捧着《SQL必知必会》啃了好几遍一碰到实际需求还是写不出高效的查询也有一些工作两三年的开发基础的增删改查没问题但遇到稍微复杂点的统计、去重、多层嵌套就卡壳。这背后的差距不是知识点没记住而是练得太少。所以我一直有个习惯不管是带新人还是自己复盘都会把SQL练习题当成磨刀石——每道题背后都藏着一种查询思路练透了写起来自然就有感觉。这篇文章我会结合一套覆盖基础到进阶的MySQL SQL练习题目从题目怎么设计、每个知识点背后在考什么、实际写题时有哪些要注意的坑到慢SQL怎么排查、面试题怎么举一反三完整过一遍。内容适合正在学SQL的初学者、想系统补基础的数据分析师以及准备面试的开发同学。1. 内容整体设计与思路拆解很多人在准备SQL练习时有一个误区以为题目越多越好于是下载了一堆1000道SQL练习题结果做来做去都是重复的JOIN和GROUP BY做完感觉提升了但遇到真实的业务问题还是无从下手。真正有效的练习题设计不应该是一个简单的题量堆砌而是要有一个清晰的难度阶梯和知识点覆盖逻辑。1.1 练习题目设计的三个原则第一是覆盖一条完整的学习链路。从单表的基础查询SELECT、WHERE、ORDER BY、LIMIT到多表关联INNER JOIN、LEFT JOIN再到聚合统计GROUP BY、HAVING、COUNT/SUM/AVG然后是进阶窗口函数ROW_NUMBER、RANK、LAG/LEAD、子查询、临时表最后是性能优化相关的“用EXPLAIN分析慢查询”。这其实对应了一个人从“会写”到“写得对”再到“写得好 ”的进阶路径。第二是每个知识点都要对应一个业务场景。题目不能是“把表A和表B连接起来”这种抽象的指令而应该是“查一下每个品类销量前三的商品”“找出连续三个月销售额下降的门店”这样贴近真实需求的场景。因为做题的目的是为了迁移到真实工作里如果你的练习全是脱离业务的语法套用那迁移成本就很高。第三是题目之间最好有前后依赖关系用一套数据贯穿。比如建一个电商数据库里面有用户表、订单表、商品表、品类表、支付流水表一套数据可以设计几十道题从简单到复杂还能复用表结构做题的时候不用反复切换上下文。1.2 一道好题为什么难出很多初学SQL的人以为出题很容易“不就是写个需求然后写SQL吗”其实一道好题的关键在于它不能只有一种解法或者不能仅靠一种技巧就能完成。我比较推崇的题目设计是一道题至少涉及两个知识点并且隐含着“多种解法路径”。举例来说同样是“查每个用户的最近一笔订单”初级做法是关联子查询进阶做法是窗口函数ROW_NUMBER()。这道题就能同时考察关联子查询的写法和窗口函数的用法还能引导做题人思考两者的性能差异。而“查询连续登录3天以上的用户”这种题更是涉及自连接、日期函数、行号差值等多个坑点能真正把只会背语法的人区分出来。所以我在这套练习题里刻意把题目分成了四层基础语法层、关联与聚合层、窗口函数与子查询层、综合业务题。前两层保证基础知识全覆盖后面两层用来拉差距每道题都尽量做到“一题多解”或“解法背后有取舍”。1.3 一套练习数据的设计练习数据我用了一套电商订单模型。这里我要多说一句做题最忌讳的是表结构只有三五个字段——太简单练不出什么。真实业务里的表结构往往有十几个字段而且会有脏数据NULL、重复、关联不上SQL能力恰恰是在处理这些“现实问题”时体现的。我的这套练习数据用的是四张关联表用户表useruser_id、user_name、register_time、user_level、gender、age商品表productproduct_id、product_name、category_id、price、stock、shelf_time订单表ordersorder_id、user_id、product_id、order_amount、pay_status、order_time、pay_time订单明细表order_detaildetail_id、order_id、product_id、quantity、price下单时快照价四张表用外键关联起来数据我用一个存储过程随机生成大概能造出几百个用户、几千个商品、几万条订单。为什么不用现成的数据库因为没有真实业务感的随机数据很难模拟出“订单下到一半用户取消”“商品下架但历史订单还在”这种坑点——而这些正是工作里最常见的脏数据场景。另外光有数据结构还不够练习题里一定要设计几道关于NULL值处理的题。因为新手最容易忽略NULL的存在。比如统计用户平均年龄的时候究竟是直接AVG(age)还是先过滤掉NULL结果差异很大再比如LEFT JOIN匹配不上的时候字段值变成NULL用WHERE过滤时要加IS NULL而不是用 NULL这类细节做题踩一次比看书十遍都管用。2. 核心细节解析与实操要点2.1 环境准备本地练习MySQL环境怎么搭在开始做题之前我先说说环境准备的问题。我见过不少人因为环境搭不起来直接导致学习进度卡了一个星期。实际上本地起一个MySQL练习环境没那么复杂。最推荐的方式是直接用Docker。我给出的命令如下docker run --name mysql-practice \ -e MYSQL_ROOT_PASSWORDroot123 \ -e MYSQL_DATABASEshop \ -p 3306:3306 \ -d mysql:8.0这个命令会直接拉起一个MySQL 8.0容器并且自动创建名为shop的数据库。如果你不想用Docker也可以去官网下载MySQL Installer一路默认配置但要注意root密码用MySQL 8.0默认的caching_sha2_password认证后面用Navicat连接的时候要选对版本不然老版本的客户端会报认证失败。另外我特别推荐一种做法在练习SQL的时候用命令行客户端跑而不是一上来就依赖图形化工具。命令行的好处是它不会给你任何语法提示逼着你把每一条语句和每个关键字都写对。Navicat或DataGrip这类工具适合做数据查看和结果浏览但如果你一直在里面写SQL很可能会养成“一半靠提示”的依赖习惯。注意MySQL 8.0和5.7在窗口函数支持上没有太大差异但如果你用了5.6或更老版本窗口函数就完全不支持了。练习环境建议直接用8.0跟主流生产环境的版本也更接近。初始化数据我用一个脚本文件来执行建表后灌入测试数据mysql -uroot -proot123 shop init.sqlinit.sql里面包含建表语句、随机数据的存储过程、以及触发器的逻辑。这里有一个细节我很想强调练习数据需要有一定的数据量级至少让orders表有几万行这样涉及连接和聚合的题目跑起来才能体会到索引的作用。只有几百行数据的话写不写索引性能毫无差别你也永远体会不到为什么JOIN要在关联字段上加索引。2.2 MySQL基础SQL语法要点排序、去重与分页基础部分我一共设计了十来道小题重点覆盖三个高频场景排序、去重、分页。先看去重。MySQL里去重有两个工具DISTINCT和GROUP BY。什么时候用哪个以我的经验单纯查询不重复的单个字段/多个字段组合用DISTINCT。涉及到其他聚合字段同时统计时用GROUP BY更灵活。当需要对重复数据“保留其中一条”时上述两个都不合适需要用窗口函数ROW_NUMBER()配合分区。练习题目可以是这样查询全部分区下有没有重复的商品名称。我先教你用GROUP BY查出来有哪些商品的名称出现了不止一次SELECT product_name, COUNT(*) AS cnt FROM product GROUP BY product_name HAVING cnt 1;这里有个新手高频坑用HAVING过滤聚合结果而不是用WHERE。WHERE在GROUP BY之前执行没法对聚合后的结果做过滤只有HAVING可以。如果你写WHERE cnt 1MySQL会直接报错——因为你不能在WHERE里引用别名这是SQL的执行顺序决定的。排序方面ORDER BY在MySQL里的一个隐藏能力是可以用数字表示第几列。比如说ORDER BY 2, 3表示先按第2个查询字段排再按第3个排。这种写法省事但一旦调整了SELECT字段顺序就会出错我不太推荐在生产代码里用。练习题里我倒是专门出了一道让大家自己踩一下这个坑——这样后面才有记忆。分页查询的写法大家基本都会LIMIT 10 OFFSET 20或者简写成LIMIT 20, 10。这里有个性能坑值得专门做一道题数据量大时深分页会导致MySQL扫描前面所有数据然后丢掉。我通常的做法是把LIMIT的深度结合一个起始主键IDWHERE id 上次最大id ORDER BY id LIMIT 20这样每翻一页都是走主键索引快速定位。2.3 多表关联JOIN该怎么选多表JOIN是SQL练习题里的重头戏。很多新手在写JOIN时只记规则比如LEFT JOIN就是左边表全要“INNER JOIN就是取交集”除了记口诀我更希望大家理解JOIN背后的数据形态两个表连接之后行数是怎么变化的这是理解JOIN的关键。比如说订单表orders关联用户表user一个用户可能有多个订单那么在JOIN之后用户表的数据会被“放大”成跟他的订单一样多的行数。如果用户表里一个用户有5个订单JOIN后这个用户就出现5行。听起来很基础但我见过很多人在做统计时踩了“JOIN后行数变多导致SUM重复计算”的坑。一道比较好的练习题是统计每个用户的下单总金额要求保留没有下过单的用户金额为0。这题的考点在于必须用LEFT JOIN而不能用INNER JOIN否则没订单的用户会被过滤掉处理NULL值——没下单的用户SUM结果会是NULL需要用IFNULL转成0最后GROUP BY user_id因为这是一对多关系的聚合。SELECT u.user_id, u.user_name, IFNULL(SUM(o.order_amount), 0) AS total_amount FROM user u LEFT JOIN orders o ON u.user_id o.user_id GROUP BY u.user_id, u.user_name;这里我再提一个常见陷阱GROUP BY的字段必须跟SELECT中的非聚合字段一致。在MySQL 5.7.5之前默认开启了ONLY_FULL_GROUP_BY的严格模式如果只GROUP BY u.user_id而SELECT里出现了u.user_nameSQL直接报错。有些旧教程会教你只按主键分组其他字段随便选——这在MySQL 5.7早期可以“侥幸通过”但在新版本行不通。做练习的时候反而用新版本是好事能从一开始就养成规范写法。关于JOIN类型的选择我做的练习题里专门设计了一组对比场景场景推荐JOIN类型原因只查有订单的用户INNER JOIN过滤掉无订单用户性能更好查所有用户及其订单情况LEFT JOIN保留左表全部用户查每个商品及其品类名INNER JOIN或LEFT JOIN均可商品一定属于某个品类结果一样查没有品类的商品异常数据LEFT JOIN IS NULL能查出关联不上的行JOIN还有一类高级话题是自连接。自连接看起来就是把同一张表当成两张表来用实际上它解决的是一类“行与行”之间比较的问题。比如查“比自己所在品类平均价格高的商品”这个就需要自连接product表和聚合后的价格数据再比如查“员工工资高于直属领导”这种经典题用自连接会特别直观。SELECT e.emp_name AS employee, m.emp_name AS manager FROM emp e LEFT JOIN emp m ON e.manager_id m.emp_id WHERE e.salary m.salary;这种题看起来不难但能极大加深对JOIN本质的理解——SQL里的JOIN不只是“把表拼起来”更是一种“行与行关联比较”的工具。2.4 聚合函数与GROUP BY细节说到聚合我见到的错误用法比正确用法多得多。练习中有一类高频题就是围绕各种聚合函数的边界情况设计的。COUNT()和COUNT(columnName)的区别我认为是新手第一道必须做明白的分水岭题。COUNT()统计的是行数不管这一行里有没有NULL而COUNT(column_name)会忽略NULL值。举个例子统计订单数时订单表每条记录就是一行用COUNT(*)没问题但如果查询每个用户的支付成功订单数而有一行pay_time是NULL表示未支付成功那COUNT(pay_time)就会少算这一行。这不是语法问题是业务语义问题——SQL里每个函数的行为差异最终映射的都是业务规则。SUM也同样要注意NULL的特性。SUM(NULL)的结果是NULL不是0。这在报表里会造成“明明没有数据却显示空值”的问题。所以练习题里我专门设置了一组数据某个新注册用户没有下过单他的总消费金额应该是0还是NULL答案取决于需求但从SQL严谨性的角度我会用IFNULL或COALESCE把NULL转成0。GROUP BY这里还有一道经典易错题查每个品类下商品的平均价格但只保留平均价格高于某个阈值的品类。这考的就是WHERE和HAVING的执行顺序。我把它总结成一句话WHERE是先过滤再分组HAVING是先分组再过滤。SELECT category_id, AVG(price) AS avg_price FROM product WHERE price IS NOT NULL GROUP BY category_id HAVING avg_price 100;这里用WHERE先排除掉价格为NULL的“异常数据”再用HAVING过滤平均价。我在题目解析里会特别指出先WHERE还是后HAVING不只是一个风格选择它会直接影响结果集和运行效率。3. 实操过程与核心环节实现3.1 题目示例与完整解答我把一套完整练习题的题目列表放在这里大家可以先自己做一遍再对照解析。这套题是我用上面的shop库结构设计的每一道题都对应上面提到的知识点。基础层题目查询所有用户的信息按注册时间倒序排列取最近7天注册的10个用户。查询所有商品的品类列表去掉重复品类。查询订单金额大于100的订单按金额降序排列分页显示第3页的数据。统计订单表中订单状态为“已支付”和“待支付”的订单数量按状态分组。关联层题目查询每个用户最近一笔订单的订单号、金额和下单时间。查询每个品类下销量最高的商品按订单数量而不是金额。查询所有没有下过订单的用户。查询下单用户的用户等级分布按等级统计人数和平均客单价。进阶聚合题目统计每位用户每个月的下单金额并计算月环比增长率。查询订单金额高于全局平均金额的订单并找出这些订单对应的用户的城市分布。查询最近30天内连续3天都有下单的用户。按用户统计累计消费金额排名前5的用户用窗口函数。综合题统计每个品类每月的销售额并计算各品类占全店当月销售额的比例。查找同一个用户在同一天下的多笔订单中金额较大的一笔输出该用户、日期、金额。分析用户下单间隔找出平均下单间隔超过15天的用户。第5题的解法我这里完整写一遍因为它是子查询和窗口函数的分水岭题。题目是“查每个用户最近一笔订单”不用窗口函数的写法SELECT o.order_id, o.user_id, o.order_amount, o.order_time FROM orders o WHERE o.order_time ( SELECT MAX(o2.order_time) FROM orders o2 WHERE o2.user_id o.user_id );但这里有个小陷阱如果同一个用户在同一个秒级时间下下了多笔订单这个SQL会把多笔都查出来而需求是“最近一笔订单”应该只保留一笔。从业务严谨性考虑条件必须加上order_id的最大值判断或在关联时要求同一天内order_id最大。这也顺便引出一个重要经验做题时必须思考“同一时刻有多条记录”这种情况。换用窗口函数则更清爽SELECT order_id, user_id, order_amount, order_time FROM ( SELECT order_id, user_id, order_amount, order_time, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM orders ) t WHERE t.rn 1;我特意把这题放在进阶部分因为它是窗口函数的第一道典型题。窗口函数和传统GROUP BY最大的不同在于GROUP BY会把分组内的多行折叠成一行而窗口函数在计算分组的同事还保留每一行数据。这种“既要明细又要聚合结果”的能力是做复杂报表的利器。3.2 常用练习技巧灵活运用临时表和视图练习过程中数数是个好习惯但有时候会遇到“单条SQL写不出”的题目。这时候如果非逼着自己用一条SQL写完很容易陷入思路的死胡同。我建议的做法是先拆成多条SQL来跑通逻辑再用子查询或临时表合并。实操里有一个非常实用的技巧把中间结果存成临时表分步调试。举个例子第14题“查找同一个用户在同一天下的多笔订单中金额较大的一笔”单条SQL写法需要对同日多笔订单做窗口排序但如果你在调试的时候觉得窗口函数一下子搞不定可以先建一个临时表把每个用户每天订单的金额排个序观察清楚结果集再合并。CREATE TEMPORARY TABLE tmp_ranked_order AS SELECT order_id, user_id, DATE(order_time) AS order_day, order_amount, ROW_NUMBER() OVER(PARTITION BY user_id, DATE(order_time) ORDER BY order_amount DESC) AS rn FROM orders; SELECT user_id, order_day, order_amount FROM tmp_ranked_order WHERE rn 1;这样做的好处是每一步的结果都能直接查出来纠正。很多新手在调试复杂SQL时报错根本不知道是哪一步逻辑出了问题用临时表分步写报错范围就缩小了。而且临时表会在会话结束自动删除练习环境里反复改表结构也不会有负担。视图在练习题里也有巧妙用途。比如我有道题是“每个品类月销售趋势”这种题如果只写一条SQL要阅读起来非常困难但可以先把“各品类各月销售额”的基础视图建好再在这个视图之上做跨月环比CREATE VIEW v_category_month_sales AS SELECT category_id, DATE_FORMAT(o.order_time, %Y-%m) AS month, SUM(od.quantity * od.price) AS sales_amount FROM order_detail od JOIN orders o ON od.order_id o.order_id GROUP BY category_id, DATE_FORMAT(o.order_time, %Y-%m);然后针对这个视图继续查询逻辑瞬间清晰了。这种分层的做法也是生产环境中ETL和报表开发的标准思路——先做明细汇总层再做业务指标层最后做输出报表层。练习的时候养成这种“分层思维”比单纯刷题收获更大。3.3 围绕“慢SQL优化”的实操练习很多人练SQL只练“写法”不练“性能”等于只学了一半。一道SQL能在几十毫秒和几秒之间切换往往就是写法和索引的差别。我在练习集的后面专门加了一节“用慢SQL优化”每道题都要求做完之后跑一次EXPLAIN看执行计划。先说一个真实的例子。有一个订单查询需求查某个用户最近20笔已完成订单。新手最容易写出来的版本是SELECT order_id, order_amount, order_time FROM orders WHERE user_id 12345 AND pay_status 1 ORDER BY order_time DESC LIMIT 20;如果orders表有几百万行这个查询在没索引的情况下会全表扫描。这时候用EXPLAIN看执行计划会看到typeALL、rows几百万。解决方案很直接给(user_id, pay_status, order_time)建一个联合索引最好用( user_id, order_time )因为pay_status字段往往区分度不高把它放在索引里收益不大。ALTER TABLE orders ADD INDEX idx_user_time (user_id, order_time);加完索引后再跑EXPLAINtype会从ALL变成refrows骤减查询时间通常能缩短一到两个数量级。这个练习给了我一个很深的体会SQL优化绝大多数时候不是靠玄学记各种技巧而是要能看懂执行计划理解数据访问的路径。EXPLAIN里的type字段从system到const、eq_ref、ref、range、index、ALL对应的性能依次递减这基本是SQL优化的核心方法论。我把这作为练习题最后一大章节每个练习题都要求“先写后查再优化”。关于索引失效的情况有一类题目也值得专门练习在索引字段上做函数运算。比如WHERE DATE(order_time) 2024-01-01MySQL会放弃索引改成WHERE order_time 2024-01-01 AND order_time 2024-01-02执行计划才会走range。类似的还有WHERE user_id 1 100这种写法索引直接失效。这些细节在练习题里加上才算是把“SQL基础”跟“SQL优化”接上了轨。4. 常见问题与排查技巧实录4.1 练习题中我踩过的5个坑第一坑把GROUP BY和DISTINCT当成完全等价。我最初练习的时候以为一条“查出去重后的用户等级”的SQLGROUP BY和DISTINCT没区别。后来加了个需求“按等级统计人数”才发现DISTINCT写不出聚合只能GROUP BY。这不是谁替代谁的问题而是它们解决的场景不同。DISTINCT用于行去重GROUP BY用于分组聚合。第二坑LEFT JOIN之后过滤条件放错位置。这个坑尤其隐蔽。我想查“所有用户以及他们的已完成订单”于是写了SELECT u.user_id, u.user_name, o.order_id, o.order_amount FROM user u LEFT JOIN orders o ON u.user_id o.user_id WHERE o.pay_status 1;跑出来的结果永远是“只包含已支付订单的用户”那些没有已支付订单的用户被过滤掉了。原因很简单WHERE在JOIN完成之后执行一旦加上WHERE o.pay_status 1行数被过滤LEFT JOIN的“保留左表全部”语义就被打破了。正确写法是把过滤条件放在JOIN的ON子句里SELECT u.user_id, u.user_name, o.order_id, o.order_amount FROM user u LEFT JOIN orders o ON u.user_id o.user_id AND o.pay_status 1;现在结果会保留没有订单的用户他们的订单字段显示NULL业务上才正确。这个坑我在练习题里反复出现目的就是让大家养成“ON负责关联条件、WHERE负责结果集过滤”的意识。第三坑用GROUP BY查询时SELECT了多余字段。我之前一直用旧版MySQL练习根本不知道有这个限制。换到8.0之后一条SQL直接报错折腾了很久才明白ONLY_FULL_GROUP_BY的含意。现在的解法是要么把字段包含进GROUP BY要么用ANY_VALUE()包住非聚合字段要么用窗口函数和子查询重构。这个坑对新手来说特别容易撞上。第四坑使用IFNULL或COALESCE处理NULL时搞错预期结果。我之前处理“每个用户的订单总额”时直接把SUM(order_amount)放进了IFNULL里然后发现没下单的用户显示0了——这个没问题。但后来我把IFNULL放在了SUM里面写成了SUM(IFNULL(order_amount, 0))结果发现对于“没有订单的用户”依然返回NULL因为SUM本身没有扫到任何一行IFNULL根本不会被调用。正确的NULL处理位置是在聚合之后不是聚合之内。第五坑窗口函数里ORDER BY的方向搞反。RANK()、ROW_NUMBER()、DENSE_RANK()虽然长得像但语义有微妙差别尤其是在“并列排名”这个场景下。做第9题“按用户统计累计消费金额排名前5”时如果需求是“金额相同的用户并列”就该用RANK()或DENSE_RANK()如果只用ROW_NUMBER()虽然也能选出5行但金额完全相同的人会被随机排序不满足“并列”的业务含义。这个差别练习一次记一辈子。提示在做“排名类”题目时先确认业务需要的是“行号去重”还是“允许并列”再选择窗口函数顺序不能反过来。4.2 从练习到排查以“SQL去重查询”为例的实战思路最近“SQL去重”被讨论得很多我来把这一块单独展开说说。去重不只是一句DISTINCT碰到真实数据时经常会遇到“表面看起来重复实际上业务上要保留多条”的情况。举个例子订单明细表order_detail里记录了每个订单买了哪些商品如果同一个订单里有一个商品买了2件insert时有两条重复的detail记录吗按正确表设计当然不会但真实环境下难免有冗余或者重复上报。练习题里有一道是查出order_detail表中完全重复的明细记录。这时候就需要用DISTINCT或GROUP BY找出所有重复行并查看。SELECT detail_id, order_id, product_id, quantity, price, COUNT(*) AS cnt FROM order_detail GROUP BY order_id, product_id, quantity, price HAVING cnt 1;更复杂的业务场景是保留每个用户每个商品最新的一条下单记录。这种“按组去重”在MySQL里最稳妥的做法是用窗口函数SELECT user_id, product_id, order_id, order_time FROM ( SELECT user_id, product_id, order_id, order_time, ROW_NUMBER() OVER(PARTITION BY user_id, product_id ORDER BY order_time DESC) AS rn FROM orders ) t WHERE t.rn 1;用WHERE rn 1就能“每组取最新一条”这是生产环境做数据清洗、保留最新快照最常用的手段。理解了这条SQL几乎就能应对所有“按组去重取一条”的需求不管是取最大金额、最新时间还是最早注册都只是改一个ORDER BY字段的问题。4.3 MySQL 8.0与5.7练习环境的差异练习中我实在遇到过版本差异这里也给大家排个雷。MySQL 8.0默认的认证插件是caching_sha2_password而很多老版本客户端特别是Navicat 11、12的早期版本只支持mysql_native_password。如果你在练习环境中用Navicat连接报“Authentication plugin caching_sha2_password cannot be loaded”错误不是你的账号密码有问题而是版本认证方式不匹配。解决方案有两种一种是把客户端的连接驱动升级到支持8.0的新版本另一种是在MySQL中把用户认证改回旧方式ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY root123; FLUSH PRIVILEGES;另外关于版本选择我现在做练习优先推荐8.0主要是因为窗口函数、CTE公共表表达式这些都是8.0中才完整可用的。MySQL 5.7虽然也能支持窗口函数其实是8.0在5.7基础上增强的但CTE在5.7里就没法用练习起来限制比较多。如果公司生产环境还在用5.7我可能会在本地准备两个容器一个8.0一个5.7遇到兼容性坑随时可以切换验证。还有一个小提示MySQL 8.0.19之后的版本在导入SQL脚本时如果脚本里包含CREATE TEMPORARY TABLE客户端需要加--init-commandSET SESSION sql_modeNO_ENGINE_SUBSTITUTION之类的参数否则偶尔会有奇怪的模式报错。这类问题看着诡异实际就是sql_mode在作怪练习时可以留意一下。4.4 一套高效的复盘方法最后分享一个练题的复盘方法我管它叫“三遍做题法”。第一遍完全不看答案直接写哪怕写得又长又慢也没关系目标是跑通第二遍对照参考答案写出自己和解法之间的差距特别是“别人为什么能少写一个子查询”这背后往往是窗口函数或JOIN的更深理解第三遍把做过的题改编一下换字段、换条件、换表结构甚至自己出一个新题目。我常说真正吃透一道SQL题的标准是你不仅能AC还能给别人讲清楚为什么要这么写能根据原题变形出两三道衍生题。这种能力光靠“看”是学不来的只靠“练”也不够还得加上“复盘”。我用这套方法带过几个转行数据分析的新人他们从零基础到能独立完成业务报表取数大概就是八到十周的时间核心就是每周完成一批高质量练习题并及时复盘。SQL看着知识点多实际上高频使用的就那么十来个能力点用题目把它们串起来练熟比堆砌几十个孤立的知识点有效得多。5. 从练习题到面试题的思维迁移很多人刷题是为了准备面试但这道坎比想象中更大。练习题的题干和面试题往往有两层距离一是面试题更偏向业务场景描述不会明确告诉你“这里要用LEFT JOIN”或者“要用窗口函数”需要自己判断二是面试题往往有一些隐形的业务前提比如“统计最近30天的活跃用户”中的“活跃”到底是什么定义这个是需求分析能力不是SQL语法问题。5.1 面试中高频出现的SQL业务题拆解我挑几道面试中最常见、跟练习题直接相关的题给大家拆一下思路。第一道查每个部门的工资排名前三的员工。这道题跟练习题第5题查每个用户最近一笔订单结构完全一样都是“分组后取TopN”。思路是先用RANK()或DENSE_RANK()按部门分区、按工资降序编号再取编号小于等于3的数据。SELECT department, emp_name, salary FROM ( SELECT department, emp_name, salary, DENSE_RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS rk FROM emp ) t WHERE t.rk 3;面试追问点时“如果工资相同排名并列怎么办”“如果同一个人调岗了按最新或最旧的部门算”都是扩展讨论点。这些在练习题里都有对应变形平时多练一手面试才有底。第二道统计连续3天登录的用户。这题是经典的“连续性问题”在练习题中我安排在第11题。思路比看上去要巧妙一步先按用户登录日期排序用ROW_NUMBER()编号然后用登录日期减去编号天数得到“连续分组标识”。如果一组里数量达到3就说明该用户连续登录了3天。SELECT user_id FROM ( SELECT user_id, login_date, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date) AS rn FROM login_log ) t GROUP BY user_id, DATE_SUB(login_date, INTERVAL rn DAY) HAVING COUNT(*) 3;这里有个细节如果用户同一天有多次登录记录需要先去重DISTINCT login_date否则连续天数会被多算。这个隐藏条件恰恰是面试官想考察的点。第三道计算留存率或复购率。练习题第15题“用户下单间隔”可以延伸成“用户次月留存率”。核心逻辑是把用户首次消费月份作为基准然后看后续月份是否还有消费再做关联。这类题在面试中出现频率极高是因为它不需要什么冷门语法考察的是把业务指标拆解成SQL步骤的能力。有了练习的基础拆解能力会明显提升——你知道了“先算什么、后算什么、怎么关联”。5.2 面试题中的“一题多解”和取舍很多SQL面试题不能被单一答案限制住。以“查没有下过订单的用户”为例至少有三种写法-- 写法1LEFT JOIN IS NULL SELECT u.* FROM user u LEFT JOIN orders o ON u.user_id o.user_id WHERE o.order_id IS NULL; -- 写法2NOT EXISTS SELECT u.* FROM user u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id u.user_id); -- 写法3NOT IN注意鞋子表里order_id为NULL的情况 SELECT u.* FROM user u WHERE u.user_id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);面试中我会先给写法2因为NOT EXISTS在语义上清晰、索引利用好、遇到NULL也不容易出错。然后补充写法1说明它的可读性也不错但要理解JOIN后行数变化。最后点评写法3的坑如果子查询里的user_id存在NULL值NOT IN会返回空结果集这是一个非常经典的陷阱。通过这种对比能向面试官展示你不仅会写SQL还理解SQL背后的数据变化和执行逻辑。讲到底面试考的不是你能不能背出标准答案而是能不能在约束下权衡选择。就像我在文章前面反复提到的练习题的价值不在于“答案正确”而在于“每一次查都用上了思考”。SQL这项技能只有在一次次自查、复盘、改写的循环中才能真正建立起来。我现在每次帮新人选练习题仍然会先看这套四层设计够不够扎实再看题目有没有保留“多种解法”的空间而不是囤积几百道一眼就能看穿答案的简单题目。SQL练习练的是思路和判断力不是题量本身。