Featured image of post DuckDB GROUP BY 聚合:从基础到高级的完整指南

DuckDB GROUP BY 聚合:从基础到高级的完整指南

深入探索 DuckDB 的 GROUP BY 聚合功能,包括基础语法、特色功能 GROUP BY ALL、高级分组 ROLLUP/CUBE,以及真实性能测试数据

数据分析做到最后,其实就是在做一件事:把数据分组,然后算点什么东西。

不管是统计每个区域的销售额、计算每只股票的平均价格,还是分析用户行为的时间分布,GROUP BY 都是绕不开的核心操作。这篇文章把 DuckDB 的 GROUP BY 从头到尾捋一遍,从最基础的语法到高级的 ROLLUP、CUBE,还有几个让我踩坑的地方,都记下来了。

基础语法:没什么特别的

先说最基础的,DuckDB 的 GROUP BY 和标准 SQL 完全一样,没什么学习成本:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
SELECT 
    region,
    COUNT(*) as trade_count,
    SUM(amount) as total_amount,
    AVG(amount) as avg_amount
FROM sales_data
GROUP BY region
```sql

就这么简单。`GROUP BY` 后面跟分组列,`SELECT` 里可以放分组列和聚合函数。

常用的聚合函数就这些:
- `COUNT(*)` / `COUNT(column)` - 计数
- `SUM(column)` - 求和
- `AVG(column)` - 平均值
- `MIN(column)` / `MAX(column)` - 最小值/最大值
- `MEDIAN(column)` - 中位数
- `STDDEV(column)` - 标准差
- `COUNT(DISTINCT column)` - 去重计数

来个实际例子:

```python
import duckdb

conn = duckdb.connect(':memory:')

# 创建测试数据
conn.execute("""
    CREATE TABLE financial_trades AS
    SELECT 
        'AAPL' as symbol,
        TIMESTAMP '2024-01-01' + INTERVAL (random() * 365) DAY as trade_date,
        (random() * 1000)::INTEGER as quantity,
        (150 + random() * 50)::DECIMAL(10,2) as price
    FROM generate_series(1, 10000)
""")

# 基础聚合查询
result = conn.execute("""
    SELECT 
        symbol,
        COUNT(*) as trade_count,
        SUM(quantity) as total_qty,
        AVG(price) as avg_price
    FROM financial_trades
    GROUP BY symbol
""").fetchall()

print(result)
# 输出: [('AAPL', 10000, 4975183, 174.93677)]
```text

10,000 行数据,8.42ms 搞定。性能还行。

## 多列分组:常见场景

实际业务中,单列分组往往不够用。比如要统计"每个区域每个类别"的销售情况:

```python
# 创建多维度销售数据
conn.execute("""
    CREATE TABLE sales_data AS
    SELECT 
        ['North', 'South', 'East', 'West'][floor(random() * 4 + 1)::INTEGER] as region,
        ['Electronics', 'Clothing', 'Food'][floor(random() * 3 + 1)::INTEGER] as category,
        (random() * 1000)::INTEGER as amount
    FROM generate_series(1, 50000)
""")

# 多列分组
result = conn.execute("""
    SELECT 
        region,
        category,
        COUNT(*) as cnt,
        SUM(amount) as total_amount,
        AVG(amount) as avg_amount
    FROM sales_data
    GROUP BY region, category
    ORDER BY region, category
""").fetchall()

print(f"结果行数: {len(result)}")
# 输出: 结果行数: 12
```sql

4 个区域 × 3 个类别 = 12 个组合,50,000 行数据 7.96ms 完成。

## DuckDB 特色功能:GROUP BY ALL

这里要说说 DuckDB 的一个特色功能:**GROUP BY ALL**。

写多列分组的时候,经常要手动列出所有非聚合列:

```sql
SELECT 
    region,
    category,
    COUNT(*) as cnt,
    SUM(amount) as total
FROM sales_data
GROUP BY region, category  -- 要手动写
```sql

列多的时候特别烦,尤其是改了 SELECT 还要记得改 GROUP BY。

DuckDB 的解决方案是 `GROUP BY ALL`:

```sql
SELECT 
    region,
    category,
    COUNT(*) as cnt,
    SUM(amount) as total
FROM sales_data
GROUP BY ALL  -- 自动对所有非聚合列分组
ORDER BY total DESC
LIMIT 5
```sql

效果完全一样,但省事了。这个功能在其他数据库里也有(Snowflake、Databricks),但 DuckDB 是本地分析场景里用得最顺手的。

实测耗时 4.81ms,和手动写 GROUP BY 差不多。

## FILTER 子句:条件聚合

另一个好用的功能是 **FILTER 子句**。

有时候需要在聚合函数里加条件,比如"只统计金额大于 5000 的交易"。传统做法是用 CASE WHEN:

```sql
SELECT 
    region,
    COUNT(*) as total_cnt,
    SUM(CASE WHEN amount > 5000 THEN amount ELSE 0 END) as high_value_amount
FROM large_sales
GROUP BY region
```text

DuckDB 支持更简洁的 FILTER 语法:

```sql
SELECT 
    region,
    COUNT(*) as total_cnt,
    COUNT(*) FILTER (WHERE amount > 5000) as high_value_cnt,
    SUM(amount) as total_amount,
    SUM(amount) FILTER (WHERE amount > 5000) as high_value_amount
FROM large_sales
GROUP BY region
ORDER BY region
```text

实测结果:

```text
[('East', 250434, 124911, 1251059721, 937455970), 
 ('North', 249602, 124964, 1248175625, 937222748), 
 ('South', 249899, 124966, 1248661903, 937084205), 
 ('West', 250065, 124953, 1251654874, 937810660)]
```sql

100 万行数据,9.50ms 完成。比 CASE WHEN 写法更易读,性能也没差别。

## HAVING 子句:过滤分组结果

WHERE 过滤行,HAVING 过滤分组。这个区别很多人刚接触 SQL 的时候容易搞混。

```sql
SELECT 
    region,
    COUNT(*) as cnt,
    SUM(amount) as total
FROM sales_data
GROUP BY region
HAVING SUM(amount) > 1000000  -- 过滤分组后的结果
ORDER BY total DESC
```text

实测结果:

```text
符合条件的区域数: 4
结果: [('East', 12656, 6307774), 
       ('South', 12523, 6253178), 
       ('North', 12398, 6193748), 
       ('West', 12423, 6158720)]
```text

4.31ms 完成。

有个坑要注意:**HAVING 里不能用别名**。

```sql
-- ❌ 错误
SELECT region, SUM(amount) as total
FROM sales
GROUP BY region
HAVING total > 1000000  -- 不能用别名

-- ✅ 正确
SELECT region, SUM(amount) as total
FROM sales
GROUP BY region
HAVING SUM(amount) > 1000000  -- 要重复聚合表达式
```text

这个我踩过坑,写的时候要注意。

## 高级分组:ROLLUP、CUBE、GROUPING SETS

接下来是重头戏:**层次化聚合**。

### ROLLUP:层次化聚合

假设要做"区域-类别"的层级分析,既要看到每个区域每个类别的数据,也要看到每个区域的总计,还要看到全局总计。

传统做法是写多个查询然后 UNION,或者用 ROLLUP:

```sql
SELECT 
    region,
    category,
    SUM(amount) as total
FROM sales_data
GROUP BY ROLLUP (region, category)
ORDER BY region, category
```text

结果行数:17 行

前几行:
```text
('East', 'Clothing', 2112548), 
('East', 'Electronics', 2152267), 
('East', 'Food', 2042959), 
('East', None, 6307774),  -- East 区域的总计
('North', 'Clothing', 2032822)
```sql

`GROUP BY ROLLUP (region, category)` 等价于:
- `(region, category)` - 每个区域每个类别:12 行
- `(region)` - 每个区域的总计(category 为 NULL):4 行
- `()` - 全局总计(region 和 category 都为 NULL):1 行

总计:12 + 4 + 1 = 17 行

耗时 6.98ms。

### CUBE:全组合聚合

CUBE 比 ROLLUP 更激进,生成所有可能的组合:

```sql
SELECT 
    region,
    category,
    COUNT(*) as cnt,
    SUM(amount) as total
FROM sales_data
GROUP BY CUBE (region, category)
ORDER BY region NULLS LAST, category NULLS LAST
```sql

结果行数:20 行

`GROUP BY CUBE (region, category)` 等价于:
- `(region, category)` - 12 行
- `(region)` - 4 行
- `(category)` - 3 行
- `()` - 1 行(全局总计)

总计:12 + 4 + 3 + 1 = 20 行

比 ROLLUP 多了"按类别的总计"这个维度。耗时 7.45ms。

### GROUPING SETS:自定义组合

如果觉得 ROLLUP 和 CUBE 都不够灵活,可以用 GROUPING SETS 精确控制:

```sql
SELECT 
    region,
    category,
    SUM(amount) as total
FROM sales_data
GROUP BY GROUPING SETS (
    (region),      -- 按区域分组
    (category),    -- 按类别分组
    ()             -- 全局总计
)
ORDER BY region NULLS LAST, category NULLS LAST
```text

结果行数:8 行

前几行:
```text
('East', None, 6307774), 
('North', None, 6193748), 
('South', None, 6253178), 
('West', None, 6158720), 
(None, 'Clothing', 8327577)
```sql

最灵活,可以精确控制需要哪些组合。耗时 5.08ms。

### 适用场景

- **ROLLUP**:层级报表,比如"年-月-日"、"区域-类别"
- **CUBE**:需要所有维度组合的分析
- **GROUPING SETS**:只需要特定组合的场景

有个坑要注意:ROLLUP/CUBE 生成的总计行中,未分组的列显示为 NULL。如果数据本身就有 NULL,需要用 `GROUPING()` 函数区分真实 NULL 和总计 NULL。

## 窗口函数 + GROUP BY:组合技

窗口函数可以在 GROUP BY 之后使用,这个组合非常强大:

```sql
SELECT 
    region,
    SUM(amount) as region_total,
    RANK() OVER (ORDER BY SUM(amount) DESC) as rank,
    SUM(SUM(amount)) OVER () as grand_total,
    SUM(amount) * 100.0 / SUM(SUM(amount)) OVER () as pct
FROM large_sales
GROUP BY region
ORDER BY region_total DESC
```text

实测结果:

```text
[('West', 1251654874, 1, 4999552123, 25.035340030597375), 
 ('East', 1251059721, 2, 4999552123, 25.0234359042805), 
 ('South', 1248661903, 3, 4999552123, 24.975475248185546), 
 ('North', 1248175625, 4, 4999552123, 24.965748816936575)]
```sql

几个关键点:
- `RANK() OVER (ORDER BY SUM(amount) DESC)` - 对聚合结果排名
- `SUM(SUM(amount)) OVER ()` - 嵌套聚合:先 GROUP BY 求和,再对所有组求和
- 计算每个区域的占比

耗时 11.38ms。

## 性能测试:100 万行数据

说了这么多,看看实际性能如何。用 100 万行数据测试:

```python
# 创建 100 万行数据
conn.execute("""
    CREATE TABLE large_sales AS
    SELECT 
        ['North', 'South', 'East', 'West'][floor(random() * 4 + 1)::INTEGER] as region,
        ['A', 'B', 'C', 'D', 'E'][floor(random() * 5 + 1)::INTEGER] as product,
        (random() * 10000)::INTEGER as amount
    FROM generate_series(1, 1000000)
""")

# 性能测试
start = time.time()
result = conn.execute("""
    SELECT 
        region,
        product,
        COUNT(*) as cnt,
        SUM(amount) as total
    FROM large_sales
    GROUP BY region, product
    ORDER BY total DESC
""").fetchall()
duration = time.time() - start

print(f"耗时: {duration*1000:.2f}ms")
print(f"处理速度: {1000000/duration:.0f} 行/秒")
```text

实测结果:

```text
耗时: 12.17ms
处理速度: 82,202,571 行/秒
```sql

**8200 万行/秒**,这个数字有点吓人。

汇总一下各个场景的性能数据:

| 测试场景 | 数据量 | 耗时 | 处理速度 |
|---------|--------|------|---------|
| 基础 GROUP BY | 10,000 行 | 8.42ms | 119 万行/秒 |
| 多列 GROUP BY | 50,000 行 | 7.96ms | 628 万行/秒 |
| HAVING 子句 | 50,000 行 | 4.31ms | 1160 万行/秒 |
| GROUP BY ALL | 50,000 行 | 4.81ms | 1040 万行/秒 |
| ROLLUP | 50,000 行 | 6.98ms | 716 万行/秒 |
| CUBE | 50,000 行 | 7.45ms | 671 万行/秒 |
| GROUPING SETS | 50,000 行 | 5.08ms | 984 万行/秒 |
| 大数据集 GROUP BY | 1,000,000 行 | 12.17ms | **8220 万行/秒** |
| FILTER 条件聚合 | 1,000,000 行 | 9.50ms | **1.05 亿行/秒** |
| 窗口函数 + GROUP BY | 1,000,000 行 | 11.38ms | 8790 万行/秒 |

DuckDB 的向量化执行引擎在 GROUP BY 场景下表现确实优秀。即使是复杂的 ROLLUP/CUBE,也能在毫秒级完成。

## 执行计划:看看底层在干嘛

用 EXPLAIN 看看 DuckDB 是怎么执行 GROUP BY 的:

```python
explain = conn.execute("""
    EXPLAIN SELECT 
        region,
        COUNT(*),
        SUM(amount)
    FROM large_sales
    GROUP BY region
""").fetchall()

for row in explain:
    print(row)
```text

执行计划:

```text
┌───────────────────────────┐
│         PROJECTION        │  ← 最终投影
│    ────────────────────   │
│           region          │
│             #1            │
│             #2            │
│          ~4 rows          │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│       HASH_GROUP_BY       │  ← 哈希分组(核心操作)
│    ────────────────────   │
│         Groups: #0        │
│                           │
│        Aggregates:        │
│        count_star()       │
│    sum_no_overflow(#1)    │
│                           │
│          ~4 rows          │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│         PROJECTION        │  ← 数据投影
│           region          │
│           amount          │
│      ~1,000,000 rows      │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│          SEQ_SCAN         │  ← 顺序扫描
│    ────────────────────   │
│           Table:          │
│  memory.main.large_sales  │
│                           │
│   Type: Sequential Scan   │
│                           │
│        Projections:       │
│           region          │
│           amount          │
│                           │
│      ~1,000,000 rows      │
└───────────────────────────┘
```sql

执行流程:顺序扫描 → 投影 → 哈希分组 → 最终投影

DuckDB 使用 **HASH_GROUP_BY** 算法,这是高效的分组算法,适合大多数场景。估计行数从 1,000,000 行压缩到 4 行。

## 踩坑记录

写 GROUP BY 的时候,有几个坑容易踩:

### 坑 1:GROUP BY 列必须出现在 SELECT 中

```sql
-- ❌ 错误
SELECT region, SUM(amount)
FROM sales
GROUP BY region, category  -- category 不在 SELECT 中

-- ✅ 正确
SELECT region, category, SUM(amount)
FROM sales
GROUP BY region, category
```sql

或者直接用 `GROUP BY ALL`,省事。

### 坑 2:HAVING 中不能使用别名

前面说过了,再强调一下:

```sql
-- ❌ 错误
SELECT region, SUM(amount) as total
FROM sales
GROUP BY region
HAVING total > 1000000  -- 不能用别名

-- ✅ 正确
SELECT region, SUM(amount) as total
FROM sales
GROUP BY region
HAVING SUM(amount) > 1000000  -- 重复聚合表达式
```sql

### 坑 3:NULL 值在 GROUP BY 中的处理

NULL 值会被视为一个独立的分组。如果数据里有 NULL,要注意这一点。

使用 `NULLS LAST` 或 `NULLS FIRST` 控制排序:

```sql
ORDER BY region NULLS LAST
```text

### 坑 4:ROLLUP/CUBE 的 NULL 值

ROLLUP/CUBE 生成的总计行中,未分组的列显示为 NULL。如果数据本身就有 NULL,需要用 `GROUPING()` 函数区分:

```sql
SELECT 
    region,
    category,
    SUM(amount) as total,
    GROUPING(region) as is_region_total,
    GROUPING(category) as is_category_total
FROM sales_data
GROUP BY ROLLUP (region, category)
```sql

`GROUPING()` 函数返回 1 表示这是总计行,返回 0 表示这是普通分组行。

## 总结

DuckDB 的 GROUP BY 功能完整,性能优秀,几个特色功能很实用:

**基础功能:**
- 标准 SQL 语法,没什么学习成本
- 支持多列分组、HAVING 过滤

**特色功能:**
- `GROUP BY ALL` - 自动对所有非聚合列分组,省事
- `FILTER` 子句 - 条件聚合,比 CASE WHEN 更简洁

**高级功能:**
- `ROLLUP` - 层次化聚合,适合报表
- `CUBE` - 全组合聚合
- `GROUPING SETS` - 自定义组合,最灵活

**性能:**
- 100 万行数据,12ms 内完成 GROUP BY
- 处理速度:8000 万 - 1 亿行/秒
- 向量化执行引擎的优势明显

写 GROUP BY 的时候,注意那几个坑:GROUP BY 列要在 SELECT 中、HAVING 不能用别名、NULL 值的处理。

GROUP BY 是数据分析的核心操作,DuckDB 在这方面做得不错。如果要做本地数据分析,DuckDB 值得一试。

## 参考资源

- [DuckDB 官方文档 - GROUP BY](https://duckdb.org/docs/current/sql/query_syntax/groupby.html)
- [DuckDB 官方文档 - 聚合函数](https://duckdb.org/docs/current/sql/functions/aggregates.html)
- [DuckDB 官方文档 - GROUPING SETS](https://duckdb.org/docs/current/sql/query_syntax/grouping_sets.html)
- [DuckDB 官方文档 - HAVING](https://duckdb.org/docs/current/sql/query_syntax/having.html)


`GROUPING()` 函数返回 1 表示这是总计行,返回 0 表示这是普通分组行。

## 总结

DuckDB 的 GROUP BY 功能完整,性能优秀,几个特色功能很实用:

**基础功能:**
- 标准 SQL 语法,没什么学习成本
- 支持多列分组、HAVING 过滤

**特色功能:**
- `GROUP BY ALL` - 自动对所有非聚合列分组,省事
- `FILTER` 子句 - 条件聚合,比 CASE WHEN 更简洁

**高级功能:**
- `ROLLUP` - 层次化聚合,适合报表
- `CUBE` - 全组合聚合
- `GROUPING SETS` - 自定义组合,最灵活

**性能:**
- 100 万行数据,12ms 内完成 GROUP BY
- 处理速度:8000 万 - 1 亿行/秒
- 向量化执行引擎的优势明显

写 GROUP BY 的时候,注意那几个坑:GROUP BY 列要在 SELECT 中、HAVING 不能用别名、NULL 值的处理。

GROUP BY 是数据分析的核心操作,DuckDB 在这方面做得不错。如果要做本地数据分析,DuckDB 值得一试。

## 参考资源

- [DuckDB 官方文档 - GROUP BY](https://duckdb.org/docs/current/sql/query_syntax/groupby.html)
- [DuckDB 官方文档 - 聚合函数](https://duckdb.org/docs/current/sql/functions/aggregates.html)
- [DuckDB 官方文档 - GROUPING SETS](https://duckdb.org/docs/current/sql/query_syntax/grouping_sets.html)
- [DuckDB 官方文档 - HAVING](https://duckdb.org/docs/current/sql/query_syntax/having.html)
Built with Hugo
Theme Stack designed by Jimmy