☰
SQL高频考点全拆解:窗口函数、分组聚合与性能优化实战
2026/10/21 16:08:45 网站建设 项目流程

SQL相关题目。听到这个名字,可能有人觉得这是学生时代才需要应付的玩意儿,但实际上,我工作这么多年,不管是招人面试写SQL,还是在生产环境排查数据问题,发现“SQL相关题目”从来就没离开过一线。它看起来考的是一条SELECT语句,背后真正考的却是你对关系模型的理解、对数据形态的判断、以及能不能在脑子里先跑通一遍数据的流向。

这篇文章不打算罗列题库,我想以一个经常和数据打交道的人的身份,聊聊那些“看着会做,真上手就出错”的SQL题目到底难在哪,顺便把一些高频考点的拆解思路、实操过程和避坑经验完整地捋一遍。如果你正在准备面试,或者单纯想把SQL写得心里有底,这篇应该能帮到你。

1. 题型拆解:SQL题目到底在考什么

1.1 表面是语法,里子是模型

很多人在看到“SQL相关题目”这几个字时,第一反应是“背语法”。其实大错特错。SQL的语法就那么几十个关键字,真正让人翻车的从来不是语法本身,而是你拿到一张表之后,能不能在脑子里把它“跑起来”。

以我面试别人的经验,同样一道查“连续登录3天用户”的题,新手第一反应是去查“每条记录有没有相邻三行”,老手的第一反应是先画出登录日期的序列、再判断怎样把日期之间的间隔折叠掉。差别不在会不会写DATE_SUB,而在有没有数据流的概念。

所以我建议你把每一道SQL题目都当作一次“手工演算”的机会。拿到表结构之后,先别急着写SELECT,先在草稿纸上画出来:这张表有几行样例数据?我要的最终结果长什么样?中间需不需要经过一次聚合、一次关联、还是两次去重?这三步想明白了,语法只是顺手的事。

1.2 六大高频场景速查

刷过的题和面试问过的题加在一起,我发现绝大多数SQL问题可以归纳到六个场景里。整理成表格放在这,方便你对照自查:

场景典型问题核心技巧常见误区
分组统计各分类的数量、金额、均值GROUP BY + 聚合函数忘掉非聚合列的归属问题
去重计数统计某维度下的唯一用户数COUNT(DISTINCT)忽略NULL带来的偏差
排序取值每个分组的前N条窗口函数 ROW_NUMBER / RANK不加PARTITION导致全局排序
区间判断连续范围、跨天、最新状态日期偏移、自关联边界条件没想清楚
行转列把长表变宽表CASE WHEN + GROUP BY列数写死,不能动态扩展
关联匹配两表匹配、补全、排除JOIN + ON条件关联键选错导致数据膨胀

每个场景的解法都有固定套路,但套路背后的“为什么”才是关键。后端我会挑几个翻车率最高的知识点展开讲。

2. 几个让很多人翻车的核心知识点

2.1 窗口函数:一排开不出花,但排完再看就很香

窗口函数是近些年SQL题目里的常客。它解决的最大痛点,是“既要看到明细行,又要看到汇总值”。普通GROUP BY会把多行压缩成一行,窗口函数不会。

我给你一个生活化类比:GROUP BY像是把一堆混在一起的水果按品相分了篮,每篮只留一张品相标签;窗口函数则像是你在按身高排队的队伍里,一边排队一边能看到整个队伍的平均身高。你想保持每个人的位置,又想算出整体指标,窗口函数几乎是唯一解。

实操时我最常用的组合是ROW_NUMBER()配合PARTITION BY。比如一个订单表要查“每个用户最近一单”,格式长这样:

SELECT 用户ID, 订单号, 下单时间 FROM ( SELECT 用户ID, 订单号, 下单时间, ROW_NUMBER() OVER (PARTITION BY 用户ID ORDER BY 下单时间 DESC) AS rn FROM 订单表 ) t WHERE rn = 1;

这里有个细节特别值得说:ROW_NUMBER、RANK和DENSE_RANK处理并列的方式完全不同。ROW_NUMBER永远唯一编号,即使排序值相同也会硬给个先后;RANK会给并列名次留空;DENSE_RANK则把并列后紧密排序。面试题里如果出现“并列名次怎么办”,十有八九是在考察你有没有分清楚这三个的区别。

再提醒一个容易忽略的点:窗口函数中的ORDER BY方向直接影响编号顺序。查“最近一笔”就必须DESC;“最早一笔”就ASC。方向反了,结果完全不对,而且这种错误在样例数据少时很难被看出来。

2.2 分组聚合与“先看全表,再分组”的思维陷阱

GROUP BY是SQL里最基础的功能,但它有一个隐蔽的思维陷阱:绝大多数人会下意识地“先分组,再算聚合”,真在数据库里执行时,优化器反而可能会“先全表扫描,再临时分组”。

这个差异在日常小数据量下无所谓,但一旦表到了百万行级别,写法不同性能天差地别。很多SQL题目表面考逻辑,其实也考你有没有考虑到数据规模。比如:

-- 写法A:先过滤再分组 SELECT 品类, COUNT(*) FROM 流水表 WHERE 日期 >= '2024-01-01' GROUP BY 品类; -- 写法B:先分组再过滤 SELECT 品类, COUNT(*) FROM 流水表 GROUP BY 品类 HAVING 品类 != '测试数据';

写法A通常能利用日期索引减少扫描范围,写法B几乎要把整个表都分组完再扔垃圾。数据量一大,A可能秒回,B会卡到超时。

再往根上说,GROUP BY之后SELECT里到底能出现哪些列,这个规则很多初学者模糊。严格来说,SELECT后面出现的列必须是分组列或聚合函数的参数。否则在很多数据库里会直接报错,在MySQL宽松模式下则可能返回一个随机值,这是特别容易埋雷的地方。

2.3 自关联和NULL的暧昧地带

自关联就是把一张表和自己JOIN。听着好像没什么了不起,但实际处理“层级关系”“相邻记录”“状态流转”这类问题时,自关联几乎是必须掌握的套路。

举个例子:查询每个员工和他的直接主管是否同一个部门。你需要把员工表和自己关联两次,一次取员工信息,一次取主管信息:

SELECT a.员工姓名, a.部门ID, b.部门ID AS 主管部门ID FROM 员工表 a LEFT JOIN 员工表 b ON a.主管ID = b.员工ID WHERE a.部门ID != b.部门ID;

这个查询本身不难,难在NULL的处理上。如果某个员工没有主管,b.部门ID会是NULL,那么a.部门ID != b.部门ID的结果既不是TRUE也不是FALSE,而是UNKNOWN,WHERE条件会把这种“未知”直接过滤掉。这时候你以为结果是“主管在不同部门的人”,实际却偷偷漏掉了“没有主管的人”,差别很微妙,但方向完全不同。

排查这类问题有两个习惯值得养成。第一,用LEFT JOIN的地方,尽量在ON后面把过滤条件写全,而不是全部丢给WHERE;第二,看到结果行数比预期少时,马上去想“是不是有NULL字段被判断条件吞掉了”。NULL不是空字符串,它代表未知,连NULL = NULL都返回UNKNOWN,更别说NULL != 某个具体值了。这个点我几乎每次带新人都会强调一遍,属于“题目看着不深,踩坑踩到怀疑人生”的典型。

3. 实操案例拆解:老题里挖新东西

3.1 案例一:连续登录3天用户

这是面试中出现频率最高的SQL题,没有之一。先给表结构:用户登录表login_log,字段有user_id、login_date。

很多人拿到手就写:找每条记录后面的两条记录,看日期是否是当前日期加1和加2。这个思路方向对,但实现起来会陷入自关联的泥潭,一旦某个用户一天内有多次登录记录,结果就会膨胀。

我的推荐解法是用“等差数列”思想。核心逻辑是:如果一名用户在一组连续登录日里登录,那么每个登录日期减去它在组内的序号,会得到一个相同的日期基准。具体写法:

SELECT DISTINCT user_id FROM ( SELECT user_id, login_date, DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_date ) DAY) AS group_date FROM login_log -- 先按天去重,避免一天多条记录干扰 WHERE 日期范围条件 ) t GROUP BY user_id, group_date HAVING COUNT(*) >= 3;

踩坑点有两个。第一个是必须先对(user_id, login_date)去重。如果用户一天登录两次,不去重的话同样三天的记录数变成6条,连续性的判断就失真了。第二个是ROW_NUMBER的排序必须稳定,ORDER BY login_date必不可少,不然序号乱掉,基准日期也会跟着乱掉。

这个方案的优雅之处在于,它把“连续”问题转化为“分组计数”问题,一旦转化成功,思路就非常清晰。扩展一下,连续7天签到、连续15天活跃,都可以套同一套逻辑,只需要把最后的HAVING COUNT(*)条件改一下就能通吃。

3.2 案例二:分组Top N问题

“每门课成绩最高的前3名”“每个部门薪资前10的员工”,这类分组Top N问题也是必考题。解法套路非常固定:窗口函数编号 + 外层过滤。

以课程成绩为例:

SELECT 学生姓名, 课程ID, 成绩 FROM ( SELECT 学生姓名, 课程ID, 成绩, ROW_NUMBER() OVER (PARTITION BY 课程ID ORDER BY 成绩 DESC) AS num FROM 成绩表 ) ranked WHERE num <= 3;

到这里能写出来的人很多,但紧接着面试官通常会追问一个升级问题:如果并列第3名,是取3个还是全部取?如果要全部取并列的人,就得把ROW_NUMBER换成RANK或者DENSE_RANK。区别在于:RANK在并列后会跳过名次(比如两个并列第一,下一个名次是第三),DENSE_RANK则不会跳(下一个是第二)。

实际工作中我更常用RANK而不是ROW_NUMBER,因为业务上很多时候要求“并列的必须同时保留”,硬用ROW_NUMBER会随机踢掉某个人,结果不够直观。但要注意的是,RANK配合HAVING或WHERE条件时,取的并不是前3人,而是“最多排到第3名的所有人”,人数可能超过3个,这个预期业务人员是能够接受的。

还有一个小技巧:如果表的规模很大,成绩索引做得不好,窗口函数在ORDER BY字段上没有索引会非常吃力。遇到这种情况,可以先建一个覆盖索引让排序走索引,实测能快一个数量级。

3.3 案例三:行转列与列转行

行转列(长表变宽表)最常见的场景是,原始表里一个维度占多行,希望转成每个维度一行、多个属性列展开。比如按月统计销售额:

SELECT 品类, SUM(CASE WHEN 月份 = '2024-01' THEN 金额 ELSE 0 END) AS 一月, SUM(CASE WHEN 月份 = '2024-02' THEN 金额 ELSE 0 END) AS 二月, SUM(CASE WHEN 月份 = '2024-03' THEN 金额 ELSE 0 END) AS 三月 FROM 销售表 GROUP BY 品类;

这个写法原理不复杂:CASE WHEN把匹配月份的金额挑出来,再用SUM完成累加,不匹配的行自动补0。但它有个明显局限——列数是写死的。如果月份动态变多,这种静态SQL就撑不住了。

真正要动态的行转列,一般得靠拼SQL的方式实现:

-- 先查出所有需要展示的月份 SELECT GROUP_CONCAT(DISTINCT CONCAT('SUM(CASE WHEN 月份 = ''', 月份, ''' THEN 金额 ELSE 0 END) AS ', 月份) ) INTO @sql_cols FROM 销售表; -- 拼接完整查询 SET @sql = CONCAT('SELECT 品类, ', @sql_cols, ' FROM 销售表 GROUP BY 品类;'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

这段代码在MySQL里可以直接跑通,核心是利用GROUP_CONCAT动态生成列表达式,再用PREPARE执行。这个技巧第一次见的人会觉得有点绕,但它解决的是“列数不确定时能不能通用”的真实问题。数据仓库里做报表的同事,几乎每个月都要用类似方案处理动态维度。

至于列转行,思路正好反过来。把宽表上多个列合并成一个“属性名 + 属性值”的长表,最常见的是用UNION ALL:

SELECT 学生ID, '语文' AS 科目, 语文成绩 AS 分数 FROM 成绩宽表 UNION ALL SELECT 学生ID, '数学', 数学成绩 FROM 成绩宽表 UNION ALL SELECT 学生ID, '英语', 英语成绩 FROM 成绩宽表;

这类转换本身不难,但它考察的是对“宽表长表各自适用场景”的理解。长表方便过滤和聚合,宽表方便看整体和做横向对比,没有谁绝对更好,只有这个场景合不合适。

4. 常见错误与排查技巧实录

4.1 看着对但结果错:GROUP BY的隐形雷

SQL题目的一个特点是:能跑出结果不一定代表结果对。我在实际排查里见过最多的错,就是GROUP BY之后SELECT了非聚合非分组的列。在MySQL里默认居然能跑通,返回的却是一个没有规则的值。

比如:

SELECT user_id, order_id, MAX(amount) FROM orders GROUP BY user_id;

如果用户有多个订单,order_id到底取哪个,MySQL不报错,但返回结果毫无逻辑。很多线上统计出错,根子就在这。正确做法是:分组后的明细字段要么也用聚合函数包一层,要么就先进子查询把每条用户最新的order_id找出来再关联。

排查这个问题的习惯也很好养成:看到带GROUP BY的查询,第一遍检查SELECT列表里的每一个字段,问自己“这个字段在分组后还有意义吗?”没有,就别放进来。

4.2 性能相关的坑

SQL题目做到后期,基本都会涉及“结果对,但跑得太慢”的优化问题。我总结三个最常见的原因。

第一个是子查询当表用。很多人图方便,把一大段查询包在括号里再关联外层查询,结果优化器很难把它下推成更高效的执行计划。能用JOIN解决的问题,尽量不要用子查询。

第二个是索引失效。最常见的失效场景是在WHERE条件里对字段做函数运算,比如WHERE DATE(login_date) = '2024-01-01'。这个写法会让login_date上的索引失效,本质上等于全表扫描。正确写法是WHERE login_date >= '2024-01-01 00:00:00' AND login_date < '2024-01-02 00:00:00'。

第三个是排序临时表。窗口函数、DISTINCT这类操作经常会生成临时表,如果排序字段没索引,规模一大就慢。排查时可以看执行计划里有没有Using filesort、Using temporary这样的字样,一旦出现,就要考虑调整索引或改写语句结构。

4.3 排查方法:从“人工摆数据”到“分段验证”

我调试SQL题目时,基本上遵循一套自己的流程,这套流程救过我很多次。

第一步,把样例数据缩小到几行,手工把预期结果算出来。第二步,在每条SELECT前面加“试运行”,把中间态的表查出来,比如子查询里先不加WHERE条件,看中间结果是否符合想象。第三步,逐步加上JOIN和分组,每加一步就核对一次行数变化。第四步,如果预期是10行结果,查出来是12行,优先怀疑JOIN造成的数据膨胀;如果只有8行,优先怀疑WHERE把NULL过滤了。

这套方法听起来朴素,但特别管用。很多人写SQL像写作文,一口气写完再跑,结果报错了也不知道从哪查。写成“搭积木”的验证方式之后,定位问题通常用不了五分钟。

5. 我的一些体会

绕开具体题目,我想说说这些“SQL相关题目”背后我更在意的东西。

第一,SQL不是背诵语言。真正理解它的人,拿到一张陌生的表,能迅速判断出数据长什么样、要得到什么结果、中间需要几种变换。这种判断力只能靠不断练习,光看不写、光写不验都不行。

第二,你可以多关注不同数据库之间的差异。MySQL、PostgreSQL、Oracle,窗口函数和分页写法各不相同。同一个问题能用标准SQL写出来最好,但也要知道手上的数据库有哪些“方言特性”,这些往往就是生产环境里真正能派上用场的武器。

第三,很多看起来很炫的技巧,比如动态行转列、递归查询、LATERAL JOIN,实际工作中不一定天天用,但掌握它们会让你在遇到“常规路线走不通”时多一两个备选方案。我在实际项目中就遇到过报表列数动态变化、数据量爆炸、简单SQL扛不住的时刻,当时能解决问题,靠的就是这种“边角料”知识的储备。

SQL相关的题目就像一面镜子,照出的不只是你会不会写查询,更是你对数据底层逻辑掌握得扎不扎实。希望你在这条路上越写越顺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询