Hive視窗函式保姆級教程

五分鐘學大資料發表於2021-06-15

在SQL中有一類函式叫做聚合函式,例如sum()、avg()、max()等等,這類函式可以將多行資料按照規則聚集為一行,一般來講聚集後的行數是要少於聚集前的行數的。但是有時我們想要既顯示聚集前的資料,又要顯示聚集後的資料,這時我們便引入了視窗函式。視窗函式又叫OLAP函式/分析函式,視窗函式兼具分組和排序功能。

本文分為兩部分:
第一部分是Hive視窗函式詳解,剖析各種視窗函式(幾乎涵蓋Hive所有的視窗函式);
第二部分是視窗函式實際應用,這部分總共有五個例子,都是工作常用、面試必問的非常經典的例子。

Hive 視窗函式

視窗函式最重要的關鍵字是 partition byorder by

具體語法如下:XXX over (partition by xxx order by xxx)

特別注意:over()裡面的 partition by 和 order by 都不是必選的,over()裡面可以只有partition by,也可以只有order by,也可以兩個都沒有,大家需根據需求靈活運用。

視窗函式我劃分了幾個大類,我們一類一類的講解。

1. SUM、AVG、MIN、MAX

講解這幾個視窗函式前,先建立一個表,以實際例子講解大家更容易理解。

首先建立使用者訪問頁面表:user_pv

create table user_pv(
cookieid string,  -- 使用者登入的cookie,即使用者標識
createtime string, -- 日期
pv int -- 頁面訪問量
); 

給上面這個表加上如下資料:

cookie1,2021-05-10,1
cookie1,2021-05-11,5
cookie1,2021-05-12,7
cookie1,2021-05-13,3
cookie1,2021-05-14,2
cookie1,2021-05-15,4
cookie1,2021-05-16,4

  • SUM()使用

執行如下查詢語句:

select cookieid,createtime,pv,
sum(pv) over(partition by cookieid order by createtime) as pv1 
from user_pv;

結果如下:(因命令列原因,下圖欄位名和值是錯位的,請注意辨別!)

執行如下查詢語句:

select cookieid,createtime,pv,
sum(pv) over(partition by cookieid ) as pv1 
from user_pv;

結果如下:

第一條SQL的over()裡面加 order by ,第二條SQL沒加order by ,結果差別很大

所以要注意了

  • over()裡面加 order by 表示:分組內從起點到當前行的pv累積,如,11號的pv1=10號的pv+11號的pv, 12號=10號+11號+12號;

  • over()裡面不加 order by 表示:將分組內所有值累加。

AVG,MIN,MAX,和SUM用法一樣,這裡就不展開講了,但是要注意 AVG,MIN,MAX 的over()裡面加不加 order by 也和SUM一樣,如 AVG 求平均值,如果加上 order by,表示分組內從起點到當前行的平局值,不是全部的平局值。MIN,MAX 同理。

2. ROW_NUMBER、RANK、DENSE_RANK、NTILE

還是用上述的使用者登入日誌表:user_pv,裡面的資料換成如下所示:

cookie1,2021-05-10,1
cookie1,2021-05-11,5
cookie1,2021-05-12,7
cookie1,2021-05-13,3
cookie1,2021-05-14,2
cookie1,2021-05-15,4
cookie1,2021-05-16,4
cookie2,2021-05-10,2
cookie2,2021-05-11,3
cookie2,2021-05-12,5
cookie2,2021-05-13,6
cookie2,2021-05-14,3
cookie2,2021-05-15,9
cookie2,2021-05-16,7

  • ROW_NUMBER()使用:

ROW_NUMBER()從1開始,按照順序,生成分組內記錄的序列

SELECT 
cookieid,
createtime,
pv,
ROW_NUMBER() OVER(PARTITION BY cookieid ORDER BY pv desc) AS rn 
FROM user_pv;

結果如下:


  • RANK 和 DENSE_RANK 使用:

RANK() 生成資料項在分組中的排名,排名相等會在名次中留下空位

DENSE_RANK()生成資料項在分組中的排名,排名相等會在名次中不會留下空位

SELECT 
cookieid,
createtime,
pv,
RANK() OVER(PARTITION BY cookieid ORDER BY pv desc) AS rn1,
DENSE_RANK() OVER(PARTITION BY cookieid ORDER BY pv desc) AS rn2,
ROW_NUMBER() OVER(PARTITION BY cookieid ORDER BY pv DESC) AS rn3 
FROM user_pv 
WHERE cookieid = 'cookie1';

結果如下:


  • NTILE的使用:

有時會有這樣的需求:如果資料排序後分為三部分,業務人員只關心其中的一部分,如何將這中間的三分之一資料拿出來呢?NTILE函式即可以滿足。

ntile可以看成是:把有序的資料集合平均分配到指定的數量(num)個桶中, 將桶號分配給每一行。如果不能平均分配,則優先分配較小編號的桶,並且各個桶中能放的行數最多相差1。

然後可以根據桶號,選取前或後 n分之幾的資料。資料會完整展示出來,只是給相應的資料打標籤;具體要取幾分之幾的資料,需要再巢狀一層根據標籤取出。

SELECT 
cookieid,
createtime,
pv,
NTILE(2) OVER(PARTITION BY cookieid ORDER BY createtime) AS rn1,
NTILE(3) OVER(PARTITION BY cookieid ORDER BY createtime) AS rn2,
NTILE(4) OVER(ORDER BY createtime) AS rn3
FROM user_pv 
ORDER BY cookieid,createtime;

結果如下:

3. LAG、LEAD、FIRST_VALUE、LAST_VALUE

講解這幾個視窗函式時還是以例項講解,首先建立使用者訪問頁面表:user_url

CREATE TABLE user_url (
cookieid string,
createtime string,  --頁面訪問時間
url string       --被訪問頁面
);

表中加入如下資料:

cookie1,2021-06-10 10:00:02,url2
cookie1,2021-06-10 10:00:00,url1
cookie1,2021-06-10 10:03:04,1url3
cookie1,2021-06-10 10:50:05,url6
cookie1,2021-06-10 11:00:00,url7
cookie1,2021-06-10 10:10:00,url4
cookie1,2021-06-10 10:50:01,url5
cookie2,2021-06-10 10:00:02,url22
cookie2,2021-06-10 10:00:00,url11
cookie2,2021-06-10 10:03:04,1url33
cookie2,2021-06-10 10:50:05,url66
cookie2,2021-06-10 11:00:00,url77
cookie2,2021-06-10 10:10:00,url44
cookie2,2021-06-10 10:50:01,url55

  • LAG的使用:

LAG(col,n,DEFAULT) 用於統計視窗內往上第n行值

第一個引數為列名,第二個引數為往上第n行(可選,預設為1),第三個引數為預設值(當往上第n行為NULL時候,取預設值,如不指定,則為NULL)

SELECT cookieid,
createtime,
url,
ROW_NUMBER() OVER(PARTITION BY cookieid ORDER BY createtime) AS rn,
LAG(createtime,1,'1970-01-01 00:00:00') OVER(PARTITION BY cookieid ORDER BY createtime) AS last_1_time,
LAG(createtime,2) OVER(PARTITION BY cookieid ORDER BY createtime) AS last_2_time 
FROM user_url;

結果如下:

解釋:

last_1_time: 指定了往上第1行的值,default為'1970-01-01 00:00:00'  
             			 cookie1第一行,往上1行為NULL,因此取預設值 1970-01-01 00:00:00
             			 cookie1第三行,往上1行值為第二行值,2021-06-10 10:00:02
             			 cookie1第六行,往上1行值為第五行值,2021-06-10 10:50:01
last_2_time: 指定了往上第2行的值,為指定預設值
						 cookie1第一行,往上2行為NULL
						 cookie1第二行,往上2行為NULL
						 cookie1第四行,往上2行為第二行值,2021-06-10 10:00:02
						 cookie1第七行,往上2行為第五行值,2021-06-10 10:50:01

  • LEAD的使用:

與LAG相反

LEAD(col,n,DEFAULT) 用於統計視窗內往下第n行值。

第一個引數為列名,第二個引數為往下第n行(可選,預設為1),第三個引數為預設值(當往下第n行為NULL時候,取預設值,如不指定,則為NULL)

SELECT cookieid,
createtime,
url,
ROW_NUMBER() OVER(PARTITION BY cookieid ORDER BY createtime) AS rn,
LEAD(createtime,1,'1970-01-01 00:00:00') OVER(PARTITION BY cookieid ORDER BY createtime) AS next_1_time,
LEAD(createtime,2) OVER(PARTITION BY cookieid ORDER BY createtime) AS next_2_time 
FROM user_url;

結果如下:


  • FIRST_VALUE的使用:

取分組內排序後,截止到當前行,第一個值

SELECT cookieid,
createtime,
url,
ROW_NUMBER() OVER(PARTITION BY cookieid ORDER BY createtime) AS rn,
FIRST_VALUE(url) OVER(PARTITION BY cookieid ORDER BY createtime) AS first1 
FROM user_url;

結果如下:


  • LAST_VALUE的使用:

取分組內排序後,截止到當前行,最後一個值

SELECT cookieid,
createtime,
url,
ROW_NUMBER() OVER(PARTITION BY cookieid ORDER BY createtime) AS rn,
LAST_VALUE(url) OVER(PARTITION BY cookieid ORDER BY createtime) AS last1 
FROM user_url;

結果如下:

如果想要取分組內排序後最後一個值,則需要變通一下:

SELECT cookieid,
createtime,
url,
ROW_NUMBER() OVER(PARTITION BY cookieid ORDER BY createtime) AS rn,
LAST_VALUE(url) OVER(PARTITION BY cookieid ORDER BY createtime) AS last1,
FIRST_VALUE(url) OVER(PARTITION BY cookieid ORDER BY createtime DESC) AS last2 
FROM user_url 
ORDER BY cookieid,createtime;

注意上述SQL,使用的是 FIRST_VALUE 的倒序取出分組內排序最後一個值!

結果如下:

此處要特別注意order by

如果不指定ORDER BY,則進行排序混亂,會出現錯誤的結果

SELECT cookieid,
createtime,
url,
FIRST_VALUE(url) OVER(PARTITION BY cookieid) AS first2  
FROM user_url;

結果如下:

上述 url2 和 url55 的createtime即不屬於最靠前的時間也不屬於最靠後的時間,所以結果是混亂的。

4. CUME_DIST

先建立一張員工薪水錶:staff_salary

CREATE EXTERNAL TABLE staff_salary (
dept string,
userid string,
sal int
);

表中加入如下資料:

d1,user1,1000
d1,user2,2000
d1,user3,3000
d2,user4,4000
d2,user5,5000

  • CUME_DIST的使用:

此函式的結果和order by的排序順序有關係。

CUME_DIST:小於等於當前值的行數/分組內總行數。 order預設順序
:正序

比如,統計小於等於當前薪水的人數,所佔總人數的比例。

SELECT 
dept,
userid,
sal,
CUME_DIST() OVER(ORDER BY sal) AS rn1,
CUME_DIST() OVER(PARTITION BY dept ORDER BY sal) AS rn2 
FROM staff_salary;

結果如下:

解釋:

rn1: 沒有partition,所有資料均為1組,總行數為5,
     第一行:小於等於1000的行數為1,因此,1/5=0.2
     第三行:小於等於3000的行數為3,因此,3/5=0.6
rn2: 按照部門分組,dpet=d1的行數為3,
     第二行:小於等於2000的行數為2,因此,2/3=0.6666666666666666

5. GROUPING SETS、GROUPING__ID、CUBE、ROLLUP

這幾個分析函式通常用於OLAP中,不能累加,而且需要根據不同維度上鑽和下鑽的指標統計,比如,分小時、天、月的UV數。

還是先建立一個使用者訪問表:user_date

CREATE TABLE user_date (
month STRING,
day STRING, 
cookieid STRING 
);

表中加入如下資料:

2021-03,2021-03-10,cookie1
2021-03,2021-03-10,cookie5
2021-03,2021-03-12,cookie7
2021-04,2021-04-12,cookie3
2021-04,2021-04-13,cookie2
2021-04,2021-04-13,cookie4
2021-04,2021-04-16,cookie4
2021-03,2021-03-10,cookie2
2021-03,2021-03-10,cookie3
2021-04,2021-04-12,cookie5
2021-04,2021-04-13,cookie6
2021-04,2021-04-15,cookie3
2021-04,2021-04-15,cookie2
2021-04,2021-04-16,cookie1

  • GROUPING SETS的使用:

grouping sets是一種將多個group by 邏輯寫在一個sql語句中的便利寫法。

等價於將不同維度的GROUP BY結果集進行UNION ALL。

SELECT 
month,
day,
COUNT(DISTINCT cookieid) AS uv,
GROUPING__ID 
FROM user_date 
GROUP BY month,day 
GROUPING SETS (month,day) 
ORDER BY GROUPING__ID;

注:上述SQL中的GROUPING__ID,是個關鍵字,表示結果屬於哪一個分組集合,根據grouping sets中的分組條件month,day,1是代表month,2是代表day。

結果如下:

上述SQL等價於:

SELECT month,
NULL as day,
COUNT(DISTINCT cookieid) AS uv,
1 AS GROUPING__ID 
FROM user_date 
GROUP BY month 

UNION ALL 

SELECT NULL as month,
day,
COUNT(DISTINCT cookieid) AS uv,
2 AS GROUPING__ID 
FROM user_date 
GROUP BY day;

  • CUBE的使用:

根據GROUP BY的維度的所有組合進行聚合。

SELECT 
month,
day,
COUNT(DISTINCT cookieid) AS uv,
GROUPING__ID 
FROM user_date 
GROUP BY month,day 
WITH CUBE 
ORDER BY GROUPING__ID;

結果如下:

上述SQL等價於:

SELECT NULL,NULL,COUNT(DISTINCT cookieid) AS uv,0 AS GROUPING__ID FROM user_date

UNION ALL 

SELECT month,NULL,COUNT(DISTINCT cookieid) AS uv,1 AS GROUPING__ID FROM user_date GROUP BY month 

UNION ALL 

SELECT NULL,day,COUNT(DISTINCT cookieid) AS uv,2 AS GROUPING__ID FROM user_date GROUP BY day

UNION ALL 

SELECT month,day,COUNT(DISTINCT cookieid) AS uv,3 AS GROUPING__ID FROM user_date GROUP BY month,day;

  • ROLLUP的使用:

是CUBE的子集,以最左側的維度為主,從該維度進行層級聚合。

比如,以month維度進行層級聚合:

SELECT 
month,
day,
COUNT(DISTINCT cookieid) AS uv,
GROUPING__ID  
FROM user_date 
GROUP BY month,day
WITH ROLLUP 
ORDER BY GROUPING__ID;

結果如下:

把month和day調換順序,則以day維度進行層級聚合:

SELECT 
day,
month,
COUNT(DISTINCT cookieid) AS uv,
GROUPING__ID  
FROM user_date 
GROUP BY day,month 
WITH ROLLUP 
ORDER BY GROUPING__ID;

結果如下:

這裡,根據日和月進行聚合,和根據日聚合結果一樣,因為有父子關係,如果是其他維度組合的話,就會不一樣。

視窗函式實際應用

1. 第二高的薪水

難度簡單。

編寫一個 SQL 查詢,獲取 Employee 表中第二高的薪水(Salary)。

+----+--------+
| Id | Salary |
+----+--------+
| 1  | 100    |
| 2  | 200    |
| 3  | 300    |
+----+--------+

例如上述 Employee 表,SQL查詢應該返回 200 作為第二高的薪水。如果不存在第二高的薪水,那麼查詢應返回 null。

+---------------------+
| SecondHighestSalary |
+---------------------+
| 200                 |
+---------------------+

這道題可以用 row_number 函式解決。

參考程式碼:

SELECT
  *
  FROM(
    SELECT Salary, row_number() over(order by Salary desc) rk 
    FROM Employee
  ) t WHERE t.rk = 2;

更簡單的程式碼:

SELECT DISTINCT Salary
FROM Employee
ORDER BY Salary DESC
LIMIT 1 OFFSET 1

OFFSET:偏移量,表示從第幾條資料開始取,0代表第1條資料。

2. 分數排名

難度簡單。

編寫一個 SQL 查詢來實現分數排名。

如果兩個分數相同,則兩個分數排名(Rank)相同。請注意,平分後的下一個名次應該是下一個連續的整數值。換句話說,名次之間不應該有“間隔”。

+----+-------+
| Id | Score |
+----+-------+
| 1  | 3.50  |
| 2  | 3.65  |
| 3  | 4.00  |
| 4  | 3.85  |
| 5  | 4.00  |
| 6  | 3.65  |
+----+-------+

例如,根據上述給定的 Scores 表,你的查詢應該返回(按分數從高到低排列):

+-------+------+
| Score | Rank |
+-------+------+
| 4.00  | 1    |
| 4.00  | 1    |
| 3.85  | 2    |
| 3.65  | 3    |
| 3.65  | 3    |
| 3.50  | 4    |
+-------+------+

參考程式碼:

SELECT Score,
dense_rank() over(order by Score desc) as `Rank`
FROM Scores;

3. 連續出現的數字

難度中等。

編寫一個 SQL 查詢,查詢所有至少連續出現三次的數字。

+----+-----+
| Id | Num |
+----+-----+
| 1  |  1  |
| 2  |  1  |
| 3  |  1  |
| 4  |  2  |
| 5  |  1  |
| 6  |  2  |
| 7  |  2  |
+----+-----+

例如,給定上面的 Logs 表, 1 是唯一連續出現至少三次的數字。

+-----------------+
| ConsecutiveNums |
+-----------------+
| 1               |
+-----------------+

參考程式碼:

SELECT DISTINCT `Num` as ConsecutiveNums
FROM
  (
    SELECT Num,
    lead(Num, 1, null) over(order by id) n2,
    lead(Num, 2, null) over(order by id) n3 
    FROM Logs
  ) t1
WHERE Num = n2 and Num = n3

4. 連續N天登入

難度困難。

寫一個 SQL 查詢, 找到活躍使用者的 id 和 name,活躍使用者是指那些至少連續 5 天登入賬戶的使用者,返回的結果表按照 id 排序。

表 Accounts:

+----+-----------+
| id | name      |
+----+-----------+
| 1  | Winston   |
| 7  | Jonathan  |
+----+-----------+

表 Logins:

+----+-------------+
| id | login_date  |
+----+-------------+
| 7  | 2020-05-30  |
| 1  | 2020-05-30  |
| 7  | 2020-05-31  |
| 7  | 2020-06-01  |
| 7  | 2020-06-02  |
| 7  | 2020-06-02  |
| 7  | 2020-06-03  |
| 1  | 2020-06-07  |
| 7  | 2020-06-10  |
+----+-------------+

例如,給定上面的Accounts和Logins表,至少連續 5 天登入賬戶的是id=7的使用者

+----+-----------+
| id | name      |
+----+-----------+
| 7  | Jonathan  |
+----+-----------+

思路:

  1. 去重:由於每個人可能一天可能不止登陸一次,需要去重
  2. 排序:對每個ID的登入日期排序
  3. 差值:計算登入日期與排序之間的差值,找到連續登陸的記錄
  4. 連續登入天數計算:select id, count(*) group by id, 差值(虛擬碼)
  5. 取出登入5天以上的記錄
  6. 通過表合併,取出id對應使用者名稱

參考程式碼:

SELECT DISTINCT b.id, name
FROM
  (SELECT id, login_date,
    DATE_SUB(login_date, ROW_NUMBER() OVER(PARTITION BY id ORDER BY login_date)) AS diff 
   FROM(SELECT DISTINCT id, login_date FROM Logins) a) b
INNER JOIN Accounts ac
ON b.id = ac.id
GROUP BY b.id, diff
HAVING COUNT(b.id) >= 5

注意點:

  1. DATE_SUB的應用:DATE_SUB (DATE, X),注意,X為正數表示當前日期的前X天;
  2. 如何找連續日期:通過排序與登入日期之間的差值,因為排序連續,因此若登入日期連續,則差值一致;
  3. GROUP BY和HAVING的應用:通過id和差值的GROUP BY,用COUNT找到連續天數大於5天的id,注意COUNT不是一定要出現在SELECT後,可以直接用在HAVING中

5. 給定數字的頻率查詢中位數

難度困難。

Numbers 表儲存數字的值及其頻率。

+----------+-------------+
|  Number  |  Frequency  |
+----------+-------------|
|  0       |  7          |
|  1       |  1          |
|  2       |  3          |
|  3       |  1          |
+----------+-------------+

在此表中,數字為 0, 0, 0, 0, 0, 0, 0, 1, 2, 2, 2, 3,所以中位數是 (0 + 0) / 2 = 0。

+--------+
| median |
+--------|
| 0.0000 |
+--------+

請編寫一個查詢來查詢所有數字的中位數並將結果命名為 median 。

參考程式碼:

select
avg(cast(number as float)) as median
from
  (
    select Number,
    Frequency,
    sum(Frequency) over(order by Number) - Frequency as prev_sum,
    sum(Frequency) over(order by Number) as curr_sum 
    from Numbers
  ) t1, (
    select sum(Frequency) as total_sum 
    from Numbers
  ) t2
where
t1.prev_sum <= (cast(t2.total_sum as float) / 2) 
and
t1.curr_sum >= (cast(t2.total_sum as float) / 2)

相關文章