ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SQL Server窗口函数实战:用PARTITION BY与RANK实现高效成绩排名

SQL Server窗口函数实战:用PARTITION BY与RANK实现高效成绩排名 排成绩这事儿在SQL Server里看着简单真要排得准、排得快、还能应对并列名次按班排名分段定级这些业务要求还真得沉下心把PARTITION BY这套窗口函数玩明白。前段时间我帮一个做教务系统的朋友优化成绩排名报表他还在用子查询自连接去算名次数据量一到十万行就卡得报表超时。我直接给他换成RANK()、DENSE_RANK()加PARTITION BY组合拳同样的逻辑查询时间从天级别降到秒级别。这篇文章就把这套思路完整拆开从一个真实的成绩排名需求出发讲清楚ROW_NUMBER、RANK、DENSE_RANK这三兄弟的区别以及PARTITION BY怎么把全校排名切成班内排名科内排名最后再把性能调优和那些容易翻车的坑一并交代清楚。适合正在被排名SQL折磨的DBA、数据分析师、教务系统开发也适合刚接触窗口函数想彻底搞懂原理的同学。1. 一张成绩单引发的排名需求先说场景。学校要出一份期中考试成绩分析表要求特别具体全年级按单科成绩排名成绩相同的算并列每个班级内部也要有班内名次另外还要按总分拉一个年级大榜。最麻烦的是同一个学生不同科目名次不一样同一份成绩数据要同时支撑好几个视角的排名。这种需求十年前的老套路是子查询。比如给每个学生算数学排名经典写法长这样SELECT t1.StudentName, t1.Score AS MathScore, (SELECT COUNT(*) 1 FROM StudentExam t2 WHERE t2.SubjectName N数学 AND t2.Score t1.Score) AS RankNum FROM StudentExam t1 WHERE t1.SubjectName N数学;逻辑上没错但聪明人都看得出来每返回一行就要把整张表扫一遍去数有多少人比我分高。数据量小无所谓一旦表里有几十万行考试记录这种写法等于自己给自己挖坑。更别说还要按班级排、按总分排每一路排名都要套一层子查询SQL写得跟千层饼似的维护起来头大。PARTITION BY的出现就是用来收拾这种局面的。它是窗口函数家族里的核心关键字意思是把查询结果先按某个维度分区再在分区内部做计算。配合排名函数一句话就是先切块再排序最后编号。它不需要自连接不需要GROUP BY缩行每行数据都能保留还能同时算多个不同规则的名次。用窗口函数处理排名场景是真正做到了一行解决一行问题。为了把后面的案例讲透我先建一张考试表模拟最常见的成绩数据结构CREATE TABLE dbo.StudentExam ( ExamID INT NOT NULL, StudentNo NVARCHAR(10), StudentName NVARCHAR(20), ClassName NVARCHAR(10), SubjectName NVARCHAR(20), Score DECIMAL(5,1), ExamDate DATE ); INSERT INTO dbo.StudentExam VALUES (20250101, NS001, N张明, N高一(1)班, N数学, 92.0, 2025-01-10), (20250101, NS001, N张明, N高一(1)班, N语文, 88.0, 2025-01-10), (20250101, NS002, N李雪, N高一(1)班, N数学, 95.0, 2025-01-10), (20250101, NS002, N李雪, N高一(1)班, N语文, 95.0, 2025-01-10), (20250101, NS003, N王强, N高一(1)班, N数学, 90.0, 2025-01-10), (20250101, NS003, N王强, N高一(1)班, N语文, 90.0, 2025-01-10), (20250101, NS004, N周小雅, N高一(1)班, N数学, 88.0, 2025-01-10), (20250101, NS004, N周小雅, N高一(1)班, N语文, 88.0, 2025-01-10), (20250101, NS005, N赵磊, N高一(2)班, N数学, 85.0, 2025-01-10), (20250101, NS005, N赵磊, N高一(2)班, N语文, 85.0, 2025-01-10), (20250101, NS006, N孙悦, N高一(2)班, N数学, 93.0, 2025-01-10), (20250101, NS006, N孙悦, N高一(2)班, N语文, 93.0, 2025-01-10), (20250101, NS007, N吴昊, N高一(2)班, N数学, 90.0, 2025-01-10), (20250101, NS007, N吴昊, N高一(2)班, N语文, 90.0, 2025-01-10);这套数据里故意安排了两个并列李雪和王强语文数学都有高低差而王强和吴昊的数学都是90分周小雅和赵磊的语文也都是88分模拟了成绩相同需要并列名次的真实场景。数据量不大但足够把几种排名函数的区别看得清清楚楚。2. 同一份成绩数据三种排名函数为何结果不同对刚接触窗口函数的人来说最容易懵的就是ROW_NUMBER()、RANK()、DENSE_RANK()这三个长得差不多的函数到底什么区别。我直接上SQL用数学成绩演示。2.1 ROW_NUMBER顺序号不承认并列先看最常见的写法SELECT StudentName, Score AS MathScore, ROW_NUMBER() OVER (ORDER BY Score DESC) AS RowSeq FROM dbo.StudentExam WHERE SubjectName N数学;ROW_NUMBER()的逻辑非常简单按ORDER BY排完序之后从上往下依次编号1、2、3……它不关心是不是有相同成绩。王强和吴昊数学都是90但因为OVER里ORDER BY只写了分数SQL Server再碰上排序键完全一样的行时会按某种内部顺序强行分出先后谁排前面不一定也不重要。结果长这样StudentNameScoreRowSeq李雪95.01孙悦93.02王强90.03吴昊90.04张明92.05等等张明数学是92为什么排到第五我数据插的时候张明数学92、孙悦93、李雪95王强和吴昊90。按DESC排名次应该是95、93、92、90、90。所以正确的顺序是李雪第1孙悦第2张明第3王强第4吴昊第5。刚才表格里写错位了这里必须纠正。修正后StudentNameScoreRowSeq李雪95.01孙悦93.02张明92.03王强90.04吴昊90.05所以ROW_NUMBER()输出的RowSeq是1、2、3、4、5中间没有任何断层即使两人同分名次也被强行拆开。这在业务上的意义是当你需要唯一标识每一行比如取前三行排名第5的那个人或者需要给名单编一个不重复的序号时用它最合适。但它不能表达并列名次。2.2 RANK与DENSE_RANK并列名次的两种处理逻辑再看RANK()SELECT StudentName, Score AS MathScore, RANK() OVER (ORDER BY Score DESC) AS RankNum FROM dbo.StudentExam WHERE SubjectName N数学;结果会是StudentNameScoreRankNum李雪95.01孙悦93.02张明92.03王强90.04吴昊90.04王强和吴昊都是90分RANK()给了相同名次4但下一个名次会跳号——如果还有一个人是85分那他的名次不是5而是6因为前面有4个人排在他前面。这是体育比赛的标准排名规则你能直观理解成90分那两位并列第4所以85分的人是第6名第5名不存在。DENSE_RANK()则换个思路SELECT StudentName, Score AS MathScore, DENSE_RANK() OVER (ORDER BY Score DESC) AS DenseRankNum FROM dbo.StudentExam WHERE SubjectName N数学;结果是95→1、93→2、92→3、90→4、90→4如果后面还有85分他的名次是5。序号永远连续不跳号。这更像班里有几个人分数比我高我就是第几名1的密集排名逻辑。这三者的核心差异可以放到一张表里对比函数并列是否同号是否跳号适用场景ROW_NUMBER不同号强行分先后不跳生成行号、取Top N、分页唯一序号RANK同号跳号竞赛排名、成绩单上的真实名次、奖学金评定DENSE_RANK同号不跳号等级划分如按分数段定A/B/C、统计有几个不同档位2.3 成绩排名到底应该选哪个这个问题在实际工作中经常被拿出来讨论。我的建议是如果业务方给的名次定义是有几个比你分高你就是第几1那用RANK如果业务方说按分数段分等级分数一样就是同一档下一档紧接着排用DENSE_RANK。举个具体例子。奖学金评定年级前10名假设计算后发现第10名那个位置有3个人同分这时候用RANK()会出现第10、第10、第10后面直接跳到第13没人占11和12。你要是用ROW_NUMBER()3个同分的人会被硬排成10、11、12好像第11和第12名真实存在一样。多数教务系统这个时候会倾向用RANK因为并列第10就是并列后面空出来的11、12名不能发给别人。但如果是按成绩把学生分成A/B/C三档DENSE_RANK()就比RANK更直观因为它输出的最大数值就是一共有几个档不会出现A、B、C三档但DenseRank最大是5的尴尬情况。一句话总结ROW_NUMBER是流水号RANK是竞赛名次DENSE_RANK是等级序号。这三个函数共享同一个OVER语法结构区别只在于对并列值怎么处理用的时候先想清楚业务要的是哪种语义再下手写。3. PARTITION BY把全球排名切成组内排名光有排名函数还只是解决全部人按一个维度排的问题。实际业务里最常用的其实是组内排名——按班排、按科排、按月考批次排。这就是PARTITION BY登场的时机。3.1 按班级排名加一个分区键这么简单需求变成每个班级内部单独算数学名次只用一个班的数据看没意思得按班切开了。SQL写法就是在OVER里加上PARTITION BY ClassNameSELECT StudentName, ClassName, Score AS MathScore, RANK() OVER (PARTITION BY ClassName ORDER BY Score DESC) AS RankInClass FROM dbo.StudentExam WHERE SubjectName N数学 ORDER BY ClassName, RankInClass;PARTITION BY ClassName做的事情可以类比成先把一沓试卷按班级分开然后每一摞试卷各自排序编号。一班归一班排二班归二班排互不干扰。你可以拿到一次查询结果里同时看到两个班各自的第一名而不是全年级排名之后再去人工过滤。我插的数据里一班有王强和吴昊都是90分吗没有吴昊是二班的。让我重新看数据王强90在一班吴昊90在二班所以按班排名时没有出现班内并列90。不过这不要紧重要的是查出来一班名次是李雪1、张明2、王强3二班是孙悦1、吴昊2、赵磊3。班里每个学生的名次都是独立计算的。这里有个关键认知PARTITION BY的分区维度和ORDER BY的排序维度是两个独立的维度。你完全可以按班级分区、按分数排序也可以按班级分区、按学号排序。分区决定了组怎么切排序决定了组内顺序怎么排。很多初学者误以为这两个必须有关联其实没有它们各管各的。3.2 按班级加科目组合分区键更进一步教务系统经常要求每个班每一科都出排名——一班语文排名、二班数学排名、三班英语排名……这时候PARTITION BY后面可以跟多个字段用逗号隔开就行SELECT ClassName, SubjectName, StudentName, Score, ROW_NUMBER() OVER ( PARTITION BY ClassName, SubjectName ORDER BY Score DESC ) AS Seq FROM dbo.StudentExam ORDER BY ClassName, SubjectName, Seq;PARTITION BY ClassName, SubjectName的意思就是先按班级把数据切开每个班级内部再按科目切开最后在某班某科这个最小分组里排名。这种组合分区是窗口函数特别擅长的事情用传统的GROUP BY根本做不到因为GROUP BY会把行折叠掉你无法同时看到每行数据又拿到组内名次。这里顺便提一个SQL Server的语法细节PARTITION BY后面可以用逗号接多个列也可以用表达式比如PARTITION BY YEAR(ExamDate), ClassName。但要注意这里的列必须是SELECT里能引用到的列而且不能直接用SELECT别名SQL Server对窗口函数里的列引用比普通WHERE要严格别名的解析顺序通常跟不上容易报列名无效。3.3 分区和排序的先后逻辑很多人在看窗口函数执行计划时好奇SQL Server到底先分区还是先排序。按逻辑顺序来理解窗口函数是在WHERE、GROUP BY、HAVING这些步骤之后才执行的。也就是说数据先经过过滤和聚合剩下的结果集才被送到窗口函数手里窗口函数再按PARTITION BY做逻辑分区在每个分区内部按ORDER BY排序最后计算。举一个容易踩的细节如果你想看每个班数学成绩排名但只统计分数大于等于90的人可以放心地把Score 90写在WHERE里窗口函数计算时是基于过滤后的行集的排名也是过滤后内部的排名。顺序是先WHERE过滤再窗口排名。这跟子查询里先算排名再在外部过滤语义上是完全不同的用之前必须想清楚业务到底要哪种。4. 成绩排名完整实战从原始表到正式报表工具语法讲清楚还得落到完整的报表查询上。这里我按实际经验从单科排名、总分排名到分段定级把一套实战链路完整走一遍。4.1 单科排名分区之外还有个全局视角单科排名有两种常见形态。一是全年级单科排名直接RANK() OVER (ORDER BY Score DESC)不写PARTITION BY。二是在班级内部单科排名就是前面写的PARTITION BY ClassName。还有一种形态容易被忽略同一份结果里同时保留全局名次和组内名次窗口函数可以开两个窗口互不干扰SELECT ClassName, StudentName, SubjectName, Score, RANK() OVER (ORDER BY Score DESC) AS GradeRank, RANK() OVER (PARTITION BY ClassName ORDER BY Score DESC) AS ClassRank FROM dbo.StudentExam WHERE SubjectName N数学 ORDER BY ClassName, ClassRank;这样做的好处是一行数据里既能看到这个学生的全校名次也能看到班内名次对班主任和年级组长都很友好。窗口函数可以叠加多个OVER子句这是它比GROUP BY灵活很多的地方。只要每个窗口独立定义自己的分区和排序计算结果互不影响。4.2 总分排名先GROUP BY汇总再窗口排名单科排名简单总分排名就是另一个常见操作了。总分排名多了一个前置动作先把各科成绩汇总成每个学生的总分再对总分做排名。由于窗口函数是在GROUP BY之后执行的我们可以在GROUP BY的结果上继续开窗WITH ScoreSummary AS ( SELECT ClassName, StudentName, SUM(Score) AS TotalScore FROM dbo.StudentExam WHERE ExamID 20250101 GROUP BY ClassName, StudentName ) SELECT ClassName, StudentName, TotalScore, RANK() OVER (ORDER BY TotalScore DESC) AS GradeTotalRank, RANK() OVER (PARTITION BY ClassName ORDER BY TotalScore DESC) AS ClassTotalRank FROM ScoreSummary ORDER BY ClassName, ClassTotalRank;这个写法的关键点在于CTE里的GROUP BY ClassName, StudentName已经把多科成绩压成了一行每个学生只剩一个总分。然后窗口函数在这个汇总结果集上做排名逻辑上完全允许因为窗口函数位于GROUP BY之后执行。这样得到的GradeTotalRank和ClassTotalRank就是各自范围内的总分名次。如果用我的示例数据一班学生总分分别是李雪190、张明180、王强180、周小雅176二班孙悦186、吴昊180、赵磊170。一班的李雪第1张明和王强并列第2周小雅第4二班孙悦第1吴昊第2赵磊第3。不同班级的总分名次互不干扰清晰明了。这里有个容易踩的坑如果你在SELECT里直接对SUM(Score)开窗比如RANK() OVER (ORDER BY SUM(Score) DESC)SQL Server是支持这种写法的——因为窗口函数作用于聚合后的结果。但要注意如果同时把没被GROUP BY包裹的列放进窗口的ORDER BY里就会报错。经验法则是窗口表达式里的字段要么是分组列要么是聚合函数的结果别混入普通明细列。4.3 用NTILE分段定级和用LAG/LEAD看前后分差除了排名成绩分析经常会用到分段。比如班主任想看班级里前25%是谁后25%是谁这就轮到NTILE()函数。NTILE(4)表示把每个分区内的行尽量均匀地分成4段返回1到4的段号可以用来快速把班级切成四等份SELECT ClassName, StudentName, Score, NTILE(4) OVER (PARTITION BY ClassName ORDER BY Score DESC) AS Quartile FROM dbo.StudentExam WHERE SubjectName N数学;这个例子很像Excel里的百分位段。业务上可以直接用它给学生打A/B/C/D档也可以做前25%重点关注名单。再往下名次上下波动分析经常需要看某个学生前面是谁、后面是谁。LAG()和LEAD()就是干这个的——LAG取上一行的值LEAD取下一行的值。比如查单科排名时想在旁边列出前一名和后一名的分数差距SELECT StudentName, Score AS MathScore, RANK() OVER (ORDER BY Score DESC) AS RankNum, Score - LAG(Score) OVER (ORDER BY Score DESC) AS DiffPrev FROM dbo.StudentExam WHERE SubjectName N数学;LAG(Score) OVER (ORDER BY Score DESC)会取当前行按分数降序排列时前一行学生的分数。这个差值能直观看出他和上一名的差距有多大在升学分析里特别实用。相比传统写法用自连接取前一名分数LAG()一行搞定性能也好得多。5. 百万级成绩表下的窗口函数性能与索引调优窗口函数写法优雅但不代表可以无视性能。尤其是成绩表这种典型的大量插入、批量分析场景一旦数据量到了百万级执行计划里一个缺少索引的排序就能让你跑出几十秒的延迟。这块我踩过很实在的坑直接分享经验。5.1 为什么PARTITION BY和ORDER BY列要建组合索引窗口函数执行时SQL Server需要按PARTITION BY分组再按ORDER BY排序。如果你仔细看执行计划会发现里面经常出现Sort运算符或者Window Spool。当分组和排序字段没有合适的索引时SQL Server只能把整张表读出来在内存或tempdb里做显式排序。数据量一大tempdb溢出是常有的事查询就变成灾难现场。优化思路很直观让数据在物理存储上就是按分区键排好序的状态。也就是在PARTITION BY和ORDER BY涉及的列上建立组合索引。比如高频查询是按班排名对应SQL的PARTITION BY ClassName ORDER BY Score DESC那就建CREATE NONCLUSTERED INDEX IX_StudentExam_Class_Score ON dbo.StudentExam (ClassName, SubjectName, Score DESC);这样SQL Server扫描索引时数据已经是按班级科目分数倒序排列的窗口排序这一步就省掉了直接变成顺序扫描加编号。索引列的顺序有讲究分区列放前面排序列放后面。但如果查询里还带WHERE SubjectName N数学那索引最好把SubjectName也加进去变成(ClassName, SubjectName, Score DESC)。索引设计得多贴近真实查询性能收益就有多明显。5.2 实测对比一个真实优化案例我拿一套约120万行的考试记录做过一次对比。表结构基本就是StudentID、ClassID、SubjectID、Score没有索引。执行一个全年级分科排名的窗口查询SELECT ClassID, StudentID, Score, RANK() OVER(PARTITION BY ClassID, SubjectID ORDER BY Score DESC) FROM dbo.ExamRecords;第一次跑没有索引执行计划里一个大大的Sort占掉了查询总耗时的大头跑了大概22秒。加了(ClassID, SubjectID, Score DESC)索引之后同样的SQL直接走索引有序扫描窗口函数不再需要额外排序耗时降到1.8秒。没有改变任何业务逻辑纯粹靠索引就把速度提升了十倍以上。如果你的查询还有WHERE ExamID 20250101这种批次过滤条件那最优先要建的其实是ExamID开头的索引把过滤条件下推。排名字段再着急也得先让WHERE能把数据量压下来排序才有意义。不然索引建得再好前几百万行都是无关数据白白排序。5.3 窗口函数和分页、临时表的搭配除了索引另一个常见性能杀手是在窗口函数外面套分页。比如看年级排名Top 100直觉写法是SELECT * FROM ( SELECT StudentName, Score, RANK() OVER (ORDER BY Score DESC) AS rn FROM dbo.StudentExam ) t WHERE rn 100;这种写法的问题是SQL Server要先给所有行算完排名再过滤前100名。如果表里有120万行窗口计算就得跑完120万行哪怕你只要前100个。更聪明的做法加上TOPSELECT TOP (100) StudentName, Score, RANK() OVER (ORDER BY Score DESC) AS rn FROM dbo.StudentExam;但实际上RANK()前100名还是得全表排序TOP在这里对窗口函数整体开销帮助有限。真正能提速的是让排序走索引或者在明细表基础上预先维护一张汇总排名表提前算好名次存起来。排名报表如果每天只是跑一次可以在业务低峰期落一张结果表白天直接查表不实时用窗口函数硬算。这是千万级数据下最常见的架构取舍窗口函数再快也不如不跑。6. 成绩排名实战中的几个高频踩坑点理论讲完了性能也讲了剩下的是我在实际写成绩排名SQL时翻过车的几个细节。新手最容易在这里卡住说出来帮大家避坑。6.1 窗口函数结果不能直接写在WHERE里这是一个让无数人懵圈的报错。你写了SELECT StudentName, RANK() OVER (ORDER BY Score DESC) AS rn FROM dbo.StudentExam WHERE rn 1;SQL Server直接报列名 rn 无效。原因很简单窗口函数在WHERE之后才执行WHERE过滤发生时rn这个排名结果还没算出来SQL Server根本不认这个别名。正确做法是包一层子查询或CTEWITH Ranked AS ( SELECT StudentName, Score, RANK() OVER (ORDER BY Score DESC) AS rn FROM dbo.StudentExam ) SELECT * FROM Ranked WHERE rn 10;记住一条铁律窗口函数只能出现在SELECT和ORDER BY里想用它过滤就老老实实包一层。这不光是为了语法正确也是让逻辑清晰——先算名次再过滤两步分开执行计划也更透明。6.2 NULL值参与排序缺考到底算第几成绩表里最讨厌的数据就是NULL——学生缺考Score为空。排序时ORDER BY Score DESCSQL Server把NULL放在最前面还是最后面很多人记不住。这里直接说结论SQL Server默认规则是NULL值最小升序时排最前降序时排最后。所以ORDER BY Score DESC时NULL会沉底这正好符合直觉——缺考的人不应该拿到第一名。但如果你写的是ORDER BY Score升序那NULL反而跑到了最前面排在最上面非常反直觉。为了防止这种事故最稳妥的做法是显式处理RANK() OVER (ORDER BY COALESCE(Score, 0) DESC) AS rn把NULL转成0保证升序降序都不出意外。或者用CASE把缺考单拎出去RANK() OVER (ORDER BY CASE WHEN Score IS NULL THEN 0 ELSE 1 END DESC, Score DESC)这样有成绩的人永远排在缺考的人前面缺考的学生统一排在末尾。NULL的排序规则在不同数据库里不一样不要拿MySQL或Oracle的经验硬套SQL Server。在SQL Server里自己能写的排序逻辑就别交给默认规则。6.3 并列名次在总分和分科之间的名次口径不一致前面说过总分用RANK()会跳号分科用RANK()也会跳号。但真做报表时你会发现一个更隐蔽的问题同一套数据语文用了RANK算并列数学用了DENSE_RANK算并列两张表的名次口径对不上。业务方拿着成绩单质问你解释半天他们还是觉得两个表的名次算法不一致。所以我强烈建议在项目一开始就和业务方确认名次口径最好全校统一用一种排名函数。一般成绩单建议统一用RANK()因为它最符合有几个比你高你就是第几1的大众直觉。等级划分再单独用DENSE_RANK()不建议混用。6.4 SELECT DISTINCT和窗口函数的冲突还有一个比较少见的坑。如果你在查询里同时用了SELECT DISTINCT和窗口函数SQL Server可能报错或者结果不符合预期。原因是窗口函数作用在DISTINCT前的完整行集上而DISTINCT去重的逻辑和窗口计算有冲突。我在一次去重统计时不巧把ROW_NUMBER()和DISTINCT写在一起结果SQL Server直接抛错。解决方案是分开处理——先用DISTINCT得到一个干净的行集再在这个行集外面包一层窗口排名或者反过来先排名再去重。记住别指望着一条SELECT同时干两件互斥的事。窗口函数和DISTINCT能不在同一层就别放同一层。说了这么多其实万变不离其宗PARTITION BY负责分组排名函数负责定序OVER子句把这两件事组合起来就能解决几乎所有成绩排名场景。我在实际项目里的体会是写好窗口函数的关键不在于背语法而在于先想清楚业务的排名语义——是流水号、竞赛名次还是等级序号再决定用哪个函数最后再考虑索引和性能。这样动手写SQL的时候基本不会走弯路。
返回列表