一、问题场景:查询 3 秒变 3 分钟
你在 DuckDB 上跑一个聚合查询:
SELECT category, SUM(revenue), AVG(discount)
FROM sales_1b
WHERE date >= '2026-01-01'
GROUP BY category;
等了 3 分钟还没出结果。2GB 内存占满,风扇狂转。
问题在哪?数据太大?SQL 写得差?还是 DuckDB 本身就这么慢?
答案是:大部分情况下,问题不在 DuckDB——在你怎么用。 DuckDB 的列式引擎和向量化执行已经很快了,但默认配置和查询习惯可能导致性能打折扣。
这 5 个技巧覆盖了 DuckDB 性能优化最常忽略的环节,每个都包含可执行的 SQL,你可以直接对着自己的查询试试。
二、5 个性能优化技巧
技巧 1:用 EXPLAIN ANALYZE 替代猜测
DuckDB 的执行计划是诊断性能问题的第一步,也是最重要的一步。
很多人习惯凭直觉优化——觉得"join 慢"就去加索引,觉得"数据大"就去换硬件。但 90% 的情况下,真正的问题和你猜的不一样。
命令非常直接:
EXPLAIN ANALYZE
SELECT category, SUM(revenue)
FROM sales_1b
WHERE date >= '2026-01-01'
GROUP BY category;
输出包含两部分:
- 逻辑计划(Logical Plan) — 查询引擎打算怎么做
- 物理计划(Physical Plan) — 实际怎么执行,以及每步花了多久、处理了多少行
┌─────────────────────────────────────┐
│┌───────────────────────────────────┐│
││ Actual Time: 12.34s ││
││ Hash GroupBy: 9750000 rows ││
││ 82% of total time ││ ← 瓶颈在这
││ card estimate: 100 ││
││ actual cardinality: 2000 ││ ← 严重低估!
│└───────────────────────────────────┘│
│┌───────────────────────────────────┐│
││ Seq Scan: sales_1b ││
││ Actual Time: 2.11s ││
││ rows scanned: 1B -> 600M ││ ← 谓词下推裁掉了 40%
││ (filter: date >= '2026-01') ││
│└───────────────────────────────────┘│
└─────────────────────────────────────┘
读输出的三个关键:
| 指标 | 怎么看 | 问题信号 |
|---|---|---|
| Actual Time | 每步耗时占比 | 某步超过 50% 就是瓶颈 |
| Row count vs estimate | 实际行数 vs 估算 | 偏差超过 10 倍,优化器选错 join 策略 |
| Filter efficiency | 扫描后剩余行数 | 谓词下推没生效 — 检查 WHERE 条件 |
实战案例: 有一次线上查询从 45 秒降到 0.3 秒——不是因为重写了 SQL,而是发现 DuckDB 估算 cardinality 偏差 100 倍,导致选了 nested loop join 而不是 hash join。加了
ANALYZE更新统计信息就解决了。
执行计划中的常见性能信号
"HASH_GROUP_BY"+"ACTUAL_TIME: 80%"— 分组键太多或基数太高"CROSS_PRODUCT"— 漏写了 join 条件,隐式笛卡尔积"SEQ_SCAN: rows=1B"— 全表扫描不可避免,但可以列裁剪减少读取量"card estimate: 2"/"actual: 500000"— 统计信息过期,需要ANALYZE
马上可做的: 在任何慢查询前加 EXPLAIN ANALYZE,先看瓶颈在哪,再动手优化。
技巧 2:文件格式和分区策略
DuckDB 的数据源选择直接影响第一阶段的读取效率。选择顺序应该是:
Parquet > DuckDB 原生格式 > CSV/JSON
CSV vs Parquet 的实测对比
| 指标 | CSV | Parquet |
|---|---|---|
| 1亿行扫描 | 18.4s | 1.2s |
| 文件大小 | 4.2GB | 780MB |
| 只查 3 列 | 仍然读全部列 | 只读需要的 3 列 |
| 谓词下推 | 不支持(要全读) | 支持(按 stripe 裁剪) |
-- CSV:虽然快,但每次都要全量解析类型
SELECT SUM(amount) FROM 'sales.csv'; -- 18s
-- Parquet:只读 amount 列,列裁剪自动生效
SELECT SUM(amount) FROM 'sales.parquet'; -- 1.2s
-- 加谓词下推,Parquet 的优势更大
SELECT SUM(amount) FROM 'sales.parquet'
WHERE date >= '2026-06-01'; -- 0.3s(只扫描相关 stripe)
分区读取:HIVE_PARTITIONING
如果你有百万级文件,DuckDB 的 read_parquet 配合 hive 分区可以把扫描量从全部文件降到只扫相关子目录:
-- 全量扫描:100 个 parquet 文件
SELECT region, SUM(sales)
FROM read_parquet('data/*.parquet')
GROUP BY region;
-- 只扫 1 月的文件:用 hive 分区模式
SELECT region, SUM(sales)
FROM read_parquet('data/*/*.parquet',
hive_partitioning = true)
WHERE month = '2026-01'
AND region = 'APAC'; -- 只读匹配的分区目录
同样是一个查询,第一种扫全部 100 个文件,第二种只读 2-3 个。在生产环境,这通常是从 30 秒到 1 秒的差距。
FILE_GLOB 模式控制
当需要精确控制读哪些文件时:
SELECT * FROM read_parquet('data/2026-{01,02,03}/*.parquet');
-- 或者用 glob 模式
SELECT * FROM read_parquet('data/2026-0[1-3]/*.parquet');
马上可做的: 如果你的数据还躺在 CSV 里,花 10 分钟转成 Parquet——这通常是投入产出比最高的优化。
技巧 3:DuckDB 的索引——有,但和你想的不一样
很多从 PostgreSQL/MySQL 过来的用户第一反应是「查询慢?加索引」。在 DuckDB 里,索引的作用范围要小得多。
DuckDB 的索引类型
| 索引类型 | 加速场景 | 何时无效 |
|---|---|---|
| ART(自适应基数树) | WHERE id = 123 单点查询 | 范围查询、聚合、join |
| B-tree(MIN/MAX 索引) | 自动维护的 Zone Map(列统计信息) | 对高基数列效果有限 |
-- ART 索引适合单点查询
CREATE INDEX idx_user ON users USING ART(user_id);
SELECT * FROM users WHERE user_id = 42; -- 走索引,微秒级
SELECT * FROM users WHERE user_id > 100; -- 不走索引,全表扫
真正的性能武器:物化预聚合
DuckDB 列的存储和向量化执行引擎意味着:对于分析查询,物化预聚合(Materialized Pre-aggregation)比索引有效得多。
-- 优化前:每次查询都聚合 10 亿行
SELECT DATE_TRUNC('day', ts), region,
SUM(revenue), COUNT(DISTINCT user_id)
FROM raw_events
WHERE ts >= NOW() - INTERVAL '7 days'
GROUP BY ALL;
-- 优化后:先预聚合到小时级别(写时洗数据)
CREATE TABLE hourly_metrics AS
SELECT DATE_TRUNC('hour', ts) AS hour,
region,
SUM(revenue) AS total_revenue,
COUNT(DISTINCT user_id) AS unique_users
FROM raw_events
GROUP BY ALL;
-- 查询时:从小时表读取,7 天只需扫描 168 行
SELECT DATE_TRUNC('day', hour) AS day,
region,
SUM(total_revenue),
SUM(unique_users)
FROM hourly_metrics
WHERE hour >= NOW() - INTERVAL '7 days'
GROUP BY ALL; -- 毫秒级
-- 另一种方式:用 CREATE MACRO 做轻量级缓存
CREATE MACRO daily_active_users(d DATE) AS (
SELECT COUNT(DISTINCT user_id)
FROM sessions
WHERE session_date = d
);
-- 调用:DuckDB 会缓存宏的结果
SELECT daily_active_users('2026-05-01');
马上可做的: 找到你频率最高的聚合查询,建一个小粒度的物化表——扫描行数从亿级降到万级,查询从分钟到毫秒。
技巧 4:内存管理——你的查询是不是在 spill?
DuckDB 查询慢最常见的原因之一:数据放不进内存,溢出到磁盘了(spill)。
spill 的典型症状:
- 查询一开始很快,然后突然变慢
- 磁盘 IO 飙升,但 CPU 使用率不高
- 查询耗时和之前完全不一样(数据量没变)
如何判断是否在 spill
-- 查看 temp 文件目录
PRAGMA show_temporary_files;
-- 或直接检查
SELECT * FROM duckdb_temporary_files();
如果运行中的查询产生了 temp 文件(默认在 /tmp/duckdb),说明内存不够,正在往磁盘写——性能会差 10-100 倍。
内存配置三板斧
-- 1. 分配足够内存(默认只有 75% 的可用 RAM)
PRAGMA memory_limit = '8GB';
-- 2. 把临时文件放到 SSD(别用 HDD 或网络挂载)
PRAGMA temp_directory = '/mnt/ssd/duckdb_tmp';
-- 3. 控制单个操作的内存上限(防止一个 query 占满所有给 DuckDB 的内存)
PRAGMA hash_table_size_limit = '2GB';
PRAGMA out_of_core_threshold = '2GB';
💡 实战经验: 除非你确定数据量一定在内存内(比如单表 100MB),否则总是显式设置
temp_directory到 SSD。默认/tmp可能是内存盘(ramdisk),spill 到那里等于没 spill——不仅不缓解,反而竞争同一块内存。
各操作的最低内存需求
| 操作 | 最小需求 | 建议配置 |
|---|---|---|
GROUP BY 全表聚合 | 结果集大小 | 一般 < 1GB 即可 |
ORDER BY 全表排序 | 数据大小的 1.2 倍 | 建议数据量 < 内存的 80% |
HASH JOIN 两张大表 | 左表大小 | 左表 < 内存 |
DISTINCT 高基数 | 去重后大小 | 基数过千万时注意 |
UNION / UNION ALL | 输入数据量 | UNION 额外消耗多一倍内存 |
马上可做的: 加 PRAGMA memory_limit = '80% of RAM' 和 PRAGMA temp_directory = 'SSD 路径',然后重跑慢查询——如果速度变化超过 5 倍,就是之前 spill 到磁盘了。
技巧 5:并行度调优——你的 8 核可能只用了 1 个
DuckDB 使用 Morsel-Driven Parallelism(分片驱动并行),意味着查询被切分成小块(morsels),由多个线程并行处理。
但默认配置不一定适合你的硬件和数据。
-- 查看当前线程数
SELECT current_setting('threads');
-- 显式设置(等于物理核心数通常是甜区)
SET threads = 8;
-- 生产环境:超过 16 核收益递减
SET threads = 16;
哪些操作能并行?
| 操作 | 能否并行 | 扩展性 |
|---|---|---|
Seq Scan(Parquet) | ✅ 文件级并行 | 线性 |
HASH_GROUP_BY | ✅ 分阶段并行 | 接近线性 |
HASH_JOIN | ✅ 构建阶段并行 | 好 |
ORDER BY | ✅ 多路归并 | 中等 |
WINDOW 函数 | ⚠️ 部分并行 | 依赖 PARTITION BY |
UNION ALL | ✅ 每个子查询并行 | 好 |
COPY 写入 | ⚠️ 受文件锁定限制 | 需关掉有序写入 |
大批量 INSERT 加速
-- 默认 DuckDB 保持插入顺序(用于 MVCC)
-- 关掉它加速批量导入:
SET preserve_insertion_order = false;
-- 批量插入速度可以提升 2-3 倍
INSERT INTO large_table
SELECT * FROM read_parquet('batch_*.parquet');
线程数的甜区
实测数据(64GB RAM, 1B 行 CSV 聚合查询):
| 线程数 | 耗时 | 相对单核 |
|---|---|---|
| 1 | 84s | 1x |
| 2 | 43s | 1.9x |
| 4 | 22s | 3.8x |
| 8 | 11s | 7.6x |
| 16 | 7s | 12x |
| 32 | 6.2s | 13.5x(收益递减明显) |
超过物理核心数后,线性扩展结束,增加线程反而因上下文切换而退化。
-- 推荐:设为物理核心数
SET threads = 8; -- 8 核
-- 或自动:DuckDB 默认检测,但建议显式设置
马上可做的: 在慢查询前加一行 SET threads = <你的物理核心数>,简单到不需要理由。
三、更多内容
优化检查清单
| 步骤 | 做什么 | 预期效果 |
|---|---|---|
| 1 | EXPLAIN ANALYZE 定位瓶颈 | 找到耗时 >50% 的步骤 |
| 2 | 检查 card 偏差 | >10x 则执行 ANALYZE |
| 3 | CSV → Parquet 转换 | 通常 5-15x 提速 |
| 4 | 加 memory_limit + SSD temp_directory | 防止 spill |
| 5 | SET threads = N 设置 | 2-8x 提速(多核) |
| 6 | 建预聚合物化表 | 频率最高的查询 50-100x 提速 |
调试脚本
-- 一键诊断:查看当前配置
SELECT name, value
FROM duckdb_settings()
WHERE name IN ('threads', 'memory_limit',
'temp_directory', 'preserve_insertion_order');
-- 查看临时文件(如果有正在运行的 query)
SELECT * FROM duckdb_temporary_files()
ORDER BY size DESC;
下期预告: DuckDB 与 Polars 的实战性能对比——同样数据同样查询,谁更快?
本文是周三快讯系列。周六会有深度长文:DuckDB 和 Pandas 在 100GB 级数据上的真实性能对比。
