- 发布于
DuckDB + Parquet:把分析引擎和数据文件分工好
- 作者

- 姓名
- Garfield Zhu
- @_AlohaYo_
@Author: Garfield Zhu
小附录:本来写博客确实有些母语羞耻,所以这其实是我第一篇中文博客来着(:facepalm:)。最近刚给博客加了中英双语切换,工作流里也顺手让 AI 自动翻译并提交每篇博客的另一个语言版本了。写中文好像蛮好、蛮偷懒的——顺便测一下,提交中文博客后生成的英文版本有没有啥问题(bushi)。
先别急着把 DuckDB 和 Parquet 当成一回事
它们关系很好,但职责完全不同:
| 东西 | 核心职责 | 更像什么 |
|---|---|---|
| DuckDB | 执行 SQL、过滤、连接、聚合、排序 | 分析引擎 |
| Parquet | 压缩、按列组织并交换分析数据 | 分析文件格式 |
一句话版:DuckDB 负责查和算,Parquet 负责装和带走。
Parquet 是面向分析的列式文件格式。它适合按批次写入、压缩、分区和分享;Spark、Polars、Pandas、Trino、DuckDB 等工具都能读。它不是会处理逐行事务的数据库,也不适合把一个文件当成表格每天插一行。
DuckDB 则是嵌入式 SQL 引擎:没有服务端、端口、连接池,库加载进进程就能查。它的列式执行和向量化批处理,正好适合对很多行做过滤、聚合和连接。
所以,DuckDB + Parquet 的好处不是“两个数据库叠在一起”,而是引擎和可移植存储解耦:今天用 DuckDB 查,明天换 Spark、Polars 或云数仓,文件还在,数据没有被锁死。
.duckdb 和 Parquet 怎么分工?
这条边界由应用定义,不是 DuckDB 自动替你做决定。一个实用的热/冷分层大概是:
- 经常修改的设置、元数据、最近数据:放在 .duckdb;
- 旧的、不可变的历史数据:按月或按天导出成 Parquet;
- 用一个 view 把两边拼成应用看到的逻辑表。
例如,应用可以在归档任务里自己写规则:
COPY (
SELECT *
FROM events
WHERE event_time < current_date - INTERVAL '90 days'
) TO 'archive/events_2026_05.parquet'
(FORMAT parquet, COMPRESSION zstd);
DELETE FROM events
WHERE event_time < current_date - INTERVAL '90 days';
然后把冷热数据藏在同一个查询入口后面:
CREATE VIEW all_events AS
SELECT * FROM recent_events
UNION ALL
SELECT * FROM read_parquet('archive/events/*.parquet');
业务代码只管查 all_events,不必天天操心数据实际住哪儿。DuckDB 给你 COPY、read_parquet、view 这些积木;“90 天归档一次”是你的业务策略。
这个模型还有一个小原则:不要把单个 Parquet 文件当可更新的数据库表。需要追加时,写新的批次文件并按日期分区,查询时用通配路径读它们。Parquet 擅长“写一批、读很多”,不擅长事务级的单行更新。
浏览器里为什么要 DuckDB-Wasm?
浏览器本来就有 IndexedDB、OPFS 和 localStorage,但它们解决的是存储问题,不是同一种分析问题:
| 需求 | 更合适的工具 |
|---|---|
| 设置、缓存、草稿、频繁 CRUD | IndexedDB 或 SQLite-Wasm |
| 关系型的本地应用状态 | SQLite-Wasm |
| 百万行历史数据、聚合和探索 | DuckDB-Wasm |
| 可移植的分析分发文件 | Parquet |
DuckDB-Wasm 把完整的 DuckDB 编译进 WebAssembly,通常跑在 Web Worker 里。网页因此可以直接对本地 CSV、JSON、Parquet,甚至用户拖进来的文件执行 SQL;结果再以 Arrow 这样的列式结构交给表格和图表。
这会解锁几类很实用的场景:
数据留在用户机器上
医疗、财务、日志和个人数据可以在本地分析,不必为了画一个图把原始数据先上传。隐私不是“加一层 API”解决的,而是根本不发送。
把后端分析接口变成静态数据
服务端可以从 PostgreSQL 等业务库定期产出 Parquet,放到对象存储或 CDN。浏览器通过 DuckDB-Wasm 查询它,计算发生在用户机器上。用户数增长时,服务器少做很多聚合工作;带宽和存储当然还是要付钱,白嫖没有那么彻底。
Parquet 的列、行组和统计信息还能配合 HTTP Range 请求:查询只取需要的字节,不必把一个 1GB 文件从头下载到尾。跨域时记得配置 CORS,并确保对象存储/CDN 支持 Range。
离线和本地优先
把 Parquet 随 PWA 发下去,或第一次打开时缓存起来,之后断网也能继续分析。飞机上、内网里、地铁隧道里,图表照样营业。
Web、Tauri、Electron 共用一套思路
桌面版可以用原生 DuckDB + Parquet,网页端用 DuckDB-Wasm + Parquet;前端组件和 SQL 尽量保持一致。Tauri/Electron 负责文件系统、窗口和系统集成,DuckDB 负责分析,Parquet 负责可移植的历史数据。这样既能做桌面级本地工具,也不用维护两套完全不同的查询模型。
当然,Wasm 不是魔法:
- 浏览器内存和单线程限制仍然存在,PB 级数仓别硬塞进标签页;
- 扩展需要编译进对应的 Wasm bundle,不能想装就装;
- DuckDB-Wasm 更适合读和分析,频繁写回磁盘仍应交给应用存储层。
跑一次,看看分工如何变成性能差距
下面的 demo 生成同一份确定性交易数据,让 DuckDB-Wasm、SQLite-Wasm 和 IndexedDB + JS 同时执行同一条语义的聚合查询:
SELECT region, product, SUM(amount) AS total, COUNT(*) AS cnt
FROM trades
GROUP BY region, product
ORDER BY total DESC;
加载和写入阶段也计入各自耗时;首次加载 Wasm 的一次性成本会先预热,不混进比赛。柱子使用同一条实时刻度:引擎一完成,就把柱子固定在它自己的实测时间,不再被全局时钟拖着跑。
⚡ 三引擎性能对比 Demo
生成 200,000 行确定性模拟交易数据,三个引擎同时起跑执行相同查询:SELECT region, product, SUM(amount) AS total, COUNT(*) AS cnt FROM trades GROUP BY region, product ORDER BY total DESC
🔍 核心源码对比(三种实现的查询关键部分 · 点击展开)▾
// DuckDB 查询:列式存储 + 向量化执行
// 数据转成 Arrow 列式表,再交给 SQL 做聚合
const arrow = await import('apache-arrow')
const table = arrow.tableFromJSON(rows)
await conn.query('DROP TABLE IF EXISTS trades')
await conn.insertArrowTable(table, { name: 'trades' })
const result = await conn.query(
"SELECT region, product, SUM(amount) AS total, COUNT(*) AS cnt" +
" FROM trades GROUP BY region, product ORDER BY total DESC"
)
// 引擎只读需要的列,并按批次处理。⚠️ 数据量越大,DuckDB 的列式优势越明显。柱子显示真实耗时,完成后会固定在实测位置;满格时间轴是预估值。当前环境:? 核
Demo 文案与实现:DeepSeek V4 Flash 生成。
这不是“IndexedDB 很差”。IndexedDB 是很好的浏览器原生事务存储,只是让它负责百万行分析时,你得先把所有对象捞回来,再用 JavaScript 手写分组。SQLite 有 SQL,但它是行式存储;DuckDB 则是为这种列式聚合工作负载准备的。
我会怎么选
如果数据是小对象、经常变、需要逐条 CRUD,选 IndexedDB 或 SQLite。若数据是本地生成的大量记录,需要很多筛选、分组、窗口计算,优先考虑 DuckDB。若还要跨工具、跨语言、跨机器分发历史数据,就让 Parquet 当“公共语言”。
最舒服的组合通常是:
业务库 / .duckdb 保存可变热数据 → 定期导出 Parquet → DuckDB-Wasm 或桌面 DuckDB 查询冷热数据 → Arrow → 表格和图表。
把它塞进博客时踩到的四个坑
- CSP 不让 CDN worker 进来。 把 Wasm 和 worker 自托管到 public/vendor/,同源就省心。
- SSR 没有 Worker。 DuckDB-Wasm 必须在浏览器回调里动态加载,别放在模块顶层。
- Arrow 要 RecordBatch,不是随便一个对象数组。 用 tableFromJSON 先把表格形状说清楚。
- SQLite-Wasm 的 statement 用 finalize(),不是 free()。 一个单词,半小时人生。
结语
DuckDB 是分析引擎,Parquet 是可携带的分析数据,DuckDB-Wasm 是把这套组合搬进浏览器的方式。它最适合“数据已经在本地、数据量不小、用户还想自己探索”的应用:本地工具、隐私分析、公共数据门户、离线看板,以及同时拥有 Web 和桌面形态的产品。
把引擎随应用交付,把数据留在用户手边,服务器只负责发布文件。听起来像把后端偷偷变轻了;实际上只是把该干活的 CPU 叫醒了。