CUBE
CUBE 是 GROUPING SETS 的全组合缩写:GROUP BY CUBE (a, b, c) 会一次性算出 a、b、c 三列的所有 2³ = 8 种维度组合。它适合「任意维度都能下钻/上卷」的交叉分析场景,比如城市 × 品类 × 渠道的销量-利润矩阵。代价是结果集行数与分组集个数都随列数指数增长,务必控制列数。学完本节,你将能正确区分 ROLLUP 与 CUBE 的适用边界,并用 CUBE 一次产出全维度汇总。
前置知识
阅读本节前,建议先了解:ROLLUP
基础概念
数据立方体(Data Cube) 是 OLAP 的经典模型:把 N 个维度上的所有聚合组合预先算出来,形成可在任意维度切片(Slice)、切块(Dice)、下钻(Drill Down)的多维结构。PostgreSQL 的 CUBE 关键字就是它的 SQL 语法糖。
| 概念 | 术语 | 说明 |
|---|---|---|
| 数据立方体 | Data Cube | N 个维度全部组合的聚合结果集合 |
| 全组合 | Full Combination | 每个维度「参与 / 不参与」的 2^N 种可能 |
| 分组集 | Grouping Set | 一组参与分组的列,CUBE 会展开出 2^N 个 |
| 切片 | Slice | 固定某个维度值后观察子立方体 |
| 下钻 / 上卷 | Drill Down / Roll Up | 沿层级增/减维度细粒度 |
| 行膨胀 | Row Explosion | 分组集个数指数增长导致结果行数激增 |
| 汇总行标记 | Grouping Flag | GROUPING() 返回值,标识某列是否被汇总 |
核心语义:CUBE 生成的是 ROLLUP 的超集——它既包含 ROLLUP 的前缀阶梯,也包含非前缀的组合(如只按 b、只按 c、按 b,c)。
GROUP BY CUBE (a, b)
≡ GROUP BY GROUPING SETS ((a, b), (a), (b), ())
共 2² = 4 个分组集(含空集)
GROUP BY CUBE (a, b, c)
≡ GROUP BY GROUPING SETS (
(a, b, c), (a, b), (a, c), (b, c),
(a), (b), (c), ()
)
共 2³ = 8 个分组集完整展开对照表(务必记牢这张表,它是理解 CUBE 与 ROLLUP 差异的关键):
CUBE 写法 | 展开后的分组集 | 个数 |
|---|---|---|
CUBE () | () | 1 |
CUBE (a) | (a), () | 2 |
CUBE (a, b) | (a, b), (a), (b), () | 4 |
CUBE (a, b, c) | (a,b,c), (a,b), (a,c), (b,c), (a), (b), (c), () | 8 |
CUBE (a, b, c, d) | 上表每个分组集各自再分裂出「含 d」与「不含 d」两版 | 16 |
CUBE (a, b, c, d, e) | 同上递归 | 32 |
CUBE vs ROLLUP 的差异:
| 对比项 | ROLLUP (a, b, c) | CUBE (a, b, c) |
|---|---|---|
| 分组集个数 | N + 1 = 4 | 2^N = 8 |
是否含 () | 是 | 是 |
是否含 (a, c)、(b, c) 等非前缀组合 | 否 | 是 |
| 语义模型 | 层级上卷(有父子关系) | 任意维度正交组合(无层级关系) |
| 适合场景 | 时间/组织层级报表 | 交叉分析、多维切片 |
| 行数增长 | 线性 O(N) | 指数 O(2^N) |
组合嵌套:CUBE 内部可以再嵌 ROLLUP,形成「立方体中的层级轴」:
| 写法 | 展开结果 | 个数 |
|---|---|---|
CUBE (a, ROLLUP (b, c)) | (a,b,c), (a,b), (a), (b,c), (b), () | 6 |
ROLLUP (a), CUBE (b, c) | (a,b,c), (a,b), (a,c), (a), (b,c), (b), (c), () | 8 |
规则是:把内层每个分组集与外层的每种「是否加入」组合相乘。CUBE (a, ROLLUP (b, c)) 中内层 ROLLUP (b, c) 给出 {(b,c), (b), ()} 三个分组集,外层 a 可加入或不加入,但内层的 () 加入 a 后变成 (a) 与内层原有的 (b,c) 等合并去重,最终得到 6 个。
语法与用法
-- 基本形态:对 N 列求全组合
SELECT 维度列..., 聚合函数(...)
FROM 表
GROUP BY CUBE (维度列1, 维度列2, 维度列3)
ORDER BY ...;准备本节贯穿使用的示例表(在 demo 库中执行):
-- 销售明细表:城市 × 品类 × 渠道
CREATE TABLE sales (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
city TEXT NOT NULL,
category TEXT NOT NULL,
channel TEXT NOT NULL,
qty INTEGER NOT NULL CHECK (qty > 0),
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0),
cost NUMERIC(12, 2) NOT NULL CHECK (cost >= 0),
sold_at DATE NOT NULL
);
-- 造数:5 城市 × 4 品类 × 3 渠道,共 3 万行
INSERT INTO sales (city, category, channel, qty, amount, cost, sold_at)
SELECT
(ARRAY['北京', '上海', '广州', '深圳', '杭州'])[1 + (i % 5)],
(ARRAY['手机', '电脑', '家电', '配件'])[1 + (i % 4)],
(ARRAY['线上', '门店', '分销'])[1 + (i % 3)],
1 + (i % 20),
round((random() * 2000 + 100)::numeric, 2),
round((random() * 1000 + 50)::numeric, 2),
DATE '2026-01-01' + (i % 180)
FROM generate_series(1, 30000) AS i;
CREATE INDEX idx_sales_city_cat_ch ON sales (city, category, channel);
ANALYZE sales;全维度矩阵的最小可运行示例:
-- 一条 SQL 产出城市×品类×渠道的 8 种维度组合
SELECT
city,
category,
channel,
sum(amount) AS 销售额,
sum(amount - cost) AS 利润,
GROUPING(city) AS g_city,
GROUPING(category) AS g_category,
GROUPING(channel) AS g_channel
FROM sales
GROUP BY CUBE (city, category, channel)
ORDER BY GROUPING(city), city,
GROUPING(category), category,
GROUPING(channel), channel;预期输出(截取,8 个层级各出现若干行):
city | category | channel | 销售额 | 利润 | g_city | g_category | g_channel
-------+----------+---------+----------+----------+--------+------------+-----------
北京 | 手机 | 门店 | 8821.40 | 4210.33 | 0 | 0 | 0
北京 | 手机 | 线上 | 9133.02 | 4501.88 | 0 | 0 | 0
北京 | 手机 | 分销 | 8890.11 | 4322.09 | 0 | 0 | 0
北京 | 手机 | | 26844.53 | 13034.30 | 0 | 0 | 1 <- 城市×品类小计
北京 | | 门店 | 35201.77 | 17002.11 | 0 | 1 | 0 <- 城市×渠道小计
北京 | | |105430.20 | 51003.55 | 0 | 1 | 1 <- 城市小计
上海 | 手机 | 门店 | 8712.99 | 4188.42 | 0 | 0 | 0
... | ... | ... | ... | ... | ... | ... | ...
北京 | | |105430.20 | 51003.55 | 0 | 1 | 1
| | 门店 |491233.10 |240118.44 | 1 | 1 | 0 <- 渠道小计
| | |1489002.55|721330.12 | 1 | 1 | 1 <- 全表总计注意最后几行:g_category = 1 的行是「城市 × 渠道」组合,这在 ROLLUP 中是不会出现的——ROLLUP (city, category, channel) 只有 (city,category,channel)、(city,category)、(city)、() 四层。
详细说明
与 GROUPING SETS 的等价改写
CUBE 只是语法糖,任何 CUBE 都能手写成 GROUPING SETS,两者计划形态一致:
-- CUBE 写法
SELECT city, category, sum(amount)
FROM sales
GROUP BY CUBE (city, category);
-- 完全等价的 GROUPING SETS 写法
SELECT city, category, sum(amount)
FROM sales
GROUP BY GROUPING SETS ((city, category), (city), (category), ());当只需要部分组合时,请直接用 GROUPING SETS——CUBE 会算全部 2^N 个,多算的部分既耗时又占内存:
-- 不推荐:CUBE 会多算 (category, channel) 等不需要的组合
SELECT city, category, channel, sum(amount)
FROM sales
GROUP BY CUBE (city, category, channel);
-- 推荐:明确列出真正需要的 4 个分组集
SELECT city, category, channel, sum(amount)
FROM sales
GROUP BY GROUPING SETS (
(city, category, channel),
(city, category),
(city),
()
);行数膨胀风险
分组集个数是 2 的幂,列数每加 1,聚合工作量翻倍:
| 列数 N | 分组集个数 2^N | 单分组集最坏行数 | 结果总行数量级 |
|---|---|---|---|
| 2 | 4 | 明细 | 明细 + 3 |
| 3 | 8 | 明细 | 明细 + 7 |
| 4 | 16 | 明细 | 明细 + 15 |
| 5 | 32 | 明细 | 明细 + 31 |
| 6 | 64 | 明细 | 明细 + 63 |
实际影响有两层:
- 输出行数:
CUBE (a, b, c)会输出「明细行 + 7 类汇总行」,若明细本身有 100 万行,结果轻易突破百万级; - 聚合中间状态:每个分组集都要维护独立的哈希表或排序缓冲,
CUBE (a,b,c,d)意味着 16 份中间状态同时存活,极易超出work_mem。
-- 查看是否因 work_mem 不足而落盘
EXPLAIN (ANALYZE, BUFFERS)
SELECT city, category, channel, sum(amount)
FROM sales
GROUP BY CUBE (city, category, channel); HashAggregate (cost=1892.00..2103.44 rows=1544 width=...) (actual time=33.221..40.118 rows=231 loops=1)
Group Key: city, category, channel
Group Key: city, category
Group Key: city, channel
Group Key: category, channel
Group Key: city
Group Key: category
Group Key: channel
Group Key: ()
Batches: 1 Memory Usage: 481kB -- Batches > 1 表示溢出落盘
Buffers: shared hit=214
Execution Time: 40.118 ms出现 Batches: 2(或更大)即说明哈希表溢出。解决方式:临时提高 work_mem(会话级)或减少 CUBE 列数:
-- 会话级临时调大,仅对当前连接生效
SET work_mem = '256MB';高基数列是禁忌
CUBE 的分组集是笛卡尔式全组合,高基数(重复值少)的列会让「非前缀组合」的行数逼近明细行数:
-- 错误!user_id / order_no 基数极高,CUBE 后结果行数几乎等于明细行数
SELECT user_id, order_no, city, sum(amount)
FROM sales
GROUP BY CUBE (user_id, order_no, city);经验规则:CUBE 的列应是低基数维度(城市、品类、渠道、状态),基数建议每个都在 1000 以内。
用 GROUPING() 标记汇总层级
CUBE 的结果中,多列可能同时为 NULL,仅凭列值无法判断当前行是哪个层级。用 GROUPING() 拼接出可读标签:
SELECT
CASE WHEN GROUPING(city) = 1 THEN '全部城市' ELSE city END AS 城市,
CASE WHEN GROUPING(category) = 1 THEN '全部品类' ELSE category END AS 品类,
CASE WHEN GROUPING(channel) = 1 THEN '全部渠道' ELSE channel END AS 渠道,
sum(amount) AS 销售额
FROM sales
GROUP BY CUBE (city, category, channel)
HAVING GROUPING(city) = 1 AND GROUPING(category) = 1 -- 只看「按渠道」的汇总层
ORDER BY GROUPING(channel), channel;还可以用多参数形式一次性拿到位图整数,便于排序与分组:
-- 多参数 GROUPING() 返回位图整数,替代其他数据库的 GROUPING_ID()
SELECT
city, category, channel,
GROUPING(city, category, channel) AS gid, -- 0~7,越小层级越细
sum(amount) AS 销售额
FROM sales
GROUP BY CUBE (city, category, channel)
ORDER BY gid, city, category, channel;gid = 0 是明细行,gid = 7 是全表总计。详见 GROUPING / GROUPING_ID。
执行计划:一次扫描,多分组集聚合
PostgreSQL 对 GROUPING SETS 家族(含 CUBE)有两种实现路径:
| 计划节点 | 触发条件 | 特征 |
|---|---|---|
MixedAggregate | 部分分组集可复用哈希、部分可复用排序 | 同时出现 Hash Key: 与 Group Key: |
HashAggregate | 所有分组集都走哈希 | 多个 Group Key: 行,关注 Batches |
GroupAggregate | 分组集可按索引/排序顺序复用 | 多个 Group Key: 行,输入已排序 |
关键点是:无论多少个分组集,基表只扫描一次,各分组集在一次遍历中「同时喂给各自的聚合状态」。
MixedAggregate (actual time=0.031..42.117 rows=231 loops=1)
Hash Key: city
Hash Key: category
Hash Key: channel
Hash Key: <>
Group Key: city, category, channel
Group Key: city, category
Group Key: city, channel
Group Key: category, channel
-> Seq Scan on sales (actual time=0.012..12.884 rows=30000 loops=1)
Buffers: shared hit=214若改用 UNION ALL 手写 8 个 GROUP BY,计划会变成 Append 下挂 8 个聚合节点、扫描 8 次。
与 UNION ALL 的性能对比
-- CUBE:单次扫描
EXPLAIN (ANALYZE, BUFFERS)
SELECT city, category, sum(amount)
FROM sales
GROUP BY CUBE (city, category); MixedAggregate (actual time=0.030..41.552 rows=33 loops=1)
Buffers: shared hit=214
Execution Time: 41.552 ms-- UNION ALL:四次扫描
EXPLAIN (ANALYZE, BUFFERS)
SELECT city, category, sum(amount) FROM sales GROUP BY city, category
UNION ALL SELECT city, NULL, sum(amount) FROM sales GROUP BY city
UNION ALL SELECT NULL, category, sum(amount) FROM sales GROUP BY category
UNION ALL SELECT NULL, NULL, sum(amount) FROM sales; Append (actual time=35.001..140.221 rows=33 loops=1)
-> GroupAggregate (actual time=0.028..35.001 rows=20 loops=1)
-> GroupAggregate (actual time=0.022..34.882 rows=5 loops=1)
-> GroupAggregate (actual time=0.024..35.110 rows=4 loops=1)
-> Aggregate (actual time=0.019..33.447 rows=1 loops=1)
Buffers: shared hit=856
Execution Time: 140.221 ms| 指标 | CUBE (city, category) | 四个 GROUP BY + UNION ALL | 相对提升 |
|---|---|---|---|
| 执行时间 | 41.6 ms | 140.2 ms | 3.4× |
| 共享缓冲命中 | 214 | 856 | 4.0× |
| 基表扫描次数 | 1 | 4 | — |
注意:CUBE 更快是相对同样的分组集而言。若 UNION ALL 只写 2 个分组集而 CUBE 算了 4 个,那就不是同口径比较。
慎用于生产的关键约束
| 风险 | 说明 | 缓解手段 |
|---|---|---|
| 结果集过大 | 2^N 个分组集,输出行数激增 | 减少列数、外层加 LIMIT、改 GROUPING SETS 只列所需组合 |
| 内存落盘 | 多套聚合状态挤爆 work_mem | 调大 work_mem、加复合索引走排序聚合 |
| 明细行混杂 | 明细与 7 类汇总混在一起,应用侧易误算 | 用 GROUPING() 打标签并显式过滤 |
| 排序成本 | 结果需按层级排序,Sort 可能落盘 | 用 ORDER BY GROUPING(...), 列 并配合索引 |
| 高基数列 | 组合爆炸且业务无意义 | 只放低基数维度列 |
实战示例
步骤 1:城市 / 品类 / 渠道全维度销量-利润矩阵
-- 完整 8 层矩阵,带中文层级标签
SELECT
CASE WHEN GROUPING(city) = 1 THEN '全部城市' ELSE city END AS 城市,
CASE WHEN GROUPING(category) = 1 THEN '全部品类' ELSE category END AS 品类,
CASE WHEN GROUPING(channel) = 1 THEN '全部渠道' ELSE channel END AS 渠道,
sum(qty) AS 销量,
sum(amount) AS 销售额,
sum(amount - cost) AS 利润,
round(100.0 * sum(amount - cost) / NULLIF(sum(amount), 0), 2) AS 利润率
FROM sales
GROUP BY CUBE (city, category, channel)
ORDER BY GROUPING(city), city,
GROUPING(category), category,
GROUPING(channel), channel; 城市 | 品类 | 渠道 | 销量 | 销售额 | 利润 | 利润率
-----------+----------+----------+------+----------+----------+--------
上海 | 手机 | 门店 | 201 | 8812.40 | 4211.30 | 47.79
上海 | 手机 | 线上 | 198 | 9033.02 | 4501.88 | 49.84
上海 | 手机 | | 399 | 17845.42 | 8713.18 | 48.83
上海 | | 门店 | 811 | 35201.77 | 17002.11 | 48.30
上海 | | | 1633 | 70332.20 | 33911.55 | 48.22
全部城市 | | 门店 | 4011 |491233.10 |240118.44 | 48.88
全部城市 | | | 8001 |1489002.55|721330.12 | 48.44步骤 2:与等价的 GROUPING SETS 自检一致性
-- CUBE 与手写 GROUPING SETS 双向差集应为空
WITH c AS (
SELECT city, category, channel, sum(amount) AS amt
FROM sales
GROUP BY CUBE (city, category)
-- 注意:这里 CUBE 只两列,便于与下方 4 个分组集对照
),
g AS (
SELECT city, category, NULL::text AS channel, sum(amount) AS amt
FROM sales
GROUP BY GROUPING SETS ((city, category), (city), (category), ())
)
SELECT '仅 CUBE' AS 差异, * FROM (SELECT * FROM c EXCEPT ALL SELECT * FROM g) x
UNION ALL
SELECT '仅 GROUPING SETS', * FROM (SELECT * FROM g EXCEPT ALL SELECT * FROM c) y; 差异 | city | category | channel | amt
------+------+----------+---------+-----
(0 rows) -- 完全等价步骤 3:只交付「按渠道 × 品类」的汇总层
-- 先用 CUBE 算全组合,再用 GROUPING() 精确筛出两层
SELECT * FROM (
SELECT
category,
channel,
GROUPING(category) AS g_cat,
GROUPING(channel) AS g_ch,
sum(qty) AS 销量,
sum(amount) AS 销售额
FROM sales
GROUP BY CUBE (category, channel)
) t
WHERE (g_cat = 0 AND g_ch = 0) -- 品类 × 渠道明细
OR (g_cat = 0 AND g_ch = 1) -- 品类小计
ORDER BY g_cat, category, g_ch, channel; category | channel | g_cat | g_ch | 销量 | 销售额
----------+---------+-------+------+------+----------
电脑 | 分销 | 0 | 0 | 661 | 176334.20
电脑 | 门店 | 0 | 0 | 670 | 179012.55
电脑 | 线上 | 0 | 0 | 663 | 177890.11
电脑 | | 0 | 1 | 1994 | 533236.86
手机 | 分销 | 0 | 0 | 655 | 175221.09
...步骤 4:组合嵌套 CUBE (city, ROLLUP (category, channel))
-- 只对 city 做全组合,对 category/channel 做层级上卷,避免 3 列全组合
SELECT
city,
category,
channel,
GROUPING(city) AS g_city,
GROUPING(category) AS g_cat,
GROUPING(channel) AS g_ch,
sum(amount) AS 销售额
FROM sales
GROUP BY CUBE (city, ROLLUP (category, channel))
ORDER BY GROUPING(category), category,
GROUPING(channel), channel,
GROUPING(city), city; city | category | channel | g_city | g_cat | g_ch | 销售额
-------+----------+---------+--------+-------+------+----------
北京 | 手机 | 门店 | 0 | 0 | 0 | 8821.40
北京 | 手机 | 线上 | 0 | 0 | 0 | 9133.02
北京 | 手机 | | 0 | 0 | 1 | 26844.53
北京 | | | 0 | 1 | 1 |105430.20
上海 | 手机 | 门店 | 0 | 0 | 0 | 8812.40
| 手机 | 门店 | 1 | 0 | 0 | 44106.99
| 手机 | | 1 | 0 | 1 |133412.55
| | | 1 | 1 | 1 |1489002.55共 6 个分组集:(city,category,channel)、(city,category)、(city)、(category,channel)、(category)、()。相比 CUBE (city, category, channel) 的 8 个,省掉了 (city, channel) 与 (channel) 两个通常无意义的组合。
步骤 5:验证内存占用与 work_mem 影响
-- 默认 work_mem 下观察 Batches
SET work_mem = '4MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT city, category, channel, sum(amount)
FROM sales
GROUP BY CUBE (city, category, channel); HashAggregate (cost=1892.00..2103.44 rows=1544 width=...) (actual time=48.221..52.118 rows=231 loops=1)
...
Batches: 1 Memory Usage: 481kB
Execution Time: 52.118 ms-- 换成高基数维度后,Batches 立即上升
EXPLAIN (ANALYZE, BUFFERS)
SELECT sold_at, city, category, sum(amount)
FROM sales
GROUP BY CUBE (sold_at, city, category); -- sold_at 基数 180,组合行数暴增 HashAggregate (actual time=121.441..188.903 rows=18422 loops=1)
...
Batches: 4 Memory Usage: 4225kB -- 已溢出一部分到磁盘
Disk Usage: 1344kB
Execution Time: 188.903 ms一个 sold_at 列的加入,把结果从 231 行推到 18422 行、耗时涨了 3.6 倍,并且开始落盘。这就是「CUBE 列数每加一,代价翻倍」的直接体感。
注意事项
CUBE (a, b)已包含(),无需再手动补总计层;而嵌套写法CUBE (a, ROLLUP (b, c))也含(),但ROLLUP (a, ROLLUP (b, c))不含。CUBE生成 2^N 个分组集,ROLLUP只有 N+1 个,别把两者当等价语法糖混用:sql-- 错误!需要层级上卷却用了 CUBE,多算了 (category, channel) 等组合 SELECT city, category, channel, sum(amount) FROM sales GROUP BY CUBE (city, category, channel);- 不要在
CUBE里放高基数列(user_id、order_no、sold_at细粒度日期),结果行数与内存会失控。 GROUPING()不能写在WHERE里——WHERE在聚合前求值,必须用HAVING或外层查询:sql-- 错误!聚合尚未产生,GROUPING() 无意义 SELECT city, sum(amount) FROM sales WHERE GROUPING(city) = 1 GROUP BY CUBE (city, category); -- 正确 SELECT city, sum(amount) FROM sales GROUP BY CUBE (city, category) HAVING GROUPING(city) = 1;- 不要用
IS NULL判断汇总行,CUBE下多个列可能同时为NULL,且真实数据也可能含空值,用GROUPING()。 Batches > 1或Disk Usage非零要立刻警觉,说明哈希聚合已落盘,应先降列数再考虑调work_mem。CUBE结果集大,应用层务必按GROUPING()区分层级,否则会把汇总行当明细行二次累加。- 不要用
CUBE替代多次精确查询。只需要 2~3 个特定组合时,GROUPING SETS明确列举更省资源。 CUBE需要 PostgreSQL 9.5+。
最佳实践
- 列数控制在 3 列以内作为硬性经验值:
CUBE (a, b, c)已是 8 个分组集,第 4 列就变 16 个。 - 优先用
GROUPING SETS显式列举所需组合,只有当真的需要「任意维度切片」时才用CUBE。 - 层级维度用
ROLLUP、正交维度用CUBE,混用时优先CUBE (a, ROLLUP (b, c))这类嵌套形式而非全CUBE。 - 统一用
CASE WHEN GROUPING(x) = 1 THEN '全部xx' ELSE x END输出中文标签,让结果可直接交付。 - 排序固定为
ORDER BY GROUPING(x), x逐列展开,层级结构清晰、汇总行紧跟明细。 - 筛选汇总层一律用
HAVING GROUPING(...) = 1,或包一层子查询在外层过滤。 - 上线前用
EXPLAIN (ANALYZE, BUFFERS)复核:确认只扫描一次基表、Batches = 1、Disk Usage为空。 - 大表上先建覆盖所有分组键的复合索引(如
(city, category, channel)),让优化器有机会选GroupAggregate复用索引顺序。 - 会话级临时调大
work_mem(SET work_mem = '256MB')跑报表,跑完即随连接释放,不要全局调大。
下一节