目录
- 一、 概念
- 二、语法
- 基本语法
- 语法解析
- 进阶语法
- 命名窗口WINDOW AS
- 框架FRAME_CLAUSE
- 三、窗口函数
- ROW_NUMBER
- RANK
- DENSE_RANK
- PERCENT_RANK
- CUME_DIST
- FIRST_VALUE与LAST_VALUE
- NTH_VALUE
- LAG与LEAD
- NTILE
- 四、窗口函数使用事项
- 五、窗口函数优化方法
- 六、面试常问(持续更新)
本文将介绍MySQL8的新特性——窗口函数,从概念、语法、示例等各方面详细解析窗口函数,以及解析面试中窗口函数常考的题目和知识点。
窗口函数(Windows Function),也被称为分析函数(Analytic Functions),在Oracle中使用已久,但直到MySQL8才开始引入。窗口函数非常强大,在工作中应用非常广泛,专门用于处理复杂的统计分析问题,例如移动平均、累计总和、百分比排名等,它可直接添加新的聚合字段,而不必在数据库应用程序级别编写额外的代码。
环境:MySQL 8.0.32
下文语法中[]包括的内容表示可选项
以下示例均用下表以及下表数据进行演示:
-- 创建一个销售表(sales),其中包含销售日期(sale_date)、销售员ID(sale_id)和销售额(sale_amount)
CREATE TABLE sales ( id INT AUTO_INCREMENT PRIMARY KEY, sale_date DATE, sale_id INT, sale_amount DECIMAL(10, 2)
); -- 插入一些示例数据
INSERT INTO sales (sale_date, sale_id, sale_amount) VALUES
('2023-01-01', 1, 1000.00),
('2023-01-02', 1, 1200.00),
('2023-01-03', 2, 800.00),
('2023-01-03', 1, 1000.00),
('2023-01-05', 2, 900.00),
('2023-01-01', 3, 700.00),
('2023-01-02', 3, 750.00),
('2023-01-03', 4, 600.00),
('2023-01-04', 3, 800.00),
('2023-01-05', 4, 650.00);
一、 概念
窗口函数会对数据进行分组,每个分组即为一个窗口。和分组聚合不同,窗口函数会对窗口中每一条记录进行应用,不会使记录变少(每一行返回一个结果),其重点是当前行与窗口以及窗口中其他行的联系。
二、语法
基本语法
SELECT WINDOW_FUNCTION() OVER([PARTITION BY `字段1`, `字段2`, ...][ORDER BY `字段3`, `字段4`][FRAME_CLAUSE]) [AS `别名`]
FROM `表名` ;
注意: 窗口函数可使用别名
语法解析
WINDOW_FUNCTION
MySQL8支持以下几类窗口函数:
- 序号函数:用于为窗口内的每一行生成一个序号,例如 ROW_NUMBER(),RANK(),DENSE_RANK() 等。
- 分布函数:用于计算窗口内的每一行在整个分区中的相对位置,例如 PERCENT_RANK(),CUME_DIST() 等。
- 前后函数:用于获取窗口内的当前行的前后某一行的值,例如 LAG(),LEAD() 等。
- 头尾函数:用于获取窗口内的第一行或最后一行的值,例如 FIRST_VALUE(),LAST_VALUE() 等。
- 聚合函数:用于计算窗口内的某个字段的聚合值,例如 SUM(),AVG(),MIN(),MAX() 等。
OVER
OVER关键字用于标识是否使用窗口函数,有两种用法:
- OVER(),常规用法,窗口规范放在括号中。如窗口规范为空,表示没有窗口划分,默认所有数据为一组。
- OVER window_name:由FROM后的WINDOW子句定义窗口规范,可重复使用。下文进阶语法-命名窗口中会进行详细解析。
PARTITION BY
用于将记录划分为不同的分区,窗口函数在每个分区上分别执行。可以使用多个字段进行分区。
ORDER BY
用于将每个分区中的记录进行排序,窗口函数将按照排序后的顺序进行计算。可使用多个字段进行排序,默认ASC(升序)。
FRAME_CLAUSE
框架子句,用于指定每个分区的数据范围。下文进进阶语法-框架中会进行详细解析
进阶语法
命名窗口WINDOW AS
有时候多次重复定义或引用相同的窗口,很繁琐。为了避免重复定义,减少语句的繁琐,可以在WINDOW子句中定义并命名窗口,并在OVER中通过窗口名进行引用。一次定义,多次引用。
语法定义如下:
SELECT WINDOW_FUNCTION() OVER `窗口名`|(`窗口名` ...)
FROM `表名`
WINDOW `窗口名` AS ()
说明:
- WINDOW子句中的括号内的部分就是原OVER子句后的窗口定义。使用OVER关键字调用窗口时,可直接引用窗口名,或者可对窗口进行进一步的加工,使用括号括起来并在其中使用其他的窗口规范。
- 一个命名窗口的定义本身也可以以一个窗口名开头。这样可以实现窗口之间的引用,但不能形成循环。
示例:
-- 查询每个销售员最早和最晚的销售日期
SELECT DISTINCT `sale_id`,FIRST_VALUE(`sale_date`) OVER (w ORDER BY `sale_date` DESC) first_date_desc,FIRST_VALUE(`sale_date`) OVER w first_date_asc
FROM `salse`
WINDOW w AS (PARTITION BY `sale_id`);
-- 定义了三个命名窗口,其中第二个和第三个都引用了第一个,这样每个窗口就可以根据不同的范围来计算累计和
SELECT sale_amount,SUM(`sale_amount`) OVER w1 AS sum_amount1,SUM(`sale_amount`) OVER w2 AS sum_amount2,SUM(`sale_amount`) OVER w3 AS sum_amount3
FROM `salse`
WINDOW w1 AS (ORDER BY `sale_amount`),w2 AS (w1 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),w3 AS (w1 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);
框架FRAME_CLAUSE
框架是窗口的一个子集,通过框架子句可以显式定义框架的范围,指定每个分区内应用窗口函数的一小部分数据的区域。可根据当前行动态的变化,或者选定一个静态的范围。
frame_clause由两个子句组成,语法定义如下:
frame_units frame_extent
解析
frame_units(框架单位)指定框架使用的单位,有两种:
-
ROWS:基于行数,通过起始行和结束行来划定框架的范围,边界是明确的一行。
-
RANGE:基于值的大小,通过具有相同值的行来划定框架的范围,边界是一个范围,具有相同值的行作为一个整体看待。
frame_extend(框架范围)指定窗口范围的起始位置和结束位置,可通过BETWEEN frame_start AND frame_end子句定义,frame_start和frame_end有以下几种形式:
- CURRENT ROW:当前行
- UNBOUNDED PRECEDING:分区中第一行
- UNBOUNDED FOLLOWING:分区中最后一行
- expr PRECEDING:当框架单位是ROWS时, 边界是当前行的前expr行。当框架单位是RANGE时,边界是值和"当前行的值减去expr"相等的行,可能有多个,统一视为一个边界。
- expr FOLLOWING:当框架单位是ROWS时, 边界是当前行的后expr行。当框架单位是RANGE时,边界是值和"当前行的值加上expr"相等的行,可能有多个,统一视为一个边界。
以下为框架范围图示:
注意:
- 含有ORDER BY子句,框架从组内第一行到当前行
- 不含ORDER BY子句,框架从组内第1行到最后一行(组内所有行)
- 如果没有指定frame_clause,则默认为RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,即从分区开始到与当前行值相同的行。
- 如果没有定义框架的终点,则默认终点为CURRENT ROW当前行
示例:
SELECT `sale_id`, `sale_amount`,SUM(`sale_amount`) OVER (PARTITION BY `sale_id` ORDER BY `sale_amount` ROWS 1 PRECEDING) AS s1,SUM(`sale_amount`) OVER (PARTITION BY `sale_id` ORDER BY `sale_amount` RANGE 1 PRECEDING) AS s2,SUM(`sale_amount`) OVER (PARTITION BY `sale_id` ORDER BY `sale_amount` RANGE 100 PRECEDING) AS s3
FROM `sales`;
结果如下:
以上示例中:
- s1框架单位为ROWS,框架范围为1 PRECEDING,起点边界为当前行的前一行,终点默认为当前行。
- s2框架范围为RANGE,框架范围为1 PRECEDING,起点边界为“当前行的值减去1”,终点默认为当前行。但是值差距都比1大,起点都无法触及之前的行,所以每行的框架都只包含当前行。
- s3框架范围为RANGE,框架范围为100 PRECEDING,起点边界为“当前行的值减去100”,终点默认为当前行,即取每个分区中值的范围在区间[当前行值-100,当前行值]内的所有行。
- sale_id为1的分组中,第2行的sale_amount为1000,因此框架包含值在[1000-100, 1000]范围内的所有行,即第1,2行,sum求和结果为2000。其他分组同理。
- sale_id为1的分组中,第3行的sale_amount为1200,因此框架包含值在[1200-100, 1200]范围内的所有行,只有第3行,则sum求和结果为其本身的值。
三、窗口函数
聚合函数在窗口中使用与分组中相同,不做详细解析。以下介绍的专用窗口函数必须搭配OVER关键字使用。
ROW_NUMBER
无参数,返回当前行在分组内的序号,从1开始,每一行都有唯一的序号。
如果无ORDER BY子句,行的编号不确定。
RANK
无参数,返回当前行在分组内的序号,排序带间隙(序号不连续),值相同,序号相同。
可使用ORDER BY子句指定按某列进行排序。如无ORDER BY子句,则所有行的序号都为1。
DENSE_RANK
无参数,返回当前行在分组内的排序,排序不带间隙(序号连续),值相同,序号相同。
示例:
SELECT `sale_id`, `sale_amount`,ROW_NUMBER() OVER (PARTITION BY `sale_id` ORDER BY `sale_amount`) AS row_number排序,RANK() OVER (PARTITION BY `sale_id` ORDER BY `sale_amount`) AS rank排序,DENSE_RANK() OVER (PARTITION BY `sale_id` ORDER BY `sale_amount`) AS dense_rank排序
FROM `sales`;
结果如下:
PERCENT_RANK
百分比排序,返回当前行在分组内的百分比位置,返回值范围为[0, 1],第1行的百分比位置是0%。
计算公式:(rank-1)/(rows-1),rank为RANK函数返回的序号,rows为组内总行数。
示例:
SELECT `sale_id`,`sale_amount`,RANK() OVER (PARTITION BY `sale_id` ORDER BY `sale_amount`) rank排序PERCENT_RANK() OVER (PARTITION BY `sale_id` ORDER BY `sale_amount`) percent_rank排序
FROM `sales`;
结果如下:
以上示例中:sale_id为1的分组中,第3行RANK排序为3,组内总行数为3,所以PRECENT_RANK为(3-1)/(3-1)=1。其他同理。
CUME_DIST
累积分布(cumulative distribution),返回分组中值的累积分布,数据分布从0到1。
计算公式:(值小于等于当前行的行数)/分组内总行数。
示例:
SELECT`sale_id`,`sale_amount`,CUME_DIST() OVER (PARTITION BY `sale_id` ORDER BY `sale_amount`) 累积分布
FROM `sales`;
结果如下:
以上示例中:
- sale_id为1的分组中,小于等于第1行值的行为第1、2行,所以2/3=0.666…。第2行同理。
- sale_id为2的分组中,小于等于第4行值的行只有第4行,所以1/2=0.5。而小于等于第5行的值的行有第4、5行,所以2/2=1。其他分组同理。
FIRST_VALUE与LAST_VALUE
FIRST_VALUE(offset):返回当前框架对应参数第一行的值,参数expr为要检索的列或表达式,根据排序定义不同而变化。
LAST_VALUE(offset):返回当前框架对应参数最后一行的值,参数expr为要检索的列或表达式,根据排序定义不同而变化。
NTH_VALUE
NTH_VALUE(offset, N):返回当前框架第N行对应参数的值,没有时返回NULL。参数expr为要检索的列或表达式,参数offset为第几行。
用得较少,可以根据以上示例自己尝试练习。
LAG与LEAD
LAG(expr [, offset [, default]]):返回当前框架内当前行之前的某一行的值。参数expr为要检索的列或表达式,参数offset为要返回的行数,如果不指定,默认为1,即上一行。参数default为当指定的行数超出结果集范围时,返回的默认值。
LEAD(expr [, offset [, default]]):返回当前框架内当前行之后的某一行的值。参数expr为要检索的列或表达式,参数offset为要返回的行数,如果不指定,默认为1,即下一行。参数default为当指定的行数超出结果集范围时,返回的默认值。
示例1:
SELECT`sale_id`,`sale_amount`,LAG(`sale_amount`*2) OVER w 当前行前1行乘2,LAG(`sale_amount`,1,'不存在') OVER w 当前行前1行带默认值,`sale_amount`-LAG(`sale_amount`,2) OVER w 当前值与前2行的差
FROM `sales`WINDOW w AS (PARTITION BY `sale_id` ORDER BY `sale_amount`);
结果如下:
示例2:
SELECT`sale_id`,`sale_amount`,LEAD(`sale_amount`*2) OVER w 当前行后1行乘2,LEAD(`sale_amount`,1,'不存在') OVER w 当前行后1行带默认值,`sale_amount`-LEAD(`sale_amount`,1) OVER w 当前值与后1行的差
FROM `sales`WINDOW w AS (PARTITION BY `sale_id` ORDER BY `sale_amount`);
结果如下:
NTILE
NTILE(N):分区内有序数据根据不同等级分为N个桶,记录桶编号。参数为子分区数量。
用得较少,可以根据以上示例自己尝试练习。
四、窗口函数使用事项
- 窗口函数可以在不改变原表行数的情况下,对每个分区内的查询行进行聚合、排序、排名等操作,提高了数据分析的灵活性和效率。
- 窗口函数可以使用滑动窗口来处理动态的数据范围。
- 窗口函数可以与普通聚合函数、子查询等结合使用,实现更复杂的查询逻辑。
- 窗口函数的语法较为复杂,需要注意OVER子句中的各个参数的含义和作用。
- 窗口函数的执行效率可能不如普通聚合函数,因为它需要对每个分区内的每个查询行进行计算,而不是折叠为单个输出行。但一般来说,窗口函数的性能优于使用子查询或连接的方法,因为窗口函数只需要扫描一次数据,而子查询或连接可能需要多次扫描或连接。
- 窗口函数只能在SELECT列表和ORDER BY子句中使用,不能用于WHERE、GROUP BY、HAVING等子句中。
五、窗口函数优化方法
- 选择合适的窗口函数,避免使用复杂或重复的窗口函数。
- 使用Named Windows来定义和引用窗口,避免在多个OVER子句中重复定义相同的窗口。
- 尽量减少分区和排序的代价,使用索引或物化视图来加速分区和排序。
- 尽量减少窗口的大小,使用合适的frame_clause来限制窗口内的数据范围。
- 尽量使用并行处理来加速窗口函数的计算,利用多核或分布式系统来提高效率。
六、面试常问(持续更新)
1.序号函数:row_number(),rank(),dense_rank()的区别
ROW_NUMBER,顺序排序——1、2、3
RANK,并列排序,跳过重复序号——1、1、3
DENSE_RANK,并列排序,不跳过重复序号——1、1、2
2.求连续登录3天的用户
解题思路:
①同一用户不可能在同一时间登录,不可能出现相同的登录时间,所以可以使用窗口函数根据用户进行分组,再根据登录时间进行排序,窗口函数可使用任一排序函数。
②如果连续登录,那么登录时间减去序号所获得的日期相同。
③统计获得的相同日期的个数,等于3的即为所求
解题过程(使用以上数据集):
根据sale_id分区,根据sale_date排序,DATA_SUB函数在sale_date日期上减去序号的天数获得data_flag。
SELECT `sale_id`,`sale_date`,`rn`,DATE_SUB(`sale_date`, INTERVAL `rn` DAY) AS `date_flag`
FROM (SELECT *,ROW_NUMBER() OVER (PARTITION BY `sale_id` ORDER BY `sale_date` ASC) AS `rn`FROM `sales`
) tmp
最终答案(使用以上数据集):
根据sale_id分区排序后,分组统计登录时间减去序号获得的相同日期的个数,等于3即为连续登录3天的用户。
SELECT `sale_id`,COUNT(*) AS`days_count`
FROM (SELECT *,ROW_NUMBER() OVER (PARTITION BY `sale_id` ORDER BY `sale_date` ASC) AS `rn`FROM `sales`) tmp
GROUP BY `sale_id`, DATE_SUB(`sale_date`, INTERVAL `rn` DAY)
HAVING days_count=3
如有问题和建议,可私信或评论,非常感谢。