DE Day 4 SQL 進階(二):排名、累計與移動平均
執行需求:CPU 可跑。昨天我們學會了 OVER() 與 PARTITION BY、ORDER BY、frame 子句這四個視窗函式的核心觀念。今天把排名函式(ROW_NUMBER()、RANK()、DENSE_RANK()、NTILE())與前後列取值(LAG()、LEAD())一次收進來,並接著昨天的主題,把累計與移動平均的實戰寫法拉成可以照抄的範本。這一篇做完,你就具備了商業分析 80% 的查詢能力。
引言
排名是分析報表裡最常見的需求之一:每位使用者前 3 名消費月份、各地區貢獻前 5 名業務員、產品類別前 10 大營收來源。這些問題用 ORDER BY ... LIMIT 看似可行,但每群都要重做一次;而排名函式讓我們可以在同一個 SELECT 裡標出每列在群內的位次,並能用 ROW_NUMBER() = 1 過濾掉「每群的第一列」。同樣地,LAG() 與 LEAD() 直接讀前一列或後一列,免去 self-join 的麻煩;移動平均與移動總和則靠昨天學的 frame 子句組合而成。
今天的目標有四個:第一,看懂 ROW_NUMBER()、RANK()、DENSE_RANK() 三者的差異,知道什麼情境該用哪個;第二,會用 LAG()、LEAD() 與 frame 子句計算「上個月到這個月的成長率」與「過去 3 個月的移動平均」;第三,會寫一個能整段執行的 Python + DuckDB 範例,從建立消費事實表開始,到同一個查詢同時輸出排名與累計;第四,學會用 QUALIFY 子句過濾視窗函式結果,這是 DuckDB 與新世代資料庫(Snowflake、BigQuery)的便利寫法。
三種排名的差異
排名函式有四個主要的函式,差別在「同分時」的處理。先把它們的行為用一張示意圖展現:假設四位選手成績分別是 95、90、90、85:
| 選手 | 分數 | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| A | 95 | 1 | 1 | 1 |
| B | 90 | 2 | 2 | 2 |
| C | 90 | 3 | 2 | 2 |
| D | 85 | 4 | 4 | 3 |
從這張表可以讀到三件事:第一,ROW_NUMBER() 不管分數是否相同都給連續編號(即使 B 與 C 同分,B 拿到 2、C 拿到 3);第二,RANK() 在同分時跳號(同分都是 2,下一個就是 4);第三,DENSE_RANK() 在同分時不跳號(同分都是 2,下一個就是 3)。實務上:要做「唯一排序」、要分頁、要拿第 N 筆,用 ROW_NUMBER();要呈現「同分同名次」的競賽榜,用 RANK();要做「層級篩選(如前 10%)」,用 DENSE_RANK() 比較直觀。把它記成口訣:「連號用 ROW、跳號用 RANK、不跳用 DENSE」。
完整實作:用 DuckDB 同時計算排名與累計
我們沿用 Day 3 的 demo_orders 消費事實表,加上每位使用者每月的排名,並把累計寫法與本月相對上個月的成長率一起帶進來:
"""DE Day 4:排名、累計與移動平均。"""
import duckdb
con = duckdb.connect()
con.execute("""
CREATE OR REPLACE TABLE demo_orders AS
SELECT * FROM (VALUES
('u01', '2025-09-01', 1200),
('u01', '2025-10-01', 800),
('u01', '2025-11-01', 1500),
('u02', '2025-09-15', 600),
('u02', '2025-10-20', 2200),
('u02', '2025-11-05', 1100),
('u03', '2025-09-10', 4500),
('u03', '2025-10-12', 300),
('u03', '2025-11-18', 900),
('u04', '2025-09-22', 1900),
('u04', '2025-10-30', 2400),
('u04', '2025-11-25', 1300)
) AS t(user_id, order_date, amount)
""")
print("資料筆數:", con.execute("SELECT COUNT(*) FROM demo_orders").fetchone()[0])
# 輸出:資料筆數:12
這段把昨天的資料集重建起來。我們故意不放索引,直接用 CREATE OR REPLACE TABLE 確保腳本可以重跑。Day 7 會示範在同個欄位加索引後,查詢計畫怎麼變。這份資料的人物只有 4 位、日期只有 3 個月,是討論排名與累計的最小模型。
接下來同個查詢內計算:每位使用者內、依金額排序的排名(三種版本:ROW_NUMBER、RANK、DENSE_RANK),以及本月相對上月金額的變化:
result = con.execute("""
SELECT
user_id,
order_date,
amount,
-- 在每位使用者內,依金額由大到小排名
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY amount DESC
) AS rn_by_amount,
RANK() OVER (
PARTITION BY user_id ORDER BY amount DESC
) AS rk_by_amount,
DENSE_RANK() OVER (
PARTITION BY user_id ORDER BY amount DESC
) AS drk_by_amount,
-- 在每位使用者內,依時間由早到晚計算累計
SUM(amount) OVER (
PARTITION BY user_id ORDER BY order_date
) AS running_total,
-- 取前一列的金額
LAG(amount) OVER (
PARTITION BY user_id ORDER BY order_date
) AS prev_amount,
-- 計算本期金額相對前一期金額的差異
amount - LAG(amount) OVER (
PARTITION BY user_id ORDER BY order_date
) AS diff_vs_prev
FROM demo_orders
ORDER BY user_id, order_date
""").fetchall()
for r in result:
print(f"user={r[0]} date={r[1]} amount={r[2]} rn={r[3]} rk={r[4]} drk={r[5]} rt={r[6]} prev={r[7]} diff={r[8]}")
# 輸出:user=u01 date=2025-09-01 amount=1200 rn=2 rk=2 drk=2 rt=1200 prev=None diff=None
# 輸出:user=u01 date=2025-10-01 amount=800 rn=3 rk=3 drk=3 rt=2000 prev=1200 diff=-400
# 輸出:user=u01 date=2025-11-01 amount=1500 rn=1 rk=1 drk=1 rt=3500 prev=800 diff=700
# 輸出:(依此類推)
這段一次展示六個視窗函式:我們有 rn_by_amount(ROW_NUMBER())、rk_by_amount(RANK())、drk_by_amount(DENSE_RANK())、running_total(依日期累計)、prev_amount(LAG(amount) 取前一列)、diff_vs_prev(本期減前期)。最後一個 diff_vs_prev 是把 LAG() 嵌入算術表達式,這是寫「與上一期差異」最簡潔的寫法。在 u01 的第一筆可以看到 prev=None 與 diff=None,因為 LAG() 在沒有前一期時會回 NULL。
注意 rn_by_amount、rk_by_amount、drk_by_amount 三者在這份資料裡看不出差異,因為每個使用者內的金額都不同。如果把 u01 的 800 元改成另一個 1200 元(與九月同分),ROW_NUMBER 給連號(2、3),RANK 同分同名(2、2,下一筆變 4),DENSE_RANK 同分同名(2、2,下一筆變 3)就會出現差異。建議你動手改一份輸入試試看,這是把排名函式學扎實的最快方式。
移動平均與 frame 子句組合技
移動平均(moving average)是把時間序列上「最近 N 個時間點」做平均,這在畫趨勢線與偵測異常時是基本款。我們把 frame 子句、累計函式、與排名函式混搭,做一份 u01 的「過去 2 期平均與全期排名」:
result = con.execute("""
SELECT
user_id,
order_date,
amount,
-- 過去 2 列 + 目前列的移動平均(包含本期)
AVG(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS ma_3,
-- 對稱視窗:過去 1 列 + 本期 + 未來 1 列,做移動平均
AVG(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
) AS ma_3_sym,
-- 過去 2 列的累計和
SUM(amount) OVER (
PARTITION BY user_id
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS sum_3
FROM demo_orders
WHERE user_id = 'u01'
ORDER BY order_date
""").fetchall()
for r in result:
print(f"date={r[1]} amount={r[2]} ma_3={r[3]:.2f} ma_3_sym={r[4]:.2f} sum_3={r[5]}")
# 輸出:date=2025-09-01 amount=1200 ma_3=1200.00 ma_3_sym=1000.00 sum_3=1200
# 輸出:date=2025-10-01 amount=800 ma_3=1000.00 ma_3_sym=1166.67 sum_3=2000
# 輸出:date=2025-11-01 amount=1500 ma_3=1166.67 ma_3_sym=1150.00 sum_3=3500
這段展示了 frame 子句的三種寫法:ma_3 是不對稱視窗,只看過去 2 期加本期;ma_3_sym 是對稱視窗,看前後各 1 期加本期,常用於去雜訊;sum_3 則是只算總和,這在計算「過去 7 天收入」時會用到。可以看到第一筆 ma_3 只有 1200(樣本不足),ma_3_sym 則是把 800(只有未來 1 列可看)拉進來算平均,這就是 frame 邊界條件的差異,務必在寫報表時親自驗算一次。
用 QUALIFY 過濾視窗結果
昨天我們提過:視窗函式不能直接放在 WHERE 內。DBA 與程式設計師常用的兩種變通手段是「CTE 過濾」與 QUALIFY 子句。後者是 DuckDB 1.4 與新世代資料庫(Snowflake、BigQuery、Trino)原生支援的語法,比 CTE 更簡潔:
result = con.execute("""
SELECT
user_id,
order_date,
amount,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY amount DESC
) AS rn
FROM demo_orders
QUALIFY rn = 1
ORDER BY user_id
""").fetchall()
print("每位使用者單筆最高金額:")
for r in result:
print(f" user={r[0]} date={r[1]} amount={r[2]} rn={r[3]}")
# 輸出:user=u01 date=2025-11-01 amount=1500 rn=1
# 輸出:user=u02 date=2025-10-20 amount=2200 rn=1
# 輸出:user=u03 date=2025-09-10 amount=4500 rn=1
# 輸出:user=u04 date=2025-10-30 amount=2400 rn=1
這個查詢找出每位使用者金額最高的一筆訂單。QUALIFY rn = 1 直接對 ROW_NUMBER() 的結果做過濾,不需先寫 CTE 再過濾,可以少寫兩層 SQL。注意這在 MySQL 與傳統 PostgreSQL 13 之前不支援,本系列以 DuckDB 1.4 為主,QUALIFY 是 DuckDB 原生語法,請善用這個特性。
最後一個常見的視窗函式與排名組合技:把每筆訂單算出「這是這位使用者第幾筆」。這個 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date) 在使用者行為分析裡很常用,可以用來計算「這位使用者的第 N 次購買」。
result = con.execute("""
SELECT user_id, order_date, amount,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY order_date
) AS purchase_seq
FROM demo_orders
ORDER BY user_id, order_date
""").fetchall()
for r in result:
print(f"user={r[0]} date={r[1]} amount={r[2]} 序號={r[3]}")
# 輸出:user=u01 date=2025-09-01 amount=1200 序號=1
# 輸出:user=u01 date=2025-10-01 amount=800 序號=2
# 輸出:user=u01 date=2025-11-01 amount=1500 序號=3
# 輸出:(其餘使用者依此累加)
這個查詢把所有訂單依使用者 + 日期順序編上序號,可以一眼看出每位使用者的購買時間軸。如果再配合 CASE WHEN purchase_seq = 1 THEN '首次' ... END,就可以為每筆訂單加上「新客/回客」等業務標籤,這在 CRM 資料分析裡非常實用。Day 24 做訂單分析維度建模時會用到。
用 NTILE 做分群切塊
NTILE(n) 是把資料切成 n 個等量的桶(bucket),並把每列標出所屬桶號。這在分層抽樣、客戶分群(如前 20%、中間 60%、後 20%)時很常見。我們依金額把 12 筆訂單切成 4 桶:
result = con.execute("""
SELECT user_id, order_date, amount,
NTILE(4) OVER (ORDER BY amount) AS quartile
FROM demo_orders
ORDER BY amount
""").fetchall()
for r in result:
print(f"user={r[0]} date={r[1]} amount={r[2]} quartile={r[3]}")
# 輸出:user=u03 date=2025-10-12 amount=300 quartile=1
# 輸出:user=u02 date=2025-09-15 amount=600 quartile=1
# 輸出:user=u01 date=2025-10-01 amount=800 quartile=1
# 輸出:user=u03 date=2025-11-18 amount=900 quartile=2
# 輸出:user=u02 date=2025-11-05 amount=1100 quartile=2
# 輸出:user=u01 date=2025-09-01 amount=1200 quartile=2
# 輸出:user=u04 date=2025-11-25 amount=1300 quartile=3
# 輸出:user=u01 date=2025-11-01 amount=1500 quartile=3
# 輸出:user=u04 date=2025-09-22 amount=1900 quartile=3
# 輸出:user=u04 date=2025-10-30 amount=2400 quartile=4
# 輸出:user=u02 date=2025-10-20 amount=2200 quartile=4
# 輸出:user=u03 date=2025-09-10 amount=4500 quartile=4
把 12 筆切成 4 桶時每桶剛好 3 筆,可以看到 12 筆按金額由小到大被均分到 1-4 桶。NTILE() 在邊界的處理是「前幾桶可能多 1 筆」,而不是「最後一桶多 N 筆」。實務上,NTILE(4) 常被用於「客戶分層」:前 1 桶是高價值、中間 2 桶是常客、最後 1 桶是流失或低價值。整個 DAG 建模與消費洞察常見這類四分位切法。
另一個 NTILE() 的變體:把每位使用者「內部」切成兩組(高消費、低消費)。同一份資料集,你可以這麼寫:
result = con.execute("""
SELECT user_id, order_date, amount,
NTILE(2) OVER (
PARTITION BY user_id
ORDER BY amount DESC
) AS bucket
FROM demo_orders
ORDER BY user_id, order_date
""").fetchall()
for r in result:
print(f"user={r[0]} date={r[1]} amount={r[2]} bucket={r[3]}")
# 輸出:user=u01 date=2025-09-01 amount=1200 bucket=1
# 輸出:user=u01 date=2025-10-01 amount=800 bucket=2
# 輸出:user=u01 date=2025-11-01 amount=1500 bucket=1
# 輸出:(依此類推)
這個變體的關鍵是 PARTITION BY user_id 先切每位使用者,ORDER BY amount DESC 再依金額由大到小排序,NTILE(2) 把每位使用者內部的訂單分成兩群。可以看到 u01 的 1500、1200 是 bucket=1(高消費),800 是 bucket=2(低消費)。在做 RFM 客戶分群時,這個寫法可以幫助快速標出每位使用者內部的高額訂單,是 Day 24 維度建模的常見技巧。
常見錯誤與踩雷
第一個雷:ROW_NUMBER 與 RANK 同分時的解讀錯誤。許多新手以為 RANK() 「比較好用」,結果在「分頁拿第 10 筆」時出錯。請牢記:要做分頁或限額抓取,永遠用 ROW_NUMBER();要做排名榜或呈現名次,用 RANK()。第二個雷:LAG()、LEAD() 的偏移量(offset)忘了寫,預設是 1,但實務上常常要跳兩列或三列。請寫成 LAG(amount, 2)、LEAD(amount, 3) 等具體偏移量。
第三個雷:frame 子句的 ROWS 與 RANGE 沒有預期效果。ROWS 是按列數計算,當資料有重複 ORDER BY 值時,RANGE 會把同值的列一起算;當你想嚴格限定「過去 3 列」就用 ROWS,若想限定「過去 30 天內」就用 RANGE BETWEEN INTERVAL 30 DAYS PRECEDING AND CURRENT ROW。這個差異是 Day 7 探討查詢計畫時的重要觀念。
第四個雷:QUALIFY 子句用在不支援的引擎上。DuckDB、PostgreSQL 18、Trino、Snowflake、BigQuery 支援,但舊版 MySQL 與 SQLite 不支援。如果你的下游系統不支援,請改用 CTE。先寫出 WITH ranked AS (...),再 SELECT ... WHERE rn = 1,這在所有 SQL 引擎都通用。
第五個雷:NTILE() 桶數量超過資料筆數。例如 5 筆資料切成 10 桶,會出現桶號超過桶數的情況,DuckDB 會限制桶號不超過實際桶數,但仍可能讓分析師困惑。請把桶數寫成 ceil(N / 每桶筆數) 的合理值。
當我們要做「過去 12 個月移動平均」這類報表時,LAG() 的偏移量參數就派上用場了。底下這段示範怎麼用 LAG(amount, 2) 一次跳兩列:
result = con.execute("""
SELECT user_id, order_date, amount,
LAG(amount, 1) OVER (
PARTITION BY user_id ORDER BY order_date
) AS prev_1,
LAG(amount, 2) OVER (
PARTITION BY user_id ORDER BY order_date
) AS prev_2,
amount - LAG(amount, 1) OVER (
PARTITION BY user_id ORDER BY order_date
) AS diff_prev_1
FROM demo_orders
WHERE user_id IN ('u01', 'u02')
ORDER BY user_id, order_date
""").fetchall()
for r in result:
print(f"user={r[0]} date={r[1]} amount={r[2]} prev_1={r[3]} prev_2={r[4]} diff_1={r[5]}")
# 輸出:user=u01 date=2025-09-01 amount=1200 prev_1=None prev_2=None diff_1=None
# 輸出:user=u01 date=2025-10-01 amount=800 prev_1=1200 prev_2=None diff_1=-400
# 輸出:user=u01 date=2025-11-01 amount=1500 prev_1=800 prev_2=1200 diff_1=700
# 輸出:user=u02 date=2025-09-15 amount=600 prev_1=None prev_2=None diff_1=None
# 輸出:(其餘省略)
這段展示了 LAG(amount, n) 的兩個變化:prev_1 是前 1 列、prev_2 是前 2 列,並且 diff_1 用算術表達式直接減去前者做差。在 u01 的第三筆可以看到 prev_2=1200(跳過 800 元那筆,直接讀兩列前),這對「跟上上個月比」特別有用。當你想要做「同期比去年同期」、「對齊雙月資料比對」這類需要跨期讀取的分析時,LAG() 的偏移量參數會非常方便。
另一個常見寫法是把 LAG() 與 CASE WHEN 配合,根據讀取到的前期值分類。例如下面這段把每筆訂單分成「高於前期」、「等於前期」、「低於前期」三類:
result = con.execute("""
SELECT user_id, order_date, amount,
CASE
WHEN amount > LAG(amount) OVER (PARTITION BY user_id ORDER BY order_date)
THEN '高於前期'
WHEN amount = LAG(amount) OVER (PARTITION BY user_id ORDER BY order_date)
THEN '等於前期'
ELSE '低於前期'
END AS trend
FROM demo_orders
ORDER BY user_id, order_date
""").fetchall()
for r in result:
print(f"user={r[0]} date={r[1]} amount={r[2]} trend={r[3]}")
# 輸出:user=u01 date=2025-09-01 amount=1200 trend=低於前期
# 輸出:user=u01 date=2025-10-01 amount=800 trend=低於前期
# 輸出:user=u01 date=2025-11-01 amount=1500 trend=高於前期
# 輸出:(其餘類似)
這個寫法在第一筆雖然 LAG() 為 NULL,但 CASE WHEN 邏輯自動落到 ELSE,所以歸類為「低於前期」。實務上你可以把它改成更有意義的標籤,例如 WHEN ... THEN '回購成長',這樣每筆訂單就有一個業務意義的分類欄位,可以直接餵給儀表板做甜甜圈圖。整個 RFM 客戶分層、新客 vs 回客分析,都是這個模式的延伸。
效能與實務提醒
排名函式的效能議題主要在「排序成本」。當 PARTITION BY 切的群數量小、每群內列數大時(例如 100 群 × 10 萬列),DuckDB 需要對每群內部做排序,這是 O(N log N) 成本。如果你的查詢主要在做「取每群前 N 名」,可以考慮改用 ORDER BY ... LIMIT 與 subquery 搭配,這在大資料量時有時比視窗函式快。請用 EXPLAIN 比較兩條路的成本再決定。
實務上:LAG()、LEAD() 搭配 frame 子句是時間序列分析的最常見組合,但請務必把 ORDER BY 內的欄位加上索引(Day 7 會談)。當資料量到數百萬列時,這個索引能讓 LAG() 從全表掃描降到快速游標掃描,把查詢時間從數分鐘降到秒級。
另一個提醒:當你的同事或主管抱怨「為什麼這份報表這麼慢?」,第一個檢查點就是:是否有重複的視窗函式計算。例如一個查詢內有三個不同排序方向的 RANK(),每個都得單獨排序一次,這時把結果拆成多個 CTE 反而會更快。建議你跑一份 EXPLAIN ANALYZE 看每個視窗的成本,調優時比較有依據。
這裡有個小建議:把今天常用的視窗函式寫成一個查詢範本(例如 queries/rank_template.sql),未來做新報表時複製貼上改欄位即可。DuckDB CLI 對多行檔案的支援也很好,可以用 duckdb warehouse/de-journey.duckdb < queries/rank_template.sql 直接跑管線輸出。視窗函式與排名函式結合起來,可以應付 Day 24 訂單分析 80% 以上的需求,剩下的 20% 才是複雜的維度建模與 CTE 技巧,這會交給 Day 5 與 Day 23 處理。接著來回顧今天學過的關鍵寫法。
小結
今天把排名函式(ROW_NUMBER()、RANK()、DENSE_RANK()、NTILE())與前後取值(LAG()、LEAD())一次到位,並把 frame 子句結合 SUM() 與 AVG() 寫成移動平均的標準範本。我們用消費事實表展示了「同查詢多排名」、「本期減前期」、「過去 N 期平均」這三個最常見的實戰需求,並用 QUALIFY 子句把視窗結果直接過濾,這比傳統 CTE 更簡潔。NTILE() 則是客戶分層切塊的核心,會在 Day 23、Day 24 維度建模被反覆提到。
把今天學到的關鍵詞抄進筆記本:排名函式、ROW_NUMBER()、RANK()、DENSE_RANK()、NTILE()、LAG()、LEAD()、QUALIFY、移動平均、frame 子句。明天會把這兩天學的視窗函式與「資料表達力」的主題:CTE(Common Table Expression)做結合,把多層巢狀的查詢改寫為可讀性更高的鏈式結構,並且開始談「遞迴 CTE」這個 SQL 中最具威力的寫法。
結語
排名與移動分析是商業數據的兩大軸:前者告訴你「誰在前面」、後者告訴你「現在在走的方向」。這兩種分析都可以用純 SQL 寫出來,不需要進 Python 也不需要寫 ETL。我們今天的寫法是業界最常用的「視窗函式為主、輔助 CTE 與 QUALIFY」,在 DuckDB 與 Snowflake、BigQuery 都可以直接對應。資料工程與資料分析的差別就在這裡:分析師拿到現成的事實表就可以寫查詢,資料工程師要把事實表建好、索引做好、模型取捨做好——這也是 Day 6、Day 7、Day 23 會陸續展開的主題。
明天,我們會進入「CTE 與遞迴查詢」的主題:把過去兩天寫的多層視窗查詢改寫成更易讀的鏈式 WITH ... AS (... 結構,並開始談「遞迴 CTE」這個 SQL 標準中最具威力的進階寫法。我們會用階層式組織圖(員工找直屬主管)當範例,示範 WITH RECURSIVE 的標準寫法,並把它改成可整段執行的 Python + DuckDB 範例。
延伸資源
- DuckDB 視窗函式完整指南:
https://duckdb.org/docs/sql/window_functions.html。本篇所用QUALIFY、NTILE、ROWS BETWEEN等語法以這份為主。 - LeetCode SQL Top 50 練習題:
https://leetcode.com/studyplan/top-sql-50/。視窗函式題目密集,是練功好去處。 - Mode Analytics SQL 視窗函式教學:
https://mode.com/sql-tutorial/sql-window-functions/。包含排名函式差異圖解。 - PostgreSQL 18 視窗函式文件:
https://www.postgresql.org/docs/current/tutorial-window.html。查詢計畫與索引策略的權威來源。 - 《SQL 視窗函式解析》(書籍):Antoni Olszowski 等,深入探討 frame 邊界與實務效能。
留言
張貼留言