重学SQL窗口函数

Posted 捷后愚生

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了重学SQL窗口函数相关的知识,希望对你有一定的参考价值。

窗口函数语法

SQL窗口函数是SQL中的一种高级函数,它允许用户在不显式分组查询的情况下对结果集进行分组和聚合计算。
窗口函数的特别之处在于,它们将结果集中的每一行看作一个单独的计算对象,而不是将结果集划分为分组并计算每个分组的聚合值。这就使得窗口函数能够为结果集中的每一行计算类似排名、行号、百分比和移动聚合函数等值。

SQL窗口函数的语法如下:

<窗口函数> OVER ([PARTITION BY <分组列> [, <分组列>...]]
                     [ORDER BY <排序列> [ASC | DESC] [, <排序列> [ASC | DESC]]...]
                     [<rows or range clause>])

其中:

  • <窗口函数> : 定义要在窗口中计算的聚合函数或其它分析函数,如COUNT、RANK、SUM等。
  • OVER : 窗口函数的核心关键字。
  • PARTITION BY : 定义要用来分组的一组列名。
  • ORDER BY : 定义用来排序的一组列名。
  • <rows or range clause> : 定义窗口的行集合。默认为 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ,表示窗口包括从窗口开始到当前行的所有行。

下面的例子说明了如何使用窗口函数计算每个部门的平均工资,并根据平均工资进行排名。

SELECT department, employee_name, salary, 
  AVG(salary) OVER (PARTITION BY department) average_salary, 
  RANK() OVER (PARTITION BY department ORDER BY salary DESC) salary_rank
FROM employee;

在这个例子中,PARTITION BY定义了用于分组计算平均工资的列名,OVER后面依次定义了需要计算的列名和相应的窗口函数。 AVG(salary) OVER (PARTITION BY department) 的意思是对于每个部门,计算 salary 列的平均值,而 RANK() OVER (PARTITION BY department ORDER BY salary DESC) 的意思是计算每个部门中 salary 列的排名。
SQL窗口函数的语法和用法比较复杂,但它极大地拓展了SQL查询的能力,使得更多复杂的查询可以得以实现。

更多说明

窗口函数是一种SQL函数,可以在不影响查询结果的情况下,为查询结果集中的每一行计算聚合函数、排名、行号等。

窗口函数的使用场景有很多,例如统计某个时间段内的每天销售量排名、计算每个部门的平均工资并与其他部门进行比较、计算每个产品上个月和本月的销售额变化等。

以下是一个使用窗口函数的查询示例和结果,该查询用于计算某个时间段内每天的销售量排名。

假设有一张包含销售信息的表sales,包括销售日期、销售量和产品ID等字段。

现在需要查询2021年1月1日至1月10日期间每天的销售量排名,包括每天的日期和排名信息。

以下是使用窗口函数实现该查询的SQL语句:

SELECT sale_date, sale_volume, ROW_NUMBER() OVER(PARTITION BY sale_date ORDER BY sale_volume DESC) as sales_rank
FROM sales
WHERE sale_date BETWEEN \'2021-01-01\' AND \'2021-01-10\'

该查询将sales表根据销售日期进行分组,计算每个日期的销售量,并使用ROW_NUMBER()函数为每个日期的销售量进行排名。

以下是该查询的查询结果:

sale_date sale_volume sales_rank
2021-01-01 12500 1
2021-01-02 13800 1
2021-01-03 13000 1
2021-01-04 11800 2
2021-01-05 12500 1
2021-01-06 14000 1
2021-01-07 11500 1
2021-01-08 12800 1
2021-01-09 13100 1
2021-01-10 12900 1

ps:上面的结果有问题。

SQL的窗口函数中常见的排序方法

SQL的窗口函数中常见的排序方法包括以下几种:

  1. ROW_NUMBER()函数:按照指定的列或表达式对结果集进行排序,并为排序后的每一行分配一个唯一的行号。
  2. RANK()函数:对结果集进行排序,并为排序后的相同值的行分配相同的排名,排名之间存在空缺。
  3. DENSE_RANK()函数:对结果集进行排序,并为排序后的相同值的行分配相同的排名,排名之间不存在空缺。
  4. CUME_DIST()函数:计算结果集中某个值在排序后出现的位置占总行数的比例。
  5. NTILE(n)函数:将结果集划分为n份,并为每个分组分配一个编号。

注意,以上窗口函数中的排序方法都是基于ORDER BY子句中指定的列或表达式的排序。

以下是五种窗口函数的排序方法及其例子:

  1. ROW_NUMBER():按照指定的排序方法为每行分配一个唯一的整数行号,行号从1开始递增。示例如下:
SELECT ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num, employee_name, salary
FROM employee;

该语句会对employee表按照salary字段降序排序,并为每行分配一个不重复的行号存储在row_num字段中。

  1. RANK():按照指定的排序方法为每行分配一个排名,相等值将被分配相同的排名并跳过下一个排名。示例如下:
SELECT RANK() OVER (ORDER BY total_sales DESC) AS ranking, region_name, total_sales
FROM sales_summary
WHERE year = \'2021\';

该语句会对2021年销售总额按照降序排序,并为每行分配一个排名存储在ranking字段中。

  1. DENSE_RANK():按照指定的排序方法为每行分配一个密集排名,相等值将被分配相同的排名但不会跳过下一个排名。示例如下:
SELECT DENSE_RANK() OVER (ORDER BY exam_score DESC) AS rank, student_name, exam_score
FROM exam_results
WHERE exam_type = \'final\';

该语句会对final考试的成绩按照降序排序,并为每行分配一个密集排名存储在rank字段中。

  1. NTILE(n):将行分成n个近似相等的桶,并为每行分配一个桶号(从1到n)。示例如下:
SELECT NTILE(4) OVER (ORDER BY product_sales DESC) AS quartile, product_name, product_sales
FROM product_summary
WHERE year = \'2021\';

该语句会将2021年产品销售额按照降序排序,并将其分成四个桶,为每行分配一个桶号存储在quartile字段中。

  1. LAG()/LEAD():LAG函数用于获取当前行之前的行数据,LEAD函数用于获取当前行之后的行数据。示例如下:
SELECT employee_name, salary,
       LAG(salary, 1) OVER (ORDER BY salary) AS prev_salary,
       LEAD(salary, 1) OVER (ORDER BY salary) AS next_salary
FROM employee;

该语句会对employee表按照salary字段排序,并为每行获取前一行和后一行的salary值存储在prev_salary和next_salary字段中。

窗口函数的常见场景

窗口函数是在SELECT语句中用来执行聚合、排序、排名等操作的一种特殊函数。它们操作一个窗口(Window)内的数据集,并返回一个与当前行相关的聚合结果。

以下是一些使用窗口函数的常见场景和示例:

  1. 排序:使用窗口函数的排名功能可以将结果按照指定的列进行排序,而不影响结果集中其他列的排序。
    例如,以下SQL语句用排名窗口函数将销售额进行降序排列,并在结果集中显示销售额排名的值:
    SELECT order_no, order_date, customer_name, order_amount,
           RANK() OVER (ORDER BY order_amount DESC) as rank
    FROM orders;
  1. 聚合:窗口函数可以将数据集划分为多个分区,并在每个分区内执行聚合操作。
    例如,以下SQL语句使用SUM函数计算每个客户的累计销售额,并使用窗口函数将结果分区:
    SELECT customer_name, order_date, order_amount,
           SUM(order_amount) OVER (PARTITION BY customer_name ORDER BY order_date) as cumulative_sales
    FROM orders;
  1. 分析:使用窗口函数的分析功能可以计算每行与其他行之间的差异。例如,以下SQL语句计算每个销售额相对于上一个销售额的增量,并使用窗口函数进行分析:
    SELECT order_no, order_date, order_amount,
           order_amount - LAG(order_amount, 1, 0) OVER (ORDER BY order_date) as sales_increase
    FROM orders;

假设有一张包含学生成绩信息的表scores,包括学生ID,课程ID和成绩。现在需要查询每个学生最高成绩和次高成绩,并计算两者的差值(用作成绩提升分析)。

以下是使用窗口函数实现该查询的SQL语句:

SELECT student_id, MAX(score) as max_score, 
       LAG(MAX(score), 1, 0) OVER (PARTITION BY student_id ORDER BY score DESC) as second_max_score, 
       MAX(score) - LAG(MAX(score), 1, 0) OVER (PARTITION BY student_id ORDER BY score DESC) as score_gap
FROM scores
GROUP BY student_id

该查询将scores表按照student_id分组,计算每个学生的最高成绩和次高成绩,并计算两者的差值。以下是该查询的查询结果:

student_id max_score second_max_score score_gap
1 95 89 6
2 88 82 6
3 86 75 11
4 92 84 8
5 94 91 3

以上是用markdown展示的查询结果,包括查询到的学生ID,最高成绩,次高成绩和两者的差值。

  1. 移动平均数:使用窗口函数的移动平均功能可以计算指定数量的前后数据行的平均值。
    例如,以下SQL语句计算每个销售额的一个2周滑动平均值,并使用窗口函数进行计算:
    SELECT order_date, order_amount,
           AVG(order_amount) OVER (ORDER BY order_date ROWS BETWEEN 13 PRECEDING AND CURRENT ROW) as moving_average
    FROM orders;

这些只是窗口函数的一些常见用途和示例。实际上,窗口函数非常灵活,可以用于各种不同的数据分析和处理场景。

SQL 窗口函数是什么?涨见识了!

作者:Eric Fu
链接:https://ericfu.me/sql-window-function/

窗口函数(Window Function) 是 SQL2003 标准中定义的一项新特性,并在 SQL2011、SQL2016 中又加以完善,添加了若干处拓展。窗口函数不同于我们熟悉的普通函数和聚合函数,它为每行数据进行一次计算:输入多行(一个窗口)、返回一个值。在报表等分析型查询中,窗口函数能优雅地表达某些需求,发挥不可替代的作用。

本文首先介绍窗口函数的定义及基本语法,之后将介绍在 DBMS 和大数据系统中是如何实现高效计算窗口函数的,包括窗口函数的优化、执行以及并行执行。

什么是窗口函数?

窗口函数出现在 SELECT 子句的表达式列表中,它最显著的特点就是 OVER 关键字。语法定义如下:

window_function (expression) OVER (
   [ PARTITION BY part_list ]
   [ ORDER BY order_list ]
   [ { ROWS | RANGE } BETWEEN frame_start AND frame_end ] )

其中包括以下可选项:

  • PARTITION BY 表示将数据先按 part_list 进行分区
  • ORDER BY 表示将各个分区内的数据按 order_list 进行排序

Figure 1. 窗口函数的基本概念

最后一项表示 Frame 的定义,即:当前窗口包含哪些数据?

  • ROWS 选择前后几行,例如 ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING 表示往前 3 行到往后 3 行,一共 7 行数据(或小于 7 行,如果碰到了边界)
  • RANGE 选择数据范围,例如 RANGE BETWEEN 3 PRECEDING AND 3 FOLLOWING 表示所有值在 [c−3,c+3][c−3,c+3] 这个范围内的行,cc 为当前行的值

Figure 2. Rows 窗口和 Range 窗口

逻辑语义上说,一个窗口函数的计算“过程”如下:

  1. 按窗口定义,将所有输入数据分区、再排序(如果需要的话)
  2. 对每一行数据,计算它的 Frame 范围
  3. 将 Frame 内的行集合输入窗口函数,计算结果填入当前行

举个例子:

SELECT dealer_id, emp_name, sales,
       ROW_NUMBER() OVER (PARTITION BY dealer_id ORDER BY sales) AS rank,
       AVG(sales) OVER (PARTITION BY dealer_id) AS avgsales 
FROM sales

上述查询中,rank 列表示在当前经销商下,该雇员的销售排名;avgsales 表示当前经销商下所有雇员的平均销售额。查询结果如下:

+------------+-----------------+--------+------+---------------+
| dealer_id  | emp_name        | sales  | rank | avgsales      |
+------------+-----------------+--------+------+---------------+
| 1          | Raphael Hull    | 8227   | 1    | 14356         |
| 1          | Jack Salazar    | 9710   | 2    | 14356         |
| 1          | Ferris Brown    | 19745  | 3    | 14356         |
| 1          | Noel Meyer      | 19745  | 4    | 14356         |
| 2          | Haviva Montoya  | 9308   | 1    | 13924         |
| 2          | Beverly Lang    | 16233  | 2    | 13924         |
| 2          | Kameko French   | 16233  | 3    | 13924         |
| 3          | May Stout       | 9308   | 1    | 12368         |
| 3          | Abel Kim        | 12369  | 2    | 12368         |
| 3          | Ursa George     | 15427  | 3    | 12368         |
+------------+-----------------+--------+------+---------------+

注:语法中每个部分都是可选的:

  • 如果不指定 PARTITION BY,则不对数据进行分区;换句话说,所有数据看作同一个分区
  • 如果不指定 ORDER BY,则不对各分区做排序,通常用于那些顺序无关的窗口函数,例如 SUM()
  • 如果不指定 Frame 子句,则默认采用以下的 Frame 定义:
    • 若不指定 ORDER BY,默认使用分区内所有行 RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    • 若指定了 ORDER BY,默认使用分区内第一行到当前值 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

最后,窗口函数可以分为以下 3 类:

  • 聚合(Aggregate)AVG(), COUNT(), MIN(), MAX(), SUM()...
  • 取值(Value)FIRST_VALUE(), LAST_VALUE(), LEAD(), LAG()...
  • 排序(Ranking)RANK(), DENSE_RANK(), ROW_NUMBER(), NTILE()...

受限于篇幅,本文不去探讨各个窗口函数的含义。

注:Frame 定义并非所有窗口函数都适用,比如 ROW_NUMBER()RANK()LEAD() 等。这些函数总是应用于整个分区,而非当前 Frame。

窗口函数 VS. 聚合函数

聚合这个意义上出发,似乎窗口函数和 Group By 聚合函数都能做到同样的事情。但是,它们之间的相似点也仅限于此了!这其中的关键区别在于:窗口函数仅仅只会将结果附加到当前的结果上,它不会对已有的行或列做任何修改。而 Group By 的做法完全不同:对于各个 Group 它仅仅会保留一行聚合结果。

有的读者可能会问,加了窗口函数之后返回结果的顺序明显发生了变化,这不算一种修改吗?因为 SQL 及关系代数都是以 multi-set 为基础定义的,结果集本身并没有顺序可言ORDER BY 仅仅是最终呈现结果的顺序。

另一方面,从逻辑语义上说,SELECT 语句的各个部分可以看作是按以下顺序“执行”的:

Figure 3. SQL 各部分的逻辑执行顺序

注意到窗口函数的求值仅仅位于 ORDER BY 之前,而位于 SQL 的绝大部分之后。这也和窗口函数只附加、不修改的语义是呼应的——结果集在此时已经确定好了,再依此计算窗口函数。

窗口函数的执行

窗口函数经典的执行方式分为排序函数求值这 2 步。

Figure 4. 一个窗口函数的执行过程,通常分为排序和求值 2 步

窗口定义中的 PARTITION BYORDER BY 都很容易通过排序完成。例如,对于窗口 PARTITION BY a, b ORDER BY c, d,我们可以对输入数据按 (a,b,c,d)(a,b,c,d) 或 (b,a,c,d)(b,a,c,d) 做排序,之后数据就排列成 Figure 1 中那样了。

接下来考虑:如何处理 Frame?

  • 对于整个分区的 Frame(例如 RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING),只要对整个分区计算一次即可,没什么好说的;
  • 对于逐渐增长的 Frame(例如 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),可以用 Aggregator 维护累加的状态,这也很容易实现;
  • 对于滑动的 Frame(例如 ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING)相对困难一些。一种经典的做法是要求 Aggregator 不仅支持增加还支持删除(Removable),这可能比你想的要更复杂,例如考虑下 MAX() 的实现。

窗口函数的优化

对于窗口函数,优化器能做的优化有限。这里为了行文的完整性,仍然做一个简要的说明。

通常,我们首先会把窗口函数从 Project 中抽取出来,成为一个独立的算子称之为 Window。

Figure 5. 窗口函数的优化过程

有时候,一个 SELECT 语句中包含多个窗口函数,它们的窗口定义(OVER 子句)可能相同、也可能不同。显然,对于相同的窗口,完全没必要再做一次分区和排序,我们可以将它们合并成一个 Window 算子。

对于不同的窗口,最朴素地,我们可以将其全部分成不同的 Window,如上图所示。实际执行时,每个 Window 都需要先做一次排序,代价不小。

那是否可能利用一次排序计算多个窗口函数呢?某些情况下,这是可能的。例如本文例子中的 2 个窗口函数:

... ROW_NUMBER() OVER (PARTITION BY dealer_id ORDER BY sales) AS rank,
    AVG(sales) OVER (PARTITION BY dealer_id) AS avgsales ...

虽然这 2 个窗口并非完全一致,但是 AVG(sales) 不关心分区内的顺序,完全可以复用 ROW_NUMBER() 的窗口。

窗口函数的并行执行

现代 DBMS 大多支持并行执行。对于窗口函数,由于各个分区之间的计算完全不相关,我们可以很容易地将各个分区分派给不同的节点(线程),从而达到分区间并行

但是,如果窗口函数只有一个全局分区(无 PARTITION BY 子句),或者分区数量很少、不足以充分并行时,怎么办呢?上文中我们提到的 Removable Aggregator 的技术显然无法继续使用了,它依赖于单个 Aggregator 的内部状态,很难有效地并行起来。

TUM 的这篇论文中提出使用线段树(Segment Tree)实现高效的分区内并行。线段树是一个 N 叉树数据结构,每个节点包含当前节点下的部分聚合结果。

下图是一个使用二叉线段树计算 SUM() 的例子。例如下图中第三行的 1212,表示叶节点 5+75+7 的聚合结果;而它上方的 2525 表示叶节点 5+7+3+105+7+3+10 的聚合结果。

Figure 6. 使用线段树计算给定范围的总和

假设当前 Frame 是第 2 到第 8 行,即需要计算 7+3+10+...+47+3+10+...+4 区间之和。有了线段树以后,我们可以直接利用 7+13+207+13+20 (图中红色字体)计算出聚合结果。

线段树可以在 O(nlogn)O(nlog⁡n) 时间内构造,并能在 O(logn)O(log⁡n) 时间内查询任意区间的聚合结果。更棒的是,不仅查询可以多线程并发互不干扰,而且线段树的构造过程也能被很好地并行起来。

最后,关注公众号Java技术栈,在后台回复:面试,可以获取我整理的 MySQL 系列面试题和答案,非常齐全。

References

  1. http://www.vldb.org/pvldb/vol8/p1058-leis.pdf
  2. http://vldb.org/pvldb/vol5/p1244_yucao_vldb2012.pdf
  3. https://drill.apache.org/docs/sql-window-functions-introduction/)
  4. https://modern-sql.com/blog/2019-02/postgresql-11
  5. https://www.red-gate.com/simple-talk/sql/learn-sql-server/window-functions-in-sql-server/

近期热文推荐:

1.600+ 道 Java面试题及答案整理(2021最新版)

2.终于靠开源项目弄到 IntelliJ IDEA 激活码了,真香!

3.阿里 Mock 工具正式开源,干掉市面上所有 Mock 工具!

4.Spring Cloud 2020.0.0 正式发布,全新颠覆性版本!

5.《Java开发手册(嵩山版)》最新发布,速速下载!

觉得不错,别忘了随手点赞+转发哦!

以上是关于重学SQL窗口函数的主要内容,如果未能解决你的问题,请参考以下文章

重学JavaScript之匿名函数

重学ES6:函数和箭头函数

重学scala:scala函数式编程与高阶函数

重学前端-4函数式编程

重学scala:scala中的变量方法函数

重学Java 8新特性 | 第5讲——函数式接口