news 2026/10/14 16:44:31

SQL进阶练习复盘:从聚合、连接到窗口函数的避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL进阶练习复盘:从聚合、连接到窗口函数的避坑指南

做完第六章到第十二章这批练习,我最大的感受是:SQL从"能查出来"到"能查得对、查得快"的跨越,基本都集中在这个阶段。很多人在入门阶段觉得SQL简单,不就是SELECT、FROM、WHERE嘛,但真正到了聚合、连接、子查询、窗口函数叠在一起的时候,才发现自己在思路和细节上有太多欠账。这篇文章不是官方题解,也不是什么课程笔记的搬运,而是我把整个Lesson 6-12的练习做完之后,针对每一类题目做了一次完整复盘。哪些题值得反复做,哪些错误在真实业务里也会高频出现,我把这些全部整理在这里,希望对正在学SQL、尤其是卡在进阶路口的你有点参考价值。

1. 从第六章到第十二章:一次从"能查询"到"会查询"的转折

1.1 为什么说这段时间是个分水岭

前五章的练习基本都在单表内兜圈子,SELECT字段、WHERE过滤、ORDER BY排序、LIMIT截断,再复杂一点也就是多几个条件组合。但到了Lesson 6之后,情况完全变了,每一章都会引入一个新的"思维维度":第六章开始要你把多行数据压缩成一组结论,第七章强迫你想清楚两张表到底怎么拼在一起,第八章开始让你在查询结果里再套查询。这已经不是语法问题,而是逻辑组织问题。

我记得很清楚,做到第六章的聚合练习时,整个人还有点不适应。以前每一行都能看到原始数据,但GROUP BY一出来,视觉上就是"啪"地归并成了一组组摘要,很多细节被隐藏了。而第十二章的窗口函数更离谱——它把聚合后的结果又铺开回到每一行,同时保留聚合的上下文。这种"从行到组、从组回到行"的思维切换,我觉得就是这个阶段最有价值的训练内容。

1.2 难度分布与我的时间投入

我大致统计了一下自己做完六到十二章总的时间,差不多用了五天半,每天大概两到三个小时。前面第六章、第七章还好,大部分都是套路题,错也错在具体语法上。真正让我反复卡壳的是第九章的子查询嵌套和第十一章的窗口函数。尤其是Chapter 11的题目,我第一次做的时候有好几道完全没思路,不是不知道ROW_NUMBER怎么写,而是压根不知道该在哪一步用它。后来是一道一道画执行逻辑图,才慢慢吃透。

这一批练习还有一个特点:题目本身不要求你考虑性能,但如果真的跑过大数据量,你会发现同一个结果,不同写法执行效率差很多倍。金老师课上虽然没细讲执行计划,但有几道题他专门强调了"不要用子查询实现了就不管了,尝试用JOIN重写一遍"。这个提示我觉得比题目本身更有价值。

2. 聚合与分组的题目:GROUP BY的三个"别忘"

2.1 WHERE和HAVING的执行顺序陷阱

第六章练习题里有好几道都同时涉及WHERE和HAVING,比如"筛选出订单数量大于5的客户,并且只要2024年的订单"。听起来很简单,但很多人会写错,因为搞不清WHERE和HAVING谁先执行、谁后执行。

核心顺序是:WHERE在分组之前过滤原表行,HAVING在分组之后过滤聚合结果。也就是说,WHERE条件作用于每一条原始记录,而HAVING条件作用于GROUP BY生成的分组统计数据。所以"只要2024年的订单"这个条件必须放在WHERE里,先切掉非2024年的数据,"订单数量大于5"这个条件必须放在HAVING里,因为它是基于COUNT的计算结果。

我在练习里还试过一种写法:把年份条件用CASE表达式包起来再放进HAVING里。就是HAVING COUNT(CASE WHEN YEAR(order_date)=2024 THEN 1 END) > 5,当时是想偷懒不写WHERE,直接一个HAVING搞定。结果发现逻辑上是能跑通的,但它会把所有年份的数据都拉进来做计算,只是在CASE里把非2024年的记录变成NULL不计入COUNT,效率比先用WHERE过滤掉整整一年的数据差很多。这个例子给了我一个很深的印象:能下推的过滤条件越早执行越好。

2.2 聚合函数遇到NULL值的三种不同表现

第六章练习里专门有题是统计某列的非空数量。当时我在COUNT(column)和COUNT(*)之间犹豫了几分钟,后来在数据集里造了一列含NULL的数据,实际跑一遍才算彻底明白了区别。

COUNT(*)数的是行的数量,哪怕这一行所有字段都是NULL,它照样算一条。COUNT(字段名)则只数这个字段非NULL的行数,NULL会被忽略。这个区别在做"有效订单数""已填写资料用户数"之类的统计时特别重要。另外SUM和AVG在遇到NULL时也会自动跳过NULL,但如果你整组数据全是NULL,AVG的结果会是NULL而不是0。这个坑我也踩过:用AVG求用户平均消费时,对于那些没有任何消费记录的用户组,AVG返回NULL,然后在外面套一层数值格式化就直接显示成空串了,业务上完全不对。这时候得用COALESCE包一层,把NULL变成0。

我当时写的自测SQL是这样:

SELECT region, COUNT(*) AS total_orders, COUNT(discount) AS orders_with_discount, AVG(order_amount) AS avg_amount, COALESCE(AVG(order_amount), 0) AS avg_amount_safe FROM orders WHERE order_date BETWEEN '2024-01-01' AND '2024-12-31' GROUP BY region;

跑出来的结果很清楚:total_orders和orders_with_discount数字不一样,某几个区域的avg_amount直接是NULL,但加了COALESCE之后变成了0。从此以后我写聚合报表SQL,凡是可能没有原始数据的维度,一律用COALESCE兜底。

2.3 GROUP BY的"宽容"反而会让新手放松警惕

第六章练习题里还专门有一类题要求对多个字段分组,比如"按年份和月份统计订单总额"。这本身不难,但金老师的批注里有一个提醒让我印象很深:在MySQL里,如果SELECT了某个非聚合字段而没有写进GROUP BY,默认配置下它不会报错,只会随机返回该分组内某个值。

这个"宽容"挺坑的。如果你在其他数据库里习惯了严格模式,回到MySQL上很容易觉得自己写对了,但实际上数据是错的。我的习惯是:只要SELECT列表里出现了既不是聚合函数、也不是常量的字段,就必须一字不差地出现在GROUP BY里。这个规则虽然死板,但在练习和工程里都能避免90%以上的分组错误。

3. 多表连接练习里的那些"连错行"案例

3.1 INNER JOIN与LEFT JOIN的结果集差异:一次误读统计引发的思考

第七章有一道题是这样的:查所有客户及其最近一张订单的信息。很多人第一反应就是直接JOIN订单表取最新时间。但如果你用INNER JOIN,那些没有下过单的客户直接就从结果里消失了。我当时做这道题时也犯了同样的毛病,以为"所有客户"写不写LEFT JOIN无伤大雅,结果一数行数,不对,少了几个客户。

后来我把问题反思得更清楚了:INNER JOIN和LEFT JOIN的本质差别不在于语法,在于你对主表的业务完整性要求。如果统计口径是"每个客户的状态",那么没有订单的客户也必须出现,这时候必须用LEFT JOIN,右表匹配不上就补NULL。如果统计口径是"每笔订单对应的客户信息",那INNER JOIN就够了,因为订单表里的客户ID不可能为空,INNER JOIN也不会丢任何有意义的数据。

这个道理说起来简单,但做练习时如果不看结果行数,非常容易糊弄过去。我建议每做完一道JOIN题,都用COUNT(*)分别跑一下INNER和LEFT两种版本,比较行数差异。只有知道了差在哪,才能真正理解这两者的物理差异。

3.2 ON条件与WHERE条件的存放位置,背后是执行逻辑的差异

第七章后面有几道题是在JOIN之后还要过滤,比如"查所有在2024年有订单的客户"。大部分人写的是:

SELECT DISTINCT c.customer_id, c.customer_name FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.order_date >= '2024-01-01' AND o.order_date < '2025-01-01';

这个写法在数据上大概率是能查出正确结果的,但它的逻辑有个细微问题:如果你把LEFT JOIN改成,然后把手表的过滤条件放进ON里,结果会完全不一样。金老师的练习中就专门设置了一道题来对比这两种写法的区别:

-- 写法A:过滤条件在WHERE SELECT c.customer_id, o.order_id FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id WHERE o.order_id IS NOT NULL; -- 写法B:过滤条件在ON SELECT c.customer_id, o.order_id FROM customers c LEFT JOIN orders o ON c.customer_id = o.customer_id AND o.order_id IS NOT NULL;

写法A中,不能匹配订单的客户行会被先计算出来,但到了WHERE阶段,o.order_id IS NOT NULL又把那些空值行筛掉了,所以结果等效于INNER JOIN。写法B则是在连接阶段就限定了只有满足条件的订单才会被拼上来,客户无论有没有订单都在,没有订单的就显示为NULL。两者行数差异很大,WHERE子句里的过滤对于LEFT JOIN来说有"二次杀数据"的作用。真实业务里,"查所有客户以及他们2024年的订单情况"要的是写法B的结果,而不是写法A。

3.3 多表连接时笛卡尔积爆炸的排查方法

第七章最后几道题涉及三张表以上的连接。这时候最典型的问题就是忘了写某一个连接条件,结果查询出来的行数凭空多出十几倍。第一次遇到时我愣了一下,后来就在练习本上记下了一个排查方法:每JOIN一张表,就预估一下结果集的行数量级。

比如主表有1000条,连接一张每人只有一条记录的表,结果最多不会超过1000条;但如果连接的表每人可能有5条记录,结果就可能是5000条。如果这时候结果跑出来超过50000条,那大概率是有一个连接条件没写或者写错了,导致中间层出现了笛卡尔积。

我当时排查错题的套路是先一步步把SELECT改成SELECT COUNT(*),然后逐个注释掉JOIN,观察行数的变化规律。从一条JOIN开始加,每加一条就对比一下行数和预期,问题通常出现在"行数突然暴涨"的那一步。这个方法特别笨,但特别有效。

4. 子查询练习:我是怎么发现"嵌套过深"也是一种坏味道的

4.1 子查询版本与JOIN版本的可读性对比

第八章、第九章的子查询题目让我重新审视了自己写SQL的风格。之前我一直觉得子查询很直观——先查出一个结果集合,再在这个集合上做二次查询,思考路径特别顺畅。直到有一道题,我写出了一版三层的嵌套子查询,跑是能跑,但回头一看代码,差点没把自己看晕。

SELECT employee_name, salary FROM employees WHERE department_id IN ( SELECT department_id FROM departments WHERE location_id IN ( SELECT location_id FROM locations WHERE city = 'Shanghai' ) );

这个版本其实逻辑不复杂,但"从里往外读"的过程很费劲,而且一旦业务条件变化,比如还要加一个部门人数下限,每一层都得跟着改。后来我用JOIN重写了一遍,发现反而更平顺:

SELECT e.employee_name, e.salary FROM employees e JOIN departments d ON e.department_id = d.department_id JOIN locations l ON d.location_id = l.location_id WHERE l.city = 'Shanghai';

两种写法结果一样,但JOIN版能通过表别名把路径展示得很清楚。金老师在练习里也没有强制要求用哪一种,但我做完之后总结了一条属于自己的原则:能用JOIN表达的两个表之间的关联,优先用JOIN;只有涉及到"存在性判断""属于某组最大值"这类语义时,保留子查询的写法。

4.2 EXISTS与IN在数据量变大后的表现差异

第九章有一道练习题是查"从未下过单的客户",大部分人会直接写NOT IN:

SELECT customer_id, customer_name FROM customers WHERE customer_id NOT IN ( SELECT customer_id FROM orders );

这个写法在小数据量下没有任何问题,但我在练习中故意试了一下:给orders表塞了近百万条记录,这个查询慢得离谱。然后把NOT IN换成NOT EXISTS,快了很多。

原因在于:NOT IN在碰到子查询结果里存在NULL的时候,还会出现更严重的逻辑问题——整个条件会变成"未知名",一行都查不出来。而NOT EXISTS是逐行关联判断,不会受NULL影响。所以从第九章开始,我给自己立了一个规矩:涉及"不属于/不存在"的语义,一律用NOT EXISTS,除非能确保子查询结果不含NULL并且数据量小。

4.3 派生表必须起别名:一个低级且常见的报错

第八章练习里我第一次写FROM子句的子查询时,报了一个让当时还很困惑的错误:"Every derived table must have its own alias"。当时我写成:

SELECT * FROM ( SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id )

直接就报错了,因为派生表没起名字。其实这个错误放到现在看,就是一个"身份证"问题。FROM子句里的子查询相当于一张临时表,数据库没法在后续的JOIN或查询里引用它,所以必须给它一个别名。加上一句AS t就解决了。这个虽然是个很基础的坑,但几乎每个学SQL的人都会踩一遍,我在练习笔记里特意记了它,因为在面试手写SQL时,这种低级报错最容易影响状态。

5. 窗口函数与CTE题目:这个阶段最值得做三遍的一组练习

5.1 ROW_NUMBER与RANK的排序差异,以及我掉进过的坑

第十一章讲窗口函数的时候,金老师非常强调一组对比:ROW_NUMBER、RANK和DENSE_RANK。从定义上看,ROW_NUMBER不管有没有并列,都会生成1、2、3、4这样连续递增的编号;RANK在遇到并列值时会跳过部分编号,比如两个人并列第一名,那第三名排到的是3;DENSE_RANK虽然也允许并列,但它不跳号,第三名排到的是2。

我做练习时一开始写了个"按部门薪水排名"的查询,用的是ROW_NUMBER,结果发现相同薪水的两个人被排出了前后名次,跟业务上"并列"的预期不符。后来改用RANK或者DENSE_RANK才正确。这个差异不是语法层面的对错,完全是业务语义的选择。

SELECT employee_name, 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 drk FROM employees;

跑出来之后,同一组数据里三列编号各不一样,视觉冲击力非常强。从那以后我再也不把RANK和DENSE_RANK混用了。

5.2 用CTE把三层嵌套改写成人能看懂的三段式

第十章引入了CTE(公用表表达式),第十一章做题时我已经比较自然地用上它了。遇到"每个品类销量前三的商品"这类题,第一版老代码是嵌套子查询,写完自己看着都皱眉头。用CTE改写之后,思路清晰得像三段式作文:

WITH sales_data AS ( SELECT category_id, product_id, SUM(quantity) AS total_qty FROM order_items GROUP BY category_id, product_id ), ranked_data AS ( SELECT category_id, product_id, total_qty, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY total_qty DESC) AS rn FROM sales_data ) SELECT * FROM ranked_data WHERE rn <= 3;

每一步只做一件事:第一步聚合,第二步排名,第三步过滤。我做完这道题最大的收获是:CTE不只是语法糖,它强迫你把复杂的计算步骤显性化,写出来的代码隔两周再看还能一眼看懂。对于自己维护的脚本来说,这就是最大的效率。

5.3 窗口函数里的PARTITION BY,其实就是把GROUP BY的一种变体

金老师在练习里给了一个很妙的说法:PARTITION BY可以理解为"只分组、不折叠"的GROUP BY。GROUP BY会把多行变成一行,导致你没法在同一行里既看到原始明细,又看到组内统计值。窗口函数不会折叠,所有原始行都还在,只是在每一行的后面额外挂上了这个组内计算的编号或SUM结果。

理解了这一点之后,很多"想同时看到明细和汇总"的需求就迎刃而解。比如查每笔订单金额、同时显示当日总订单金额:

SELECT order_id, order_date, order_amount, SUM(order_amount) OVER (PARTITION BY order_date) AS daily_total FROM orders;

这个需求如果用GROUP BY,就得先把订单表汇总成按日销售额,再回JOIN订单明细,非常麻烦。窗口函数一行搞定。在学习第十一章练习时我甚至觉得,如果当初先学窗口函数再看GROUP BY,很多困惑会少一半,但只要能把这两者对比着练一遍,理解就能上一个台阶。

6. 做完这批练习后,我总结的实操避坑清单

6.1 练习中反复出现、真实业务也会踩到的五类错误

把第六章到第十二章的错题汇总了一遍,我发现来来回回也就是五类问题。下面这张表我直接抄在自己的笔记本上,每次写SQL前都翻一遍:

问题类型典型场景我的防御姿势
NULL参与计算AVG返回NULL、NOT IN失效聚合前用COALESCE/COUNT(字段)检查NULL
GROUP BY顺序错误非聚合字段未全量分组SELECT每个非聚合字段必须进GROUP BY
JOIN漏条件行数暴增每加一张JOIN就跑一次COUNT(*)对比
过滤位置错误LEFT JOIN后把右表条件放WHERE保留左表完整行时,条件必须放进ON
窗口函数理解偏差ROW_NUMBER当成并列排名先问业务是否接受并列,再选RANK

这个过程不是一次就能记住的。我在做练习时每错一次,就回到这张表里对应一行,用红笔画一个正字。到第十二章做完,正字最多的恰好是"过滤位置错误"和"NULL参与计算"这两个方向。这两类问题在真实数据分析里特别常见,因为业务数据里的脏值比练习题里多得多。

6.2 我的自检流程:写完SQL先做这四步

我给自己定的查错流程非常简单粗暴,但很管用。任何一个复杂一点的SQL,写完不急着运行,按顺序做四步:

第一步,看表的粒度。这个查询的主表到底是明细级还是汇总级,输出结果应该是每一行的明细,还是每个组汇总,先心里有个数。第二步,看过滤位置。WHERE、HAVING、ON三个位置的条件有没有放错,尤其是涉及LEFT JOIN时。第三步,看多对多风险。这步JOIN会不会让行数翻倍,如果会,有没有去重手段或者唯一的关联键。第四步,看NULL边界。聚合函数和布尔条件是不是被NULL干扰了,COALESCE是不是该加。

这套流程做完大概只要一分钟,但能挡掉大部分低级错误。练习题阶段可能看起来没必要,但一旦到了写报表SQL、数据清洗SQL的日常,这四步能省下大量的排错时间。

6.3 练习环境与生产环境的另一个重要差异

练习题数据量不大,怎么写都能秒出结果,所以大家写SQL很容易只顾着正确性,忽略性能。但真实的生产表往往是千万级起步。金老师在第十二章的最后一道综合题里放了一个提示,说"如果你的SQL运行时间超过了一秒,想一想有没有更好的连接方式"。我当时用子查询跑了十几秒,换成JOIN加索引之后瞬间就出来了。

这个提示对我的影响是:练习合格不等于工程合格。从Lesson 6到12这一套下来,真正的收获不只是会写各种SQL语法,而是开始有了"查询成本"的意识。拿到一个需求,先想数据量,再想表关系,最后才落笔写SELECT,这个习惯远比记住某个函数更值钱。

还有一个跟这个阶段所有练习题都有关系的小细节:表别名和字段别名一定要好好起。写JOIN的时候如果到处都是冗长的表名还不加别名,一个查询写下来,眼睛都花了。我后来写SQL的习惯是主表一个字母,比如orders表就叫o,order_items就叫oi,字段列出来一目了然。这不影响数据结果,但能大大减轻后续检查的精力消耗。

做完这七章练习,我明显感觉自己对SQL的执行顺序、表间关系、聚合粒度都有了更成体系的理解。回头再看前五章那些"简单题",甚至能看出若干种更好的写法。这个阶段的练习题确实值得反复做,尤其是窗口函数和CTE那几章,每做一遍都有新的体会。如果你也正卡在SQL学习的中间地带,不妨把重点放在"每道题为什么这样写"上,而不是一味刷题量。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 16:44:21

Agent高并发架构:从容器到isolate与Durable Objects的轻量隔离实践

1. 从一句“狂言”说起&#xff1a;为什么全世界算力不够给每个 Agent 发容器Cloudflare 那句“全世界的算力&#xff0c;不够给每个 Agent 发一个容器”&#xff0c;我第一次看到的时候&#xff0c;正蹲在工位上给一个多 Agent 编排系统调并发。当时第一反应是&#xff1a;这话…

作者头像 李华
网站建设 2026/10/14 16:44:04

STM32寄存器白话手册:GPIO、定时器与串口调试实战指南

前阵子帮一位同事排查串口问题&#xff0c;代码用的是HAL库&#xff0c;编译没有任何报错&#xff0c;程序也正常下载进板子&#xff0c;可串口就是死活不出数据。折腾了一个下午&#xff0c;最后我打开调试器的外设寄存器窗口&#xff0c;发现串口控制寄存器的使能位根本没被置…

作者头像 李华
网站建设 2026/10/14 16:43:21

嵌入式Qt Widgets记事本:从源码到交叉编译全链路实践

简介&#xff1a;这是一份基于QT6开发的跨平台记事本项目源码&#xff0c;面向嵌入式开发初学者与QT桌面应用入门者&#xff0c;聚焦GUI界面构建、文件编码处理及资源受限环境下的性能优化实践。项目完整实现文本编辑、多编码文件读写、图标资源集成等核心功能&#xff0c;兼顾…

作者头像 李华
网站建设 2026/10/14 16:43:10

DMA与Cache一致性:从旧数据问题到Clean/Invalidate与硬件方案

一块主流嵌入式SoC&#xff0c;跑着某项采集任务&#xff0c;DMA中断按时来了&#xff0c;CPU读了缓冲区却发现数据是几百毫秒前的旧货。这种情况不少调过DMA和Cache的人都遇见过&#xff1a;外设从内存DMA读的仍是老值&#xff0c;或者DMA写进DDR的新值到CPU这边却一直读不出来…

作者头像 李华
网站建设 2026/10/14 16:43:08

STM32驱动DS1302实时时钟芯片:三线时序、BCD码转换与工程避坑指南

最近在做一个带时间记录功能的小设备&#xff0c;需要一颗能掉电走时的时钟芯片&#xff0c;就把 DS1302 翻出来完整摸了一遍。从硬件接线到三线时序&#xff0c;从 BCD 转换到写保护&#xff0c;中间踩了不少坑&#xff0c;整理成这份 STM32 驱动 DS1302 的开源学习笔记。它适…

作者头像 李华
网站建设 2026/10/14 16:43:07

UnicornViewer 0.12j实战:扫描版PDF秒翻页的轻量阅读器调优指南

简介&#xff1a;UnicornViewer 0.12j 是老马原创的独角兽系列电子书阅读工具&#xff0c;定位面向电子书收藏与深度阅读爱好者&#xff0c;解决 EPUB、PDF、MOBI 等常见格式的浏览、标注与书库管理问题。资源包为 rar 压缩格式&#xff0c;仅 1017KB&#xff0c;共 2 个文件&a…

作者头像 李华