Featured image of post DuckDB 性能优化:从慢查询到毫秒级响应的 5 个技巧

DuckDB 性能优化:从慢查询到毫秒级响应的 5 个技巧

查询跑太慢?别盲目加索引。从 EXPLAIN ANALYZE 到并行调优,5 个实测有效的 DuckDB 性能优化技巧,每招都有可执行的 SQL。

一、问题场景:查询 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;

输出包含两部分:

  1. 逻辑计划(Logical Plan) — 查询引擎打算怎么做
  2. 物理计划(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 的实测对比

指标CSVParquet
1亿行扫描18.4s1.2s
文件大小4.2GB780MB
只查 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 聚合查询):

线程数耗时相对单核
184s1x
243s1.9x
422s3.8x
811s7.6x
167s12x
326.2s13.5x(收益递减明显)

超过物理核心数后,线性扩展结束,增加线程反而因上下文切换而退化。

-- 推荐:设为物理核心数
SET threads = 8;  -- 8 核
-- 或自动:DuckDB 默认检测,但建议显式设置

马上可做的: 在慢查询前加一行 SET threads = <你的物理核心数>,简单到不需要理由。


三、更多内容

优化检查清单

步骤做什么预期效果
1EXPLAIN ANALYZE 定位瓶颈找到耗时 >50% 的步骤
2检查 card 偏差>10x 则执行 ANALYZE
3CSV → Parquet 转换通常 5-15x 提速
4memory_limit + SSD temp_directory防止 spill
5SET 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 级数据上的真实性能对比。

DuckDB 性能优化

📺 Watch video tutorials → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计