團隊介紹
網易樂得DBA組,負責網易樂得電商、網易郵箱、網易技術部數據庫日常運維,負責數據庫私有雲平台的開發和維護,負責數據庫及數據庫中間件Cetus的開發和測試等等。
一、窗口函數的使用場景
作為IT人士,日常工作中經常會遇到類似這樣的需求:
醫院看病,怎樣知道上次就醫距現在的時間?環比如何計算?怎么樣得到各部門工資排名前N名員工列表?查找各部門每人工資占部門總工資的百分比?
對於這樣的需求,使用傳統的SQL實現起來比較困難。這類需求都有一個共同的特點,需要在單表中滿足某些條件的記錄集內部做一些函數操作,不是簡單的表連接,也不是簡單的聚合可以實現的,通常會讓寫SQL的同學焦頭爛額、絞盡腦汁,費了大半天時間寫出來一堆長長的晦澀難懂的自連接SQL,且性能低下,難以維護。
要解決此類問題,最方便的就是使用窗口函數。
二、MySQL窗口函數簡介
MySQL從8.0開始支持窗口函數,這個功能在大多商業數據庫和部分開源數據庫中早已支持,有的也叫分析函數。
什么叫窗口?
窗口的概念非常重要,它可以理解為記錄集合,窗口函數也就是在滿足某種條件的記錄集合上執行的特殊函數。對於每條記錄都要在此窗口內執行函數,有的函數隨着記錄不同,窗口大小都是固定的,這種屬於靜態窗口;有的函數則相反,不同的記錄對應着不同的窗口,這種動態變化的窗口叫滑動窗口。
窗口函數和普通聚合函數也很容易混淆,二者區別如下:
-
聚合函數是將多條記錄聚合為一條;而窗口函數是每條記錄都會執行,有幾條記錄執行完還是幾條。
-
聚合函數也可以用於窗口函數中,這個后面會舉例說明。
下面是一個窗口函數的簡單例子:

上面例子中,row_number()over(partition by user_no order by amount desc)這部分都屬於窗口函數,它的功能是顯示每個用戶按照訂單金額從大到小排序的序號。
按照功能划分,可以把MySQL支持的窗口函數分為如下幾類:
-
序號函數:row_number() / rank() / dense_rank()
-
分布函數:percent_rank() / cume_dist()
-
前后函數:lag() / lead()
-
頭尾函數:first_val() / last_val()
-
其他函數:nth_value() / nfile()
三、窗口函數如何使用
窗口函數的基本用法如下:
函數名([expr]) over子句
其中,over是關鍵字,用來指定函數執行的窗口范圍,如果后面括號中什么都不寫,則意味着窗口包含滿足where條件的所有行,窗口函數基於所有行進行計算;如果不為空,則支持以下四種語法來設置窗口:
-
window_name:給窗口指定一個別名,如果SQL中涉及的窗口較多,采用別名可以看起來更清晰易讀。上面例子中如果指定一個別名w,則改寫如下:
select * from
(
select row_number()over w as row_num,
order_id,user_no,amount,create_date
from order_tab
WINDOW w AS (partition by user_no order by amount desc)
)t ;
-
partition子句:窗口按照那些字段進行分組,窗口函數在不同的分組上分別執行。上面的例子就按照用戶id進行了分組。在每個用戶id上,按照order by的順序分別生成從1開始的順序編號。
-
order by子句:按照哪些字段進行排序,窗口函數將按照排序后的記錄順序進行編號。可以和partition子句配合使用,也可以單獨使用。上例中二者同時使用,如果沒有partition子句,則會按照所有用戶的訂單金額排序來生成序號。
-
frame子句:frame是當前分區的一個子集,子句用來定義子集的規則,通常用來作為滑動窗口使用。比如要根據每個訂單動態計算包括本訂單和按時間順序前后兩個訂單的平均訂單金額,則可以設置如下frame子句來創建滑動窗口:

從結果可以看出,order_id為5訂單屬於邊界值,沒有前一行,因此平均訂單金額為(900+800)/2=850;order_id為4的訂單前后都有訂單,所以平均訂單金額為(900+800+300)/3=666.6667,以此類推就可以得到一個基於滑動窗口的動態平均訂單值。此例中,窗口函數用到了傳統的聚合函數avg(),用來計算動態的平均值。
對於滑動窗口的范圍指定,有兩種方式,基於行和基於范圍,具體區別如下:
基於行:
通常使用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 FOLLOWING 窗口范圍是當前行到分區中的最后一行。
rows BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 窗口范圍是當前分區中所有行,等同於不寫。
基於范圍:
和基於行類似,但有些范圍不是直接可以用行數來表示的,比如希望窗口范圍是一周前的訂單開始,截止到當前行,則無法使用rows來直接表示,此時就可以使用范圍來表示窗口:INTERVAL 7 DAY PRECEDING。Linux中常見的最近1分鍾、5分鍾負載是一個典型的應用場景。
有的函數不管有沒有frame子句,它的窗口都是固定的,也就是前面介紹的靜態窗口,這些函數包括如下:
-
CUME_DIST()
-
DENSE_RANK()
-
LAG()
-
LEAD()
-
NTILE()
-
PERCENT_RANK()
-
RANK()
-
ROW_NUMBER()
接下來我們以上例的訂單表為例,來介紹每個函數的使用方法。表中各字段含義按順序分別為訂單號、用戶id、訂單金額、訂單創建日期。
四、序號函數
序號函數——row_number() / rank() / dense_rank()。
-
用途:顯示分區中的當前行號
-
使用場景:希望查詢每個用戶訂單金額最高的前三個訂單

此時可以使用ROW_NUMBER()函數按照用戶進行分組並按照訂單日期進行由大到小排序,最后查找每組中序號<=3的記錄。
對於用戶‘002’的訂單,大家發現訂單金額為800的有兩條,序號隨機排了1和2,但很多情況下二者應該是並列第一,而訂單為600的序號則可能是第二名,也可能為第三名,這時候,row_number就不能滿足需求,需要rank和dense_rank出場。
這兩個函數和row_number()非常類似,只是在出現重復值時處理邏輯有所不同。
上面例子我們稍微改一下,需要查詢不同用戶的訂單中,按照訂單金額進行排序,顯示出相應的排名序號,SQL中用row_number() / rank() / dense_rank()分別顯示序號,我們看一下有什么差別:

上面紅色粗體顯示了三個函數的區別,row_number()在amount都是800的兩條記錄上隨機排序,但序號按照1、2遞增,后面amount為600的的序號繼續遞增為3,中間不會產生序號間隙;rank()/dense_rank()則把amount為800的兩條記錄序號都設置為1,但后續amount為600的需要則分別設置為3(rank)和2(dense_rank)。即rank()會產生序號相同的記錄,同時可能產生序號間隙;而dense_rank()也會產生序號相同的記錄,但不會產生序號間隙。
五、分布函數
分布函數——percent_rank()/cume_dist()。
percent_rank()
-
用途:和之前的RANK()函數相關,每行按照如下公式進行計算:
(rank - 1) / (rows - 1)
其中,rank為RANK()函數產生的序號,rows為當前窗口的記錄總行數。
-
應用場景:沒想出來……感覺不太常用,看個例子吧↓

從結果看出,percent列按照公式(rank - 1) / (rows - 1)帶入rank值(row_num列)和rows值(user_no為‘001’和‘002’的值均為5)。
cume_dist()
-
用途:分組內小於等於當前rank值的行數/分組內總行數,這個函數比percen_rank使用場景更多。
-
應用場景:大於等於當前訂單金額的訂單比例有多少。
SQL如下:

列cume顯示了預期的數據分布結果。
六、前后函數
前后函數——lead(n)/lag(n)。
-
用途:分區中位於當前行前n行(lead)/后n行(lag)的記錄值。
-
使用場景:查詢上一個訂單距離當前訂單的時間間隔。
SQL如下:

內層SQL先通過lag函數得到上一次訂單的日期,外層SQL再將本次訂單和上次訂單日期做差得到時間間隔diff。
七、頭尾函數
頭尾函數——first_val(expr)/last_val(expr)。
-
用途:得到分區中的第一個/最后一個指定參數的值。
-
使用場景:查詢截止到當前訂單,按照日期排序第一個訂單和最后一個訂單的訂單金額。
SQL如下:

結果和預期一致,比如order_id為4的記錄,first_amount和last_amount分別記錄了用戶‘001’截止到時間2018-01-03 00:00:00為止,第一條訂單金額100和最后一條訂單金額800,注意這里是按時間排序的最早訂單和最晚訂單,並不是最小金額和最大金額訂單。
八、其他函數
其他函數——nth_value(expr,n)/nfile(n)。
nth_value(expr,n)
-
用途:返回窗口中第N個expr的值,expr可以是表達式,也可以是列名。
-
應用場景:每個用戶訂單中顯示本用戶金額排名第二和第三的訂單金額。
SQL如下:

nfile(n)
-
用途:將分區中的有序數據分為n個桶,記錄桶號。
-
應用場景:將每個用戶的訂單按照訂單金額分成3組。
SQL如下:

此函數在數據分析中應用較多,比如由於數據量大,需要將數據平均分配到N個並行的進程分別計算,此時就可以用NFILE(N)對數據進行分組,由於記錄數不一定被N整除,所以數據不一定完全平均,然后將不同桶號的數據再分配。
九、聚合函數作為窗口函數
-
用途:在窗口中每條記錄動態應用聚合函數(sum/avg/max/min/count),可以動態計算在指定的窗口內的各種聚合函數值。
-
應用場景:每個用戶按照訂單id,截止到當前的累計訂單金額/平均訂單金額/最大訂單金額/最小訂單金額/訂單數是多少?
SQL如下:

除了這幾個常用的聚合函數,還有一些也可以使用,比如BIT_AND()、STD()等等,具體查看官方文檔。
窗口函數非常有意思,對於一些使用常規思維無法實現的SQL需求,大家嘗試一下窗口函數吧,相信會有意想不到的收獲。
