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
| import duckdb
# 256MB 内存限制
con = duckdb.connect(config={'memory_limit': '256MB'})
result = con.execute("""
SELECT device_id, AVG(temperature), MAX(humidity)
FROM read_csv_auto('iot_sensor_data.csv')
GROUP BY device_id
""").fetchdf()
print(f"耗时: {result}")
# 耗时约 0.730s,成功
```text
| memory_limit | 查询耗时 | 结果 |
|--------------|----------|------|
| 默认 (12.7 GiB) | 1.675s | ✅ 成功 |
| 256 MB | 0.730s | ✅ 成功 |
| 64 MB | - | ❌ OOM |
有意思的是,256MB限制下查询反而比默认配置快了——从1.675s降到0.730s。原因是DuckDB在内存受限时会更积极地使用磁盘溢出(spill to disk),减少了内存分配的开销。
但64MB就扛不住了,直接报`could not allocate block of size 30.5 MiB`。DuckDB再怎么优化,也需要一个基本的内存空间来执行查询计划。
**经验值:** 最小推荐256MB,一般场景1-2GB,大数据场景4-8GB。
## 线程配置:4线程是性价比最高的选择
DuckDB默认会用满所有CPU核心。在我的12线程机器上,它自动开了12个线程。但线程数真的越多越好吗?
```python
import duckdb
import time
for thread_count in [1, 2, 4, 12]:
con = duckdb.connect(config={'threads': thread_count})
start = time.time()
result = con.execute("""
SELECT device_id, AVG(temperature), COUNT(*)
FROM read_csv_auto('iot_sensor_data.csv')
GROUP BY device_id
""").fetchdf()
elapsed = time.time() - start
print(f"{thread_count} 线程: {elapsed:.3f}s")
```text
| threads | 查询耗时 | 相对性能 |
|---------|----------|----------|
| 1 | 1.088s | 基准 |
| 2 | 0.338s | 3.22x |
| 4 | 0.251s | 4.33x |
| 12 | 0.240s | 4.53x |
从1线程到4线程,性能提升了4.3倍。但从4线程到12线程,只快了5%——多出来的8个线程几乎没贡献,反而增加了线程切换和内存竞争的开销。
对于这种聚合查询,4线程已经完全够用了。如果你的机器还要跑其他服务,设成4线程是个不错的选择,把剩余的核心留给系统。
```python
# 推荐配置
con = duckdb.connect(config={
'memory_limit': '2GB',
'threads': 4,
})
```text
## CSV vs Parquet:45倍的性能差距
这是整个测试里最夸张的数据。
同样的100万行数据,同样的聚合查询,CSV格式要0.726秒,Parquet格式只要0.016秒——差了45倍。
```python
import duckdb
con = duckdb.connect()
# 第一步:CSV 转 Parquet
con.execute("""
COPY (
SELECT * FROM read_csv_auto('iot_sensor_data.csv')
) TO 'iot_sensor_data.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)
""")
# 转换耗时约 0.416s
# 第二步:查询对比
import time
# CSV 查询
start = time.time()
r1 = con.execute("""
SELECT device_id, AVG(temperature)
FROM read_csv_auto('iot_sensor_data.csv')
GROUP BY device_id
""").fetchdf()
print(f"CSV: {time.time() - start:.3f}s")
# CSV: 0.726s
# Parquet 查询
start = time.time()
r2 = con.execute("""
SELECT device_id, AVG(temperature)
FROM read_parquet('iot_sensor_data.parquet')
GROUP BY device_id
""").fetchdf()
print(f"Parquet: {time.time() - start:.3f}s")
# Parquet: 0.016s
```text
| 格式 | 文件大小 | 查询耗时 | 性能对比 |
|------|----------|----------|----------|
| CSV | 55.30 MB | 0.726s | 基准 |
| Parquet | 14.57 MB | 0.016s | **45.83x** |
Parquet的优势来自三个方面:
1. **列式存储**:查询只需要读取temperature和device_id两列,不用扫描整行数据
2. **压缩效率高**:ZSTD压缩后只有14.5MB,是CSV的1/3.8
3. **内置统计信息**:每个row group都有min/max值,DuckDB可以直接跳过不需要的数据块
转换本身有开销(0.416秒),但如果你需要多次查询同一个文件,这个一次性投入绝对值。
## 分块处理:LIMIT/OFFSET是个坑
处理大文件时,一个自然的想法是分块读取——每次处理10万行,处理10次。
```python
# ❌ 不推荐的方式
chunk_size = 100000
for offset in range(0, 1000000, chunk_size):
result = con.execute(f"""
SELECT * FROM read_csv_auto('iot_sensor_data.csv')
LIMIT {chunk_size} OFFSET {offset}
""").fetchdf()
# 处理这一块...
```text
这种方式的问题是:每次查询都要从头扫描CSV文件。第一次读1-10万行要扫10万行,第二次读10-20万行要扫20万行(因为要跳过前10万),第十次要扫100万行。总扫描量是O(n²),10块数据的总耗时达到了4.756秒。
更好的方式是先导入到临时表:
```python
# ✅ 推荐的方式
con.execute("""
CREATE TABLE temp_data AS
SELECT * FROM read_csv_auto('iot_sensor_data.csv')
""")
# 导入耗时 0.298s
# 然后分块查询就很快了
chunk_size = 100000
for offset in range(0, 1000000, chunk_size):
result = con.execute(f"""
SELECT * FROM temp_data
LIMIT {chunk_size} OFFSET {offset}
""").fetchdf()
# 每块查询约 0.047s
```text
| 方法 | 总耗时 | 备注 |
|------|--------|------|
| LIMIT/OFFSET 直接读CSV | 4.756s | 每次重新扫描 |
| 先导入临时表再分块 | 0.298s + 0.466s | 导入+查询 |
先导入再查询的总耗时约0.76秒,是直接读的6倍快。而且导入后数据在内存里,后续查询可以反复利用。
## OOM应急处理
即使配置了内存限制,复杂查询还是可能OOM。这时候不要慌,有三个旋钮可以调:
```python
import duckdb
con = duckdb.connect()
try:
result = con.execute(complex_query).fetchdf()
except duckdb.OutOfMemoryException as e:
print(f"OOM: {e}")
# 第一步:减少线程数(减少并发内存占用)
con.execute("SET threads = 2")
# 第二步:禁用插入顺序保持(减少排序开销)
con.execute("SET preserve_insertion_order = false")
# 第三步:增加内存限制
con.execute("SET memory_limit = '4GB'")
# 重试
result = con.execute(complex_query).fetchdf()
```text
这三个配置的优先级是:先减线程,再关排序,最后加内存。因为减线程和关排序都不会增加系统负担,而加内存可能会挤压其他进程。
## 分区策略:时序数据的加速器
对于IoT传感器这种时序数据,按时间分区可以大幅提升查询效率。
```python
import duckdb
con = duckdb.connect()
# 导入时提取日期字段
con.execute("""
CREATE TABLE sensor_data AS
SELECT
DATE_TRUNC('day', timestamp) as date,
device_id,
temperature,
humidity
FROM read_csv_auto('iot_sensor_data.csv')
""")
# 查询特定日期的数据
result = con.execute("""
SELECT device_id, AVG(temperature)
FROM sensor_data
WHERE date = '2024-01-15'
GROUP BY device_id
""").fetchdf()
```text
DuckDB的查询优化器会自动利用分区信息,跳过不相关的数据块。对于"查询某一天数据"这种场景,性能提升非常明显。
## 完整性能数据汇总
把上面所有测试数据放在一起看:
| 操作 | 耗时 | 内存占用 |
|------|------|----------|
| CSV 全表扫描 | 1.675s | ~200MB |
| CSV 聚合查询 | 0.726s | ~150MB |
| Parquet 聚合查询 | 0.016s | ~50MB |
| CSV → Parquet 转换 | 0.416s | ~300MB |
| 导入临时表 | 0.298s | ~250MB |
线程数影响:
| threads | 耗时 | 相对性能 |
|---------|------|----------|
| 1 | 1.088s | 1.00x |
| 2 | 0.338s | 3.22x |
| 4 | 0.251s | 4.33x |
| 12 | 0.240s | 4.53x |
## 踩坑记录
### 坑1:内存限制设太小直接崩
设了64MB的memory_limit,结果查询直接报OOM。DuckDB的spill to disk不是万能的,它需要一个最小的内存空间来执行查询计划。256MB是实测能跑通100万行聚合查询的最低值。
### 坑2:LIMIT/OFFSET读大文件慢得离谱
以为分块读取能省内存,结果每次查询都从头扫描文件。10块数据读下来,总扫描量是原始文件的55倍。正确做法是先导入到表,再分块查询。
### 坑3:Parquet转换不是免费的
第一次查询Parquet文件时,需要先做格式转换。如果只查一次,CSV可能更快。但如果要查多次,Parquet的45倍性能提升会远远超过转换成本。
### 坑4:线程数开满不一定最快
12线程只比4线程快5%,但内存占用和CPU调度开销明显增加。对于聚合查询,4线程是性价比最高的配置。
## 最佳实践总结
根据这些测试数据,整理一个推荐配置清单:
```python
import duckdb
# 通用推荐配置
con = duckdb.connect(config={
'memory_limit': '2GB', # 一般场景够用
'threads': 4, # 性价比最高
})
# 大文件处理流程
# 1. 先转 Parquet
con.execute("""
COPY (
SELECT * FROM read_csv_auto('large_file.csv')
) TO 'large_file.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)
""")
# 2. 查询 Parquet(支持列裁剪)
result = con.execute("""
SELECT col1, col2
FROM read_parquet('large_file.parquet')
WHERE col1 > 100
""").fetchdf()
```text
内存紧张时的降级方案:
```python
con = duckdb.connect(config={
'memory_limit': '256MB', # 最低安全值
'threads': 2, # 减少并发
})
con.execute("SET preserve_insertion_order = false")
```text
## 参考资源
- [DuckDB Memory Management](https://duckdb.org/docs/guides/performance/how-to-tune-memory) - 官方内存调优指南
- [DuckDB Parallelism](https://duckdb.org/docs/guides/performance/how-to-tune-parallelism) - 线程配置和并行优化
- [DuckDB Parquet](https://duckdb.org/docs/data/parquet/overview) - Parquet格式支持
- [DuckDB Performance Tuning](https://duckdb.org/docs/guides/performance/overview) - 全面性能调优指南
|