ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

SQL窗口函数详解:从OVER()到PARTITION BY,实现数据分组计算与排名

SQL窗口函数详解:从OVER()到PARTITION BY,实现数据分组计算与排名 1. 从“排序”到“窗口”为什么我们需要窗口函数如果你用过MySQL那ORDER BY肯定不陌生。它能帮你把查询结果按某个字段排得整整齐齐无论是升序还是降序。但不知道你有没有遇到过这样的场景你想给每个部门的员工按工资高低排个名次或者计算每个销售大区里每个销售员的业绩占该大区总业绩的百分比。这时候单纯的ORDER BY加上GROUP BY就显得有点力不从心了。GROUP BY能把数据分组然后对每组进行聚合计算比如SUM、AVG但它有个“副作用”——会把每组的多行数据压缩成一行。你再也看不到组内每个成员的原始数据了。而窗口函数Window Function的出现就是为了解决这个痛点。它允许你在保留原始数据行的同时对每一行数据基于一个与之相关的“窗口”内的数据进行计算。这个“窗口”就是由OVER()子句来定义的。简单来说窗口函数就像给你的数据行开了一扇“窗”透过这扇窗你能看到与当前行相关的其他行并对它们进行计算但最终结果会“贴”回当前行不会改变查询结果的行数。PARTITION BY就是用来定义这扇“窗”的范围的它相当于在OVER()子句内部进行了一次“分组”但不像GROUP BY那样会合并行。举个例子没有窗口函数时你想知道每个员工的工资在其部门内的排名可能需要写复杂的自连接或子查询。而有了窗口函数一句RANK() OVER(PARTITION BY department_id ORDER BY salary DESC)就能搞定既清晰又高效。今天我们就来深入聊聊这个在数据分析、报表生成和复杂业务逻辑中极其强大的工具——窗口函数特别是OVER(PARTITION BY ...)这个核心语法的各种玩法。2. 窗口函数基础理解 OVER() 与 PARTITION BY 的协作在深入具体函数之前我们必须先打好地基彻底理解OVER()子句特别是PARTITION BY和ORDER BY在其中的作用。这决定了你的“窗口”长什么样。2.1 OVER() 子句定义你的数据窗口OVER()是窗口函数的灵魂。所有窗口函数如ROW_NUMBER(),RANK(),SUM(),AVG()等都必须与OVER()子句配合使用。它的基本结构如下窗口函数 OVER ( [PARTITION BY 列清单] [ORDER BY 排序用列清单] [frame_clause] -- 如 ROWS BETWEEN ... AND ... )PARTITION BY可选。用于将结果集划分成多个分区窗口窗口函数会分别应用于每个分区。如果省略则整个结果集被视为一个单一分区。ORDER BY可选。用于定义分区内的排序规则。这对于排名函数ROW_NUMBER,RANK和计算累计值的聚合函数如SUM、AVG配合ORDER BY至关重要。frame_clause可选。用于定义当前行所在窗口的一个子集称为“框架”例如“从分区的开头到当前行”。这决定了聚合函数具体对哪些行进行计算。2.2 PARTITION BY 的深度解析静态分组与动态视野PARTITION BY是理解窗口函数的关键。你可以把它想象成在数据内部划出一个个“小组”但和GROUP BY不同这些小组的边界是透明的。场景对比GROUP BY vs. PARTITION BY假设我们有一张sales表字段有salesperson销售员、region大区、amount销售额。目标计算每个大区的总销售额。使用 GROUP BYSELECT region, SUM(amount) as total_amount FROM sales GROUP BY region;结果每个大区只返回一行数据包含大区名和总销售额。你失去了每个销售员的明细。使用 SUM() OVER(PARTITION BY ...)SELECT salesperson, region, amount, SUM(amount) OVER(PARTITION BY region) as region_total FROM sales;结果每一行销售记录都被保留同时新增一列region_total该列的值是当前行所属大区的所有销售额总和。对于同一个大区的所有行这个值是一样的。这就是PARTITION BY的核心价值它提供了组内计算的上下文而不折叠数据。这个“组内总和”像是一个背景板贴在了每一行明细数据旁边让你既能看明细又能看汇总。PARTITION BY可以基于多列这为你提供了更精细的分区控制。例如SELECT employee_id, department_id, project_id, salary, AVG(salary) OVER(PARTITION BY department_id, project_id) as avg_salary_in_dept_project FROM employee_project;这里窗口函数会为每个唯一的(department_id, project_id)组合创建一个独立的分区并计算该分区内的平均工资。2.3 ORDER BY 在窗口函数中的双重角色在OVER()子句中的ORDER BY有两个重要作用定义排名顺序对于排名函数ROW_NUMBER,RANK,DENSE_RANKORDER BY决定了排名的依据。没有ORDER BY这些函数无法工作。定义默认框架对于聚合窗口函数SUM,AVG,COUNT等当指定了ORDER BY但没有显式指定frame_clause时MySQL会使用一个默认的框架RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。这会导致计算从分区开始到当前行的累计值而不是整个分区的总值。这是一个非常重要的区别-- 示例1没有ORDER BYSUM计算整个分区的总和静态 SELECT date, amount, SUM(amount) OVER(PARTITION BY YEAR(date)) as year_total_static FROM transactions; -- 示例2有ORDER BYSUM计算从年初到当前日期的累计和动态 SELECT date, amount, SUM(amount) OVER(PARTITION BY YEAR(date) ORDER BY date) as year_to_date_running_total FROM transactions;在示例2中year_to_date_running_total这一列的值会随着date的推移而不断增加形成一条累计曲线。这是时间序列分析如计算累计营收、移动平均的基石。3. 核心窗口函数实战从排名到累计计算理解了OVER(PARTITION BY ...)如何定义窗口后我们就可以让各种窗口函数在这个舞台上表演了。它们主要分为两大类专用窗口函数和聚合窗口函数。3.1 专用窗口函数ROW_NUMBER, RANK, DENSE_RANK这三个函数是解决排名问题的“三剑客”都必须与OVER(ORDER BY ...)一起使用。结合PARTITION BY可以实现组内排名。我们先创建一个示例数据employee_salessalespersonregionsales张三华北150李四华北200王五华北200赵六华北180钱七华东220孙八华东2101. ROW_NUMBER()连续唯一的序号为每一行分配一个唯一的连续整数即使值相同排名也不同。SELECT salesperson, region, sales, ROW_NUMBER() OVER(PARTITION BY region ORDER BY sales DESC) as row_num FROM employee_sales;结果与解析salespersonregionsalesrow_num李四华北2001王五华北2002赵六华北1803张三华北1504钱七华东2201孙八华东2102实操心得ROW_NUMBER()非常适合用来做“取每组前N名”的操作。例如用子查询或CTE包裹上述查询再过滤row_num 3就能轻松拿到每个大区的前三名销售。它在去重根据某些字段排序后取第一条场景中也很有用。2. RANK()跳跃排名排名相等时会占用名次后续排名会跳过并列的位次。SELECT salesperson, region, sales, RANK() OVER(PARTITION BY region ORDER BY sales DESC) as rank_num FROM employee_sales;结果与解析salespersonregionsalesrank_num李四华北2001王五华北2001赵六华北1803张三华北1504钱七华东2201孙八华东21023. DENSE_RANK()密集排名排名相等时占用名次但后续排名连续不跳跃。SELECT salesperson, region, sales, DENSE_RANK() OVER(PARTITION BY region ORDER BY sales DESC) as dense_rank_num FROM employee_sales;结果与解析salespersonregionsalesdense_rank_num李四华北2001王五华北2001赵六华北1802张三华北1503钱七华东2201孙八华东2102选择哪个需要绝对唯一序号或取Top N时用ROW_NUMBER()。需要反映真实竞赛排名如奥运会颁奖并列金牌没有银牌时用RANK()。需要反映等级或梯队如成绩分为A、B、C档同分同档档位连续时用DENSE_RANK()。3.2 聚合窗口函数SUM, AVG, MAX/MIN, COUNT聚合函数搭配OVER(PARTITION BY ...)实现了“鱼与熊掌兼得”——既能看到明细又能看到基于分区的聚合值。1. SUM() 与 AVG()分区汇总与均值-- 计算每个销售员的销售额及其所在大区的总销售额和平均销售额 SELECT salesperson, region, sales, SUM(sales) OVER(PARTITION BY region) as region_total, AVG(sales) OVER(PARTITION BY region) as region_avg, -- 计算累计销售额需要ORDER BY SUM(sales) OVER(PARTITION BY region ORDER BY salesperson) as running_total_in_region FROM employee_sales ORDER BY region, salesperson;这个查询能让你一眼看出每个销售员的贡献度与其所在大区整体水平的对比。running_total_in_region则展示了按销售员姓名排序后销售额在区内的累计过程。2. MAX() / MIN()分区内的极值常用于查找组内的最大值/最小值并计算当前行与极值的差距。-- 找出每个大区的销售冠军及与冠军的差距 SELECT salesperson, region, sales, MAX(sales) OVER(PARTITION BY region) as region_top_sales, MAX(sales) OVER(PARTITION BY region) - sales as gap_to_top FROM employee_sales;对于“华北”区region_top_sales列的值都是200李四和王五的销售额gap_to_top则直观显示了每个人离冠军还差多少。3. COUNT()分区计数-- 计算每个大区的销售人数 SELECT salesperson, region, sales, COUNT(*) OVER(PARTITION BY region) as headcount_in_region FROM employee_sales;headcount_in_region列对于“华北”区的所有行都会显示4对于“华东”区显示2。注意事项聚合窗口函数中如果使用了ORDER BY一定要清楚其默认框架行为是计算累计值。如果你想要的是整个分区的静态聚合值请确保不要在聚合窗口函数后使用ORDER BY或者使用ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING来显式指定框架为整个分区。4. 高级窗口框架ROWS vs. RANGE 与移动计算这是窗口函数中最强大也最容易让人困惑的部分——窗口框架frame_clause。它让你能定义比PARTITION BY更精确的、相对于当前行的计算范围。4.1 框架语法详解框架子句通常跟在ORDER BY后面格式为{ROWS | RANGE} BETWEEN frame_start AND frame_endROWS基于物理行的偏移。它看的是行的位置顺序。RANGE基于值的偏移。它看的是ORDER BY列的值。frame_start/frame_end可以是以下之一UNBOUNDED PRECEDING分区的第一行/第一个值。UNBOUNDED FOLLOWING分区的最后一行/最后一个值。CURRENT ROW当前行。N PRECEDING当前行之前的N行ROWS或值小于等于当前值-N的行RANGE。N FOLLOWING当前行之后的N行ROWS或值大于等于当前值N的行RANGE。4.2 ROWS 与 RANGE 的实战对比假设我们有一个简单的每日销售额表daily_salessale_dateamount2024-01-011002024-01-021502024-01-032002024-01-051202024-01-06180场景计算3天移动平均包括当前行及前两行使用 ROWSSELECT sale_date, amount, AVG(amount) OVER(ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) as moving_avg_rows FROM daily_sales;结果sale_dateamountmoving_avg_rows计算逻辑2024-01-01100100.0000(100)/12024-01-02150125.0000(100150)/22024-01-03200150.0000(100150200)/32024-01-05120156.6667(150200120)/32024-01-06180166.6667(200120180)/3ROWS严格地数“行数”。对于2024-01-05这一行它的前两行是2024-01-03和2024-01-02不管日期是否连续。使用 RANGE(假设我们想基于“日期间隔”):SELECT sale_date, amount, AVG(amount) OVER(ORDER BY sale_date RANGE BETWEEN INTERVAL 2 DAY PRECEDING AND CURRENT ROW) as moving_avg_range FROM daily_sales;结果sale_dateamountmoving_avg_range计算逻辑2024-01-01100100.00001号前2天内只有自己2024-01-02150125.0000(100150)/22024-01-03200150.0000(100150200)/32024-01-05120120.0000关键5号前2天是3号但3号与5号间隔2天这里RANGE对日期处理需注意2024-01-06180150.0000(120180)/2RANGE的行为更复杂。对于日期类型RANGE BETWEEN INTERVAL 2 DAY PRECEDING AND CURRENT ROW意味着“取日期值在当前行日期减去2天范围内的所有行”。对于2024-01-05前2天是2024-01-03但2024-01-03的日期值并不在[2024-01-03, 2024-01-05]这个区间内因为区间起点是2024-01-03但RANGE通常包含边界且比较的是值。实际上在标准SQL中RANGE与ORDER BY的列类型紧密相关对于日期N PRECEDING可能要求列是数值或日期并且N是同类型的间隔。在MySQL中对日期直接使用RANGE N PRECEDING可能不如ROWS直观和常用。更常见的做法是对于日期时间的移动窗口我们更倾向于使用ROWS来明确控制行数或者使用RANGE配合UNBOUNDED PRECEDING来做真正的基于值的范围查询如计算到当前日期为止的累计值。核心建议在大多数涉及“最近N条记录”的移动窗口计算中如移动平均、移动求和使用ROWS更直观、更可控。RANGE更适合处理诸如“将当前行与所有具有相同值的行视为一组”的场景或者在数值列上定义基于值的范围。4.3 经典应用移动平均与累计占比移动平均Moving Average常用于平滑时间序列数据观察趋势。-- 计算近7天包括当天的移动平均销售额 SELECT sale_date, amount, AVG(amount) OVER(ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) as ma_7days FROM daily_sales ORDER BY sale_date;累计占比Running Percentage计算当前行累计值占总量的百分比。-- 计算每个销售员销售额的累计占比按销售额降序 SELECT salesperson, sales, SUM(sales) OVER(ORDER BY sales DESC) as running_total, SUM(sales) OVER(ORDER BY sales DESC) / SUM(sales) OVER() as running_percentage FROM employee_sales;这里SUM(sales) OVER()没有PARTITION BY和ORDER BY表示对整个结果集求和作为分母。5. 复杂场景综合应用与性能优化掌握了基本部件后我们来看看如何将它们组合起来解决更复杂的业务问题并谈谈使用时的性能考量。5.1 组合使用解决多层次分析问题场景分析员工绩效。我们需要看到1) 员工本人信息与薪资2) 他在本部门的薪资排名3) 他比部门平均薪资高多少4) 他的薪资在公司总薪资中的占比。SELECT employee_id, name, department_id, salary, -- 部门内排名 ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) as dept_salary_rank, -- 部门平均薪资 ROUND(AVG(salary) OVER(PARTITION BY department_id), 2) as dept_avg_salary, -- 与部门平均薪资的差值 salary - ROUND(AVG(salary) OVER(PARTITION BY department_id), 2) as diff_from_dept_avg, -- 公司总薪资 SUM(salary) OVER() as company_total_salary, -- 个人薪资占比 ROUND(salary / SUM(salary) OVER() * 100, 4) as salary_percentage FROM employees ORDER BY department_id, dept_salary_rank;一句查询多维度信息尽收眼底。这就是窗口函数在制作复杂报表时的威力。5.2 性能考量与优化建议窗口函数很强大但处理大数据集时也可能成为性能瓶颈。以下是一些优化思路索引是王道OVER()子句中的PARTITION BY和ORDER BY列如果能被索引覆盖将极大提升性能。尤其是当窗口函数操作需要排序时几乎所有排名函数和带ORDER BY的聚合函数在(PARTITION BY col1, ORDER BY col2)上建立复合索引可以让数据库直接利用索引的有序性避免昂贵的全表排序Filesort。减少不必要的分区和排序每个PARTITION BY和ORDER BY都会引发一次排序操作。如果业务允许尽量复用相同的分区和排序条件。例如多个窗口函数使用相同的OVER(PARTITION BY a ORDER BY b)子句数据库可能只执行一次排序。警惕RANGE如前所述RANGE基于值在处理非唯一排序键或大数据集时其性能可能不如ROWS因为数据库需要计算值的范围。在明确需要基于行位置的移动窗口时优先使用ROWS。与WHERE子句的配合窗口函数的计算是在WHERE、GROUP BY、HAVING子句之后进行的。这意味着先通过WHERE条件过滤掉大量无关数据再应用窗口函数效率会高很多。尽量避免在子查询中先计算窗口函数再在外层过滤。理解执行计划使用EXPLAIN查看查询计划。关注是否有“Using filesort”或临时表操作。对于复杂的分层窗口计算有时将其拆分为多个CTECommon Table Expressions或子查询分步计算可能比一个超级复杂的单句查询更易优化和阅读。5.3 一个常见的坑窗口函数与 GROUP BY 的混用窗口函数是在SELECT列表中被计算的时间点在GROUP BY聚合之后。这意味着你可以先对数据进行分组聚合再在聚合后的结果上使用窗口函数。-- 先按日期和产品分组求和再计算每个产品每日销售额占该产品总销售额的百分比 SELECT sale_date, product_id, daily_sales, SUM(daily_sales) OVER(PARTITION BY product_id) as product_total_sales, daily_sales / SUM(daily_sales) OVER(PARTITION BY product_id) * 100 as daily_contribution_percent FROM ( SELECT sale_date, product_id, SUM(amount) as daily_sales FROM sales_details GROUP BY sale_date, product_id ) as agg_sales ORDER BY product_id, sale_date;这里子查询先完成了GROUP BY得到了每个产品每日的销售总额daily_sales。外层查询再以product_id分区计算每个产品的销售总和以及每日贡献度。这种“聚合后开窗”的模式在多层汇总分析中非常常见。窗口函数彻底改变了我们处理“既要看明细又要看关联汇总”这类需求的方式。它把原本需要多次自连接或复杂子查询才能完成的逻辑变得清晰、简洁且高效。从简单的组内排名到复杂的移动平均、累计计算、差异分析OVER(PARTITION BY ...)这个语法结构是这一切的基石。掌握它你的SQL数据分析能力将迈上一个全新的台阶。在实际工作中多思考“这个统计是否需要保留原始行”如果需要窗口函数很可能就是最优解。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表