【分析函数】一文遍识开窗函数

引言


数据库中的窗口函数也叫分析函数,顾名思义,窗口函数可用于一些复杂的统计分析计算,另外,窗口函数还具有优越的性能表现,可以节约时间和资源,因此窗口函数经常用于数据仓库和大型报表应用中。

为了先对窗口函数有个大概了解,以诸位sqlboy比较熟悉的聚合函数(count、sum等)作为对比,聚合函数为每个分组返回一行数据,但窗口函数可以为每个分组返回多行数据,不仅如此,分析函数可以通过物理间隔或逻辑间隔来框定用于计算的窗口范围,可以实现比聚合函数复杂得多的计算,比如可以跨行引用值、生成多层聚集以及对数据进行更细粒度的操作。


窗口函数的结构


窗口函数由四部分组成,分别是分析函数名、分区子句、排序子句和开窗子句,语法结构为:

FUNCTION_NAME(argument1,argument2 ...) OVER([Partition-By-Clause] [Order-By-Clause] [Windowing-Clause])

对于开窗子句 [Windowing-Clause] ,进一步展开如下

[Rows | Range] BETWEEN AND

其中, 有如下几种关键字形式

[UNBOUNDED PRECEDING |CURRENT ROW |n PRECEDING |n FOLLOWING]

有如下几种关键字形式

[UNBOUNDED FOLLOWING |CURRENT ROW |n PRECEDING |n FOLLOWING]

语法注解:

  • FUNCTION_NAME:分析函数名称,Oracle中目前有30个左右的分析函数

    • argument:分析函数的参数,依具体函数而定,参数可以是字段名或表达式
  • OVER:标识分析函数的关键字,函数名后面跟上OVER表示这是一个窗口函数

    • Partition-By-Clause:分区子句,类似聚合函数的group by,数据按Partition by定义的分区列来分区(组),所有分区列相同的数据行会被分到同一个分区,分区的数据会按分区列进行排序。分区子句是可选的,如果没有显示指定,则默认将所有数据行作为一个单一的大区。
    • Order-By-Clause:排序子句,可选。排序子句按给定的排序列来对分组的数据行进行排序,每个排序字段后面可以指定升序(ASC)或降序(DESC)。对于存在空值null的排序列,可以使用NULLS FIRST将空值排到最上面或使用NULLS LAST将空值排到最下面。
    • Windowing-Clause:窗口子句划定分析函数进行计算时的数据子集,这个数据子集对应的窗口可以是动态的滑动窗口,滑动窗口的上下边界依排序后的分区数据集且通过 [Rows | Range] 配合 / 的几种关键字指定,其中Rows通过与当前行数的比较来指定物理窗口范围,Range通过与当前行值的比较来指定逻辑窗口范围。如果不显示指定窗口子句,默认为 Rows Between Unbounded Preceding and Current Row 。注意不是所有分析函数都支持窗口子句。

窗口函数的使用


下面以实例来讲解分析函数的使用,使用的数据库环境为Oracle。在此之前,先造一些客户消费情况的测试数据

--建表
create table test1(
  CUS_NO varchar2(10),    --客户编号
  AGE int,                --年龄
  TRAN_MONTH varchar2(6), --交易月份
  TRAN_DATE date,         --交易日期
  TRAN_AMT numeric(20,2)  --交易金额
);
--插入测试数据
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201910',to_date('20191012', 'YYYYMMDD'),880.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201910',to_date('20191013', 'YYYYMMDD'),69.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201910',to_date('20191014', 'YYYYMMDD'),128.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201910',to_date('20191015', 'YYYYMMDD'),12.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201910',to_date('20191016', 'YYYYMMDD'),99.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201910',to_date('20191018', 'YYYYMMDD'),199.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201910',to_date('20191020', 'YYYYMMDD'),28.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101028',28,'201911',to_date('20191101', 'YYYYMMDD'),39.00);commit;

insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201910',to_date('20191012', 'YYYYMMDD'),33.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201910',to_date('20191013', 'YYYYMMDD'),28.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201910',to_date('20191014', 'YYYYMMDD'),120.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201910',to_date('20191015', 'YYYYMMDD'),230.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201910',to_date('20191016', 'YYYYMMDD'),129.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201911',to_date('20191102', 'YYYYMMDD'),321.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201911',to_date('20191103', 'YYYYMMDD'),25.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201911',to_date('20191104', 'YYYYMMDD'),89.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101029',null,'201911',to_date('20191105', 'YYYYMMDD'),60.00);commit;

insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101030',35,'201910',to_date('20191015', 'YYYYMMDD'),260.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101030',35,'201910',to_date('20191016', 'YYYYMMDD'),320.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101030',35,'201910',to_date('20191016', 'YYYYMMDD'),180.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101030',35,'201910',to_date('20191017', 'YYYYMMDD'),100.00);commit;
insert into test1(CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT) values('cus_101030',35,'201910',to_date('20191018', 'YYYYMMDD'),40.00);commit;

测试数据如下图所示


【分析函数】一文遍识开窗函数_第1张图片


分区子句的使用

需求:统计每个客户的消费总额

说明:要统计每个客户的消费总额,通过聚合函数sum即可解决,但得到的是每个客户一行结果,使用窗口函数则可以在保留原来数据行的基础上增加一列统计列。

--统计每个客户的消费总额
select CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT
      ,sum(TRAN_AMT) over(partition by CUS_NO) as total_amt --每个客户的消费总额
      ,sum(TRAN_AMT) over(partition by CUS_NO,TRAN_MONTH) as month_total_amt --每个客户每个月的消费总额
from test1
;

上面代码中,使用分区子句 partition by CUS_NO 将数据集按每个不同的客户编号来分组,然后汇总交易金额即可得到每个客户的消费总额。由于没有指定排序子句和窗口子句,所以仅会使用分区列(CUS_NO)作为排序字段应用到默认的窗口子句 Rows Between Unbounded Preceding and Current Row 中,默认的窗口子句表示滑动窗口范围为从第一行到当前行,此处分区列只有CUS_NO,即每个分区里的CUS_NO都一样,因此对每一行来说,滑动窗口都是整个分区;

使用分区子句 partition by CUS_NO,TRAN_MONTH 将数据集按每个不同的客户编号+交易月份来分组,然后汇总交易金额即可得到每个客户每个月的消费总额。


【分析函数】一文遍识开窗函数_第2张图片


排序子句的使用

需求:统计每个客户逐日累计的消费金额

说明:统计每个客户从第一个消费日开始逐日累计的消费金额,比如某个客户在2019-10-15这一天第一次消费,则这个客户到这一天的累计消费金额就是2019-10-15这一天的消费金额,如果这个客户在2019-10-16又消费了一笔,则这个客户到2019-10-16这一天的累计消费就是2019-10-15的消费金额加上2019-10-16的消费金额,以此类推。

--统计每个客户逐日累计的消费金额
select CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT
      ,sum(TRAN_AMT) over(partition by CUS_NO order by TRAN_DATE) as total_amt --统计每个客户逐日累计的消费金额
from test1
;

上面代码中,在排序子句 order by TRAN_DATE 中,按交易日期正序排序,由于没有显示指定窗口子句,因此使用默认的窗口子句 Rows Between Unbounded Preceding and Current Row ,这个窗口子句配合正序排序的交易日期,表示滑动窗口范围为每个分区中第一行的日期到当前行的日期对应的数据子集,个中区别可以由下图中红框处两笔相同日期的交易统计看出。


【分析函数】一文遍识开窗函数_第3张图片


窗口子句的使用

需求:统计每个客户每个交易日期及其前后一天的消费总额

说明:比如某个客户在2019-10-15、2019-10-16、2019-10-17几天有消费,对于这个客户在2019-10-15这一天,统计的消费金额包括当天以及前一天和后一天的的加总,即2019-10-15+2019-10-16的消费金额(因为2019-10-15是头一次消费,所以没有前一天);对于这个客户在2019-10-16这一天,统计的消费金额包括当天以及前一天和后一天的的加总,即2019-10-15+2019-10-16+2019-10-17的消费金额,其他的以此类推。

要实现这个需求,需要用到窗口子句,由于客户的消费日期并不总是连续的,所以需要使用窗口子句中的 Range 配合 n PRECEDINGn FOLLOWING 来实现。

--统计每个客户每个交易日期及其前后一天的消费总额
select CUS_NO,AGE,TRAN_MONTH,TRAN_DATE,TRAN_AMT
      ,sum(TRAN_AMT) over(partition by CUS_NO order by TRAN_DATE range between 1 preceding and 1 following ) as range_total_amt --统计每个客户每个交易日期及其前后一天的消费总额
      ,sum(TRAN_AMT) over(partition by CUS_NO order by TRAN_DATE rows between 1 preceding and 1 following ) as rows_total_amt --rows作为对比
from test1
;

上面代码中,在窗口子句中使用 range between 1 preceding and 1 following 表示滑动窗口为当前行的日期以及这个日期减去1天和加上1天对应的这个日期范围内的消费金额加总。从下图红框的交易日期2019-10-16对应的range_total_amt结果可以看出,因为2019-10-16前后没有连续的交易日期,因此这一天的前后1天都没有数据,使用range计算出来的就只是这一天的消费金额。

但如果窗口子句中使用的是 Rows ,则表示滑动窗口为当前行的日期以及这个日期上面一行的容器和下面一行的日期的消费金额加总,这里展现出的窗口子句中的range和rows的区别,就是上面说的 Rows通过与当前行数的比较来指定物理窗口范围,Range通过与当前行值的比较来指定逻辑窗口范围 这个区别。


【分析函数】一文遍识开窗函数_第4张图片


常用的分析函数

函数名 描述 是否支持窗口子句
sum 统计给定分区与窗口范围的数据的和 支持
count 统计给定分区与窗口范围的数据的条数,可以配合distinct一起使用,但要注意有些数据库不支持distinct的使用 支持
avg 统计给定分区与窗口范围的数据的均值 支持
max 统计给定分区与窗口范围的数据的最大值 支持
min 统计给定分区与窗口范围的数据的最小值 支持
lag 访问一个分区或结果集中之前的n行,n可由函数的参数指定 不支持
lead 访问一个分区或结果集中之后的n行,n可由函数的参数指定 不支持
first_value 访问一个分区或结果集中的第一行 支持
last_value 访问一个分区或结果集中的最后一行 支持
nth_value 访问一个分区或结果集中的任意一行 支持
row_number 对行进行排序并为每一行增加一个唯一编号 不支持
rank 将数据行值按照排序后的顺序进行排名,在有并列的情况下排名值将被跳过 不支持
dense_rank 将数据行值按照排序后的顺序进行排名,在有并列的情况下也不跳过排名值 不支持
listagg 将来自多行的列值以给定分隔符转化为一行的列表形式 不支持

count、avg、min、max几个函数的开窗用法与sum一样;row_number、rank、dense_rank较为简单,在下面一些示例中都会用到,按下不表。


lag与lead函数

lag与lead函数都可以实现跨行引用,语法如下:

lag( col [,n1] [,n2] ) over( [partition-by-clause] order-by-clause )
lead( col [,n1] [,n2] ) over( [partition-by-clause] order-by-clause )

lag与lead函数可以传入三个参数,lag可以返回按排序子句排序后指定列的前n1行的值(如果不指定n1,则默认为1);lead可以返回按排序子句排序后指定列的后n1行的值。如果不存在可以指定值n2,否则默认为空值null。lag与lead函数中排序子句是必要的。

lag函数常用于同比、环比的计算。

为了演示lag与lead的用法,造一些客户月度消费数据。

--建表
create table test2(
  CUS_NO varchar2(10),    --客户编号
  TRAN_MONTH varchar2(6), --交易月份
  TRAN_AMT numeric(20,2)  --交易金额
);

--插入测试数据
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101028','201910',1415.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101028','201911',39.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101028','201912',580.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101028','202010',915.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101028','202011',1200.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101028','202012',800.00);commit;

insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101029','201910',540.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101029','201911',495.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101029','201912',360.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101029','202001',990.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101029','202011',190.00);commit;

insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101030','201910',990.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101030','201911',330.00);commit;
insert into test2(CUS_NO,TRAN_MONTH,TRAN_AMT) values('cus_101030','202001',560.00);commit;

测试数据如下图所示


【分析函数】一文遍识开窗函数_第5张图片


比如想统计每个客户月度消费的环比增长率,公式为 月度消费环比增长率 =(当月消费金额-上月消费金额)/上月消费金额 ×100%

客户月度消费的同比增长率,公式为 月度消费同比增长率 =(当月消费金额-去年同期消费金额)/去年同期消费金额 ×100%

为了计算环比、同比增长率,需要获取每个月对应的上个月、去年同期的消费金额。

--上面造的客户月度消费数据,有一些月份缺失,直接使用lag或lead会有问题(因为lag和lead是按行数引用的),需要补齐缺失的月份,对于缺失的月份,消费金额置为空null。利用下面的脚本来补齐每个客户的消费月份。
create table test3 as
select coalesce(a.CUS_NO,b.CUS_NO) as CUS_NO,coalesce(a.TRAN_MONTH,b.TRAN_MONTH) as TRAN_MONTH,a.TRAN_AMT
from test2 a
full join (
    select distinct b.CUS_NO,to_char(a.date_list,'YYYYMM') as TRAN_MONTH
    from (
        select to_date('20191001', 'YYYYMMDD') + rownum - 1 as date_list
        from dual
        connect by rownum<=(to_date('20201201', 'YYYYMMDD')-to_date('20191001', 'YYYYMMDD'))+1
    ) a ,(select distinct CUS_NO from test2) b
) b on a.CUS_NO=b.CUS_NO and a.TRAN_MONTH=b.TRAN_MONTH
;

select * from test3 order by CUS_NO,TRAN_MONTH;

补齐月份后的数据如下图所示


【分析函数】一文遍识开窗函数_第6张图片


下面来计算客户月度消费的环比、同比增长率

select CUS_NO,TRAN_MONTH,TRAN_AMT
      ,lag(TRAN_AMT,1) over(partition by CUS_NO order by TRAN_MONTH) as TRAN_AMT_LAST1_MONTH --上个月
      ,lag(TRAN_AMT,12) over(partition by CUS_NO order by TRAN_MONTH) as TRAN_AMT_LAST12_MONTH --上一年同月
      ,lead(TRAN_AMT,1) over(partition by CUS_NO order by TRAN_MONTH desc) as TRAN_AMT_LAST1_MONTH2 --上个月
      ,lead(TRAN_AMT,12) over(partition by CUS_NO order by TRAN_MONTH desc) as TRAN_AMT_LAST12_MONTH2 --上一年同月
from test3
;

上面代码中,lag(TRAN_AMT,1) 表示在按客户编号分组并且按交易月份排序后,取当前行往前第1行,即当前月份的上月,如果往前第1行没有数据,则会置为空。lag(TRAN_AMT,12) 表示取当前行往前第12行,即当前月份的去年同月。lead的语用法和lag相反,上面代码排序子句中将TRAN_MONTH倒序排序后,lead取得的结果和lag一致。


first_value、last_value与nth_value函数

first_value和last_value函数常用在计算排过序的结果集中的第一行和最后一行数据,或者说是最大值和最小值(依排序而定);而nth_value则可以获取任意行的数据。

如果配合开窗子句一起使用,可以为这些函数的运算定义动态滑动窗口,这个窗口可以定义为包含几个之前或之后的数据行或者是包含数据分区中的所有行,然后在这些行里取第一行或最后一行或任意行数据,实现更复杂灵活的需求。

函数语法如下,他们都支持使用窗口子句:

first_value( col ) over( partition-by-clause order-by-clause windowing-clause )

last_value( col ) over( partition-by-clause order-by-clause windowing-clause )

nth_value( col ,n ) [ FROM FIRST | FROM LAST ] [ RESPECT NOLLS | IGNORE NOLLS ] over( partition-by-clause order-by-clause windowing-clause )


需求:统计每个客户在所有消费月份中的最大消费金额与最小消费金额

说明:统计每个客户在过往所有消费月份中,消费金额最大月份的消费金额与消费金额最小月份的消费金额

--统计每个客户在所有消费月份中的最大消费金额与最小消费金额
select CUS_NO,TRAN_MONTH,TRAN_AMT
      ,first_value(TRAN_AMT) over(partition by CUS_NO order by TRAN_AMT rows between unbounded preceding and unbounded following ) as TRAN_AMT_MIN_MONTH --最小消费金额
      ,last_value(TRAN_AMT) over(partition by CUS_NO order by TRAN_AMT rows between unbounded preceding and unbounded following ) as TRAN_AMT_MAX_MONTH --最大消费金额
      ,nth_value(TRAN_AMT,2) over(partition by CUS_NO order by TRAN_AMT rows between unbounded preceding and unbounded following ) as TRAN_AMT_MIN2_MONTH --第2小的消费金额
from test2
;

注意上面代码中指定的窗口子句 rows between unbounded preceding and unbounded following 表示滑动窗口范围是整个分区,否则默认的滑动窗口将是分区中第一行到当前行。

基于上面这个需求,可以扩展一下,比如统计每个客户在当前月以及前两个月这三个月中的最大消费金额与最小消费金额

--统计每个客户在当前月以及前两个月这三个月中的最大消费金额与最小消费金额
select CUS_NO,TRAN_MONTH,TRAN_AMT
      ,min(TRAN_AMT) over(partition by CUS_NO order by month_num rows between 2 preceding and 0 following ) as TRAN_AMT_MIN_RECENT3_MONTH
      ,max(TRAN_AMT) over(partition by CUS_NO order by month_num rows between 2 preceding and 0 following ) as TRAN_AMT_MAX_RECENT3_MONTH
from (
    select CUS_NO,TRAN_MONTH,TRAN_AMT
          ,row_number() over (partition by CUS_NO order by TRAN_MONTH) as month_num
    from test3
)
;

listagg函数

listagg函数的聚合与开窗用法具体可参考 listagg函数的聚合与开窗用法

你可能感兴趣的:(数据库,数据库,oracle)