添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
相关文章推荐
玉树临风的长颈鹿  ·  【win ...·  1 年前    · 
威武的香菇  ·  Request timeouts ...·  1 年前    · 
《PostgreSQL 开发指南》第 21 篇 窗口函数

《PostgreSQL 开发指南》第 21 篇 窗口函数

为了方便大家阅读学习,制作了专栏的电子版PDF,免费开放下载: github.com/dongxuyang19

在专栏的 第 13 篇 中我们学习了常见的聚合函数,包括 AVG、COUNT、MAX、MIN、SUM 以及 STRING_AGG。聚合函数的作用是针对一组数据行进行运算,并且返回一条汇总结果。

除了聚合函数之外,SQL 还定义了许多专门用于数据分析的窗口函数(Window Function)。但是,窗口函数不是将一组数据汇总为单个结果;而是针对每一行数据,基于和它相关的一组数据计算出一个结果。下图演示了聚合函数和窗口函数的效果:

以下示例分别将 AVG、COUNT、SUM 作为聚合函数和窗口函数,计算员工的平均月薪、人数总和以及月薪总和:

SELECT AVG(salary), COUNT(*), SUM(salary)
  FROM employees;
avg                  |count|sum      |
---------------------|-----|---------|
6461.8317757009345794|  107|691416.00|
SELECT employee_id,first_name, last_name, AVG(salary) OVER (), COUNT(*) OVER (), SUM(salary) OVER ()
  FROM employees;
employee_id|first_name |last_name  |avg                  |count|sum      |
-----------|-----------|-----------|---------------------|-----|---------|
        100|Steven     |King       |6461.8317757009345794|  107|691416.00|
        101|Neena      |Kochhar    |6461.8317757009345794|  107|691416.00|
        102|Lex        |De Haan    |6461.8317757009345794|  107|691416.00|
...

聚合函数通常也可以作为窗口函数,区别在于后者包含了 OVER 关键字;空括号表示将所有数据作为整体进行分析,所以得到的数值和聚合函数一样。显然,窗口函数为每一个员工都返回了一个结果。

窗口函数的定义

窗口函数的定义如下:

window_function ( expression, ... ) OVER (
    PARTITION BY ...
    ORDER BY ...
    frame_clause
)

其中,window_function 是窗口函数的名称;expression 是参数,有些函数不需要参数; OVER 子句包含三个选项:分区( PARTITION BY )、排序( ORDER BY )以及窗口大小( frame_clause )。

分区选项(PARTITION BY)

PARTITION BY 选项用于定义分区,作用类似于 GROUP BY 的分组。如果指定了分区选项,窗口函数将会分别针对每个分区单独进行分析;如果省略分区选项,所有的数据作为一个整体进行分析,上文中的示例就是如此。

以下语句按照部门进行分组,分析每个部门的平均月薪:

SELECT first_name, last_name, department_id, salary, AVG(salary) OVER (PARTITION BY department_id)
  FROM employees
  ORDER BY department_id;
first_name |last_name  |department_id|salary  |avg                   |
-----------|-----------|-------------|--------|----------------------|
Jennifer   |Whalen     |           10| 4400.00| 4400.0000000000000000|
Pat        |Fay        |           20| 6000.00| 9500.0000000000000000|
Michael    |Hartstein  |           20|13000




    
.00| 9500.0000000000000000|
Shelli     |Baida      |           30| 2900.00| 4150.0000000000000000|
Karen      |Colmenares |           30| 2500.00| 4150.0000000000000000|
Den        |Raphaely   |           30|11000.00| 4150.0000000000000000|
...

部门 10 只有一个员工,平均月薪就是她自己的月薪 4400;部门 20 有两个员工,平均月薪等于 (6000 + 13000)/2 = 9500;其他数据依次类推。

排序选项(ORDER BY)

ORDER BY 选项用于指定 分区内 的排序方式,通常用于数据的排名分析。以下示例用于计算每个员工在部门内的入职顺序:

SELECT first_name, last_name, department_id, hire_date,
       RANK() OVER (PARTITION BY department_id ORDER BY hire_date)
  FROM employees
  ORDER BY department_id;
first_name |last_name  |department_id|hire_date |rank|
-----------|-----------|-------------|----------|----|
Jennifer   |Whalen     |           10|2003-09-17|   1|
Michael    |Hartstein  |           20|2004-02-17|   1|
Pat        |Fay        |           20|2005-08-17|   2|
Den        |Raphaely   |           30|2002-12-07|   1|
Alexander  |Khoo       |           30|2003-05-18|   2|
Sigal      |Tobias     |           30|2005-07-24|   3|
...

其中, PARTITION BY 选项表示按照部门进行分区; ORDER BY 选项指定在部门内按照入职先后进行排序;RANK 函数用于计算名次,下文将会进行介绍。

部门 10 只有一个员工,Jennifer Whalen 就是第一个入职的员工;部门 20 有两个员工,Michael(2004-02-17)比 Pat(2005-08-17)入职更早;其他数据依次类推。

ORDER BY 子句同样支持 NULLS FIRST 和 NULLS LAST 选项,用于指定空值的排序顺序。默认为 NULLS LAST。

窗口选项(frame_clause)

frame_clause 选项用于在 当前分区 内指定一个计算窗口。指定了窗口之后,分析函数不再基于分区进行计算,而是基于窗口内的数据进行计算。以下示例用于计算每个产品当当前月份的累计销量( 示例数据 ):

SELECT product AS "产品", ym "年月", amount "销量",
       SUM(amount) OVER (PARTITION BY product ORDER BY ym ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
  FROM sales_monthly
 ORDER BY product, ym;
产品|年月   |销量    |sum      |
----|------|--------|---------|
桔子|201801|10154.00| 10154.00|
桔子|201802|10183.00| 20337.00|
桔子|201803|10245.00| 30582.00|
桔子|201804|10325.00| 40907.00|
桔子|201805|10465.00| 51372.00|
桔子|201806|10505.00| 61877.00|

其中, PARTITION BY 选项表示按照产品进行分区; ORDER BY 选项表示按照日期进行排序;窗口子句 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW 指定窗口从当前分区的第一行开始到当前行结束;因此 SUM 函数计算的是产品累计到当前月份为止的销量合计。

具体来说,窗口大小的常用选项如下:

{ ROWS | RANGE } frame_start
{ ROWS | RANGE } BETWEEN frame_start AND frame_end

其中, ROWS 表示以行为单位计算窗口的偏移量, RANGE 表示以数值(例如 30 分钟)为单位计算窗口的偏移量。其中,frame_start 用于定义窗口的起始位置,可以指定以下内容之一:

  • UNBOUNDED PRECEDING ,窗口从分区的第一行开始,默认值;
  • N PRECEDING ,窗口从当前行之前的第 N 行或者数值开始;
  • CURRENT ROW ,窗口从当前行开始。

frame_end 用于定义窗口的结束位置,可以指定以下内容之一:

  • CURRENT ROW ,窗口到当前行结束,默认值;
  • N FOLLOWING ,窗口到当前行之后的第 N 行或者数值结束;
  • UNBOUNDED FOLLOWING ,窗口到分区的最后一行结束。

下图可以方便我们理解这些选项的含义:

CURRENT ROW 表示当前正在处理的行;其他的行可以使用相对当前行的位置表示。需要注意,窗口的大小不会超出当前分区的范围。

PostgreSQL 还提供了更多复杂的窗口选项,可以参考 官方文档

常见的窗口函数可以分为以下几类:聚合窗口函数、排名窗口函数以及取值窗口函数。

聚合窗口函数

常用的聚合函数,例如 AVG、SUM、COUNT 等,也可以作为窗口函数使用。上文我们已经列举了一些聚合窗口函数的示例,再来看一个使用 AVG 函数计算移动平均值的例子:

SELECT saledate, amount, avg(amount) OVER (ORDER BY saledate ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)
  FROM sales_data
 WHERE product = '桔子' AND channel = '淘宝';
saledate  |amount |avg                  |
----------|-------|---------------------|
2019-01-01|1864.00|1893.5000000000000000|
2019-01-02|1923.00|1505.3333333333333333|
2019-01-03| 729.00|1066.3333333333333333|
2019-01-04




    
| 547.00| 966.6666666666666667|
2019-01-05|1624.00|1272.0000000000000000|
2019-01-06|1645.00|1332.0000000000000000|
...

该语句返回了“桔子”在“淘宝”上的销量,以及每一天和它前后一天(共 3 天)的平均销量。

移动平均值通常用于处理时间序列的数据。例如,厂房的温度检测器获取了每秒钟的温度,我们可以使用以下窗口计算前五分钟内的平均温度:

avg(temperature) OVER (ORDER BY ts RANGE BETWEEN interval '5 minute' PRECEDING AND CURRENT ROW)

排名窗口函数

排名窗口函数用于对数据进行分组排名。常见的排名窗口函数包括:

  • ROW_NUMBER ,为分区中的每行数据分配一个序列号,序列号从 1 开始分配。
  • RANK ,计算每行数据在其分区中的名次;如果存在名次相同的数据,后续的排名将会产生跳跃。
  • DENSE_RANK ,计算每行数据在其分区中的名次;即使存在名次相同的数据,后续的排名也是连续的值。
  • PERCENT_RANK ,以百分比的形式显示每行数据在其分区中的名次;如果存在名次相同的数据,后续的排名将会产生跳跃。
  • CUME_DIST ,计算每行数据在其分区内的累积分布,也就是该行数据及其之前的数据的比率;取值范围大于 0 并且小于等于 1。
  • NTILE ,将分区内的数据分为 N 等份,为每行数据计算其所在的位置。

排名窗口函数不支持动态的窗口大小(frame_clause),而是以当前分区作为分析的窗口。

以下示例按照部门为单位,计算员工的月薪排名:

SELECT d.department_name "部门名称", concat(e.first_name, ',' , e.last_name) "姓名", e.salary "月薪",
       ROW_NUMBER() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "row_number",
       RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "rank",
       DENSE_RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "dense_rank",
       PERCENT_RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS "percent_rank"
  FROM employees e
  JOIN departments d ON (e.department_id = d.department_id)
 WHERE d.department_name in ('IT', 'Purchasing')
 ORDER BY 1, 4;
部门名称   |姓名            |月薪    |row_number|rank|dense_rank|percent_rank|
----------|----------------|--------|----------|----|----------|------------|
IT        |Alexander,Hunold| 9000.00|         1|   1|         1|           0|
IT        |Bruce,Ernst     | 6000.00|         2|   2|         2|        0.25|
IT        |Valli,Pataballa | 4800.00|         3|   3|         3|         0.5|
IT        |David,Austin    | 4800.00|         4|   3|         3|         0.5|
IT        |Diana,Lorentz   | 4200.00|         5|   5|         4|           1|
Purchasing|Den,Raphaely    |11000.00|         1|   1|         1|           0|
Purchasing|Alexander,Khoo  | 3100.00|         2|   2|         2|         0.2|
Purchasing|Shelli,Baida    | 2900.00|         3|   3|         3|         0.4|
Purchasing|Sigal,Tobias    | 2800.00|         4|   4|         4|         0.6|
Purchasing|Guy,Himuro      | 2600.00|         5|   5|         5|         0.8|
Purchasing|Karen,Colmenares| 2500.00|         6|   6|         6|           1|

ROW_NUMBER 函数为每个员工分配了一个连续的数字编号,可以看作是一种排名。IT 部门的“Valli,Pataballa”和“David,Austin”的月薪相同,但是编号不同;

RANK 函数为每个员工指定了一个名次,IT 部门的“Valli,Pataballa”和“David,Austin”的名次都是 3;而且在他们之后的“Diana,Lorentz”的名次为 5,产生了跳跃;

DENSE_RANK 函数为每个员工指定了一个名次,IT 部门的“Valli,Pataballa”和“David,Austin”的名次都是 3;在他们之后的“Diana,Lorentz”的名次为 4,名次是连续值;

PERCENT_RANK 函数按照百分比指定名次,取值位于 0 到 1 之间。其中“Diana,Lorentz”的百分比排名为 1,也产生了跳跃。

以上示例中 4 个窗口函数的 OVER 子句完全相同,此时可以采用一种更简单的写法:

SELECT d.department_name "部门名称", concat(e.first_name, ',' , e.last_name) "姓名", e.salary "月薪",
       ROW_NUMBER() OVER w AS "row_number",
       RANK() OVER w AS "rank",
       DENSE_RANK() w AS "dense_rank",
       PERCENT_RANK() w AS "percent_rank"
  FROM employees e
  JOIN departments d ON (e.department_id = d.department_id)
 WHERE d.department_name in ('IT', 'Purchasing')
 WINDOW w AS (PARTITION BY e.department_id ORDER BY e.salary DESC)
 ORDER BY 1, 4;

其中, WINDOW 定义了一个窗口变量 w,然后在窗口函数的 OVER 子句中使用了该变量;这样可以简化函数的输入。

窗口函数在 GROUP BY 分组、聚合函数以及 HAVING 过滤之后运行。如果多个窗口函数拥有相同的 PARTITION BY ORDER BY 选项,它们会在遍历数据时一起进行计算,也就是说它们读取输入数据的顺序完全一致。

以下语句演示了 CUME_DIST 和 NTILE 函数的作用:

SELECT concat(first_name, ',' , last_name) "姓名", hire_date AS "入职日期",
       CUME_DIST() OVER (ORDER BY hire_date) AS "累积占比",
       NTILE(100) OVER (ORDER BY hire_date) AS "相对位置"
  FROM employees;
姓名             |入职日期   |累积占比             |相对位置|
-----------------|----------|--------------------|-------|
Lex,De Haan      |2001-01-13|0.009345794392523364|      1|
Hermann,Baer     |2002-06-07| 0.04672897196261682|      1|
Shelley,Higgins  |2002-06-07| 0.04672897196261682|      2|
William,Gietz    |2002-06-07| 0.04672897196261682|      2|
Susan,Mavris     |2002-06-07| 0.04672897196261682|      3|
Daniel,Faviet    |2002-08-16|0.056074766355140186|      3|
...

其中,CUME_DIST 函数显示 2001-01-13 以及之前入职的员工大概有 0.9%(1/107);NTILE(100) 函数表明前 1% 入职的员工有“Lex,De Haan”和“Hermann,Baer”,由于员工总数为 107,所以不是完全准确。

取值窗口函数

取值窗口函数用于返回指定位置上的数据。常见的取值窗口函数包括:

  • FIRST_VALUE ,返回窗口内第一行的数据。
  • LAST_VALUE ,返回窗口内最后一行的数据。
  • NTH_VALUE ,返回窗口内第 N 行的数据。
  • LAG ,返回分区中当前行之前的第 N 行的数据。
  • LEAD ,返回分区中当前行之后第 N 行的数据。

其中,LAG 和 LEAD 函数不支持动态的窗口大小(frame_clause),而是以当前分区作为分析的窗口。

以下语句使用 FIRST_VALUE、LAST_VALUE 以及 NTH 函数分别获取每个部门内部月薪最高、月薪最低以及月薪第三高的员工:

SELECT department_id, first_name, last_name, salary,
       FIRST_VALUE(salary) OVER w,
       LAST_VALUE(salary) OVER w,
       NTH_VALUE(salary, 3) OVER w
  FROM employees
WINDOW w AS (PARTITION BY department_id ORDER BY salary desc ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
 ORDER BY department_id, salary DESC;
department_id|first_name |last_name  |salary  |first_value|last_value|nth_value|
-------------|-----------|-----------|--------|-----------|----------|---------|
           10|Jennifer   |Whalen     | 4400.00|    4400.00|   4400.00|         |
           20|Michael    |Hartstein  |13000.00|   13000.00|   6000.00|         |
           20|Pat        |Fay        | 6000.00|   13000.00|   6000.00|         |
           30|Den        |Raphaely   |11000.00|   11000.00|   2500.00|  2900.00|
           30|Alexander  |Khoo       | 3100.00|   11000.00|   2500.00|  2900.00|
           30|Shelli     |Baida      | 2900.00|   11000.00|   2500.00|  2900.00|
           30|Sigal      |Tobias     | 2800.00|   11000.00|   2500.00|  2900.00|
           30|Guy        |Himuro     | 2600.00|   11000.00|   2500.00|  2900.00|
           30|Karen      |Colmenares | 2500.00|   11000.00|   2500.00|  2900.00|
...

以上三个函数的默认窗口是从当前分区的第一行到当前行,所以我们在 OVER 子句中将窗口设置为整个分区。

LAG 和 LEAD 函数同样用于计算销量数据的环比/同比增长。例如,以下语句统计不同产品每个月的环比增长率:

WITH sales_monthly AS (
  SELECT product, to_char(saledate,'YYYYMM') ym, sum(amount) sum_amount
    FROM sales_data
   GROUP BY product, to_char(saledate,'YYYYMM')
SELECT product AS "产品", ym "年月", sum_amount "销量",
       (sum_amount - LAG(sum_amount, 1) OVER (PARTITION BY product ORDER BY ym))/
       LAG(sum_amount, 1) OVER (PARTITION BY product ORDER BY ym) * 100 AS "环比增长率(%)"
  FROM sales_monthly
 ORDER BY product, ym;
产品|年月  |销量     |环比增长率(%         |
----|------|---------|-----------------------|
桔子|201901|126083.00|                       |