SQL.10 分組與視窗函數:各自的產出、選用的依據與 LAG、LEAD 的相鄰列
GROUP BY 與視窗函數都對一組列算出一個值。分開兩者的是算完之後原本那些列還在不在:分組把一組收成一列,視窗函數把值貼回每一列上。
這一篇接在 SQL.9 分組鍵的選擇:鍵與一組所代表的對象 後面。那一篇的判斷標準是「一組要代表誰」,而選好了鍵之後還有一個問題——組算出來的那個值,要不要跟原本的列擺在一起。
本篇在共用的訂單表上多加兩列,讓兩位顧客各有兩張訂單。共用的那兩列是佳穎(顧客 1)的 101 與 102,下單日 3/02 與 3/09;加的兩列屬於宗翰(顧客 2):
1INSERT INTO 訂單 VALUES (105,2,'2026-03-05',200), (106,2,'2026-03-20',450);同一個總額用分組與用視窗函數寫的輸出
「每位顧客的總消費」用分組寫,四列訂單收成兩列:
1SELECT 顧客編號, sum(金額) AS 總額 FROM 訂單 GROUP BY 顧客編號;11 800
22 650同一個總額用視窗函數寫,四列訂單仍然是四列,各自帶著自己那位顧客的總額:
1SELECT 訂單編號, 顧客編號, 金額,
2 sum(金額) OVER (PARTITION BY 顧客編號) AS 該顧客總額
3FROM 訂單;1101 1 300 800
2102 1 500 800
3105 2 200 650
4106 2 450 650PARTITION BY 承擔的是 GROUP BY 那個鍵的角色——它說明一組由誰構成。差別全在輸出:GROUP BY 那一段的結果只剩兩列,而每一張訂單的金額已經被收進總額裡取不回來了。
明細與組的值同列的計算:訂單佔該顧客消費的比例
分組收掉列之後,訂單這一層的資訊就離開了結果集。要問「這張訂單佔該顧客消費的多少」,需要的是訂單的金額與該顧客的總額同時出現在同一列上:
1SELECT 訂單編號, 金額,
2 round(金額 * 100.0 / sum(金額) OVER (PARTITION BY 顧客編號), 1) AS 佔該顧客百分比
3FROM 訂單;1101 300 37.5
2102 500 62.5
3105 200 30.8
4106 450 69.2分組要算出同一份結果,得把同一批資料讀兩次再接起來——一次算總額,一次拿明細去跟它連接:
1SELECT 訂單.訂單編號, 訂單.金額,
2 round(訂單.金額 * 100.0 / 各顧客.總額, 1) AS 佔該顧客百分比
3FROM 訂單
4JOIN (SELECT 顧客編號, sum(金額) AS 總額 -- 讀第一次:收成每位顧客一列總額
5 FROM 訂單 GROUP BY 顧客編號) AS 各顧客
6 ON 各顧客.顧客編號 = 訂單.顧客編號; -- 讀第二次:每一張訂單接上它那位顧客的總額
7-- 101 300 37.5
8-- 102 500 62.5
9-- 105 200 30.8
10-- 106 450 69.2(與視窗函數那一段的結果相同)視窗函數把這兩次寫成一次,明細與組的值在同一趟裡就都算出來。這裡說的是結構省了一次連接,不是它一定跑得比較快:兩者的代價差多少由資料量、分組數與可用的索引決定,而那要向引擎要計畫——SQL.17 查詢的代價 量了同一道題三種寫法在有無索引下的排名對調。
選用分組或視窗函數的依據:結果的一列代表什麼
判斷標準是一句話:結果的一列代表什麼。 一列代表一位顧客,用 GROUP BY;一列代表一張訂單而順便帶著顧客層的值,用視窗函數。
這一句對得上 SQL.9 分組鍵的選擇:鍵與一組所代表的對象 的鍵判斷標準,鍵的判斷標準決定一組由誰構成,輸出單位決定這一組收不收成一列;一段用到分組或視窗函數的查詢,兩者都要先回答。
後續的子句跟著這個選擇走。GROUP BY 算出來的值可以放進 HAVING 篩組(SQL.2 子句的求值順序:每一步可用的欄位、語意模型擋下的寫法與各家引擎放寬的寫法),而視窗函數算出來的值到 SELECT 那一步才誕生,WHERE 與 HAVING 都拿不到它——要拿它當條件,得把整段包成一層子查詢再從外面篩(部分引擎另有 QUALIFY 這個子句直接做這件事,標準 SQL 沒有)。算 佔該顧客百分比 的那段查詢要篩出「超過五成」的訂單時寫成這樣:
1-- 直接在同一層寫 WHERE 佔該顧客百分比 > 50,SQLite 3.51 拒絕:
2-- misuse of aliased window function 佔該顧客百分比
3SELECT 訂單編號, 佔該顧客百分比 FROM (
4 SELECT 訂單編號,
5 round(金額 * 100.0 / sum(金額) OVER (PARTITION BY 顧客編號), 1) AS 佔該顧客百分比
6 FROM 訂單
7) -- 內層算完視窗函數,佔該顧客百分比成為一個普通欄位
8WHERE 佔該顧客百分比 > 50; -- 外層的 WHERE 讀得到它
9-- 102 62.5
10-- 106 69.2LAG 與 LEAD:取同一組裡相鄰的列
明細列還在,於是「同一位顧客的上一張訂單」這個概念才存在——分組把明細收掉之後,能取的相鄰只剩組與組之間的。所以視窗函數除了對整組算值,還能直接取同一組裡相鄰的某一列。LAG 往前取、LEAD 往後取,而相鄰的定義由 ORDER BY 給:
1SELECT 訂單編號, 顧客編號, 金額,
2 金額 - LAG(金額) OVER (PARTITION BY 顧客編號 ORDER BY 下單日) AS 與上一張的差額
3FROM 訂單;1101 1 300 (空)
2102 1 500 200
3105 2 200 (空)
4106 2 450 250每一位顧客的第一張訂單前面沒有列可取,值是 NULL。取同一組裡相鄰一列的問題用自連接也寫得出來,差別在自連接要把兩列配起來,而 LAG 沿著已經排好的順序直接往前數一列。SQL.7 表的出現與別名:表名的指稱、別名的作用範圍與自連接 走的是配起來那條路,並給了配對會不會膨脹這個條件。
排序相鄰與問題要的相鄰:分區、排序鍵與距離條件
LAG 交出的是排序相鄰——照 ORDER BY 排好之後緊鄰的那一列,而問題要的相鄰往往是另一種:同一位顧客的上一張、前一天、上一個月。排序相鄰與問題要的相鄰對不上的時候,查詢照樣回結果。
把上面那段的 PARTITION BY 拿掉,其餘不動。少了分區之後 LAG 沿全表的下單日排序。兩個版本並排在同一段查詢裡:
1SELECT 訂單編號, 顧客編號,
2 金額 - LAG(金額) OVER (ORDER BY 下單日) AS 沒分區的差額, -- 沿全表的下單日往前取一列
3 金額 - LAG(金額) OVER (PARTITION BY 顧客編號 ORDER BY 下單日) AS 分區後的差額 -- 只在同一位顧客之內往前取
4FROM 訂單
5ORDER BY 下單日; -- 列序照全表的下單日:3/02、3/05、3/09、3/201訂單 顧客 沒分區的差額 分區後的差額
2101 1 (空) (空)
3105 2 -100 (空)
4102 1 300 200
5106 2 -50 250非首列的三列全部錯了。宗翰的第一張訂單拿佳穎的訂單當「上一張」算出 -100,佳穎的第二張拿宗翰的算出 300。這些數字沒有一個報錯,而它們都是拿兩位不同顧客的訂單相減得來的。
排序相鄰與問題要的相鄰對不上,除了跨到另一位顧客,也會發生在時間上:ORDER BY 下單日 排出來的相鄰兩列,日期上可能隔了半個月。問題若要的是「比前一天高」,那就要另外寫一個距離條件,因為排序只保證先後、不保證間隔。
那個條件的形狀是把距離也算成一欄,再從外面篩:
1SELECT 訂單編號 FROM (
2 SELECT 訂單編號, 金額,
3 LAG(金額) OVER w AS 前一筆,
4 julianday(下單日) - julianday(LAG(下單日) OVER w) AS 相隔天數
5 FROM 訂單 WINDOW w AS (PARTITION BY 顧客編號 ORDER BY 下單日)
6)
7WHERE 金額 > 前一筆 AND 相隔天數 = 1;
8-- 本篇的四列訂單:零列(102 與佳穎的上一張相隔 7 天,106 與宗翰的上一張相隔 15 天)
9-- 拿掉 AND 相隔天數 = 1:102、106少了距離條件,102 與 106 都會被當成「前一天」的比較。
這個缺口在逐日資料上更難察覺。逐日觀測只要偶爾缺一天,少了距離檢查的版本就會把斷點兩側當成相鄰而多算幾十次比較,而多出來的量相對於總列數小到不會有任何一處顯得異常——查詢照跑,結果集的規模也看不出差別。
所以 OVER 括號裡有三件事要分開確認:PARTITION BY 說明相鄰限定在哪個範圍之內,ORDER BY 說明沿著什麼排,而那個排序鍵要能把同一組裡的列排成唯一的次序。 排序唯一最容易漏——排序鍵有並列時「前一列」是哪一列沒有定義,同一組資料與同一個窗規格因此可以給出不同的答案,而同一段 SELECT 裡另有一個排序不同的視窗就足以讓它換一個:
1-- 本篇的四列訂單再加一張與 102 同一天下單的 107(只在這一段成立)
2INSERT INTO 訂單 VALUES (107,1,'2026-03-09',100);
3
4SELECT 訂單編號, LAG(訂單編號) OVER (PARTITION BY 顧客編號 ORDER BY 下單日) AS 上一張
5FROM 訂單;
6-- 102 的上一張是 101,107 的上一張是 102
7
8SELECT 訂單編號, LAG(訂單編號) OVER (PARTITION BY 顧客編號 ORDER BY 下單日) AS 上一張,
9 row_number() OVER (ORDER BY 金額) AS 金額名次 -- 多一個按金額排序的視窗;上一張那一欄的寫法沒動
10FROM 訂單;
11-- 107 的上一張變成 101,102 的上一張變成 107(SQLite 3.51)並列時要補一個決勝的次鍵:窗規格寫成 ORDER BY 下單日, 訂單編號 之後,兩段查詢的上一張那一欄都是 102 接在 101 後面、107 接在 102 後面。分區、排序鍵與排序唯一都確認之後,還要問排序相鄰是不是就等於問題要的相鄰——答案是否定的時候,那個距離條件得自己補上。
OVER 括號裡的分區與排序作為宣告
PARTITION BY 顧客編號 宣告的是「相鄰限定在同一位顧客之內」。寫了它而它沒在做事也是可能的——每位顧客都只有一張單的時候,拿掉它結果不變,那一類「宣告在、行為不需要它」的情形在 SQL.20 關鍵字的宣告與引擎的行為:宣告落空的形態與查證。漏寫它則是另一回事:文字裡沒有那份宣告,錯的是答案。上一節的兩個缺口(漏分區、漏距離條件)共同的形狀是查詢照跑、結果集的規模也看不出差別,SQL.13 查詢的合法性與答案的正確性:引擎檢查的範圍、答案錯掉的成因與查證方法 把輸出不帶訊號的幾種形態並排:回了正確答案的、回零列的、以及列數與欄位都看不出異常的。