三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

SQL窗口函数实战:ROW_NUMBER、RANK、DENSE_RANK排序与序号生成详解

SQL窗口函数实战:ROW_NUMBER、RANK、DENSE_RANK排序与序号生成详解

1. 项目概述:从排序到序号,一个被低估的SQL核心技能

在数据库日常开发和数据分析工作中,排序(ORDER BY)几乎是每个SQL查询的标配。但很多时候,我们需要的不仅仅是排好序的数据列表,而是一个带有明确“位置”或“序号”的结果集。比如,你需要为销售团队生成一份业绩排行榜,并清晰地标出每个人的名次;或者,在分页查询时,需要为每一行数据生成一个全局唯一的行号,以便进行更复杂的逻辑处理。这个“输出序号”的需求,看似简单,却直接关系到数据呈现的清晰度和后续处理的便利性。它不仅仅是ORDER BY的简单延伸,而是结合了排序、窗口函数、变量乃至子查询的综合应用,是衡量SQL熟练度的一个非常实用的标尺。

掌握SQL排序并输出序号,意味着你能将原始数据转化为更具洞察力的信息。无论是简单的行号,还是复杂的排名(如并列排名、跳跃排名),其实现方式的选择,直接影响到查询的性能和结果的准确性。尤其是在处理海量数据、进行实时报表分析或构建数据中台时,高效的序号生成策略往往是优化查询性能的关键一环。接下来,我将结合十多年的实战经验,为你拆解几种主流数据库(以MySQL、PostgreSQL、SQL Server为例)的实现方案,深入原理,并分享那些在官方文档里不会写的避坑技巧。

2. 核心思路与方案选型:为什么不止一种方法?

实现排序并输出序号,主要有三种经典思路,每种都有其适用的场景和背后的权衡。

2.1 方案一:使用窗口函数ROW_NUMBER(),RANK(),DENSE_RANK()

这是现代SQL(SQL:2003标准引入)中最优雅、最标准的方式。窗口函数的核心思想是在不改变原始行数据的前提下,为每一行计算一个基于其所在“窗口”(即由OVER子句定义的数据集)的值。

为什么首选它?

  1. 声明式且直观:语法清晰,ROW_NUMBER() OVER (ORDER BY column)直接表达了“按某列排序后生成行号”的意图,代码可读性极高。
  2. 功能强大且灵活:除了生成连续行号(ROW_NUMBER),还能直接处理并列情况。RANK()会在值相同时分配相同排名,并留下空位(如 1,1,3);DENSE_RANK()则不留空位(如 1,1,2)。这完美覆盖了“排名”场景。
  3. 数据库优化友好:主流数据库(如 PostgreSQL, SQL Server, MySQL 8.0+)都对窗口函数进行了深度优化,其执行计划通常比使用变量的自连接或子查询更高效,尤其是在大数据集上。

适用场景:MySQL 8.0+、PostgreSQL、SQL Server 2005+、Oracle等绝大多数现代关系型数据库。进行复杂排名分析、生成分页序列号的首选。

2.2 方案二:使用会话变量(如MySQL 5.x)

在MySQL 5.7及更早版本(不支持窗口函数)中,这是一种非常经典的“黑魔法”。通过用户自定义变量(如@row_number)在查询过程中进行累加。

为什么它曾经流行?

  1. 兼容旧版本:在窗口函数普及前,这是在不支持窗口函数的数据库(如旧版MySQL)中实现行号的唯一高效手段。
  2. 一次扫描完成:理想情况下,它可以在一次表扫描中同时完成排序和序号赋值,避免了某些子查询导致的多次扫描。

核心风险与注意事项

警告:此方法高度依赖于数据库对ORDER BY和变量赋值的执行顺序,而这在SQL标准中并未明确定义。不同MySQL版本,甚至相同版本的不同条件下,结果都可能不稳定。绝对不要在生产环境的复杂查询或关键业务中依赖此方法,除非你完全理解其底层机制并进行了充分测试。在MySQL 8.0+中,应无条件切换到窗口函数。

适用场景:仅限于对旧版MySQL(5.7及以下)的兼容性处理,且对结果稳定性要求不高的临时查询。

2.3 方案三:使用子查询或自连接

通过子查询计算“当前行之前有多少行”来生成序号。例如:SELECT t1.*, (SELECT COUNT(*) FROM table t2 WHERE t2.score >= t1.score) AS rank FROM table t1 ORDER BY score DESC

为什么现在不推荐?

  1. 性能灾难:对于有N行的表,此方法可能导致O(N²)级别的时间复杂度。外层查询的每一行都要执行一次子查询(全表或索引扫描),在大数据表上性能极差。
  2. 逻辑复杂:编写和理解起来比窗口函数更绕。

仅存的使用场景:在某些极其古老或功能受限的数据库系统(如某些嵌入式数据库)中,作为最后的手段。在现代数据库开发中,应尽量避免。

选型结论对于新项目,一律使用窗口函数方案。它标准、高效、功能全面。处理旧系统时,先评估升级数据库的可能性;若无法升级,再谨慎使用变量方案,并附上详细的风险注释。

3. 核心细节解析与实操要点

选定窗口函数方案后,我们来深入其核心细节。OVER()子句是窗口函数的灵魂,它定义了计算发生的“数据窗口”。

3.1OVER()子句的三大构件

一个完整的窗口函数定义包含以下部分,它们共同决定了序号如何生成:

函数名([参数]) OVER ( [PARTITION BY partition_expression, ...] [ORDER BY sort_expression [ASC | DESC], ...] [frame_clause] )

对于ROW_NUMBER()等排名函数,通常不使用frame_clause

  1. PARTITION BY:分区子句。它将结果集划分为多个独立的“组”或“分区”。序号的计算将在每个分区内独立重置和进行。这是实现“组内排名”的关键。

    • 示例PARTITION BY department_id意味着会分别对每个部门的员工进行独立排名。
    • 不指定:如果省略PARTITION BY,则整个结果集被视为一个分区。
  2. ORDER BY:排序子句。它决定了窗口内行的顺序,也是ROW_NUMBER()RANK()等函数分配序号的直接依据。这是必须的(对于排名函数)。

    • 示例ORDER BY sales_amount DESC表示按销售额降序排列,销售额最高的序号为1。
  3. frame_clause:窗口框架子句(如ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)。它定义了在分区内,相对于当前行的计算范围。对于简单的行号生成,我们通常不需要指定,使用默认范围(RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)即可。

3.2 三种序号函数的异同与抉择

这是最容易混淆的点,通过一个具体例子来区分。假设我们有学生成绩表scores

student_idscore
A95
B95
C90
D85

执行以下查询:

SELECT student_id, score, ROW_NUMBER() OVER (ORDER BY score DESC) as row_num, RANK() OVER (ORDER BY score DESC) as rank, DENSE_RANK() OVER (ORDER BY score DESC) as dense_rank FROM scores;

结果将是:

student_idscorerow_numrankdense_rank
A95111
B95211
C90332
D85443

解读与抉择

  • ROW_NUMBER()纯粹的行号。即使分数相同(A和B),也会分配连续的不同数字(1,2)。它保证序号绝对唯一、连续。适用于需要绝对唯一标识的场景,如分页。
  • RANK()跳跃排名。分数相同者获得相同名次(都是第1名),但下一个不同分数者,其名次会“跳跃”到实际行数(C是第3名)。常用于体育赛事排名,允许并列。
  • DENSE_RANK()密集排名。分数相同者获得相同名次,但下一个名次是连续的(C是第2名)。名次数字是密集无间隔的。适用于“等级”评定,如成绩等级(A, A, B, C)。

实操心得

在业务需求评审时,一定要和产品经理或业务方确认清楚“并列情况如何处理”。很多数据展示的bug都源于对排名规则理解不一致。默认情况下,如果业务方只说“排名”,我通常会先按RANK()实现,因为它最符合大众认知的排名逻辑。

4. 实操过程与核心环节实现

我们构建一个更复杂的实战场景:一个电商orders表,包含order_id(订单ID)、user_id(用户ID)、amount(订单金额)、order_date(订单日期)。需求是:计算每个用户的订单金额排名(金额最高的排第1),并输出每个用户最近3笔订单的消费金额及其在总排名中的位次

4.1 数据准备与表结构

假设我们有如下简化的表结构和样例数据:

CREATE TABLE orders ( order_id INT PRIMARY KEY, user_id INT NOT NULL, amount DECIMAL(10, 2) NOT NULL, order_date DATE NOT NULL, INDEX idx_user_date (user_id, order_date DESC) ); -- 插入样例数据 INSERT INTO orders VALUES (1, 101, 150.00, '2023-10-01'), (2, 101, 300.00, '2023-10-05'), (3, 102, 200.00, '2023-10-02'), (4, 101, 80.00, '2023-10-10'), (5, 102, 500.00, '2023-10-08'), (6, 103, 150.00, '2023-10-03'), (7, 101, 400.00, '2023-10-15'), (8, 103, 250.00, '2023-10-12');

4.2 分步实现与SQL解析

这个需求可以拆解为两个部分:1) 全局金额排名;2) 每个用户最近3单。我们可以用CTE(公用表表达式)或子查询来清晰组织逻辑。

方案A:使用CTE(推荐,逻辑清晰)

WITH user_order_rank AS ( -- CTE 1: 为每个订单计算其所属用户的金额排名 SELECT order_id, user_id, amount, order_date, RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) as amount_rank_in_user FROM orders ), recent_orders AS ( -- CTE 2: 为每个订单计算其所属用户的时间倒序排名,用于取最近N单 SELECT order_id, user_id, amount, order_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) as recency_rank FROM orders ) -- 主查询:连接两个CTE,筛选出最近3单,并关联上金额排名 SELECT ro.user_id, ro.order_id, ro.amount as recent_amount, ro.order_date, ror.amount_rank_in_user FROM recent_orders ro INNER JOIN user_order_rank ror ON ro.order_id = ror.order_id WHERE ro.recency_rank <= 3 -- 筛选每个用户最近3笔订单 ORDER BY ro.user_id, ro.recency_rank;

代码逐行解析

  1. CTEuser_order_rank:使用RANK()函数,按user_id分区,在每个用户内部按amount DESC(金额降序)排名。这样,用户101金额最高的订单其amount_rank_in_user就是1。
  2. CTErecent_orders:使用ROW_NUMBER()函数,按user_id分区,在每个用户内部按order_date DESC(日期降序)生成行号。最近的一单recency_rank为1,第二近为2,以此类推。
  3. 主查询:将两个CTE通过order_id连接,筛选出recency_rank <= 3的记录,即每个用户最近的三笔订单。最终结果集包含了订单基本信息、以及该订单在其所属用户所有订单中的金额排名。

方案B:使用子查询(一次性查询)对于简单逻辑或某些数据库版本,也可以写在一个查询里,但可读性稍差:

SELECT user_id, order_id, amount as recent_amount, order_date, RANK() OVER (PARTITION BY user_id ORDER BY amount DESC) as amount_rank_in_user FROM ( SELECT order_id, user_id, amount, order_date, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) as recency_rank FROM orders ) AS subq WHERE recency_rank <= 3 ORDER BY user_id, recency_rank;

执行结果示例: 对于上面的样例数据,查询可能返回如下结果(具体取决于数据):

user_idorder_idrecent_amountorder_dateamount_rank_in_user
1017400.002023-10-151
101480.002023-10-104
1012300.002023-10-052
1025500.002023-10-081
1023200.002023-10-022
1038250.002023-10-121
1036150.002023-10-032

实操心得

在编写复杂窗口函数查询时,强烈推荐使用CTE。它将复杂的逻辑分解成一个个有名字的、可独立理解的步骤,极大地提升了SQL代码的可读性、可维护性和可调试性。你可以单独运行每个CTE来验证中间结果。当需求变更时(比如从“最近3单”改成“最近一个月”),也只需要修改对应的部分,风险更低。

4.3 性能优化关键:索引设计

窗口函数的性能很大程度上依赖于OVER()子句中PARTITION BYORDER BY的字段。数据库需要根据这些字段来排序和分组数据以进行计算。

针对上述查询的最佳索引策略

  1. 对于recent_ordersCTE:它按(user_id, order_date DESC)分区和排序。我们已经创建的索引idx_user_date (user_id, order_date DESC)完全匹配这个顺序,数据库可以高效地利用索引进行排序,避免昂贵的全表文件排序(filesort)。
  2. 对于user_order_rankCTE:它按(user_id, amount DESC)分区和排序。因此,我们可以考虑添加另一个索引来加速:
    CREATE INDEX idx_user_amount ON orders (user_id, amount DESC);

索引设计原则

  • 前缀匹配:索引的第一列必须与PARTITION BY的第一个字段匹配。
  • 排序一致:索引的列顺序和排序方向(ASC/DESC)应尽可能与ORDER BY子句一致。虽然现代数据库可以反向扫描索引,但方向一致通常效率更高。
  • 覆盖索引:如果索引包含了查询中所需的全部字段(即成为覆盖索引),数据库可以仅通过扫描索引就完成查询,避免回表,性能最佳。例如,如果我们的查询只涉及user_id,amount,order_date,那么一个(user_id, amount DESC, order_date)的索引可能就是覆盖索引。

踩坑记录:我曾遇到一个慢查询,OVER (PARTITION BY category ORDER BY sales DESC)。表很大,虽然category有索引,但ORDER BY sales导致大量临时文件排序。后来为(category, sales DESC)创建复合索引后,查询时间从秒级降到毫秒级。记住:窗口函数的ORDER BY是性能关键点,务必检查执行计划确认是否用上了索引。

5. 常见问题与排查技巧实录

即使理解了原理,在实际使用中还是会遇到各种意想不到的问题。下面是我总结的几个高频问题及解决方案。

5.1 序号结果不符合预期?检查排序和分区

这是最常见的问题。症状通常是序号看起来是随机的,或者没有按预期分组。

排查步骤

  1. 确认ORDER BY子句:序号是严格依据ORDER BY的顺序生成的。检查你的ORDER BY后面跟的字段是否正确,排序方向(ASC/DESC)是否符合业务逻辑。一个常见的错误是ORDER BY了一个有大量重复值的字段(如“状态”),导致序号分组看起来很奇怪。
  2. 确认PARTITION BY子句:如果你期望序号在每个组内重置,但结果却是全局连续的,那一定是忘了写PARTITION BY,或者分区字段选错了。例如,想按部门排名却用了员工ID分区。
  3. 检查数据本身:执行一个不带窗口函数的简单查询,只使用PARTITION BYORDER BY的字段,手动观察数据的分区和排序情况是否与你预期一致。

示例:假设你想按城市分区,按人口排序,但结果序号是全局的。你的SQL可能是:

-- 错误示例:缺少PARTITION BY SELECT city_name, population, ROW_NUMBER() OVER (ORDER BY population DESC) as rank FROM cities; -- 正确示例: SELECT city_name, population, ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY population DESC) as rank_within_country FROM cities;

5.2 性能突然变慢?分析执行计划

当数据量增长后,窗口函数查询可能变慢。

诊断方法: 使用数据库的EXPLAIN命令(MySQL是EXPLAIN [你的SQL], PostgreSQL是EXPLAIN ANALYZE [你的SQL], SQL Server是查看执行计划图形或使用SET SHOWPLAN_ALL ON)来查看查询计划。

重点关注

  • 是否使用了正确的索引?在输出中寻找Using index(MySQL)或Index Scan(PostgreSQL)这样的字样。如果看到Using filesortSort操作,且成本很高,说明排序是在临时磁盘文件中进行的,需要优化索引。
  • 窗口函数操作的成本:在执行计划中,窗口函数通常是一个独立的操作节点(如WindowAgg)。观察它的计算成本(cost)和预估行数。

优化动作

  1. 创建复合索引:如前所述,创建匹配(PARTITION BY columns, ORDER BY columns)的索引。
  2. 减少处理数据量:如果允许,在应用窗口函数前,先用WHERE子句过滤掉不需要的数据。例如,先筛选出最近一年的数据再做排名。
  3. 审视SELECT字段:只选择必要的字段。SELECT *会导致数据库读取更多数据,增加I/O负担。

5.3 在分组聚合后还能加序号吗?可以!

有时我们需要先对数据进行分组聚合(如求和、求平均),然后再对聚合结果进行排名。

典型场景:计算每个销售员的总销售额,然后对他们进行排名。

SELECT salesperson_id, SUM(amount) as total_sales, RANK() OVER (ORDER BY SUM(amount) DESC) as sales_rank FROM orders GROUP BY salesperson_id ORDER BY sales_rank;

关键点:窗口函数是在GROUP BY聚合之后执行的。OVER()子句中的ORDER BY SUM(amount)引用的是聚合函数的结果。这是完全合法的,也是窗口函数强大的体现。

5.4 分页查询中的行号陷阱

一个经典需求是:用ROW_NUMBER()实现高效的分页。

-- 假设每页10条,取第3页(第21-30条) WITH numbered_rows AS ( SELECT *, ROW_NUMBER() OVER (ORDER BY create_time DESC) as rn FROM articles ) SELECT * FROM numbered_rows WHERE rn BETWEEN 21 AND 30;

这个写法有问题吗?对于深度分页(例如第1000页),WHERE rn BETWEEN 10001 AND 10010,数据库仍然需要先为前10000行计算出行号,然后才能跳过它们,性能会随着页码增加而线性下降。

更优的分页方案(Keyset Pagination): 对于排序字段唯一(如主键、时间戳)的情况,使用WHERE过滤比使用ROW_NUMBER()更高效。

-- 第一页 SELECT * FROM articles ORDER BY create_time DESC, id DESC LIMIT 10; -- 假设上一页最后一条的create_time和id是‘2023-10-01 12:00:00’和 100 -- 第二页 SELECT * FROM articles WHERE (create_time, id) < (‘2023-10-01 12:00:00’, 100) -- 使用复合条件 ORDER BY create_time DESC, id DESC LIMIT 10;

这种方法利用了索引,直接“跳”到需要的数据开始位置,性能恒定,与页码无关。ROW_NUMBER()分页更适合于需要绝对行号或排序复杂的场景,对于简单排序的深度分页并非最佳。

5.5 MySQL变量法的不稳定性演示

为了理解为什么变量法不稳定,看这个例子:

SET @row_number = 0; SELECT @row_number := @row_number + 1 AS row_num, id, name FROM users ORDER BY name;

在MySQL 5.7中,你可能会得到按name排序后正确的行号。但SQL标准并不保证SELECT列表中的变量赋值会在ORDER BY之前完成。在某些情况下(如查询涉及派生表、UNION或特定优化器路径),MySQL可能会先进行变量赋值,然后再排序,导致row_num的顺序是混乱的。

绝对可靠的替代方案(对于MySQL 5.7):如果无法升级,又想安全地生成行号,可以强制使用派生表:

SELECT @row_number := @row_number + 1 AS row_num, t.* FROM ( SELECT id, name FROM users ORDER BY name ) AS t CROSS JOIN (SELECT @row_number := 0) AS vars;

通过子查询t先完成排序,外层查询再计算行号,保证了顺序。但这仍然比窗口函数繁琐。

← 返回列表