DuckDB 1.5.6 实测:不建库直查 40MB CSV
我实际在一台 16 线程的 Windows 11 机器上做了这样一件事:把一个 40.1 MB、100 万行的 CSV,从「先导入数据库再聚合」改成「不建库、不建表、直接查文件」。同一份数据、同一台机器,端到端耗时从 5.06 秒压到 0.32 秒。这篇文章记录的是我跑出来的原始数字、可以原样复制的脚本,以及过程中真实撞到的 8 个报错原文——没有一个数字来自二手教程。
TL;DR:DuckDB 1.5.6 直接对 40.1 MB CSV 做 GROUP BY 聚合耗时 0.311 秒(中位数 0.323 秒),不需要建库;同一份数据转成 Parquet(zstd 压缩)后只有 7.7 MB,只算一列的 sum() 从 0.283 秒降到 0.0142 秒,快 19.9 倍;对照之下,SQLite 光是把这份 CSV 逐行插进去就要 4.426 秒,聚合中位数 0.6347 秒,比 DuckDB 内存表慢 138.9 倍。
先给结论:什么时候该上 DuckDB,什么时候别上
DuckDB 不是「更快的 SQLite」,它是一个跑在你的进程里的列存分析引擎。判断标准只有一条:你的查询是不是要扫一大片行、但只用其中少数几列?
- 该用:日志、埋点、导出文件的即席分析;一次性的统计与对账;把一批 CSV 或 Parquet 合并成一个结果;本地跑完再决定要不要上真正的数仓。
- 别用:需要多个进程并发写入;需要行级事务与高并发小事务;把它当成线上业务的 OLTP 主库(官方定位是 OLAP 分析系统)。
一个容易踩的坑是「既然它这么快,那就用它替掉 SQLite」。这两个东西解决的问题不同:SQLite 的强项是单行写入与事务,DuckDB 的强项是整列扫描与聚合。本文的实测是分析型查询口径,不代表写入型负载。
实测环境与方法(可复现)
环境全部来自本机实际读取,DuckDB 版本经 GitHub 官方 Releases 页核对(duckdb/duckdb 最新 release 为 v1.5.6,发布于 2026-09-28):
| 项目 | 本机实测值 |
|---|---|
| 操作系统 | Windows 11(10.0.26300) |
| Python | 3.13.14 |
| duckdb | 1.5.6(PRAGMA version 返回 v1.5.6) |
| pandas / pyarrow | 3.0.6 / 26.0.0 |
| sqlite3 | 3.53.1 |
| threads(默认) | 16 |
| memory_limit(默认) | 12.5 GiB |
后两项官方文档与本机实测一致:DuckDB 官方 Configuration 参考表写明 memory_limit(别名 max_memory)默认为 80% of RAM、threads 默认为 CPU 核心数;本机 16 GB 内存的 80% 正是 12.5 GiB。
数据集是我生成的合成访问日志,100 万行 × 6 列,写入耗时 5.87 秒,落盘 42,029,551 字节(40.1 MB):
import csv, random
N = 1_000_000
PATHS = ["/", "/pricing", "/docs", "/api/v1/query", "/blog/post-42", "/login", "/assets/app.js", "/search"]
STATUS = [200, 200, 200, 200, 301, 404, 500]
COUNTRIES = ["US", "DE", "JP", "CN", "GB", "FR", "BR", "IN", "AU", "CA"]
rnd = random.Random(20261020)
with open("events.csv", "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["ts", "user_id", "path", "status", "bytes", "country"])
for i in range(N):
w.writerow([1750000000 + i // 10, rnd.randrange(1, 500001), rnd.choice(PATHS),
rnd.choice(STATUS), rnd.randrange(120, 900000), rnd.choice(COUNTRIES)])
不建库直接查文件:40 MB CSV 用 0.32 秒
不需要 CREATE TABLE,文件路径本身就可以当表名:
import duckdb
con = duckdb.connect()
sql = "SELECT status, count(*) AS n, avg(bytes) FROM 'events.csv' GROUP BY status ORDER BY status"
print(con.sql(sql).fetchall())
# [(200, 571537, 450285.33), (301, 143115, 450638.17),
# (404, 142243, 449954.56), (500, 143105, 449404.49)]
同一台机器上跑三次取最小值与中位数,结果如下:
| 查询方式 | 耗时 |
|---|---|
直查 CSV,GROUP BY status | min 0.311 s / 中位 0.323 s |
直查 CSV,sum(bytes)(只算 1 列) | 0.283 s |
直查 CSV,WHERE status = 500 的 count(*) | 0.306 s |
CREATE TABLE AS SELECT 落内存表 | 0.357 s |
| 落表后同样的聚合 | min 0.0045 s / 中位 0.0046 s(比直查 CSV 快 70.7 倍) |
这里有个容易被忽略的点:直查 CSV 每次都要重新解析文本,所以「快」是相对「导入数据库再查」而言的,不是相对「已经在内存里的数据」。如果你要反复查同一份数据,先花 0.357 秒落表,之后的每一次聚合都是 4.6 毫秒量级。
自动类型推断的结果也值得确认一遍,我实测 6 列全部按预期推断:ts、user_id、status、bytes 为 BIGINT,path、country 为 VARCHAR。
为什么快:列存、投影下推与 zonemap
把同一份数据导出成 Parquet(zstd 压缩),变化非常直观:
| 指标 | 实测值 |
|---|---|
COPY 导出耗时 | 0.273 s |
| Parquet 体积 | 8,043,277 字节(7.7 MB) |
| 相对 CSV 的压缩率 | 5.23 倍(体积减少 80.9%) |
Parquet 上 sum(bytes)(1 列) | 0.0142 s(比 CSV 的 0.283 s 快 19.9 倍) |
Parquet 上 count(*) 带 status = 500 | 0.0082 s(比 CSV 的 0.306 s 快 37.4 倍) |
原因有两层,官方文档的 Parquet 页给了明确表述。投影下推:「when querying a Parquet file, only the columns required for the query are read」;过滤下推:过滤器会被推进扫描器,并且「can even be used to skip parts of the file using the built-in zonemaps」。我用列数做过一次阶梯验证,正好印证第一层:
| 查询 | 耗时(相对 count(*)) |
|---|---|
count(*) | 0.0015 s(1.0 倍) |
sum(bytes)(1 列) | 0.0073 s(4.8 倍) |
| 4 列求和 | 0.0150 s(9.9 倍) |
| 6 列求和(含两个字符串列) | 0.0196 s(13.0 倍) |
只要你多写几列,耗时就线性往上走。所以「SELECT * 然后再在 Python 里挑列」是最亏的写法,把过滤条件下推给 SQL 才拿得到这个收益。
第二层(zonemap 跳行组)我原本想测排序带来的差异,结果没能测出来:我生成的 ts 是 1750000000 + i // 10,本身单调递增,所以「未排序」文件的 row group 统计区间和「排序后」文件几乎一样,两者耗时 0.0088 s 与 0.0082 s,差异落在噪声范围内。这本身也是个提醒:先确认你那列数据是不是本来就相关,再谈排序收益,别为一次没有收益的排序付导出成本。
与 SQLite、pandas 的同机对照
同一份 CSV、同一台机器,我把两条最常见的老路也跑了一遍:
| 方案 | 耗时 / 体积 |
|---|---|
SQLite 逐行 executemany 导入 | 4.426 s,库文件 33.9 MB |
SQLite 聚合(同款 GROUP BY) | min 0.6057 s / 中位 0.6347 s |
SQLite 无索引 WHERE status = 500 | 0.1143 s |
给 status 建索引 | 0.613 s(建完后该查询降到 0.0074 s) |
pandas read_csv | 1.596 s,DataFrame 占内存 55.7 MB |
pandas groupby().agg() | 0.0391 s |
| DuckDB 内存表聚合 | 中位 0.0046 s |
端到端算总账更说明问题:DuckDB 不建库直查完成聚合 0.32 秒;SQLite 导入加聚合 5.06 秒(约 15.7 倍差距);pandas 读入加聚合 1.635 秒(约 5.1 倍差距)。内存上,pandas 需要 55.7 MB 的 DataFrame 常驻,而 DuckDB 在 512 MB 上限下跑同样的聚合,临时目录里一个溢出文件都没有。
注意这不是「DuckDB 比 SQLite 强」的结论——SQLite 被我用在它不擅长的全表聚合上,而它的强项单行事务我没测。口径是分析型全表扫描,换到点查点写的负载,结论会反过来。
内存与溢出:把上限压到 48 MB 会发生什么
我把 temp_directory 指到一个空目录,然后逐级压低 memory_limit,跑同一个高基数分组:
| memory_limit | 结果 |
|---|---|
| 512 MB | 成功,0.038 s,溢出文件 0 个 / 0 字节 |
| 128 MB | 成功,0.060 s,溢出文件 8 个 / 12,910,592 字节 |
| 48 MB | OutOfMemoryException(原文见下) |
128 MB 这一档很关键:查询没有失败,但慢了 58%,代价是往磁盘写了 12.3 MB 临时文件。也就是说「没报 OOM」不等于「没溢出」,看到变慢就该去查 temp_directory。
48 MB 这一档的完整报错原文(我直接贴出来,不改写):
OutOfMemoryException: Out of Memory Error: failed to pin block of size 256.0 KiB (45.6 MiB/45.7 MiB used)
Possible solutions:
* Reducing the number of threads (SET threads=X)
* Disabling insertion-order preservation (SET preserve_insertion_order=false)
* Increasing the memory limit (SET memory_limit='...GB')
官方给了三条建议,我实测了第二条:把 preserve_insertion_order 从默认的 true 改成 false,在 48 MB 上限下照样 OOM(报错里已用内存从 45.8 MiB 变 46.5 MiB,没有任何实质改善)。这条建议在某些场景有效,但在我这个高基数分组查询上不奏效——不要把它当成万能开关,该加内存还是要加。
踩坑录:8 个真实报错与原文
以下每一条都是我在本机上真实触发的,报错原文照抄。
报错一:列名写错,但只给一个候选
BinderException: Binder Error: Referenced column "nope" not found in FROM clause!
Candidate bindings: "bytes"
原因:SELECT bytes, nope FROM ev 里 nope 不存在。解法:先 DESCRIBE ev 看推断出来的列名,别凭 CSV 表头猜——大小写与空格都可能被规范化。
报错二:脏数据让整列被降级成 VARCHAR
这是最阴的一条。一个 4 行的 CSV,其中一行是 not-a-number:
BinderException: Binder Error: No function matches the given name and argument types 'sum(VARCHAR)'. You might need to add explicit type casts.
Candidate functions:
sum(DECIMAL) -> DECIMAL
sum(BIGINT) -> HUGEINT
原因:DuckDB 的嗅探器看到该列有非数字值,就把整列判成 VARCHAR,而 sum() 没有 VARCHAR 重载。用 sniff_csv() 可以看到它的判断:[{'name': 'id', 'type': 'BIGINT'}, {'name': 'amount', 'type': 'VARCHAR'}]。解法:显式指定列类型,或先 all_varchar=true 读进来再自己 TRY_CAST。
报错三:指定类型后撞上脏数据
ConversionException: Conversion Error: CSV Error on Line: 4
Original Line: 3,not-a-number
Error when converting column "amount". Could not convert string "not-a-number" to 'INTEGER'
...
sample_size = 20480
ignore_errors = false
原因:显式声明了 types={'amount':'INTEGER'},第 4 行的脏值无法转换。注意诊断块里的 sample_size = 20480:嗅探器默认只采样 20480 行,脏数据如果落在第 20481 行之后,自动推断阶段根本发现不了,报错会延迟到真正转换时才炸。
报错四:`ignore_errors=true` 会静默丢行
# 4 行数据,脏行被跳过,sum(amount) = 10+20+40 = 70
SELECT count(*), sum(amount) FROM read_csv_auto('bad.csv', types={'amount':'INTEGER'}, ignore_errors=true)
-- [(3, 70)]
原因:这一档没有报错、没有警告,4 行变成 3 行。解法:ignore_errors=true 只适合你明确接受丢数据的探索阶段;做对账类任务时不要开,改用 TRY_CAST 把异常值显式变成 NULL 再计数,让「丢了多少」变成一个可查询的数字。
报错五:类型不匹配
ConversionException: Conversion Error: Could not convert string 'yesterday' to INT64
LINE 1: SELECT * FROM ev WHERE (ts = 'yesterday')
^
原因:ts 是 BIGINT,却拿字符串去比。解法:先转时间戳再比,别指望隐式转换(官方 old_implicit_casting 默认为 false)。
报错六:表已存在
CatalogException: Catalog Error: Table with name "ev" already exists!
解法:重复跑脚本时用 CREATE OR REPLACE TABLE,或在脚本开头 DROP TABLE IF EXISTS。
报错七:跨进程写同一个 .duckdb 文件
IOException: IO Error: Cannot open file "...\locktest2.duckdb": 另一个程序正在使用此文件,进程无法访问。
File is already open in
D:\...\python.exe (PID 18316)
原因:DuckDB 的持久化文件是单进程写锁,一个 Jupyter kernel 或 ETL 脚本没退出,另一个进程就进不来。注意同一个进程内再 duckdb.connect(同一个文件) 是不会报错的(共享实例),所以本地调试时常常发现不了,一上调度就炸。解法:分析型任务优先用只读文件(直接查 CSV 或 Parquet)而不是写 .duckdb 文件;必须落库时确保同一时刻只有一个进程持有它。
顺带一个相关报错:已有读写连接时再开一个 read_only=True 连接,会拿到 ConnectionException: Can't open a connection to same database file with a different configuration than existing connections——配置必须一致,读写模式不能混用。
报错八:内存上限触发 OOM
原文见上一节。解法优先级:先确认是否真的需要那么大的中间结果(能不能先过滤再聚合,能不能少 SELECT 几列);再考虑调 threads;最后才是加 memory_limit。
FAQ
Q:DuckDB 能替掉我的 PostgreSQL 吗?
A:不能。它是进程内的分析引擎,不是多用户 OLTP 数据库。本站对 PostgreSQL 服务端的排查另有专文,见 PostgreSQL 18.6 复制槽实测。
Q:为什么我查 Parquet 没有变快?
A:先看是不是 SELECT *,或者过滤列上没有可用的 zonemap(官方文档明确写了过滤下推「will depend on whether or not your Parquet file contains zonemaps」)。另外几十 MB 以内的单文件,差异经常被操作系统缓存吃掉,测的时候多跑几次取最小值。
Q:100 万行要多少内存?
A:本文这份 100 万行 × 6 列的数据,在 512 MB 上限下聚合不产生任何溢出文件;压到 128 MB 才开始往磁盘写 12.3 MB。落盘成持久化 .duckdb 文件是 8.3 MB。
Q:跟 pandas 怎么配合?
A:我实测下来最顺手的用法是:让 DuckDB 负责过滤与聚合(把结果集压到很小),再 df = con.sql(...).df() 交给 pandas 做后续处理。反过来「pandas 全量读进来再筛」就是本文里 1.635 秒的那条路。
本文的边界
以下都是没有做的,不做任何推断:没有测多进程并发写入;没有测真实的 S3 或 HTTP 远端文件(httpfs);没有测超过单机内存的数据集;没有测写入型与点查型负载。所有结论都限定在「Windows 11 + Python 3.13.14 + duckdb 1.5.6 + 单机 100 万行合成数据」这一组条件内。
如果你对「内存和缓存到底怎么被一块块吃掉」这个话题感兴趣,可以接着看 vLLM 0.31.0 KV Cache 分块分配实测,以及 从零手搓注意力机制:NumPy 逐行实现与 PyTorch 对拍,两篇用的都是同一套「本机跑出数字、脚本原样给出」的方法。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):