
SQL16 这道题名字起得很直白就是查找GPA最高值。但你别小看这几个字它几乎涵盖了一类SQL查询的典型思路——聚合、排序、子查询、窗口函数全都围绕取最值这一件事展开。我在实际面试和带新人时也经常拿它当切入点因为它看起来简单真上手写的时候不同写法之间隐藏的坑一点都不少。这篇文章我就把这个题目从拆解需求到多种实现方案再到实际执行的完整过程捋一遍顺便把我在这个题上踩过的坑和总结的经验一并分享出来。不管你是刚入门SQL的初学者还是已经工作了一段时间想夯实基础的开发者都可以当一份实操笔记来参考。1. 拿到题目别急着写SQL先把需求拆明白很多人在做这类查找最高值的题目时最容易犯的毛病就是一上来就写SELECT * FROM ... WHERE GPA MAX(GPA)然后被数据库报错之后才开始想聚合函数怎么能出现在WHERE子句里呢所以第一步不是写代码而是把题目背后的需求逻辑理清楚。1.1 题目表面在问什么从字面上看查找GPA最高值看起来只是要返回一个数值比如3.98。但在绝大多数练习平台和面试场景里题目真正的潜台词是找到GPA最高的那个学生并把他/她的完整信息带出来包括姓名、学号、所属专业、GPA值等。也就是说你要查的并不是孤零零的MAX值而是与这个最大值相关联的那一行或几行记录。数据表结构最典型的设定如下为了贴合通用练习场景我用最常见的字段来设计字段名类型说明idINT主键自增stu_noVARCHAR学号nameVARCHAR学生姓名majorVARCHAR所属专业gpaDECIMAL(4,2)绩点范围0.00 ~ 4.00当然平台可能略作调整比如没有stu_no或者把major换成class这都不影响核心逻辑。关键要抓住的一点是GPA字段是否允许NULL同分情况怎么处理要不要返回多条记录这些都会直接改变解法。1.2 先定义边界条件再动手我个人的习惯是但凡写SQL查询先在心里过一遍边界条件。针对这道题至少要想清楚下面这几个问题第一个问题GPA字段是否可能为NULL如果部分学生没有录入成绩导致gpa为NULL那你的查询结果是否要排除这些记录通常MAX(gpa)会自动忽略NULL但如果采用ORDER BY gpa DESC LIMIT 1而不加过滤条件NULL会排在哪取决于数据库的排序规则在SQLite里NULL默认排在最后但在SQL Server里NULL默认排在最前面升序时。这一个差异就能让结果完全跑偏。第二个问题出现并列最高时应该返回几条记录如果题目没有明确说返回一条就好那稳妥的做法是返回所有达到最高GPA的记录。使用ORDER BY ... LIMIT 1只能取出一条遇到并列就漏数据。这一点在面试中特别容易被追问也是区分会写和写得好的重要节点。第三个问题最终返回哪些列如果只要GPA值一句SELECT MAX(gpa)就完事了。但如果要返回学生完整信息就要考虑使用子查询还是窗口函数。这决定了SQL语句的结构和性能表现。所以你看一个小小的题目拆开之后要决策的点并不少。我建议你以后接到类似需求先用纸笔写下输入是什么表、输出是什么粒度、有哪些隐藏条件。想清楚再开写效率反而更高。2. 几种主流解法横向拆解从直觉到最优针对查找GPA最高值这个需求SQL的写法其实有很多条路。我在这里把最高频的四种方案都列出来并讲清楚每种方案背后的思路、适用场景和潜在问题。2.1 思路一排序取第一条SELECT id, stu_no, name, major, gpa FROM student ORDER BY gpa DESC LIMIT 1;这是最符合直觉的思路把全体学生按GPA从高到低排序取第一名。它的写法简单粗暴也很好理解。但你要注意这个写法有三个明显的限制遇到并列最高时只返回其中一条。虽然具体的返回记录取决于数据库的物理存储顺序但逻辑上就是任意挑一个不够严谨。NULL 值排序位置不确定。不同数据库对NULL的排序策略不一样如果不做预处理可能把NULL当成最高值取出来。如果后续需要取前N名这个写法自然沿展为LIMIT N但从语义上讲它处理的不是最值而是排序后取前N行。所以这个方案适合只要一条记录且确信没有并列的场景。在练习题目里如果平台判定机制不严格它也能过但在工程实践中要慎用。2.2 思路二MAX聚合函数取值再结合子查询回表SELECT id, stu_no, name, major, gpa FROM student WHERE gpa (SELECT MAX(gpa) FROM student);这个写法的核心逻辑分两步第一步通过MAX(gpa)求出整个表的最高GPA数值第二步把这个数值作为过滤条件在原表中找到所有等于该GPA的记录。这个方案相比排序取第一条最大的优势是天然支持并列最高。只要有多条记录同时等于最高值它们都会被检索出来。另外它的逻辑也很直白——先在脑子里算出最高是多少再拿着这个值去匹配完全符合人类思考习惯。不过它也有一个不算缺点的缺点如果表特别大子查询要先做一次全表扫描求MAX然后外层查询再做一次全表扫描去匹配实际执行中要走两遍表。虽然现代数据库在gpa字段有索引时MAX(gpa)可以走索引快速拿到整体开销不会太夸张但相比窗口函数它的语法结构仍然是两步走。2.3 思路三窗口函数一步到位SELECT id, stu_no, name, major, gpa FROM ( SELECT id, stu_no, name, major, gpa, ROW_NUMBER() OVER (ORDER BY gpa DESC) AS rn FROM student ) t WHERE rn 1;窗口函数是处理这类分组最值问题的利器。我在这里用ROW_NUMBER()给记录按GPA从高到低编号然后在外层过滤出编号为1的记录。但要注意ROW_NUMBER()在并列时是随机编号的如果希望并列最高都返回就要换成RANK()或DENSE_RANK()SELECT id, stu_no, name, major, gpa FROM ( SELECT id, stu_no, name, major, gpa, RANK() OVER (ORDER BY gpa DESC) AS rk FROM student ) t WHERE rk 1;窗口函数方案的优势非常明显语义清晰一次扫描就可以完成排序和过滤。扩展性极强后续如果要查每个专业GPA最高的学生只需在PARTITION BY里加上major字段一行改动就搞定不需要重写整条SQL。代码结构规范尤其在复杂需求里让人一眼就能看懂计算逻辑。要说它的门槛可能就是需要你理解窗口函数的执行顺序它是在WHERE、GROUP BY、HAVING都执行完之后才进行计算的。所以如果在外层直接写WHERE rn 1普通变量是做不到的必须放到子查询里再过滤。2.4 思路四两表自连接SELECT s1.id, s1.stu_no, s1.name, s1.major, s1.gpa FROM student s1 LEFT JOIN student s2 ON s1.gpa s2.gpa WHERE s2.id IS NULL;这个方案比较学院派思路是如果某学生的GPA不是最高值那就一定存在另一个比它更高的GPA通过自连接可以发现它如果某学生的GPA是最高值那么左连接后右边不会有任何匹配行s2.id就为NULL。所以过滤出s2.id IS NULL的记录剩下的就是最高GPA的持有者。这种写法在实际工作中不算常见因为窗口函数更优雅但它能很好地考察对SQL连接语义的理解。如果题目明确禁止使用聚合函数和排序这个方案可以作为兜底。同时它的执行效率通常不高因为连接操作的开销大于一次聚合所以更适合作为思维拓展而不建议在性能敏感的场景使用。2.5 方案对比总结我把这四种方案的核心差异整理成一个表格方便你横向参考方案理解难度支持并列扩展性性能表现ORDER BY LIMIT低否弱优有索引时更好MAX 子查询低是中中两次扫描ROW_NUMBER / RANK窗口中RANK支持强优一次扫描自连接高是弱差连接开销大从面试和工程两个角度看我更建议把MAX 子查询和窗口函数作为主力方案熟练掌握前者够直接后者够通用。3. 实操过程从建表到验证的完整链路光说不练假把式。接下来我按实际操作的顺序把从零开始构造数据、写SQL、执行、校验结果的完整过程走一遍。这里我用MySQL的语法示例其他数据库的差异点我会在相应位置补充说明。3.1 第一步创建数据表并插入测试数据我习惯先造一份包含边界情况的数据这样才能把各种问题提前暴露出来。CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20), name VARCHAR(50), major VARCHAR(50), gpa DECIMAL(4,2) ); INSERT INTO student (stu_no, name, major, gpa) VALUES (2024001, A同学, 计算机科学, 3.78), (2024002, B同学, 软件工程, 3.92), (2024003, C同学, 人工智能, 3.92), (2024004, D同学, 计算机科学, 3.65), (2024005, E同学, 数据科学, NULL), (2024006, F同学, 软件工程, 3.85);这份数据里我刻意埋了两个关键点B同学和C同学并列最高3.92E同学的GPA为NULL。这两个设定几乎覆盖了前面提到的全部边界场景。3.2 第二步分别执行方案并观察结果先执行排序取第一条SELECT id, stu_no, name, major, gpa FROM student ORDER BY gpa DESC LIMIT 1;在MySQL中这条语句返回的结果是如果存储顺序是插入顺序idstu_nonamemajorgpa22024002B同学软件工程3.92虽然结果看起来是对的但它漏掉了C同学。这在你只需要任意一条记录时没问题但如果需求是找出所有最高分学生这个写法就不合格了。再执行MAX 子查询SELECT id, stu_no, name, major, gpa FROM student WHERE gpa (SELECT MAX(gpa) FROM student);这次返回的结果是两条idstu_nonamemajorgpa22024002B同学软件工程3.9232024003C同学人工智能3.92并列数据完整保留这个结果更符合最高值的完整语义。然后执行窗口函数版本SELECT id, stu_no, name, major, gpa FROM ( SELECT id, stu_no, name, major, gpa, RANK() OVER (ORDER BY gpa DESC) AS rk FROM student ) t WHERE rk 1;结果与上一个方案一致同样返回B同学和C同学两条记录。区别在于这个方案只需要扫一遍表执行计划更简洁。3.3 第三步验证NULL值的影响接下来我把E同学的NULL单独拿出来验证。在MySQL中MAX(gpa)会直接忽略NULL所以以上方案的结果不受影响。但如果排序方案不做任何处理在SQL Server中NULL排序默认在最前ORDER BY gpa DESC可能返回id5且gpaNULL的记录。所以使用排序方案时稳妥的做法是加一个过滤SELECT id, stu_no, name, major, gpa FROM student WHERE gpa IS NOT NULL ORDER BY gpa DESC LIMIT 1;这一步虽然简单但在实际生产环境里很容易被忽略。我的经验是凡是要取最大/最小的业务指标先想清楚NULL值参与不参与。执行完之后我还会再做一步结果自证把查出来的最高GPA值单独输出再与原表比对确认它确实是所有非NULL GPA中的最大值。避免因为记录多、条件复杂导致结果偏差。4. 实战中容易踩的坑问题排查与避坑实录这个部分我打算把实际做这个题目以及后续在真实业务中写类似查询时遇到的典型问题都列出来作为一份速查笔记。4.1 并列最多只返回一条现象使用ORDER BY gpa DESC LIMIT 1表里明明有两个人并列最高结果只出一行。原因LIMIT限制了输出行数并没有语义上的取出所有满足条件的数据。排查思路先执行SELECT MAX(gpa) FROM student拿到最大值再执行SELECT COUNT(*) FROM student WHERE gpa 最大值。如果count大于1就确认了并列情况。解决方案换用MAX 子查询或者RANK窗口函数。延伸思考如果业务需求是只取一个代表那LIMIT方案没问题但如果需求是把最高的人都找出来子查询方案才符合语义。这种需求歧义在真实业务里经常出现我建议先跟需求方确认不要自己替对方做决定。4.2 聚合函数与WHERE的搭配错误现象新手执行SELECT * FROM student WHERE gpa MAX(gpa)直接报错。原因WHERE在分组聚合前执行聚合函数MAX、MIN、AVG等不能直接出现在WHERE子句里它们要放在HAVING或者子查询中。解决方案使用子查询WHERE gpa (SELECT MAX(gpa) FROM student)使用HAVING但HAVING通常配合GROUP BY使用在这个场景并不合适。我见过很多人在这一步卡住其实只要记住一条执行顺序规则就行WHERE先过滤行GROUP BY再分组HAVING再过滤组SELECT最后计算聚合结果。理解了这条链路就不会犯这种错了。4.3 返回结果带有NULL记录现象排序方案在某些数据库上返回了gpa为NULL的学生。原因不同数据库对NULL的排序规则不一致SQL Server默认把NULL当最小值升序在前PostgreSQL默认把NULL当最大值降序在前。咱们用的MySQL比较友好NULL默认最大所以在ORDER BY gpa DESC时NULL会排在最上面。解决方案使用WHERE gpa IS NOT NULL先把空值排除或者改用MAX 子查询因为MAX天然忽略NULL。经验总结不只是GPA任何业务数值字段都有可能出现NULL。我写报表查询时有一个固定习惯凡是取最值的SQL第一行先看一眼SELECT COUNT(*) FROM 表 WHERE 字段 IS NULL。不为别的只为摸清数据底数避免掉进NULL的坑。4.4 性能问题子查询全表扫描现象在千万级的大表上执行MAX 子查询响应时间达到秒级甚至更久。原因如果没有索引子查询和内层查询都需要扫描整张表。排查思路使用EXPLAIN查看执行计划。我当时测试一个模拟千万级数据表时看到typeALL就知道出问题了。解决方案在gpa字段上创建索引CREATE INDEX idx_student_gpa ON student(gpa);创建索引后MAX(gpa)可以直接走索引从千万条记录中取出最大值几乎在毫秒级完成。子查询再根据该值去主表过滤速度也快得多。提示索引不是越多越好但如果某个字段频繁作为WHERE过滤或聚合条件给它建索引通常都是划算的。4.5 窗口函数执行顺序理解偏差现象直接写WHERE ROW_NUMBER() OVER (...) 1数据库报错。原因窗口函数在WHERE之后执行不能在WHERE里直接引用。解决方案必须把窗口函数放在SELECT中计算然后通过子查询或CTE包裹一层。-- 正确做法使用CTE包裹 WITH gpa_ranked AS ( SELECT id, stu_no, name, major, gpa, ROW_NUMBER() OVER (ORDER BY gpa DESC) AS rn FROM student ) SELECT id, stu_no, name, major, gpa FROM gpa_ranked WHERE rn 1;这个错误非常典型尤其在你刚接触窗口函数时几乎必然遇到。我的建议是先不用硬记窗口函数的执行顺序而是把它理解为在所有基础过滤和分组做完之后再进行的最后一步计算。有了这个意识就明白为什么它不能出现在WHERE里了。5. 举一反三从单表最值到分组Top N的进阶SQL16这个题虽然简单但它的变体却非常高频。我在实际工作中经常要处理每个班级成绩最高的学生每个部门薪资最高的员工每个商品类目销量最高的SKU本质上都是这个题的分组扩展版。这一节我把从单表最高值到分组Top N的写法讲讲清楚方便你直接照搬。5.1 分组查询最高GPA先看每个专业GPA最高的写法SELECT major, MAX(gpa) AS max_gpa FROM student WHERE gpa IS NOT NULL GROUP BY major;这条SQL能得到每个专业的最高绩点数值但它有一个问题——拿不到这个最高值对应的学生姓名和学号。因为分组之后非聚合列不能直接出现在SELECT中。如果想要每个专业GPA最高的学生完整信息就要用窗口函数SELECT id, stu_no, name, major, gpa FROM ( SELECT id, stu_no, name, major, gpa, ROW_NUMBER() OVER (PARTITION BY major ORDER BY gpa DESC) AS rn FROM student WHERE gpa IS NOT NULL ) t WHERE rn 1;这段SQL的核心就是PARTITION BY major。它的含义是把数据先按专业分成一个个组然后在每个组内部独立按GPA排序并编号。这样一来每个专业的第1名都能被各自取出来。和前面说的一样如果需要保留并列把ROW_NUMBER()换成RANK()即可。我实际操作时发现很多人最开始总想用GROUP BY解决这类问题结果写着写着就发现子查询套子查询SQL变得又长又绕。其实窗口函数就是为这类需求量身定做的多练几次就能建立条件反射。5.2 取每组前N名继续扩展查每个专业GPA前三名SELECT id, stu_no, name, major, gpa FROM ( SELECT id, stu_no, name, major, gpa, DENSE_RANK() OVER (PARTITION BY major ORDER BY gpa DESC) AS dr FROM student WHERE gpa IS NOT NULL ) t WHERE dr 3;这里我用DENSE_RANK()而不是ROW_NUMBER()是因为前三名通常隐含了并列情况。如果第四名和第三名分数一样用ROW_NUMBER()会漏掉它而DENSE_RANK()会把它也保留下来。这里我还想强调一下RANK()和DENSE_RANK()的区别RANK()并列后跳号。比如两人并列第二下一位编号就是4。DENSE_RANK()并列不跳号。两人并列第二下一位编号仍然是3。按前三名的口径DENSE_RANK()更常用。如果你在处理比赛排名这种需要跳号的场景RANK()就更合适。具体用哪个取决于业务语义。5.3 真实业务场景案例我之前处理过一个模拟的机构月度考核需求表结构大概是这样的employee_info包含员工编号、姓名、所属部门performance_score包含员工编号、考核月份、考核得分需求是找出每个月每个部门得分最高的员工并输出员工姓名和得分。如果用窗口函数写起来非常顺畅SELECT p.month, e.dept, e.emp_name, p.score FROM ( SELECT emp_no, month, score, RANK() OVER (PARTITION BY month, dept ORDER BY score DESC) AS rk FROM performance_score ) p JOIN employee_info e ON p.emp_no e.emp_no WHERE p.rk 1;注意这里PARTITION BY使用了两个字段month, dept这就实现了每个月每个部门的组合分组。这类需求如果用传统GROUP BY来实现要嵌套好几层子查询可读性很差而窗口函数版本一眼就能看懂分组逻辑。所以我的建议是SQL16是你熟悉窗口函数最好的起点。先把这个单表最值问题用窗口函数写熟再去试分组Top N你会发现整个套路是连贯的并不需要死记硬背。6. 个人实操经验与最后的建议做了这么多年SQL相关的工作我越发觉得查找GPA最高值这类基础题的价值不在于答案本身而在于它逼着你去思考查询的底层逻辑。围绕这一个题目你可以辐射出聚合函数的用法、NULL值的处理、并列情况的语义选择、索引对性能的影响、窗口函数的执行顺序、分组Top N的通用写法——这些全都是日常开发中高频出现的问题。结合我自己的项目经验最后分享三点实操心得第一写SQL之前先确认输出粒度。同样一句查找GPA最高值是要一条数值还是完整学生信息要不要并列这一决定直接影响方案选型。我在实际工作中吃过不少我以为只要数值结果产品要完整信息的亏所以现在第一件事永远是确认粒度。第二把窗口函数当成本能工具。早几年我处理分组最值问题也比较依赖GROUP BY子查询的套娃写法后来熟练使用窗口函数之后代码精简了很多出错的概率也明显降低。尤其面对每个XX最高的XX这类需求PARTITION BY几乎是无可替代的解法。第三别忽略NULL和数据质量。这道题的数据只有六行看不出问题一旦上了生产环境数据脏乱差的情况远超想象。空值、重复值、异常值每一样都能让查询结果失真。所以我强烈建议凡是取最值的SQL都养成先探测数据分布的习惯。学SQL的路上查找最高值只是第一个台阶。但把这一步踩稳了后面升序降序、分页取数、窗口统计、复杂报表走起来都会顺畅很多。希望这篇实操笔记能给你一点参考让你在自己的数据路上少踩几个我踩过的坑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。