作为一名数据分析师,日常工作中经常会遇到类似这样的需求:
怎么样得到各部门工资排名前N名员工列表?查找各部门每人工资占部门总工资的百分比?环比如何计算?
对于这样的需求,使用传统的SQL实现起来比较困难。这类需求都有一个共同的特点,需要在单表中满足某些条件的结果集内部做一些函数操作,不是简单的表连接,也不是简单的聚合可以实现的,通常费了大半天时间写出来一堆长长的晦涩难懂的SQL,且性能低下,难以维护。要解决此类问题,最方便的就是使用开窗函数。
开窗函数简介
MySQL从8.0开始支持开窗函数,这个功能在大多商业数据库如Oracle和PostgreSQL中早已支持,也叫分析函数。
什么是开窗
它可以理解为记录集合,开窗函数也就是在满足某种条件的记录集合上执行的特殊函数。对于每条记录都要在此窗口内执行函数,有的函数随着记录不同,窗口大小都是固定的,这种属于静态窗口;有的函数则相反,不同的记录对应着不同的窗口,这种动态变化的窗口叫滑动窗口。开窗函数的本质还是聚合运算,只不过它更具灵活性,它对数据的每一行,都使用与该行相关的行进行计算并返回计算结果。
开窗函数和普通聚合函数的区别
- 聚合函数是将多条记录聚合为一条;而开窗函数是每条记录都会执行,有几条记录执行完还是几条。
- 聚合函数也可以用于开窗函数中。
示例:查询每个用户的订单总金额
mysql> create table order_tab(order_id int,user_no varchar(3),amount int,create_date date);
mysql> insert into order_tab values
-> (1,'001',100,'2019-01-01'),
-> (2,'001',300,'2019-01-02'),
-> (3,'001',500,'2019-01-02'),
-> (4,'001',800,'2019-01-03'),
-> (5,'001',900,'2019-01-04'),
-> (6,'002',500,'2019-01-03'),
-> (7,'002',600,'2019-01-04'),
-> (8,'002',300,'2019-01-10'),
-> (9,'002',800,'2019-01-16'),
-> (10,'002',800,'2019-01-22');
mysql> select * from order_tab;
+----------+---------+--------+-------------+
| order_id | user_no | amount | create_date |
+----------+---------+--------+-------------+
| 1 | 001 | 100 | 2019-01-01 |
| 2 | 001 | 300 | 2019-01-02 |
| 3 | 001 | 500 | 2019-01-02 |
| 4 | 001 | 800 | 2019-01-03 |
| 5 | 001 | 900 | 2019-01-04 |
| 6 | 002 | 500 | 2019-01-03 |
| 7 | 002 | 600 | 2019-01-04 |
| 8 | 002 | 300 | 2019-01-10 |
| 9 | 002 | 800 | 2019-01-16 |
| 10 | 002 | 800 | 2019-01-22 |
+----------+---------+--------+-------------+
mysql> select *,sum(amount) over(partition by user_no) sum_amount
-> from order_tab;
+----------+---------+--------+-------------+------------+
| order_id | user_no | amount | create_date | sum_amount |
+----------+---------+--------+-------------+------------+
| 1 | 001 | 100 | 2019-01-01 | 2600 |
| 2 | 001 | 300 | 2019-01-02 | 2600 |
| 3 | 001 | 500 | 2019-01-02 | 2600 |
| 4 | 001 | 800 | 2019-01-03 | 2600 |
| 5 | 001 | 900 | 2019-01-04 | 2600 |
| 6 | 002 | 500 | 2019-01-03 | 3000 |
| 7 | 002 | 600 | 2019-01-04 | 3000 |
| 8 | 002 | 300 | 2019-01-10 | 3000 |
| 9 | 002 | 800 | 2019-01-16 | 3000 |
| 10 | 002 | 800 | 2019-01-22 | 3000 |
+----------+---------+--------+-------------+------------+
我们知道聚合函数对一组值执行计算并返回一个值,但有时候一组数据只返回一个结果值并不能满足需求。
示例:查询每个用户按时间顺序的累计订单金额
mysql> select *,sum(amount) over(partition by user_no order by create_date) sum_amount
-> from order_tab;
+----------+---------+--------+-------------+------------+
| order_id | user_no | amount | create_date | sum_amount |
+----------+---------+--------+-------------+------------+
| 1 | 001 | 100 | 2019-01-01 | 100 |
| 2 | 001 | 300 | 2019-01-02 | 900 |
| 3 | 001 | 500 | 2019-01-02 | 900 |
| 4 | 001 | 800 | 2019-01-03 | 1700 |
| 5 | 001 | 900 | 2019-01-04 | 2600 |
| 6 | 002 | 500 | 2019-01-03 | 500 |
| 7 | 002 | 600 | 2019-01-04 | 1100 |
| 8 | 002 | 300 | 2019-01-10 | 1400 |
| 9 | 002 | 800 | 2019-01-16 | 2200 |
| 10 | 002 | 800 | 2019-01-22 | 3000 |
+----------+---------+--------+-------------+------------+
开窗函数分类
按照函数功能不同,MySQL支持的开窗函数分为如下几类:
- 序号函数:row_number() / rank() / dense_rank()
- 分布函数:percent_rank() / cume_dist()
- 前后函数:lag() / lead()
- 头尾函数:first_value() / last_value()
- 其他函数:nth_value() / nfile()
开窗函数使用
语法:
开窗函数名([<字段名>]) over([partition by <分组字段>] [order by <排序字段> [desc]] [<窗口分区>])
开窗函数的一个概念是当前行,当前行属于某个窗口,窗口由
over关键字用来指定函数执行的窗口范围,如果后面括号中什么都不写,则意味着窗口包含满足where条件的所有行,开窗函数基于所有行进行计算;如果不为空,则有三个参数来设置窗口:
- partition by子句:按照指定字段进行分区,两个分区由边界分隔,开窗函数在不同的分区内分别执行,在跨越分区边界时重新初始化。
- order by子句:按照指定字段进行排序,开窗函数将按照排序后的记录顺序进行编号。可以和partition by子句配合使用,也可以单独使用。
- frame子句:当前分区的一个子集,用来定义子集的规则,通常用来作为滑动窗口使用。
示例:比如要根据每个订单动态计算包括本订单和按时间顺序前后两个订单的平均订单金额,则可以设置如下frame子句来创建滑动窗口
mysql> mysql> select *,sum(amount) over(partition by user_no order by create_date desc rows between 1 preceding and 1 following) sum_amount
-> from order_tab;
+----------+---------+--------+-------------+------------+
| order_id | user_no | amount | create_date | sum_amount |
+----------+---------+--------+-------------+------------+
| 5 | 001 | 900 | 2019-01-04 | 1700 |
| 4 | 001 | 800 | 2019-01-03 | 2000 |
| 2 | 001 | 300 | 2019-01-02 | 1600 |
| 3 | 001 | 500 | 2019-01-02 | 900 |
| 1 | 001 | 100 | 2019-01-01 | 600 |
| 10 | 002 | 800 | 2019-01-22 | 1600 |
| 9 | 002 | 800 | 2019-01-16 | 1900 |
| 8 | 002 | 300 | 2019-01-10 | 1700 |
| 7 | 002 | 600 | 2019-01-04 | 1400 |
| 6 | 002 | 500 | 2019-01-03 | 1100 |
+----------+---------+--------+-------------+------------+
对于滑动窗口的范围指定,有两种方式:基于行和基于范围。
基于行:
通常使用between frame_start and frame_end
语法来表示行范围,frame_start和frame_end可以支持如下关键字,来确定不同的动态行记录:
- current row 边界是当前行,一般和其他范围关键字一起使用
- unbounded preceding 边界是分区中的第一行
- unbounded following 边界是分区中的最后一行
- expr preceding 边界是当前行减去expr的值
- expr following 边界是当前行加上expr的值
比如,下面都是合法的范围:
- rows between 1 preceding and 1 following 窗口范围是当前行、前一行、后一行一共三行记录。
- rows unbounded preceding 窗口范围是当前行到分区中的最后一行。
- rows between unbounded preceding and unbounded following 窗口范围是当前分区中所有行,等同于不写。
基于范围:
和基于行类似,但有些范围不是直接可以用行数来表示的,比如希望窗口范围是一周前的订单开始,截止到当前行,则无法使用rows来直接表示,此时就可以使用范围来表示窗口:interval 7 day preceding。
序号函数
row_number():显示分区中的当前序号
示例:查询每个用户订单金额最高的前三个订单
mysql> select *
-> from (
-> select *,row_number() over(partition by user_no order by amount desc) row_num
-> from order_tab) t
-> where row_num<=3;
+----------+---------+--------+-------------+---------+
| order_id | user_no | amount | create_date | row_num |
+----------+---------+--------+-------------+---------+
| 5 | 001 | 900 | 2019-01-04 | 1 |
| 4 | 001 | 800 | 2019-01-03 | 2 |
| 3 | 001 | 500 | 2019-01-02 | 3 |
| 9 | 002 | 800 | 2019-01-16 | 1 |
| 10 | 002 | 800 | 2019-01-22 | 2 |
| 7 | 002 | 600 | 2019-01-04 | 3 |
+----------+---------+--------+-------------+---------+
以上结果中用户‘002’的前两个订单的金额都是800,随机排为第一和第二,但实际两笔订单金额应该并列第一。这种情况row_number函数就不能满足需求,需要rank和dense_rank函数。这两个函数和row_number函数类似,只是在出现重复值时处理逻辑不同。
示例:使用三个不同的序号函数,查询不同用户的订单中,按照订单金额进行排序,显示出相应的排名
mysql> select *,
-> row_number() over(partition by user_no order by amount desc) as rank1,
-> rank() over(partition by user_no order by amount desc) as rank2,
-> dense_rank() over(partition by user_no order by amount desc) as rank3
-> from order_tab;
+----------+---------+--------+-------------+-------+-------+-------+
| order_id | user_no | amount | create_date | rank1 | rank2 | rank3 |
+----------+---------+--------+-------------+-------+-------+-------+
| 5 | 001 | 900 | 2019-01-04 | 1 | 1 | 1 |
| 4 | 001 | 800 | 2019-01-03 | 2 | 2 | 2 |
| 3 | 001 | 500 | 2019-01-02 | 3 | 3 | 3 |
| 2 | 001 | 300 | 2019-01-02 | 4 | 4 | 4 |
| 1 | 001 | 100 | 2019-01-01 | 5 | 5 | 5 |
| 9 | 002 | 800 | 2019-01-16 | 1 | 1 | 1 |
| 10 | 002 | 800 | 2019-01-22 | 2 | 1 | 1 |
| 7 | 002 | 600 | 2019-01-04 | 3 | 3 | 2 |
| 6 | 002 | 500 | 2019-01-03 | 4 | 4 | 3 |
| 8 | 002 | 300 | 2019-01-10 | 5 | 5 | 4 |
+----------+---------+--------+-------------+-------+-------+-------+
分布函数
percent_rank():和之前的rank()函数相关,每行计算:(rank - 1) / (rows - 1)
其中,rank为rank()函数产生的序号,rows为当前窗口的记录总行数。
示例:
mysql> select order_id,user_no,amount,
-> rank() over(partition by user_no order by amount desc) rank1,
-> percent_rank() over(partition by user_no order by amount desc) percent
-> from order_tab;
+----------+---------+--------+-------+---------+
| order_id | user_no | amount | rank1 | percent |
+----------+---------+--------+-------+---------+
| 5 | 001 | 900 | 1 | 0 |
| 4 | 001 | 800 | 2 | 0.25 |
| 3 | 001 | 500 | 3 | 0.5 |
| 2 | 001 | 300 | 4 | 0.75 |
| 1 | 001 | 100 | 5 | 1 |
| 9 | 002 | 800 | 1 | 0 |
| 10 | 002 | 800 | 1 | 0 |
| 7 | 002 | 600 | 3 | 0.5 |
| 6 | 002 | 500 | 4 | 0.75 |
| 8 | 002 | 300 | 5 | 1 |
+----------+---------+--------+-------+---------+
cume_dist():分组内小于等于当前rank值的行数/分组内总行数
示例:查询大于等于当前订单金额的订单比例
mysql> select order_id,user_no,amount,
-> rank() over(partition by user_no order by amount desc) rank1,
-> cume_dist() over(partition by user_no order by amount desc) cume
-> from order_tab;
+----------+---------+--------+-------+------+
| order_id | user_no | amount | rank1 | cume |
+----------+---------+--------+-------+------+
| 5 | 001 | 900 | 1 | 0.2 |
| 4 | 001 | 800 | 2 | 0.4 |
| 3 | 001 | 500 | 3 | 0.6 |
| 2 | 001 | 300 | 4 | 0.8 |
| 1 | 001 | 100 | 5 | 1 |
| 9 | 002 | 800 | 1 | 0.4 |
| 10 | 002 | 800 | 1 | 0.4 |
| 7 | 002 | 600 | 3 | 0.6 |
| 6 | 002 | 500 | 4 | 0.8 |
| 8 | 002 | 300 | 5 | 1 |
+----------+---------+--------+-------+------+
前后函数
lag(n)/lead(n):分区中位于当前行前n行(lag)/后n行(lead)的记录值
示例:查询上一个订单距离当前订单的间隔天数
mysql> select *,datediff(create_date,last_date) diff
-> from (
-> select *,lag(create_date,1) over(partition by user_no order by create_date) last_date
-> from order_tab) t;
+----------+---------+--------+-------------+------------+------+
| order_id | user_no | amount | create_date | last_date | diff |
+----------+---------+--------+-------------+------------+------+
| 1 | 001 | 100 | 2019-01-01 | NULL | NULL |
| 2 | 001 | 300 | 2019-01-02 | 2019-01-01 | 1 |
| 3 | 001 | 500 | 2019-01-02 | 2019-01-02 | 0 |
| 4 | 001 | 800 | 2019-01-03 | 2019-01-02 | 1 |
| 5 | 001 | 900 | 2019-01-04 | 2019-01-03 | 1 |
| 6 | 002 | 500 | 2019-01-03 | NULL | NULL |
| 7 | 002 | 600 | 2019-01-04 | 2019-01-03 | 1 |
| 8 | 002 | 300 | 2019-01-10 | 2019-01-04 | 6 |
| 9 | 002 | 800 | 2019-01-16 | 2019-01-10 | 6 |
| 10 | 002 | 800 | 2019-01-22 | 2019-01-16 | 6 |
+----------+---------+--------+-------------+------------+------+
头尾函数
first_value() / last_value():分区中的第一个/最后一个指定参数的值
示例:查询截止到当前订单,按照日期排序第一个订单和最后一个订单的订单金额
mysql> select *,
-> first_value(amount) over(partition by user_no order by create_date) first_amount,
-> last_value(amount) over(partition by user_no order by create_date) last_amount
-> from order_tab;
+----------+---------+--------+-------------+--------------+-------------+
| order_id | user_no | amount | create_date | first_amount | last_amount |
+----------+---------+--------+-------------+--------------+-------------+
| 1 | 001 | 100 | 2019-01-01 | 100 | 100 |
| 2 | 001 | 300 | 2019-01-02 | 100 | 500 |
| 3 | 001 | 500 | 2019-01-02 | 100 | 500 |
| 4 | 001 | 800 | 2019-01-03 | 100 | 800 |
| 5 | 001 | 900 | 2019-01-04 | 100 | 900 |
| 6 | 002 | 500 | 2019-01-03 | 500 | 500 |
| 7 | 002 | 600 | 2019-01-04 | 500 | 600 |
| 8 | 002 | 300 | 2019-01-10 | 500 | 300 |
| 9 | 002 | 800 | 2019-01-16 | 500 | 800 |
| 10 | 002 | 800 | 2019-01-22 | 500 | 800 |
+----------+---------+--------+-------------+--------------+-------------+
以上结果中order_id为4的记录,first_amount和last_amount分别记录了用户‘001’截止到时间2018-01-03 00:00:00为止,第一条订单金额100和最后一条订单金额800,注意这里是按时间排序的最早订单和最晚订单,并不是最小金额和最大金额订单。
其他函数
nth_value(expr,n):返回窗口中第N个expr的值,expr可以是表达式,也可以是列名
示例:每个用户订单中显示金额排名第二和第三的订单金额
mysql> select *,
-> nth_value(amount,2) over(partition by user_no order by amount) second_amount,
-> nth_value(amount,3) over(partition by user_no order by amount) third_amount
-> from order_tab;
+----------+---------+--------+-------------+---------------+--------------+
| order_id | user_no | amount | create_date | second_amount | third_amount |
+----------+---------+--------+-------------+---------------+--------------+
| 1 | 001 | 100 | 2019-01-01 | NULL | NULL |
| 2 | 001 | 300 | 2019-01-02 | 300 | NULL |
| 3 | 001 | 500 | 2019-01-02 | 300 | 500 |
| 4 | 001 | 800 | 2019-01-03 | 300 | 500 |
| 5 | 001 | 900 | 2019-01-04 | 300 | 500 |
| 8 | 002 | 300 | 2019-01-10 | NULL | NULL |
| 6 | 002 | 500 | 2019-01-03 | 500 | NULL |
| 7 | 002 | 600 | 2019-01-04 | 500 | 600 |
| 9 | 002 | 800 | 2019-01-16 | 500 | 600 |
| 10 | 002 | 800 | 2019-01-22 | 500 | 600 |
+----------+---------+--------+-------------+---------------+--------------+
nfile(n):将分区中的有序数据分为n个桶,记录桶号
示例:将每个用户的订单按照订单金额分成3组
mysql> select *,
-> ntile(3) over(partition by user_no order by amount) nf
-> from order_tab;
+----------+---------+--------+-------------+------+
| order_id | user_no | amount | create_date | nf |
+----------+---------+--------+-------------+------+
| 1 | 001 | 100 | 2019-01-01 | 1 |
| 2 | 001 | 300 | 2019-01-02 | 1 |
| 3 | 001 | 500 | 2019-01-02 | 2 |
| 4 | 001 | 800 | 2019-01-03 | 2 |
| 5 | 001 | 900 | 2019-01-04 | 3 |
| 8 | 002 | 300 | 2019-01-10 | 1 |
| 6 | 002 | 500 | 2019-01-03 | 1 |
| 7 | 002 | 600 | 2019-01-04 | 2 |
| 9 | 002 | 800 | 2019-01-16 | 2 |
| 10 | 002 | 800 | 2019-01-22 | 3 |
+----------+---------+--------+-------------+------+
此函数在数据分析中应用较多,比如由于数据量大,需要将数据平均分配到N个并行的进程分别计算,此时就可以用nfile(n)对数据进行分组,由于记录数不一定被n整除,所以数据不一定完全平均,然后将不同桶号的数据再分配。
聚合函数作为开窗函数
在窗口中每条记录动态应用聚合函数(sum/avg/max/min/count),可以动态计算在指定的窗口内的各种聚合函数值。
示例:每个用户按照订单id,截止到当前的累计订单金额/平均订单金额/最大订单金额/最小订单金额/订单数
mysql> select *,
-> sum(amount) over(partition by user_no order by order_id) sum1,
-> avg(amount) over(partition by user_no order by order_id) avg1,
-> max(amount) over(partition by user_no order by order_id) max1,
-> min(amount) over(partition by user_no order by order_id) min1,
-> count(amount) over(partition by user_no order by order_id) count1
-> from order_tab;
+----------+---------+--------+-------------+------+----------+------+------+--------+
| order_id | user_no | amount | create_date | sum1 | avg1 | max1 | min1 | count1 |
+----------+---------+--------+-------------+------+----------+------+------+--------+
| 1 | 001 | 100 | 2019-01-01 | 100 | 100.0000 | 100 | 100 | 1 |
| 2 | 001 | 300 | 2019-01-02 | 400 | 200.0000 | 300 | 100 | 2 |
| 3 | 001 | 500 | 2019-01-02 | 900 | 300.0000 | 500 | 100 | 3 |
| 4 | 001 | 800 | 2019-01-03 | 1700 | 425.0000 | 800 | 100 | 4 |
| 5 | 001 | 900 | 2019-01-04 | 2600 | 520.0000 | 900 | 100 | 5 |
| 6 | 002 | 500 | 2019-01-03 | 500 | 500.0000 | 500 | 500 | 1 |
| 7 | 002 | 600 | 2019-01-04 | 1100 | 550.0000 | 600 | 500 | 2 |
| 8 | 002 | 300 | 2019-01-10 | 1400 | 466.6667 | 600 | 300 | 3 |
| 9 | 002 | 800 | 2019-01-16 | 2200 | 550.0000 | 800 | 300 | 4 |
| 10 | 002 | 800 | 2019-01-22 | 3000 | 600.0000 | 800 | 300 | 5 |
+----------+---------+--------+-------------+------+----------+------+------+--------+