我們之前學習了基礎的數據分組匯總操作,現在,讓我們討論一些更高級的分組統計分析功能,也就是 GROUP BY 子句的擴展選項。
銷售示例數據:
本節我們使用新的數據集,表名叫做sales_data,它包含了 2019 年 1 月 1 日到 2019 年 6 月 30 日三種產品在三個渠道的銷售情況。以下是該表中的部分數據:
saledate 表示銷售日期,product 表示產品,channel 表示平台,amount 表示銷售金額。
現在就讓我們來看看 GROUP BY 支持哪些高級分組選項。
層次化的統計:
這個就比較簡單了,就是我們之前使用的 GROUP BY。
SELECT product,
channel,
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY product, channel;
這是一個簡單的分組匯總操作,我們得到了每種產品在每個渠道的銷售金額。現在我們來思考一個問題:如何知道每種產品在所有渠道的銷售金額合計,以及全部產品的銷售金額總計呢?有人肯定覺得,直接按照 product 進行分組、sum 一下不就行了,但這需要單獨寫一個SQL。如果我們需要連同上面的內容一起輸出,這個時候就需要 ROLLUP 函數了。
在 SQL 中可以使用 GROUP BY 子句的擴展選項:ROLLUP。ROLLUP 可以生成按照層級進行匯總的結果,類似於財務報表中的小計、合計和總計。
-- PostgreSQL 支持
SELECT product,
channel,
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY rollup(product, channel);
-- 我們不再 GROUP BY product, channel,而是GROUP BY rollup(product, channel)
-- 注意:如果是 MySQL,那么要這樣寫,GROUP BY product, channel with rollup
我們注意到,多了四條數據,上面三條,就是按照 product、channel 匯總之后,再單獨按 product 匯總,因此,此時就給對應的 channel 賦值為 null 了。同理最后一條數據是全量匯總,不需要指定 product 和 channel,所以顯示為 product 和 channel 都顯示為 null。我們看到這就相當於按照 product 單獨聚合然后再自動拼接在上面了,排好序,並且自動將 channel 賦值為 null,同理最后一條數據也是如此。當然我們也可以寫多個語句,然后通過 union 也能實現上面的效果,有興趣可以自己試一下。但是數據庫提供了 rollup 這個非常方便的功能,我們就要利用好它。
所以該查詢的結果中多出了 4 條記錄,分別表示三種產品在所有渠道的銷售金額合計(渠道顯示為 NULL)以及全部產品的銷售金額總計(產品和渠道都顯示為 NULL)。
GROUP BY 子句加上 ROLLUP 選項時,首先按照分組字段進行分組匯總;然后從右至左依次去掉一個分組字段再進行分組匯總,被去掉的字段顯示為空;最后,將所有的數據進行一次匯總,所有的分組字段都顯示為空。
在上面的示例中,顯示為空的字段作用不太明顯。我們可以利用空值函數 COALESCE 將結果顯示為更易理解的形式:
SELECT coalesce(product, '所有產品'),
coalesce(channel, '所有渠道'),
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY rollup(product, channel);
除了 ROLLUP 之外,GROUP BY 還支持 CUBE 選項。
多維度的交叉統計:
CUBE 代表立方體,它用於對分組字段進行各種可能的組合,能夠產生多維度的交叉統計結果。CUBE 通常用於數據倉庫中的交叉報表分析。
示例數據集 sales_data 中包含了產品、日期和渠道 3 個維度,對應的數據立方體結構如下圖所示:
其中,每個個小的方塊表示一個產品在特定日期、特定渠道下的銷售金額。以下語句利用 CUBE 選項獲得每種產品在每個渠道的銷售金額小計,每種產品在所有渠道的銷售金額合計,每個渠道全部產品的銷售金額合計,以及全部產品在所有渠道的銷售金額總計:
-- PostgreSQL 支持
SELECT coalesce(product, '所有產品'),
coalesce(channel, '所有渠道'),
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY cube(product, channel);
從以上結果可以看出,CUBE 返回了更多的分組數據,其中不僅也包含了 ROLLUP 匯總的結果,還包含了相當於按照 channel 進行聚合的記錄。因此隨着分組字段的增加,CUBE 產生的組合將會呈指數級增長。
MySQL 目前還不支持 CUBE 選項。
ROLLUP 和 CUBE 都是按照預定義好的組合方式進行分組;GROUP BY 還支持一種更靈活的統計方式:GROUPING SETS。
自定義分組粒度:
GROUPING SETS 選項可以用於指定自定義的分組集,也就是分組字段的組合方式。實際上,ROLLUP 和 CUBE 都屬於特定的分組集。比如:
GROUP BY product, channel;
-- 等價於
GROUP BY GROUPING SETS ((product, channel));
(product, channel) 定義了一個分組集,也就是按照產品和渠道的組合進行分組。注意,括號內的所有字段作為一個分組集,外面再加上一個括號包含所有的分組集。
GROUP BY ROLLUP (product, channel);
-- 相當於
GROUP BY GROUPING SETS ((product, channel),
(product),
()
);
首先,按照產品和渠道的組合進行分組;然后按照不同的產品進行分組;最后的括號( () )表示將所有的數據作為整體進行統計。上文中的 CUBE 選項示例:
GROUP BY CUBE (product, channel);
-- 相當於
GROUP BY GROUPING SETS ((product, channel),
(product),
(channel),
()
);
首先,按照產品和渠道的組合進行分組;然后按照不同的產品進行分組;接着按照不同的渠道進行分組;最后將所有的數據作為一個整體。
GROUPING SETS 選項的優勢在於可以指定任意的分組方式。以下示例返回不同產品的銷售金額合計以及不同渠道的銷售金額合計:
-- 分別按照product和channel匯總
SELECT product,
channel,
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY grouping sets ((product), (channel));
/*
桔子 null 909261
蘋果 null 937052
香蕉 null 925369
null 店面 912768
null 京東 936446
null 淘寶 922468
*/
-- 我們使用union也是可以實現的
select product, null as channel, sum(amount) as sum_amount
from sales_data group by product
union
select null as product, channel, sum(amount) as sum_amount
from sales_data group by channel
order by product, channel nulls last
/*
桔子 null 909261
蘋果 null 937052
香蕉 null 925369
null 店面 912768
null 京東 936446
null 淘寶 922468
*/
可以看到我們把(product) 和 (channel) 分別指定了一個分組集。通過 GROUPING SETS 選項可以實現任意粒度(維度)的組合分析。
MySQL 目前還不支持 GROUPING SETS 選項。
GROUPING 函數:
在進行分組統計時,如果源數據中存在 NULL 值,查詢的結果會產生一些歧義。我們先插入一條模擬數據,它的渠道為空:
-- 只有 Oracle 需要執行以下 alter 語句
-- alter session set nls_date_format = 'YYYY-MM-DD';
INSERT INTO sales_data VALUES ('2019-01-01','桔子', NULL, 1000.00);
再次運行之前的 ROLLUP 示例
SELECT product,
channel,
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY rollup (product, channel);
我們說盡管 null 值無法判斷是否相等,但是在分組的時候所有為 null 都會分到同一組,不過我們這里只插入了一條 channel 為空的記錄,無所謂啦。注意看此時的數據:黃色框框的部分出現了兩個 channel 為 null 的,顯然從數據我們能看出來,sum_amount 為 1000 的,是我們在聚合的時候產生的,它並不是 "桔子" 在所有渠道的銷售金額合計,第五行才是 "桔子" 在所有渠道的銷售金額合計(910261)。問題的原因在於 GROUP BY 將空值作為了一個分組,於是有兩個null,可能有人覺得使用COALESCE 函數轉化一下不就行了,是這樣嗎?我們來試一下。
SELECT coalesce(product, '所有產品'),
coalesce(channel, '所有渠道'),
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY rollup (product, channel);
一樣的結果,匯總之后 channel 是為 null 的,但是我們的 select 后面是 coalesce(channel, '所有渠道'),所以結果也就變成了 '所有渠道',因為我們的(COALESCE 函數)無法區分是由匯總產生的 NULL 值還是源數據中存在的 NULL 值。
為了解決這個問題,SQL 提供了一個函數:GROUPING。以下語句演示了 GROUPING 函數的作用:
SELECT product,
grouping(product), -- 多加了一個grouping(product)
channel,
grouping(channel), -- 多加了一個grouping(channel)
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY rollup (product, channel);
其中,GROUPING 函數返回 0 或者 1。如果當前數據是某個字段上的匯總數據,該函數返回 1;否則返回 0。例如,第 4 行數據雖然渠道顯示為 NULL,但不是所有渠道的匯總,所以 GROUPING(channel) 的結果為 0;第 5 行數據的渠道同樣顯示為 NULL,它是 "桔子" 在所有渠道的銷售金額匯總,所以 GROUPING(channel) 的結果為 1。
因此,我們可以利用 GROUPING 函數顯示明確的信息:
SELECT case grouping(product) when 1 then '所有商品' else product end as product,
case grouping(channel) when 1 then '所有渠道' else channel end as channel,
SUM(amount) AS sum_amount
FROM sales_data
GROUP BY rollup (product, channel);
如此一來就變成我們想要的結果了,通過查詢的結果可以清楚地區分出空值和匯總數據。
當然,如果源數據中不存在 NULL 值或者進行了預處理,也可以直接使用 COALESCE 函數進行顯示。
小結
在 Excel 中有一個分析功能叫做數據透視表,利用 GROUP BY 的 ROLLUP、CUBE 以及 GROUPING SETS 選項可以非常容易地實現類似的效果,並且使用更加靈活。這些都是在線分析處理系統(OLAP)中的常用技術,能夠提供多維度的層次統計和交叉分析功能。