Featured image of post DuckDB直接查询Parquet:76倍性能提升的秘密

DuckDB直接查询Parquet:76倍性能提升的秘密

实测DuckDB查询Parquet文件,对比CSV性能提升76倍。深入解析列裁剪、谓词下推等核心优化机制,附完整代码和踩坑记录。

第一次看到76倍这个数字的时候,我有点懵。

同样是10万行数据,同样是简单的聚合查询,CSV跑了0.1267秒,Parquet只用了0.0017秒。我当时反复确认了三遍,以为是代码写错了。

但事实就是事实。Parquet这种列式存储格式,配合DuckDB的查询优化,性能差距就是这么夸张。

这篇文章不是要吹Parquet有多牛,而是想搞清楚:为什么差距这么大?这些优化是怎么工作的?实际用起来要注意什么?

从CSV到Parquet:一次性能认知的刷新

之前做数据分析,习惯了CSV的简单直接。一个文本文件,逗号分隔,打开就能看。直到有一天处理一个2GB的CSV文件,DuckDB查询慢得让人怀疑人生。

后来试着转成Parquet格式,同样的查询,速度快了几十倍。那一刻我才意识到,文件格式的选择对性能的影响,远比SQL优化来得直接。

先看看基础语法

DuckDB查询Parquet文件非常简单:

  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
-- 基础查询
SELECT * FROM read_parquet('data.parquet');

-- 带过滤条件
SELECT * FROM read_parquet('data.parquet') 
WHERE status = 'completed';

-- 查询多个文件
SELECT * FROM read_parquet('data/*.parquet');
```sql

就这么几行代码,背后却藏着一系列自动优化机制。

## 列裁剪:只读你需要的

Parquet是列式存储,这意味着数据是按列组织的,而不是按行。DuckDB利用这个特性,只读取查询中实际用到的列。

实测数据很能说明问题:

```python
import duckdb
import time

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

# 测试数据:10万行,5列
# 查询2列
start = time.time()
result = conn.execute("""
    SELECT id, amount 
    FROM read_parquet('test_100k.parquet')
""").fetchall()
time_2cols = time.time() - start

# 查询全部5列
start = time.time()
result = conn.execute("""
    SELECT * 
    FROM read_parquet('test_100k.parquet')
""").fetchall()
time_all_cols = time.time() - start

print(f"查询2列: {time_2cols:.4f}秒")
print(f"查询全部列: {time_all_cols:.4f}秒")
print(f"加速比: {time_all_cols/time_2cols:.2f}x")
```sql

运行结果:

```text
查询2列: 0.0349秒
查询全部列: 0.1208秒
加速比: 3.46x
```sql

只查询2列比查询全部5列快了3.46倍。这个差距会随着列数增加而变得更明显。

关键是,这个优化是自动的。你不需要做任何额外配置,DuckDB会自动分析查询计划,只读取需要的列。

## 谓词下推:在读取前就过滤

WHERE条件不是在读完数据后才过滤,而是在读取Parquet文件时就已经开始过滤了。

Parquet文件里存了每个数据块的min/max统计信息。DuckDB利用这些信息,直接跳过不满足条件的数据块,根本不读取那些数据。

```python
# 带过滤条件
start = time.time()
result = conn.execute("""
    SELECT * 
    FROM read_parquet('test_100k.parquet')
    WHERE status = 'completed'
""").fetchall()
time_filtered = time.time() - start

# 不带过滤
start = time.time()
result = conn.execute("""
    SELECT * 
    FROM read_parquet('test_100k.parquet')
""").fetchall()
time_all = time.time() - start

print(f"过滤查询: {time_filtered:.4f}秒")
print(f"全表扫描: {time_all:.4f}秒")
print(f"加速比: {time_all/time_filtered:.2f}x")
```text

结果:

```text
过滤查询: 0.0536秒 (返回33334行)
全表扫描: 0.1099秒 (返回100000行)
加速比: 2.05x
```sql

过滤掉66%的数据,查询速度提升2倍。选择性越高,加速效果越明显。

## Parquet vs CSV:完整的性能对比

这才是重头戏。同样的10万行数据,5列,完整的对比测试:

### 文件大小

```python
import os

csv_size = os.path.getsize('test_100k.csv') / 1024
parquet_size = os.path.getsize('test_100k.parquet') / 1024

print(f"CSV: {csv_size:.2f} KB")
print(f"Parquet: {parquet_size:.2f} KB")
print(f"压缩比: {csv_size/parquet_size:.2f}x")
```sql

```text
CSV: 5984.87 KB
Parquet: 1203.63 KB
压缩比: 4.97x
```sql

Parquet文件只有CSV的五分之一大小。

### 查询性能

```python
# CSV聚合查询
start = time.time()
result = conn.execute("""
    SELECT status, COUNT(*) as cnt, AVG(amount) as avg_amount
    FROM read_csv_auto('test_100k.csv')
    GROUP BY status
""").fetchall()
time_csv = time.time() - start

# Parquet聚合查询
start = time.time()
result = conn.execute("""
    SELECT status, COUNT(*) as cnt, AVG(amount) as avg_amount
    FROM read_parquet('test_100k.parquet')
    GROUP BY status
""").fetchall()
time_parquet = time.time() - start

print(f"CSV聚合: {time_csv:.4f}秒")
print(f"Parquet聚合: {time_parquet:.4f}秒")
print(f"性能提升: {time_csv/time_parquet:.2f}x")
```json

```text
CSV聚合: 0.1267秒
Parquet聚合: 0.0017秒
性能提升: 76.16x
```text

76倍。这个数字每次看到都会觉得不可思议。

为什么差距这么大?几个因素叠加:

1. **列式存储** - 只读取需要的列
2. **智能压缩** - 每列独立压缩,压缩比高
3. **统计信息** - min/max统计支持谓词下推
4. **类型保留** - 不需要像CSV那样推断数据类型

## 压缩算法的选择

Parquet支持多种压缩算法,不同算法在压缩比和速度上有差异。

```python
# 测试不同压缩算法
compressions = ['SNAPPY', 'ZSTD', 'GZIP', 'LZ4']

for comp in compressions:
    conn.execute(f"""
        COPY (SELECT * FROM read_parquet('test_100k.parquet'))
        TO 'test_{comp}.parquet' 
        (FORMAT PARQUET, COMPRESSION '{comp}')
    """)
    
    size = os.path.getsize(f'test_{comp}.parquet') / 1024
    
    start = time.time()
    result = conn.execute(f"""
        SELECT COUNT(*) FROM read_parquet('test_{comp}.parquet')
    """).fetchone()
    query_time = time.time() - start
    
    print(f"{comp}: {size:.2f} KB, 查询: {query_time:.4f}秒")
```sql

结果:

```text
SNAPPY: 1203.63 KB, 查询: 0.0006秒
ZSTD: 879.13 KB, 查询: 0.0004秒
GZIP: 895.88 KB, 查询: 0.0004秒
LZ4: 1188.03 KB, 查询: 0.0005秒
```sql

几个观察:

- ZSTD压缩比最高,文件最小
- ZSTD和GZIP查询速度最快
- SNAPPY是默认选择,平衡了压缩比和速度
- 压缩算法对查询性能影响很小,都在毫秒级

实际选择建议:

- 默认用SNAPPY,各方面都平衡
- 追求最小文件体积用ZSTD
- 追求压缩速度用LZ4

## 多文件查询和Hive分区

实际项目中,数据往往分散在多个文件里。DuckDB支持glob模式查询多个文件:

```python
# 创建分片文件
for i in range(3):
    conn.execute(f"""
        COPY (
            SELECT * FROM read_parquet('test_100k.parquet')
            WHERE id BETWEEN {i*33333+1} AND {(i+1)*33333}
        ) TO 'test_part_{i}.parquet' (FORMAT PARQUET)
    """)

# 查询所有分片
start = time.time()
result = conn.execute("""
    SELECT COUNT(*) 
    FROM read_parquet('test_part_*.parquet')
""").fetchone()
query_time = time.time() - start

print(f"多文件查询: {result[0]}行, 耗时: {query_time:.4f}秒")
```sql

```text
多文件查询: 99999行, 耗时: 0.0047秒
```sql

更强大的是Hive分区支持。按时间维度分区是常见做法:

```python
# 目录结构:
# hive_data/year=2024/month=01/data.parquet
# hive_data/year=2024/month=02/data.parquet

# 查询分区数据
result = conn.execute("""
    SELECT *, year, month
    FROM read_parquet('hive_data/*/*/*.parquet', hive_partitioning=true)
    LIMIT 5
""").fetchdf()

# 分区过滤
start = time.time()
result = conn.execute("""
    SELECT COUNT(*)
    FROM read_parquet('hive_data/*/*/*.parquet', hive_partitioning=true)
    WHERE year=2024 AND month=01
""").fetchone()
query_time = time.time() - start

print(f"分区过滤: {result[0]}行, 耗时: {query_time:.4f}秒")
```sql

```text
分区过滤: 50000行, 耗时: 0.0013秒
```sql

分区列会自动识别和提取,分区过滤性能极佳。这对时间序列数据和大数据集特别有用。

## 一些实用的高级技巧

### 列操作

```sql
-- REPLACE修改列
SELECT * REPLACE (amount * 1.1 AS amount)
FROM read_parquet('data.parquet');

-- EXCEPT排除列
SELECT * EXCLUDE (created_date)
FROM read_parquet('data.parquet');
```sql

### 数据采样

```sql
-- 10%采样
SELECT * FROM read_parquet('data.parquet')
USING SAMPLE 10%;
```sql

### 窗口函数

```sql
SELECT
    id,
    amount,
    ROW_NUMBER() OVER (ORDER BY amount DESC) as rank,
    LAG(amount) OVER (ORDER BY id) as prev_amount
FROM read_parquet('data.parquet')
LIMIT 10;
```sql

### CTE查询

```sql
WITH status_stats AS (
    SELECT
        status,
        COUNT(*) as cnt,
        AVG(amount) as avg_amount
    FROM read_parquet('data.parquet')
    GROUP BY status
)
SELECT * FROM status_stats WHERE cnt > 30000;
```text

这些语法和查询普通表完全一样,DuckDB会自动优化对Parquet的读取。

## 踩坑记录

### 压缩参数错误

一开始想关闭压缩,用了`COMPRESSION 'none'`,结果报错:

```text
Expected compression argument to be any of [uncompressed, brotli, gzip, snappy, lz4, lz4_raw or zstd]
```sql

正确的写法是用`uncompressed`:

```sql
COPY data TO 'file.parquet' 
(FORMAT PARQUET, COMPRESSION 'uncompressed')
```sql

### Hive分区列重复

查询Hive分区数据时,发现分区列重复了:

```sql
SELECT *, year, month
FROM read_parquet('data/*/*/*.parquet', hive_partitioning=true)
-- 结果出现 year, month, year_1, month_1
```sql

原因是分区列会自动添加,不需要手动SELECT。正确的做法:

```sql
SELECT * EXCLUDE (year_1, month_1)
FROM read_parquet('data/*/*/*.parquet', hive_partitioning=true)
```sql

或者直接:

```sql
SELECT *
FROM read_parquet('data/*/*/*.parquet', hive_partitioning=true)
```sql

### USE_STATISTICS参数

看到文档提到统计信息优化,试着加了`USE_STATISTICS true`参数:

```sql
COPY data TO 'file.parquet'
(FORMAT PARQUET, USE_STATISTICS true)
-- 报错: Unrecognized option "USE_STATISTICS"
```text

后来才明白,DuckDB自动使用Parquet的统计信息,不需要手动开启。Parquet文件本身就包含min/max统计,DuckDB会自动利用。

## 性能数据汇总

把所有测试结果整理一下:

| 测试项 | 数值 | 说明 |
|--------|------|------|
| 列裁剪加速比 | 3.46x | 2列 vs 5列 |
| 谓词下推加速比 | 2.05x | 过滤66%数据 |
| Parquet vs CSV | 76.16x | 聚合查询 |
| 文件压缩比 | 4.97x | Parquet vs CSV |
| 多文件查询 | 0.0047秒 | 3个分片文件 |
| 分区过滤 | 0.0013秒 | Hive分区 |

压缩算法对比:

| 算法 | 文件大小 | 查询耗时 | 推荐场景 |
|------|---------|---------|---------|
| SNAPPY | 1203.63 KB | 0.0006秒 | 默认选择 |
| ZSTD | 879.13 KB | 0.0004秒 | 追求最小文件 |
| GZIP | 895.88 KB | 0.0004秒 | 平衡选择 |
| LZ4 | 1188.03 KB | 0.0005秒 | 追求压缩速度 |

## 什么时候该用Parquet

说了这么多Parquet的好话,但也不是所有场景都适合。

**适合用Parquet的场景:**

- 数据分析、BI报表
- 大规模数据查询
- 需要频繁聚合操作
- 数据写入后主要用来读取
- 需要长期存储的历史数据

**不适合用Parquet的场景:**

- 需要频繁单行更新
- 数据量很小(几百行)
- 需要人类直接阅读编辑
- 需要流式写入

CSV的优势在于简单、通用、可读。Parquet的优势在于性能、压缩、类型安全。选择哪个,取决于具体的使用场景。

## 最后的思考

76倍的性能提升,不是魔法,是列式存储、智能压缩、统计信息、查询优化这些技术叠加的结果。

DuckDB把这些优化做得很透明,用户不需要关心底层细节,写普通的SQL就能享受到这些性能提升。这可能是现代数据工具最重要的特质:把复杂的事情变简单。

从CSV到Parquet,不只是文件格式的改变,更是思维方式的转变。当你开始关注数据的存储格式、访问模式、查询特征,你就从"能用就行"进入了"性能优化"的领域。

这个转变,可能比任何具体的技术细节都重要。

## 参考资源

- [DuckDB官方文档 - Parquet读取](https://duckdb.org/docs/data/parquet/read)
- [DuckDB官方文档 - Parquet存储](https://duckdb.org/docs/data/parquet/write)
- [Apache Parquet格式规范](https://parquet.apache.org/docs/)
- [DuckDB性能优化](https://duckdb.org/docs/optimization/overview)


后来才明白,DuckDB自动使用Parquet的统计信息,不需要手动开启。Parquet文件本身就包含min/max统计,DuckDB会自动利用。

## 性能数据汇总

把所有测试结果整理一下:

| 测试项 | 数值 | 说明 |
|--------|------|------|
| 列裁剪加速比 | 3.46x | 2列 vs 5列 |
| 谓词下推加速比 | 2.05x | 过滤66%数据 |
| Parquet vs CSV | 76.16x | 聚合查询 |
| 文件压缩比 | 4.97x | Parquet vs CSV |
| 多文件查询 | 0.0047秒 | 3个分片文件 |
| 分区过滤 | 0.0013秒 | Hive分区 |

压缩算法对比:

| 算法 | 文件大小 | 查询耗时 | 推荐场景 |
|------|---------|---------|---------|
| SNAPPY | 1203.63 KB | 0.0006秒 | 默认选择 |
| ZSTD | 879.13 KB | 0.0004秒 | 追求最小文件 |
| GZIP | 895.88 KB | 0.0004秒 | 平衡选择 |
| LZ4 | 1188.03 KB | 0.0005秒 | 追求压缩速度 |

## 什么时候该用Parquet

说了这么多Parquet的好话,但也不是所有场景都适合。

**适合用Parquet的场景:**

- 数据分析、BI报表
- 大规模数据查询
- 需要频繁聚合操作
- 数据写入后主要用来读取
- 需要长期存储的历史数据

**不适合用Parquet的场景:**

- 需要频繁单行更新
- 数据量很小(几百行)
- 需要人类直接阅读编辑
- 需要流式写入

CSV的优势在于简单、通用、可读。Parquet的优势在于性能、压缩、类型安全。选择哪个,取决于具体的使用场景。

## 最后的思考

76倍的性能提升,不是魔法,是列式存储、智能压缩、统计信息、查询优化这些技术叠加的结果。

DuckDB把这些优化做得很透明,用户不需要关心底层细节,写普通的SQL就能享受到这些性能提升。这可能是现代数据工具最重要的特质:把复杂的事情变简单。

从CSV到Parquet,不只是文件格式的改变,更是思维方式的转变。当你开始关注数据的存储格式、访问模式、查询特征,你就从"能用就行"进入了"性能优化"的领域。

这个转变,可能比任何具体的技术细节都重要。

## 参考资源

- [DuckDB官方文档 - Parquet读取](https://duckdb.org/docs/data/parquet/read)
- [DuckDB官方文档 - Parquet存储](https://duckdb.org/docs/data/parquet/write)
- [Apache Parquet格式规范](https://parquet.apache.org/docs/)
- [DuckDB性能优化](https://duckdb.org/docs/optimization/overview)
Built with Hugo
Theme Stack designed by Jimmy