发布于

DuckDB-Wasm:把分析引擎带进浏览器

作者

@Author: Garfield Zhu

本来写博客确实有些母语羞耻,所以这其实是我第一篇中文博客来着 🤦。最近刚给博客加了中英双语切换,工作流里也顺手让AI自动翻译添加每篇博客的另一个语言版本了。所以直接写中文好像就..还好、也蛮偷懒的——唔这里就当顺便测一下,从中文自动生成的英文版本有没有啥问题(bushi

先认识主角:DuckDB 到底在负责什么

如果把数据分析比作做饭,数据文件是食材,DuckDB 就是那位很会备菜的厨师:它负责读列、筛选、连接、聚合、排序,再把结果端给表格或图表。它不是一个要单独部署的数据库服务,而是一个可以嵌进应用进程的分析引擎:没有端口、连接池和“先把服务启动起来”这一步。

先插一句:偶尔作为CRUD仔的时候,打交道的工作数据库基本都是 PostgreSQL。Postgres 强到几乎什么都能做:事务、MVCC、并发控制、复杂 SQL、JSON/JSONB、索引、复制、权限和扩展,甚至能顶个 NoSQL 用。反正官方的特性总览里老长了呗。它很适合做多人共享、持续写入、必须守住一致性的业务数据库。DuckDB 和 PostgreSQL 完全不是一个赛道:前者是嵌入应用、面向读多写少 OLAP 的分析引擎,后者是通用的服务型业务数据库。不是“谁替代谁”,而是“谁守住真相、谁负责切片”——需要事务和并发写入时,让 PostgreSQL 坐主桌;拿业务快照、Parquet 或本地文件做大范围筛选聚合时,让 DuckDB 上场。

两者还可以直接握手:DuckDB 的 PostgreSQL 扩展可以把正在运行的 PostgreSQL 表当作查询输入,再导出分析结果或 Parquet。我的实际心智模型就是:Postgres 负责生产事实,DuckDB 负责把事实切成图表。

DuckDB 的设计重点也很明确:分析(OLAP)优先,而不是逐行事务(OLTP)优先。它使用列式执行和向量化批处理,一次处理一批值,再配合 SQL 优化器决定过滤、聚合和连接的执行顺序。官方的向量化执行说明写得很直白:数据不是一行一行慢慢挪,而是按向量批次流过算子。

这带来一个很舒服的边界:业务代码负责把数据放到一个可读的位置,DuckDB 负责“怎么查得快”。本地文件、对象存储、内存表,甚至用户刚拖进页面的文件,都可以成为查询输入。分析引擎和数据放在哪里,不必绑死在同一台服务器上。

Parquet 是话题,但不是另一个数据库

这时 Parquet 很适合登场,不过它和 DuckDB 的职责不是一回事:

东西核心职责更像什么
DuckDB执行 SQL、过滤、连接、聚合、排序分析引擎
Parquet按列组织、压缩并交换分析数据分析文件格式

一句话:DuckDB 负责查和算,Parquet 负责装和带走。

Parquet 适合“写一批、读很多”:后端可以从业务库定期导出按日期分区的历史数据,前端、Python、Spark、Polars 或 DuckDB 都能继续读。它不擅长每天对单个文件做一行一行的事务更新;那种活交给业务库或 .duckdb 文件更自然。

这就是前后端共享分析数据的关键:后端发布 Parquet,浏览器用 DuckDB-Wasm 直接查同一份文件,桌面端也能复用同一套 SQL。数据格式是公共语言,分析引擎是可替换的执行层,没必要为每个图表再造一个“只返回这张图”的接口。

DuckDB-Wasm:浏览器也可以有一台小分析机

DuckDB-Wasm把 DuckDB 编译成 WebAssembly,通常放进 Web Worker 里运行。于是浏览器可以直接查询 CSV、JSON、Parquet,或者用户刚拖进来的文件,再把结果交给表格、图表和交互式筛选器。

它的核心价值不是“把数据库塞进网页炫技”,而是把分析路径变短:

  • 数据可以留在本地。 医疗、财务、日志、个人账单等数据,不必为了画一个饼图先上传服务器。隐私不是多包一层 API,而是根本不发出去。
  • 后端可以发布数据,前端自己探索。 后端从 PostgreSQL 等业务库产出 Parquet,浏览器负责临时筛选、分组和排序。服务器少做一些重复聚合,用户多获得一点即时反馈——带宽和存储仍然收费,免费午餐只提供半份。
  • 文件不一定要整包下载。 Parquet 的列、行组和统计信息可以配合 DuckDB 的 HTTP Range 读取,只拿查询需要的字节。对象存储的 CORS 和 Range 支持记得开,不然优化会变成“怎么还在下载”。
  • 离线也能继续查。 PWA 可以缓存一批 Parquet,断网后仍然能看数据;内网门户也不必为了每次筛选都绕一趟后端。

桌面只是同一思路的延伸:Tauri 或 Electron 负责窗口、文件系统和系统集成,DuckDB(原生版或 Wasm 版)负责分析,Parquet 负责携带历史数据。重点不是选哪个壳,而是让 Web 和桌面共享一套数据模型和查询习惯。

当然,Wasm 不是魔法:浏览器内存有限,超大数据要做分区、抽样或渐进加载;频繁逐行写回磁盘也不该硬塞给分析引擎。让存储层做存储,DuckDB 做分析,边界清楚就已经很强了。

做个小实验:不是比谁会插入,而是比谁会分析

下面的 demo 生成同一份确定性交易数据,让 DuckDB-Wasm、SQLite-Wasm 和 IndexedDB + JavaScript 执行同一语义的全年 Top-N 查询:

WITH full_year AS (
  SELECT SUBSTR(t.date, 1, 7) AS month,
         t.region, t.product, t.channel, t.device,
         c.segment, c.weight, t.campaign_id, t.amount, t.units, t.discount, t.latency_ms
  FROM trades AS t
  JOIN campaigns AS c ON c.id = t.campaign_id
  WHERE t.date >= '2024-01-01'
    AND t.date < '2025-01-01'
), monthly AS (
  SELECT month, region, product, channel, device, segment,
         SUM(amount * (1 - discount) * weight) AS total,
         SUM(units) AS units,
         AVG(latency_ms) AS avg_latency_ms,
         QUANTILE_DISC(latency_ms, 0.95) AS p95_latency_ms,
         COUNT(*) AS cnt,
         COUNT(DISTINCT campaign_id) AS campaign_count
  FROM full_year
  GROUP BY month, region, product, channel, device, segment
), ranked AS (
  SELECT *, ROW_NUMBER() OVER (
    PARTITION BY region, product ORDER BY total DESC
  ) AS product_rank,
  RANK() OVER (PARTITION BY segment ORDER BY total DESC) AS segment_rank,
  SUM(total) OVER (PARTITION BY month, region) AS region_month_total
  FROM monthly
)
SELECT month, region, product, channel, device, segment, total, units, avg_latency_ms, p95_latency_ms, cnt, campaign_count
FROM ranked
WHERE product_rank <= 3
ORDER BY total DESC
LIMIT 20;

这个查询故意不止按两个字段做一个小聚合:它先从包含渠道、设备、原始 payload 和多个数值指标的宽表里筛全年(payload 这次故意不读,给列裁剪留点发挥空间),再连接一个活动维表,按月份、区域、产品、渠道、设备、活动分群做多维分组,同时计算加权净收入、销量、平均延迟、P95 延迟和参与活动数(COUNT(DISTINCT)),接着用多个窗口函数取每个产品的前三名并算分区总额,最后再做全局 Top-N。这个形状更像销售看板、游戏遥测或事件回放摘要,也更接近列式引擎擅长的星型分析。

数据生成阶段显示总进度;生成完成后先显示三行空柱。然后 DuckDB、SQLite、IndexedDB 按顺序各自准备并查询,当前引擎从开始准备时就立即计时,完成后释放资源再轮到下一个。这样每次只有一个实现占用 CPU,页面不会因为三套 Wasm/存储同时开工而卡住,也不会出现“页面卡了几秒,进度突然从 0 跳到 80%”的假动画。柱子展示用户实际等待的端到端耗时,完成行另外标出纯查询耗时,最后的结论也按纯查询耗时比较:准备成本不藏,分析优势也不被小数据的固定开销盖住。

⚡ 三引擎性能对比 Demo

生成 50,000 行确定性事件遥测。每个引擎回答同一个分析问题:全年筛选 → 连接活动维表 → 按月/区域/产品分组 → 收入 + P95 延迟 → Top-N

数据量:
🔍 核心源码对比(三种实现的查询关键部分 · 点击展开)▾
// DuckDB 查询:列式存储 + 向量化执行
// 数据转成 Arrow 列式表,再交给 SQL 做星型分析
const arrow = await import('apache-arrow')
const table = arrow.tableFromArrays({ id, date, region, product, channel, device, payload, campaign_id, amount, units, discount, latency_ms })

await conn.query('DROP TABLE IF EXISTS trades')
await conn.insertArrowTable(table, { name: 'trades' })

const result = await conn.query(
  "WITH full_year AS (SELECT SUBSTR(t.date, 1, 7) AS month, t.region, t.product, t.channel, t.device, c.segment, c.weight, t.campaign_id," +
   " t.amount, t.units, t.discount, t.latency_ms FROM trades t JOIN campaigns c" +
   " ON c.id = t.campaign_id WHERE t.date >= '2024-01-01' AND t.date < '2025-01-01'), monthly AS (" +
   "SELECT month, region, product, channel, device, segment, SUM(amount * (1 - discount) * weight) AS total," +
   " SUM(units) AS units, AVG(latency_ms) AS avg_latency_ms, QUANTILE_DISC(latency_ms, 0.95) AS p95_latency_ms," +
   " COUNT(*) AS cnt, COUNT(DISTINCT campaign_id) AS campaign_count FROM full_year" +
   " GROUP BY month, region, product, channel, device, segment), ranked AS (SELECT *, ROW_NUMBER() OVER" +
   " (PARTITION BY region, product ORDER BY total DESC) AS product_rank, RANK() OVER" +
   " (PARTITION BY segment ORDER BY total DESC) AS segment_rank, SUM(total) OVER" +
   " (PARTITION BY month, region) AS region_month_total FROM monthly)" +
   " SELECT month, region, product, channel, device, segment, total, units, avg_latency_ms, p95_latency_ms, cnt, campaign_count FROM ranked" +
   " WHERE product_rank <= 3 ORDER BY total DESC LIMIT 20"
)

// 引擎只读需要的列,并按批次处理。

⚠️ 数据量越大,DuckDB 的列式优势越明显。每个引擎单独准备并查询,避免互相抢 CPU;柱子显示端到端耗时,完成行同时标出纯查询耗时,结论也按纯查询耗时比较。满格时间轴是预估值。当前环境:? 核

DeepSeek V4 Flash 生成。

这里不是要宣布 IndexedDB 失业。它是很好的浏览器事务存储;只是让它分析百万行时,通常要把对象全量读回 JavaScript,再自己筛选、分组、排序。SQLite 有 SQL,但更偏行式事务;这个实验还加了 P95 延迟和 DISTINCT 活动数,SQLite 需要用窗口函数手工模拟离散分位数,DuckDB 则直接用分析聚合函数。DuckDB 的列式、向量化执行更贴近这种“读很多列的一部分、做聚合和排序”的工作负载。具体数字会受浏览器、CPU 和首次缓存影响,实验看的是工作负载形状,不是宇宙真理。

最近的风向:分析引擎正在靠近应用

这条路线并不孤单。DuckDB 官方近年的基准与生态更新持续把重点放在本地分析、列式文件和更广泛的工具互操作上;在浏览器里查询 Iceberg也说明,浏览器端分析不再只停留在“上传一个 CSV 玩一下”。

我觉得真正有意思的趋势是:数据平台的“查询能力”正在从后端服务下沉为应用能力。用户打开页面就拿到一个小型分析工作台,而不是只能等待后端把预设好的四张图发过来。前端不再只是渲染器,开始拥有一部分数据产品的主动权。

我的结论:开盒即用的分析能力,很适合这些地方

如果数据是小对象、经常修改、需要逐条 CRUD,IndexedDB、SQLite 或业务数据库更合适;如果数据是本地生成的大量记录,需要筛选、分组、排序、窗口计算,就值得认真考虑 DuckDB。历史数据要跨工具、跨语言、跨机器流动,再让 Parquet 做那个公共格式。

我脑中的组合大概是:

业务库或 .duckdb 保存可变热数据 → 定期导出 Parquet → DuckDB-Wasm 在 Web 端查询冷热数据 → Arrow/JSON 交给图表。

它可以长成 Web/桌面级 BI 工具,可以是下载即用的个人数据分析器,也可以是带实时回放和排行榜的游戏工具。服务器负责发布数据,应用负责让用户探索数据;该叫醒的 CPU 还是会叫醒,只是终于不用每个筛选器都先去敲一次后端的门了。

最后留几个实现层面的坑,给未来的自己贴便利贴:

  1. CSP 不喜欢 CDN Worker,就把 Wasm 和 Worker 文件自托管到同源路径。
  2. SSR 没有 Worker,DuckDB-Wasm 要在浏览器端动态加载。
  3. Arrow 表要先把列形状说清楚,再交给 DuckDB;不要把任意对象数组当成魔法通行证。
  4. SQLite-Wasm 的 statement 记得 finalize(),不是 free()。一个单词,半小时人生。