发布于

DuckDB + Parquet:把分析引擎和数据文件分工好

作者

@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,但它们解决的是存储问题,不是同一种分析问题:

需求更合适的工具
设置、缓存、草稿、频繁 CRUDIndexedDB 或 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 → 表格和图表。

把它塞进博客时踩到的四个坑

  1. CSP 不让 CDN worker 进来。 把 Wasm 和 worker 自托管到 public/vendor/,同源就省心。
  2. SSR 没有 Worker。 DuckDB-Wasm 必须在浏览器回调里动态加载,别放在模块顶层。
  3. Arrow 要 RecordBatch,不是随便一个对象数组。 用 tableFromJSON 先把表格形状说清楚。
  4. SQLite-Wasm 的 statement 用 finalize(),不是 free()。 一个单词,半小时人生。

结语

DuckDB 是分析引擎,Parquet 是可携带的分析数据,DuckDB-Wasm 是把这套组合搬进浏览器的方式。它最适合“数据已经在本地、数据量不小、用户还想自己探索”的应用:本地工具、隐私分析、公共数据门户、离线看板,以及同时拥有 Web 和桌面形态的产品。

把引擎随应用交付,把数据留在用户手边,服务器只负责发布文件。听起来像把后端偷偷变轻了;实际上只是把该干活的 CPU 叫醒了。