ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL窗口函数:ROW_NUMBER、RANK、DENSE_RANK与NTILE的深度解析与应用

SQL窗口函数:ROW_NUMBER、RANK、DENSE_RANK与NTILE的深度解析与应用 1. 项目概述为什么排名函数是SQL进阶的必修课在数据分析和后端开发的工作中我们经常遇到这样的需求找出每个部门业绩最高的员工、为商品按销量排名次、或者在一组数据中筛选出前N名。如果只用基础的GROUP BY和ORDER BY处理这类“分组内排序”或“排名”问题会变得异常繁琐常常需要写多层嵌套子查询代码冗长且性能堪忧。这时SQL窗口函数中的排名函数Ranking Functions就成了我们手中的利器。今天我们就来深入聊聊SQL中最常见的四种排名函数ROW_NUMBER()、RANK()、DENSE_RANK()和NTILE()。这不仅仅是记住语法那么简单更重要的是理解它们在不同业务场景下的细微差别和选择逻辑。掌握了它们你写的SQL语句将从“能跑通”升级到“既优雅又高效”。2. 核心概念与语法基础拆解在深入每个函数之前我们必须先建立一个统一的认知框架什么是窗口函数排名函数又是什么窗口函数顾名思义它不像普通聚合函数那样将多行“压缩”成一行而是为查询结果集中的每一行基于一个定义的“窗口”一组相关的行进行计算并将计算结果附加到该行上。这个“窗口”通过OVER()子句来定义。排名函数是窗口函数的一个子集专门用于为行分配一个排名序号。所有排名函数都遵循相同的基础语法结构排名函数() OVER ( [PARTITION BY 分区列1, 分区列2, ...] ORDER BY 排序列1 [ASC|DESC], 排序列2 [ASC|DESC] )PARTITION BY可选。它定义了数据的分区。排名计算会在每个分区内部独立进行。例如PARTITION BY department_id意味着会分别对每个部门的员工进行排名。如果省略则将所有数据视为一个分区。ORDER BY必选。它决定了在每个分区内行与行之间的排序规则也就是排名的依据。例如ORDER BY sales_amount DESC表示按销售额降序排名销售额最高的排第1。这个OVER()子句是理解排名函数的关键。它划定了计算的“战场”PARTITION BY决定了战场有多少个独立的赛区而ORDER BY则决定了每个赛区内的比赛规则。接下来我们将看到四个函数在相同的“战场”上如何给出不同的“比赛结果”。3. 四大排名函数深度解析与对比3.1 ROW_NUMBER()唯一的连续序号ROW_NUMBER()函数的作用最为直观它为每一行分配一个唯一的、连续的整数序号从1开始。这个序号在分区内严格按照ORDER BY子句的顺序生成。核心特性唯一性即使在ORDER BY的字段值完全相同的情况下ROW_NUMBER()也会强制给出不同的序号通常是按照数据库内部某种确定的顺序但你不应依赖于此顺序的具体规则。这是它最显著的特点。连续性序号永远是1, 2, 3, 4... 中间不会断号。典型应用场景数据去重当需要从可能有重复的数据中选取每组的一条记录时例如每个用户最近的一次登录记录。分页查询实现高效的分页尤其是在Web应用中。生成代理键或唯一标识在数据迁移或ETL过程中为没有合适主键的数据生成一个临时唯一ID。示例与思考 假设我们有一个sales表记录销售员的销售额。SELECT salesperson, sales_amount, ROW_NUMBER() OVER (ORDER BY sales_amount DESC) as row_num FROM sales;如果sales_amount有并列比如两个销售员都是100万row_num依然会是1和2但具体谁得1谁得2是不确定的。因此ROW_NUMBER()不适合用于处理并列排名的情况如果你需要并列名次它可能给出误导性的结果。注意ROW_NUMBER()的“唯一性”意味着它不能处理平局。在需要体现公平并列排名的场景如成绩排名中应避免单独使用它。3.2 RANK()允许并列的跳跃排名RANK()函数的行为更像我们熟悉的体育比赛排名允许并列并且并列会占用名次导致后续名次“跳跃”。核心特性允许并列如果ORDER BY字段值相同这些行会获得相同的排名。跳跃性并列排名后下一个排名数字会跳过被占用的位置。例如如果有两个第1名那么下一个名次就是第3名。典型应用场景成绩排名学生考试成绩排名分数相同则名次相同。竞赛排名任何允许并列的竞赛场景。市场占有率排名计算产品在市场中的排名份额相同的产品名次相同。示例与思考SELECT salesperson, sales_amount, RANK() OVER (ORDER BY sales_amount DESC) as rank_num FROM sales;假设数据为Alice(100万), Bob(100万), Charlie(90万), David(80万)。那么排名结果是Alice和Bob并列第1Charlie第3David第4。你会发现Charlie的排名从ROW_NUMBER()可能得到的3“跳跃”到了3但名次数字3是符合我们常规认知的。RANK()的跳跃特性是其最需要被理解的一点它保证了排名数字的“名次”意义但会导致排名数字序列不连续。3.3 DENSE_RANK()允许并列的连续排名DENSE_RANK()可以看作是RANK()的“紧凑”版本。它也允许并列但处理并列后的方式不同。核心特性允许并列与RANK()相同值相同的行排名相同。连续性并列排名后下一个排名数字紧接着上一个排名数字不会跳跃。例如两个第1名之后下一个名次是第2名。典型应用场景等级划分将员工绩效分为“A级”、“B级”、“C级”同绩效的员工等级相同且等级是连续的。阶梯定价或折扣根据购买金额划分折扣等级金额相同的客户享受同一档折扣且折扣档位是连续的如9折、8.5折、8折没有空缺档位。需要连续序号且考虑并列的报表。示例与思考 沿用上面的销售数据SELECT salesperson, sales_amount, DENSE_RANK() OVER (ORDER BY sales_amount DESC) as dense_rank_num FROM sales;排名结果将是Alice和Bob并列第1Charlie第2David第3。可以看到排名数字序列是连续的1, 1, 2, 3。DENSE_RANK()在业务上常用于当排名本身被视为一种“等级”或“层级”且我们关心层级数量而非绝对位置时。例如领导可能只关心“有多少人属于第一梯队”而不关心第一梯队之后的人是从“第三名”开始算的。3.4 NTILE()数据等频分桶NTILE(N)函数与前三个函数的目标不同它不是要给出一个具体的排名而是将有序分区内的行尽可能平均地分配到指定数量N的“桶”中并为每一行分配其所属的桶编号从1开始。核心特性分桶而非排名目标是数据分割。尽可能平均数据库会努力使每个桶的行数相等或最多相差1。受分区行数影响如果分区内的总行数不能被N整除那么多出来的行会依次分配到前面的桶中即编号小的桶可能多一个元素。典型应用场景数据分片或分治将大数据集分成N个部分进行并行处理。创建百分位数NTILE(100)可以近似地创建百分位数但由于是等频分桶并非精确的百分位数精确计算常用PERCENT_RANK()。客户分层将客户按交易额分为“高价值”、“中价值”、“低价值”三组N3。示例与思考 假设有11行数据我们使用NTILE(3)。SELECT salesperson, sales_amount, NTILE(3) OVER (ORDER BY sales_amount DESC) as ntile_group FROM sales;分配结果可能是前4行在组1接下来4行在组2最后3行在组3。NTILE()的核心价值在于其“分组”能力它提供了一种将有序数据均匀切分的标准方法。为了更直观地对比这四个函数我们用一个简单的数据集演示假设student_scores表数据如下studentscore张三95李四95王五90赵六85钱七85孙八80执行以下查询SELECT student, score, ROW_NUMBER() OVER (ORDER BY score DESC) as row_num, RANK() OVER (ORDER BY score DESC) as rank_num, DENSE_RANK() OVER (ORDER BY score DESC) as dense_rank_num, NTILE(3) OVER (ORDER BY score DESC) as ntile_3 FROM student_scores;结果将会是studentscorerow_numrank_numdense_rank_numntile_3张三951111李四952111王五903322赵六854432钱七855433孙八806643这个对比表清晰地揭示了四个函数的区别ROW_NUMBER无视并列强制连续RANK允许并列并跳跃DENSE_RANK允许并列且连续NTILE则专注于将6行数据尽可能均分到3个桶里。4. 高级应用场景与组合技巧掌握了基础用法后我们可以将这些函数组合起来解决更复杂的业务问题。PARTITION BY子句在这里扮演了核心角色它让我们能在每个分组内独立应用排名逻辑。4.1 分区排名组内竞争分析这是排名函数最强大的功能之一。例如我们想找出每个部门department内销售额最高的员工。SELECT department, employee, sales_amount, RANK() OVER (PARTITION BY department ORDER BY sales_amount DESC) as dept_rank FROM sales_records WHERE dept_rank 1; -- 注意在标准SQL中不能直接在WHERE中引用窗口函数结果上面的查询会报错因为窗口函数的结果在WHERE子句执行时还不可用。正确的做法是使用子查询或公共表表达式CTEWITH ranked_sales AS ( SELECT department, employee, sales_amount, RANK() OVER (PARTITION BY department ORDER BY sales_amount DESC) as dept_rank FROM sales_records ) SELECT * FROM ranked_sales WHERE dept_rank 1;这样我们就能得到每个部门的销售冠军。如果使用DENSE_RANK()当部门内有多个并列第一时它们都会被选出。如果只想选一个即使并列可以用ROW_NUMBER()但需要额外的排序条件来保证确定性例如ORDER BY sales_amount DESC, employee_id。4.2 复杂排序与分页优化在Web应用的分页查询中直接使用LIMIT ... OFFSET在深度分页时OFFSET值很大性能很差因为数据库需要先扫描并跳过大量行。结合ROW_NUMBER()可以实现“键集分页”性能更稳定。假设我们有一个按时间倒序展示文章列表的需求-- 低效的传统分页第100页每页20条 SELECT * FROM articles ORDER BY publish_time DESC LIMIT 20 OFFSET 1980; -- 高效的键集分页思路需要客户端配合 -- 第一页 SELECT * FROM articles ORDER BY publish_time DESC LIMIT 20; -- 假设上一页最后一条的publish_time是 ‘2023-10-27 08:00:00‘, id是 12345 -- 第二页 SELECT * FROM articles WHERE (publish_time, id) (‘2023-10-27 08:00:00‘, 12345) ORDER BY publish_time DESC LIMIT 20;而ROW_NUMBER()可以更清晰地封装这种逻辑或者用于在应用层生成绝对行号。4.3 数据清洗与采样NTILE()在数据准备阶段非常有用。例如我们需要将一个大型数据集随机但均匀地分成训练集和测试集WITH numbered_data AS ( SELECT *, NTILE(10) OVER (ORDER BY RAND()) as bucket -- 分成10桶 FROM large_dataset ) SELECT * FROM numbered_data WHERE bucket 8; -- 80% 训练集 -- SELECT * FROM numbered_data WHERE bucket 8; -- 20% 测试集通过ORDER BY RAND()我们实现了随机排序然后NTILE(10)将其均匀分到10个桶再按桶选取这就实现了分层随机抽样保证了样本的均匀性。ROW_NUMBER()常用于删除重复数据DELETE FROM duplicates_table WHERE id IN ( SELECT id FROM ( SELECT id, ROW_NUMBER() OVER (PARTITION BY unique_field1, unique_field2 ORDER BY id) as rn FROM duplicates_table ) t WHERE t.rn 1 );这个语句会保留每组重复数据中id最小的那条删除其他重复项。PARTITION BY后面跟的是判断重复的业务字段组合。5. 性能考量与避坑指南窗口函数虽然强大但如果使用不当也可能成为性能瓶颈。以下是一些关键的注意事项和优化心得。5.1 索引是性能的基石窗口函数的OVER()子句中的ORDER BY和PARTITION BY能否利用索引对性能有决定性影响。ORDER BY优化如果窗口函数的ORDER BY子句与表中已有的索引顺序一致数据库可以避免一次昂贵的全表排序操作。例如OVER (ORDER BY create_time DESC)如果create_time上有索引性能会好很多。PARTITION BY优化PARTITION BY的列如果也有索引可以帮助数据库快速定位分区边界。一个覆盖(department_id, sales_amount)的复合索引对RANK() OVER (PARTITION BY department_id ORDER BY sales_amount DESC)这样的查询将是极大的助力。实操心得在编写复杂窗口函数查询前先用EXPLAIN或数据库对应的执行计划查看命令分析一下。重点关注是否有“Sort”或“WindowAgg”操作以及它们处理的行数。如果发现全表排序就要考虑调整索引。5.2 分区大小与数据倾斜当使用PARTITION BY时每个分区的数据量直接影响内存使用。如果某个分区的数据量极大例如按“城市”分区但90%的数据都属于“上海”可能会导致单个工作线程内存溢出拖慢整个查询。排查技巧在开发测试时可以先用一个查询看看分区键的数据分布SELECT partition_column, COUNT(*) as cnt FROM your_table GROUP BY partition_column ORDER BY cnt DESC LIMIT 10;如果发现严重倾斜需要考虑是否有更合理的分区键能否用多个列组合来使分区更均匀是否真的需要对整个超大分区进行排名业务逻辑能否调整例如只对每个分区的前N名感兴趣对于NTILE()如果数据倾斜严重所谓的“平均分桶”可能失去意义。5.3 常见错误与语义混淆在WHERE/GROUP BY/HAVING中直接引用窗口函数列这是最常见的语法错误。窗口函数在SELECT列表中的逻辑顺序很晚在WHERE等子句之后执行。必须使用子查询或CTE。混淆RANK()和DENSE_RANK()这是业务逻辑错误。需要明确回答当出现并列时你希望下一个名次是跳跃的如1,1,3还是连续的如1,1,2这完全取决于业务规则。成绩排名通常用RANK()而等级划分常用DENSE_RANK()。对NULL值的处理在ORDER BY中NULL值的排序位置取决于数据库设置通常NULLS FIRST或NULLS LAST。这会影响排名结果。务必明确业务上对NULL值的处理要求并在ORDER BY子句中显式声明例如ORDER BY sales_amount DESC NULLS LAST。NTILE(N)中N的选择N必须是一个正整数。如果N大于分区内的行数那么前面(N - 行数)个桶将是空的桶编号从1到行数。例如5行数据NTILE(10)结果桶号是1,2,3,4,5。5.4 窗口框架Frame的扩展虽然本文聚焦排名函数但了解窗口框架能让你更上一层楼。排名函数使用默认的窗口框架即“从分区开始到当前行”RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。但其他聚合类窗口函数如SUM(),AVG()可以通过定义框架来实现移动平均、累计求和等。-- 计算每个员工截至当前月份的累计销售额 SELECT employee, month, sales, SUM(sales) OVER (PARTITION BY employee ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) as cumulative_sales FROM sales_monthly;理解框架是精通所有窗口函数的关键。6. 实战一个综合案例演练让我们通过一个模拟的电商数据分析需求串联使用多个排名函数。假设我们有订单表orders(order_id, user_id, product_id, amount, order_time)用户表users(user_id, reg_city)产品表products(product_id, category)。业务需求找出每个城市消费金额排名前3的用户考虑并列。在每个产品类别内按销售额对产品进行排名并标识出销售额在前20%的产品即第一梯队。为每个用户生成其所有订单的消费流水号按时间顺序。解决方案-- 需求1各城市消费TOP3用户使用RANK允许并列 WITH user_city_spending AS ( SELECT u.reg_city, o.user_id, SUM(o.amount) as total_spent FROM orders o JOIN users u ON o.user_id u.user_id GROUP BY u.reg_city, o.user_id ), city_user_rank AS ( SELECT reg_city, user_id, total_spent, RANK() OVER (PARTITION BY reg_city ORDER BY total_spent DESC) as city_rank FROM user_city_spending ) SELECT * FROM city_user_rank WHERE city_rank 3; -- 需求2产品类别内销售额排名与头部标识使用NTILE划分前20% WITH product_category_sales AS ( SELECT p.category, p.product_id, SUM(o.amount) as category_sales FROM orders o JOIN products p ON o.product_id p.product_id GROUP BY p.category, p.product_id ), product_ranked AS ( SELECT category, product_id, category_sales, RANK() OVER (PARTITION BY category ORDER BY category_sales DESC) as sales_rank_in_cat, NTILE(5) OVER (PARTITION BY category ORDER BY category_sales DESC) as quintile -- 分成5份前1/5即20% FROM product_category_sales ) SELECT * FROM product_ranked WHERE quintile 1; -- 筛选出每个类别的前20%产品 -- 需求3生成用户订单流水号使用ROW_NUMBER保证唯一连续 SELECT user_id, order_id, order_time, amount, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time ASC) as user_order_seq FROM orders ORDER BY user_id, user_order_seq;这个案例展示了如何根据不同的业务语义是否允许并列、是否需要分组、是否需要分桶灵活选用不同的排名函数。在实际工作中将这些函数与JOIN、GROUP BY、CTE等组合使用能解决绝大多数复杂的排序和分组Top-N问题。7. 在不同数据库系统中的细微差别虽然SQL标准定义了这些函数但各数据库厂商的实现和支持程度仍有差异。了解这些差异有助于写出可移植性更强的SQL。MySQL在MySQL 8.0之前不支持窗口函数。从8.0版本开始全面支持。对于低版本只能用极其繁琐的自连接或变量模拟性能很差。PostgreSQL对窗口函数的支持非常完善和早熟性能优化也很好。SQLite从3.25.0版本开始支持窗口函数。SQL Server从2005版本就开始支持ROW_NUMBER(),RANK(),DENSE_RANK()NTILE()则从2012版本开始支持更完整的窗口框架。Oracle同样很早就支持语法基本一致。语法兼容性提示最大的通用性差异在于对ORDER BY中NULLS FIRST/LAST的支持。在排名时NULL值排在最前还是最后会影响结果。在写跨数据库SQL时如果不确定最好先测试一下NULL值的排序行为或者提前用COALESCE()函数处理NULL值。掌握这四种排名函数相当于为你的SQL工具箱添加了一套精密的“排序手术刀”。从简单的序号生成到复杂的分组排名分析它们都能优雅高效地完成任务。关键在于深刻理解ROW_NUMBER的唯一性、RANK的跳跃性、DENSE_RANK的连续性以及NTILE的分桶本质并结合PARTITION BY在具体业务场景中灵活运用。下次当你面对需要排序、排名或分组的复杂查询时不妨先想想这个问题用窗口排名函数是不是可以更简单地解决
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进