多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL窗口函数三剑客:ROW_NUMBER、RANK、DENSE_RANK深度解析与应用实战

MySQL窗口函数三剑客:ROW_NUMBER、RANK、DENSE_RANK深度解析与应用实战 1. 项目概述从“排序”到“分组排序”的思维跃迁在数据库的日常开发中排序ORDER BY是再基础不过的操作。但你是否遇到过这样的场景老板让你“找出每个部门里业绩前三的员工”或者“统计每个班级里成绩最高的学生”如果只用传统的 GROUP BY 和 ORDER BY你会发现写出来的 SQL 要么异常复杂要么根本无法一步到位。这时传统的“排序”思维就遇到了瓶颈我们需要一个更强大的工具——窗口函数特别是其中的分组排序函数。我最初接触窗口函数时也经历过一段“硬着头皮写子查询”的时期。为了给每个部门的人按工资排名我得先分组聚合再关联回原表SQL 写得又长又绕性能还差。直到 MySQL 8.0 正式引入了窗口函数我才真正体会到什么叫“降维打击”。今天要聊的ROW_NUMBER()、RANK()和DENSE_RANK()就是窗口函数家族里解决分组排序问题的“三剑客”。它们能让你在保留原始数据行的同时为每一行在其所属的“窗口”比如一个部门、一个班级内计算出一个排名序号从而轻松解决“组内 Top N”、“排名与并列”等经典难题。理解并熟练运用它们是 SQL 能力从“会用”到“精通”的关键一步。2. 核心概念解析窗口函数与分组排序的本质在深入这三个函数之前我们必须先搞清楚“窗口”是什么。你可以把它想象成照相机的取景框。GROUP BY是把所有人按部门分组然后只拍一张整个部门的“集体照”你失去了每个人的细节。而窗口函数则是为每一行数据都单独拍一张“特写照”但取景框的范围即“窗口”是由你定义的比如“同一个部门的所有人”。在这个取景框内你可以进行排序、计算排名、求移动平均等操作并且计算结果会作为新的一列附加到当前这一行上原始数据一行都不会少。这就是窗口函数的核心魅力它允许你在不聚合、不丢失行数据的前提下进行跨行的计算。ROW_NUMBER()、RANK()、DENSE_RANK()这三个函数正是专门用于在窗口内进行排序并生成序号。它们的语法骨架是一致的函数名() OVER ( [PARTITION BY 分区字段1, 字段2...] ORDER BY 排序字段1 [ASC|DESC], 排序字段2... )PARTITION BY定义窗口的范围即“按什么分组”。比如PARTITION BY department_id就是按部门开窗。如果省略则整个结果集视为一个窗口。ORDER BY定义在窗口内按什么规则排序。这是这三个函数必须的组成部分因为排名总得有个依据。函数名()根据排序结果为每一行生成一个序号。虽然语法类似但它们在处理“并列”情况时的逻辑截然不同这也是它们最核心的区别和应用场景的分水岭。3. 三剑客深度对比ROW_NUMBER、RANK、DENSE_RANK光看概念容易迷糊我们直接用一个最经典的“成绩排名”场景来对比。假设有一张学生成绩表scores数据如下student_idclass_idscore1A952A953A924B885B886B85现在我们分别用三个函数为每个班级class_id的学生按分数score降序排名。3.1 ROW_NUMBER()无情的连续编号器ROW_NUMBER()的逻辑最简单粗暴在窗口内严格按照ORDER BY的顺序从1开始生成连续且唯一的序号。即使排序值完全相同它也会强制分配不同的序号顺序是不确定的但保证唯一。SELECT student_id, class_id, score, ROW_NUMBER() OVER (PARTITION BY class_id ORDER BY score DESC) AS rn FROM scores;查询结果student_idclass_idscorern1A9512A9523A9234B8815B8826B853核心特点与适用场景绝对唯一性序号 1, 2, 3... 永不重复。这对于需要绝对区分每一行的场景至关重要比如“为每个订单生成唯一的流水号”、“删除组内的重复记录保留rn1的”。不确定性对于并列值如A班两个95分ROW_NUMBER()分配1和2的顺序是不确定的可能这次是学生1得1下次执行就是学生2得1。所以不要用它来处理需要明确处理并列关系的排名比如比赛名次。典型应用分页查询配合WHERE rn BETWEEN x AND y、数据去重。实操心得用ROW_NUMBER()做去重是我最常用的技巧之一。比如有一张用户操作日志表存在同一秒内的重复记录你可以PARTITION BY user_id, DATE(action_time)然后ORDER BY action_time DESC最后在外层查询中WHERE rn 1就能高效地取出每个用户每天最新的唯一一条记录。3.2 RANK()真实的比赛排名器RANK()模拟了真实的比赛排名规则并列的会获得相同的名次并且会跳过后续的名次。SELECT student_id, class_id, score, RANK() OVER (PARTITION BY class_id ORDER BY score DESC) AS rk FROM scores;查询结果student_idclass_idscorerk1A9512A9513A9234B8815B8816B853核心特点与适用场景允许并列序号跳跃A班两个95分并列第1名下一个92分就是第3名跳过了第2名。B班同理。符合现实认知这正是奥运会、考试成绩排名的常用规则。如果有两个金牌就没有银牌下一个是铜牌。典型应用任何需要展示真实位次的场景如销售排行榜允许并列、竞赛名次。注意事项RANK()的“跳跃”特性会导致当你需要取“前N名”时结果行数可能少于N。比如你想取每个班前2名A班因为并列第一会返回3个人第1名两个第3名一个。这在业务上是否被接受需要提前和需求方确认。3.3 DENSE_RANK()紧凑的阶梯排名器DENSE_RANK()可以看作是RANK()的“紧凑版”并列的获得相同名次但后续名次连续不跳跃。SELECT student_id, class_id, score, DENSE_RANK() OVER (PARTITION BY class_id ORDER BY score DESC) AS drk FROM scores;查询结果student_idclass_idscoredrk1A9512A9513A9224B8815B8816B852核心特点与适用场景允许并列序号连续A班两个95分并列第192分紧挨着是第2名。序号始终保持1,2,3...的连续性。业务场景当业务上不关心名次是否跳跃只关心“等级”或“梯队”时使用。例如将员工绩效分为“A(1), B(2), C(3)”三档同分同档档位连续。典型应用等级划分、阶梯定价如消费金额达到某个区间享受某个折扣等级。为了更直观地对比我们将三个函数的结果放在一起student_idclass_idscoreROW_NUMBERRANKDENSE_RANK1A951112A952113A923324B881115B882116B85332这张表清晰地揭示了本质面对并列数据ROW_NUMBER选择“分个高下”强制唯一RANK选择“真实排名”并列但跳跃DENSE_RANK选择“划分等级”并列且连续。理解了这个你就能在90%的场景下做出正确选择。4. 高级应用与组合技巧实战掌握了基本用法我们来看看如何用它们解决更复杂的实际问题。这些场景都是我实际工作中反复遇到的代码可以直接套用。4.1 解决经典Top N问题“找出每个部门工资最高的前3名员工”。这是面试高频题也是业务常见需求。在没有窗口函数的年代需要写复杂的自连接或子查询现在一句 SQL 就能搞定。假设有员工表employees(id, name, department_id, salary)。SELECT * FROM ( SELECT id, name, department_id, salary, DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS drk FROM employees ) AS ranked_employees WHERE drk 3;为什么这里用DENSE_RANK这取决于业务定义。如果业务说“我们要严格的前3个工资档位的人”那么即使一个档位有多人也只取到第三档这时用DENSE_RANK。如果业务说“我们要工资排名前3的个人允许并列”那么应该用RANK。如果业务要求“即使工资相同也要排出绝对的1、2、3名例如按入职时间再排序”那就用ROW_NUMBER并在ORDER BY中加上第二排序条件ORDER BY salary DESC, hire_date ASC。4.2 实现复杂的分段与分组窗口函数的PARTITION BY可以指定多个字段实现多维度的分组。例如统计每年每个月的销售额排名。SELECT year, month, sales_amount, RANK() OVER (PARTITION BY year ORDER BY sales_amount DESC) AS year_rank, RANK() OVER (PARTITION BY year, month ORDER BY sales_amount DESC) AS month_rank FROM sales_data;这个查询会同时给出“某月销售额在当年所有月份中的排名”以及“某月销售额在该月内的排名”如果数据粒度是天则是在当月天数内的日销售额排名。PARTITION BY year, month创建了一个“年-月”的复合窗口。4.3 与聚合窗口函数结合使用窗口函数不只有排序函数还有聚合函数如SUM(),AVG()。它们可以强强联合。比如计算每个员工工资在其部门内的排名以及他工资占部门总工资的比例。SELECT id, name, department_id, salary, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS salary_rank, salary / SUM(salary) OVER (PARTITION BY department_id) AS salary_ratio FROM employees;这里SUM(salary) OVER (PARTITION BY department_id)就是一个聚合窗口函数它为每一行计算其所在部门的总工资但不会将行合并。这种“各行独立计算聚合值”的能力是普通GROUP BY无法做到的。4.4 在UPDATE或DELETE语句中使用你甚至可以在数据更新中利用这些排名。例如有一个需求只保留每个用户最近的三条登录日志删除更早的。-- 首先创建一个带排名的临时视图或使用子查询标识数据 DELETE FROM login_logs WHERE id IN ( SELECT id FROM ( SELECT id, user_id, login_time, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_time DESC) AS rn FROM login_logs ) AS t WHERE t.rn 3 -- 删除排名大于3的即非最近的三条 );重要提示在生产环境执行此类删除操作前务必先使用SELECT语句验证子查询结果确认要删除的数据准确无误。最好在事务中执行并准备好回滚方案。5. 性能优化与避坑指南窗口函数很强大但用得不好也会成为性能杀手。以下是我在千万级数据表上摸爬滚打总结出的经验。5.1 索引是性能的基石窗口函数OVER()子句中的PARTITION BY和ORDER BY的字段强烈建议建立复合索引。这能极大加速窗口的划分和排序过程。优化案例 对于查询SELECT ... RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) ...。最佳索引(department_id, salary DESC)。索引顺序与PARTITION BY和ORDER BY完全匹配数据库可以高效地进行索引范围扫描和排序。次优索引仅有(department_id)或(salary)。数据库可能需要进行额外的排序filesort操作数据量大时性能差异巨大。无效索引索引字段与开窗字段无关。你可以用EXPLAIN查看执行计划如果看到Using filesort或Using temporary在窗口函数相关步骤中出现就要考虑索引是否没命中。5.2 警惕全表扫描与数据倾斜PARTITION BY的字段选择性很重要。如果你按一个只有两三种枚举值的字段如性别分区然后在一个上亿的表上排序会导致每个窗口内的数据量极其庞大排序开销巨大。相反如果按用户ID分区每个窗口可能只有几条数据速度就很快。应对策略减少窗口内数据量在子查询中先用WHERE条件过滤掉不必要的数据再进行窗口计算。避免过度分区如果业务允许考虑是否真的需要那么细的粒度。有时按“月”分区比按“日”分区性能好得多。分而治之对于超大数据集是否可以按时间范围分批处理5.3 常见错误与排查技巧错误ORDER BY子句缺失-- 错误缺少ORDER BY语法虽然可能不报错但结果无意义 SELECT ROW_NUMBER() OVER (PARTITION BY dept_id) FROM emp;ROW_NUMBER(),RANK(),DENSE_RANK()必须有ORDER BY子句否则排序是未定义的。错误在WHERE子句中直接使用窗口函数别名-- 错误WHERE子句不能直接引用SELECT列表中定义的窗口函数别名 SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM table WHERE rn 10;正确做法必须使用子查询或公共表表达式CTE。-- 正确做法使用子查询 SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM table ) AS t WHERE t.rn 10;结果不符合预期检查排序字段和顺序排名结果完全依赖于ORDER BY。如果你要按分数从高到低排名却写成了ORDER BY score ASC那第一名就是最低分。多字段排序时顺序也很关键。ORDER BY score DESC, submit_time ASC分数优先同分按提交时间早的排前面和ORDER BY submit_time ASC, score DESC的结果天差地别。MySQL版本兼容性问题窗口函数是MySQL 8.0 及以上版本才支持的功能。如果你在 5.7 或更早版本上运行相关SQL会直接收到语法错误。在编写和维护脚本时务必确认数据库版本。6. 在复杂业务场景中的综合实战让我们通过一个更贴近真实业务的例子把前面的知识串联起来。假设我们有一个电商订单明细表order_details包含字段order_id订单号product_id商品IDcategory_id品类IDquantity销量price单价sale_date销售日期。业务需求找出每个品类category_id下每日销量排名前3的商品。计算每个商品在其所属品类中的累计销售额排名截至当前日期。标记出那些在任何一个品类中单日销量都从未进入过当日品类销量前10的商品可能是滞销品。这个需求涉及了按“品类日期”分组取Top N以及跨所有日期的累计排名。步骤一构建基础数据视图我们先计算每个商品每日在每个品类的销量和销售额。WITH daily_sales AS ( SELECT sale_date, category_id, product_id, SUM(quantity) AS daily_quantity, SUM(quantity * price) AS daily_amount FROM order_details GROUP BY sale_date, category_id, product_id )步骤二解决需求1 - 每日品类销量Top 3这里我们需要的是“每日每品类”的排名并且允许并列。考虑到业务可能希望看到所有并列前三的商品我们使用DENSE_RANK。, daily_top AS ( SELECT sale_date, category_id, product_id, daily_quantity, DENSE_RANK() OVER (PARTITION BY sale_date, category_id ORDER BY daily_quantity DESC) AS daily_rank FROM daily_sales ) SELECT * FROM daily_top WHERE daily_rank 3;步骤三解决需求2 - 商品在品类内的累计销售额排名“累计”意味着我们需要从历史第一天到当前行日期的销售额总和。这需要用到窗口函数的“框架子句”。默认的ORDER BY sale_date的窗口范围是“从分区第一行到当前行”正好符合累计需求。, cumulative_rank AS ( SELECT product_id, category_id, SUM(daily_amount) OVER (PARTITION BY category_id, product_id ORDER BY sale_date) AS cum_amount, RANK() OVER (PARTITION BY category_id ORDER BY SUM(daily_amount) OVER (PARTITION BY category_id, product_id ORDER BY sale_date) DESC) AS cum_rank_in_cat FROM daily_sales -- 注意这里为了演示清晰cum_rank_in_cat计算可能因数据库优化器而有差异实际可拆解步骤 )更稳妥的写法是分步计算累计额再排名, product_cumulative AS ( SELECT sale_date, product_id, category_id, SUM(daily_amount) OVER (PARTITION BY category_id, product_id ORDER BY sale_date) AS cum_amount_by_product FROM daily_sales ) , cumulative_rank_final AS ( SELECT product_id, category_id, -- 取最后一天的累计额作为总累计额 MAX(cum_amount_by_product) AS total_cum_amount, RANK() OVER (PARTITION BY category_id ORDER BY MAX(cum_amount_by_product) DESC) AS final_cum_rank FROM product_cumulative GROUP BY product_id, category_id )步骤四解决需求3 - 标记从未进入日榜前10的商品我们可以利用需求1中daily_top的结果通过一个反向逻辑来筛选。, never_top10_product AS ( SELECT DISTINCT category_id, product_id FROM daily_sales WHERE (category_id, product_id) NOT IN ( SELECT DISTINCT category_id, product_id FROM daily_top WHERE daily_rank 10 -- 从未进入过前10即不在“进入过前10”的名单里 ) )最后我们可以将这几个CTE公共表表达式连接起来得到一份综合报告。这个例子展示了如何将多个窗口函数、CTE、子查询组合使用解决包含多层次、多维度分析的复杂业务问题。关键在于分而治之先用CTE将每个子问题计算清楚最后再整合这样SQL逻辑清晰也便于调试和优化。窗口函数的学习曲线可能有点陡但一旦掌握你就会发现之前许多需要编写冗长、低效SQL的场景现在都能用清晰、高效的几行代码搞定。从理解PARTITION BY和ORDER BY定义你的“数据窗口”开始到根据业务场景精准选择“三剑客”中的一位再到利用索引优化和组合高级用法这条路径上的每一步都能实实在在地提升你处理数据的效率和深度。多在实际数据上练习尝试用窗口函数的思维去重构旧的复杂查询你会不断获得新的惊喜。
返回列表