提示:这篇文字适合在你已经掌握了 MySQL 的基础操作后阅读。
MySQL 自 8.0 版本开始添加了窗口函数 (window function) 功能。所谓窗口函数,是指对查询的每一行,利用和这一行相关的一些行构成一个窗口,对这个窗口进行计算,得到一个值作为结果。窗口函数和聚合函数 (aggregate function ) 有点类似,但窗口函数的功能往往要比聚合函数更强。 为了方便说明,这里用一个小的数据库来做演示。这是uCourse小程序2020年3月2日和3日五个页面的访问人数和平均停留时长数据。
1 | CREATE TABLE page( |
1 | SELECT * FROM page; |
引子
对于聚合函数来说,它有一个无法打破的禁锢:你不能把聚合后的数据和聚合前的数据混在一张表格里。比如,你不能把求和后的值和求和前的值放在同一张表里。如果你想对这张表中所有页面的访问人数进行求和,你得这么写:
1 | SELECT |
1 | +---------+ |
但是窗口函数打破了这个禁锢。请看这个:
1 | SELECT |
1 | +------------+--------------+---------+---------+ |
这对于习惯了聚合函数的我们来说未免有些神奇。所以停下来,看一下这个最简单的窗口函数实例。每个窗口函数必然有一个 OVER
关键字。OVER
后面的括号中指出窗口的范围,OVER
前面指出对窗口进行什么运算。这里的运算可以是我们熟悉的聚合运算,也有一些其他的运算。在上面的例子中,窗口的范围写了()
,意思是默认的整张表格;对整张表格进行的运算是对val
列进行求和,其中SUM
函数就是我们熟悉的聚合函数。最后,其中AS
关键字的意思一如既往,即给查询结果的那一列重命名;其实AS
在此处可以省略,但为可读性考虑,这篇教程将保留它。总之,那个窗口函数语句的意思是:对于查询的每一行,创建一个范围是“整张表格”的窗口,对窗口内val
列进行求和,求和结果作为查询结果的val_sum
列。 确保你理解了上面这个例子,后面的各种花样都是对这个例子的变形。
窗口内排序
你可以对选定的窗口内的数据进行排序。你可能会觉得,如果我们的窗口函数用的是我们常用的聚合函数,排序并没什么用,因为不管数据是什么顺序,求和、均值、计数等等运算的结果是一样的。但是,事实上,数据进行排序之后,窗口的范围就发生了改变,从原来默认的“整个表格”变成了“排序后的第一行到当前行(包含并列行)”。因此,我们可以这样求“前缀和”:
1 | SELECT |
1 | +------------+--------------+---------+-------------+ |
这个查询语句的意思是明确的:对表格的每一行,建立一个窗口,窗口内数据按日期升序排列,且窗口涵盖的范围是所有日期小于等于自己的行,对窗口内num_psn
列进行求和作为当前行的running_sum
列。 在此,我们再介绍两个常用的窗口函数:RANK()
和 ROW_NUMBER()
。顾名思义,RANK
返回当前行在窗口中的排名,ROW_NUMBER
返回当前行在窗口中的行号。两个函数的差别在于对并列的处理:RANK
对于并列行,排名一致,后面的行的排名则有一个间隙;ROW_NUMBER
则是连续排下来的。 想知道某一天所有页面访问人数的排名:
1 | SELECT |
1 | +------------+--------------+---------+--------------+ |
对于page
表中3月2日的每一行,建立一个窗口,窗口内数据按访问人数降序排列,且窗口的范围是所有人数大于等于自己的行,找出当前行在现在窗口中的排名,重命名为num_psn_rank
列。当然,因为在这里,人数小于自己的行对自己的排名和行号都不影响,所以窗口范围限制的效果并不能显现出来。 不过例外是LEAD
和LAG
函数,它们并不受到排序后窗口范围的限制。它们的含义是找出在当前行在窗口中前/后第若干行中某一列的值。
窗口内分组
在上面的例子中,我们只限定了某一天。如果想知道每一天中,当天的每个页面的访问人数的排名呢?你会觉察到这个问题和我们的GROUP BY
有点相似。在这里我们用PARTITION BY
关键字。
1 | SELECT |
1 | +------------+--------------+---------+--------------+ |
这个语句的意思是:对于每一行,建立一个窗口,首先分组筛选保证窗口中所有行的date
值和当前行一致,然后窗口内数据按访问人数降序排列,窗口的范围是所有排序后顺序在当前行之前或和当前行并列的行,最后求出当前行在窗口中的排序。那么这样的话,对于不同的日期,就有一套自己的页面排名。不过这两天这五个页面的排名是一样的,可见这两天(其实大多数日子都是这样)uCourse的用户大多数还是从首页打开小程序,然后使用课表功能。
窗口范围
你还可以自己限定窗口的范围。如果想求移动平均值,即当前行和上下两行的平均值,那么就要把这三行限定成一个窗口。这就需要自定义的窗口范围限制。 自定义窗口范围限制有两个关键字可用,一是ROWS
,二是RANGE
。我们先看一下ROWS
怎么用。 下面查询语句就能查询移动平均值:
1 | SELECT |
1 | +------------+--------------+---------+-------------+ |
两个日期之间的分割是我手动加的。这个窗口,首先是把date
值和当前行一样的行筛选出来,排序,然后把窗口限定为“当前行前面一行”和“当前行后面一行”之间。有一个问题:如果当前行是第一行呢?那么“前面一行”被省略,因此这个窗口只有两行,即当前行加上当前行后面一行。如果是最后一行类似。例如,你可以注意到,上面的那个数据表格的第一行,移动平均值是1549,这个值是1591和1507的均值。 你注意到ROWS
的语法是
1 | ROWS BETWEEN line1 AND line2 |
这里,每一行可以有以下几种表示方法:
X PRECEDING
:当前行上面X行X FOLLOWING
:当前行下面X行CURRENT ROW
:当前行UNBOUNDED PRECEDING
:当前窗口第一行UNBOUNDED FOLLOWING
:当前窗口最后一行
那么,这样就不难理解ROWS
和BETWEEN AND
连在一起的意思:
ROWS BETWEEN 5 PRECEDING AND CURRENT ROW
:所有满足当前行行号-5<=行号<=当前行行号的行ROWS BETWEEN 5 FOLLOWING AND 10 FOLLOWING
:所有满足当前行行号+5<=行号<=当前行行号+10的行ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
:所有行号<=当前行的行
你现在可能快不认识“行号”两个字了。 RANGE
和ROWS
的区别在于对X PRECEDING
, X FOLLOWING
, CURRENT ROW
的解读不同。RANGE
的处理是这样:首先,它要求你必须有一个ORDER BY
,并且只有一个排序键;然后,它把ROWS
规则中的所有“行号”改成这个键在行内的“值”。 比如RANGE BETWEEN 5 PRECEDING AND CURRENT ROW
。假如当前行的排序键的值是100,那么窗口涵盖的就是所有值在区间[95, 100]内的行。 比如RANGE BETWEEN 5 FOLLOWING AND 10 FOLLOWING
。假如当前行的排序键的值是100,那么窗口涵盖的就是所有值在区间[105, 110]内的行。 比如RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
:所有值<=当前行的行。请注意,这里和ROWS
的区别在于对并列行的处理:ROWS
对并列行是分开处理,RANGE
是一定同时涵盖所有并列行。(有一些网站说RANGE
和ROWS
的区别就在于对并列行的处理,说的就是这种情况,但是总体而言这种说法是以偏概全的。)再次注意,如果你不自定义窗口的范围,那么如果有ORDER BY
,这个范围就是默认值。 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
和 RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
就是一个意思了,全选。这也就是,如果你没有自定义窗口范围,也没有使用ORDER BY
语句的默认范围。 最后一件事,你要注意到RANGE
中X PRECEDING
和X FOLLOWING
中的X
其实是要直接和行内的排序键的值进行加减运算的。也就是说,如果你写了RANGE BETWEEN 5 FOLLOWING AND 10 FOLLOWING
,那么行内的排序键的值的类型一定是整型,因为需要进行+5和+10的运算;如果行内的排序键的值是浮点数,那就要写RANGE BETWEEN 5.0 FOLLOWING AND 10.0 FOLLOWING
;如果行内的排序键的值是日期格式,那么就要用INTERVAL
来表示日期的加减,比如RANGE BETWEEN INTERVAL 5 DAY FOLLOWING AND INTERVAL 10 DAY FOLLOWING
(注意MariaDB是不支持RANGE
使用日期的)。 还有一个问题是RANGE
如果遇到当前行是NULL
怎么办。根据运算法则,不管对NULL
进行什么加减运算,结果都是NULL
。这能有助于理解它的行为。但这里不具体展开了。官方文档有详细的例子。 我们来(不加解释地)举几个例子。这里我们用一个非常简单的数据库:
1 | MariaDB [test]> SELECT * FROM t; |
1 | SELECT |
1 | SELECT |
1 | SELECT |
1 | SELECT |
1 | SELECT |
1 | SELECT |
1 | SELECT |
显而易见,一切都是我们所说的那样。
命名窗口&常用的窗口函数
如果你要重复使用一个窗口多次,以便用于不同的窗口函数上,你可以命名一个窗口,使用WINDOW AS
语句,其位置是HAVING
和ORDER BY
之间。 下面这个例子将展示命名窗口和一些常用的窗口函数。窗口函数的具体描述请参考官方文档。
1 | SELECT |
除此之外,还有
NTILE
ROW_NUMBER
RANK
PERCENT_RANK
等。
结语
这文章写的我累死了。不想写结语了。总之就是博大精深就对了。