浏览器内数据分析:DuckDB-WASM 与 Apache Arrow 零拷贝实践

系统讲解浏览器内数据分析的完整链路:DuckDB-WASM 架构与执行模型、Apache Arrow 列式内存格式与零拷贝、Parquet 与 CSV 读取、Web Worker 并行与 OPFS 持久化、查询结果回传 JS 的边界成本、内存上限与分块策略,以及它与后端查询引擎的取舍。

导语:把 OLAP 引擎塞进浏览器

过去「在浏览器里做数据分析」意味着把数据抽样后送到后端跑 SQL,再把结果 JSON 回来。DuckDB-WASM 改变了这件事:整个列式分析引擎被编译成 WASM,运行在用户标签页里,直接读 Parquet、跑聚合、出结果,全程不上传一行数据。对隐私敏感、网络受限或交互式探索的场景,这是一次范式变化。

但要把它用对,必须理解两件事:一是 Apache Arrow 的列式内存布局如何让数据在 JS 与 WASM 之间零拷贝流动,二是 浏览器给 WASM 划下的内存与线程边界。本文从架构讲到落地清单,把这条链路完整拆开。

前置:WASM 基础、线性内存、浏览器内数据库。


目录


1. DuckDB-WASM 架构

1.1 整体结构

浏览器标签页
┌──────────────────────────────────────────────────────┐
│  主线程 (UI)                                          │
│   AsyncDuckDB ──postMessage──▶  Worker 线程            │
│                                  ┌──────────────────┐ │
│                                  │ duckdb.wasm      │ │
│                                  │ (列式执行引擎)    │ │
│                                  └──────────────────┘ │
│                                        ▲              │
│   Arrow Table ◀────零拷贝共享内存───────┘              │
│   (JS 侧消费结果)                                      │
└──────────────────────────────────────────────────────┘

1.2 执行模型

import * as duckdb from '@duckdb/duckdb-wasm';

// 选择 bundle:根据浏览器是否支持 SIMD / threads 决定
const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
const worker = new Worker(bundle.mainWorker);
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

const conn = await db.connect();
const res = await conn.query(`SELECT region, sum(amount) FROM sales GROUP BY 1`);
关键设计:
  1. 引擎跑在 Worker,主线程只负责发指令与消费结果 → UI 不卡
  2. 查询返回 Arrow Table,而非 JSON → 避免逐行序列化
  3. 数据库实例是「进程内」的,没有网络往返,延迟只受 CPU 限制

一句话总结:DuckDB-WASM 把列式引擎放进 Worker,用 Arrow Table 而非 JSON 回传结果;主线程只发指令,UI 与计算彻底解耦。


2. Arrow 内存格式

2.1 列式与行式

行式(JSON / 数组对象):{region:"A",amount:10}, {region:"B",amount:20}
  内存里交错存放,扫描某一列要跳过其他列 → 缓存不友好

列式(Arrow):region:["A","B"]  amount:[10,20]
  同一列连续存放,扫描与聚合只碰需要的列 → 缓存友好、可 SIMD 化

2.2 Arrow 的三大组成

Arrow Columnar Format 三个层次:
  Schema     字段名与类型(int32 / utf8 / timestamp 等)
  RecordBatch 一批行,内部按列存放,带长度与 null 位图
  Buffer     实际字节,分 validity / offsets / data 三种
字符串列不是「一堆字符串」,而是 offsets 数组 + data 字节数组。
// Rust 侧(arrow-rs)构造一个 Int32 列
use arrow::array::Int32Array;
let col = Int32Array::from(vec![1, 2, 3, 4]);
// 底层就是一段连续 i32 缓冲 + 一个 validity 位图

理解这一点是零拷贝的前提:Arrow 列在内存里就是「连续缓冲区 + 元数据」,只要双方认可同一段内存,就不需要复制。

一句话总结:Arrow 是「Schema + RecordBatch + Buffer」三段式列式格式,字符串列由 offsets 加字节数组表达;理解缓冲布局是零拷贝的前提。


3. 零拷贝与列式布局

3.1 为什么能零拷贝

零拷贝成立的条件:
  1. WASM 线性内存与 JS 的 ArrayBuffer 指向同一块物理内存
  2. 结果在 WASM 侧已经是 Arrow 布局,无需重新序列化
  3. JS 侧直接在该 buffer 上建 TypedArray 视图读取
Arrow Table 的每个 Buffer 都能映射成一段 Uint8Array,逐列解读。
// 从查询结果里取出某一列的原始缓冲区(示意)
const table = res;                       // Arrow Table
const col = table.getChild('amount');
const data = col.data[0];                // 一个 Data 对象
const values = new Int32Array(
  data.values.buffer, data.values.byteOffset, data.length
);

3.2 视图失效与生命周期

两个必须记住的约束:
  1. WASM 内存一旦增长,所有已建视图立即失效(detached)
  2. 结果缓冲区由 WASM 侧分配,JS 视图生命周期不能超过其存活期
安全做法:在结果上调用 toArray() / slice() 拿到 JS 拥有的副本再长期持有。

只在消费结果的那一刻建视图,不要缓存。若要在 UI 中长期持有,显式复制成 JS 自有内存,代价是一次拷贝但换来确定性。

一句话总结:零拷贝依赖「同一段内存 + Arrow 布局」,JS 侧用 TypedArray 视图直接读;视图绝不可跨 WASM 内存增长存活,长期持有必须显式复制。


4. Parquet 与 CSV 读取

4.1 格式选择

格式体积读取速度是否列裁剪适用
Parquet最小最快支持生产数据、大表
CSV最大慢不支持小数据、临时导入
Arrow IPC中极快支持引擎间传递
// 把文件注册进 DuckDB 的虚拟文件系统,再用 SQL 读
await db.registerFileURL('sales.parquet', url,
  duckdb.DuckDBDataProtocol.HTTP, false);

await conn.query(`CREATE TABLE sales AS
  SELECT * FROM parquet_scan('sales.parquet')`);

4.2 列裁剪与谓词下推

-- Parquet 是列式存储:只读需要的列,只扫匹配的行组
SELECT region, sum(amount)
FROM parquet_scan('sales.parquet')
WHERE year = 2026          -- 谓词下推,跳过不相关的行组
GROUP BY region;
性能要点:
  只 SELECT 需要的列(列裁剪)→ 读的字节数可降一个数量级
  WHERE 尽量落在排序/分区键上(谓词下推 + 行组跳过)
  避免 SELECT * 后再在 JS 里过滤 —— 等于把优化机会全丢掉

一句话总结:优先 Parquet 并做列裁剪与谓词下推;把过滤放进 SQL 让引擎跳过行组,而不是读全表再在 JS 里筛。


5. Web Worker 并行

5.1 线程模型

三种并行度:
  单 Worker       引擎在独立线程跑,UI 不卡,但计算是单线程
  Pthreads        duckdb 编译为多线程 WASM,需要 COOP/COEP 响应头
  多实例并行      开多个 Worker 各跑一个实例,各自处理不同数据分片
// 多实例:把数据按分片交给多个 Worker,各自聚合后合并
const workers = Array.from({ length: 4 }, () =>
  new Worker(bundle.mainWorker));
const partials = await Promise.all(shards.map((shard, i) =>
  runOn(workers[i], shard)));
const total = partials.reduce(mergeAggregate);

5.2 COOP/COEP 约束

启用 Pthreads 的前提是跨源隔离:
  Cross-Origin-Opener-Policy: same-origin
  Cross-Origin-Embedder-Policy: require-corp
代价:所有第三方资源(图片、脚本、字体)都必须带 CORP 头或走 CORS,
      否则会被浏览器拦截 —— 这是上线时最容易被忽略的坑。

不要为了多线程牺牲整站资源加载。如果站点依赖大量第三方资源,先用「单 Worker + 多实例」的并行方案,它不要求跨源隔离。

一句话总结:Pthreads 需要 COOP/COEP 跨源隔离并会波及全站第三方资源;不想付这个代价就用「单 Worker + 多实例分片」拿并行度。


6. OPFS 持久化

6.1 为什么需要 OPFS

内存态数据库的两个问题:
  1. 刷新页面即丢,重新下载与解析成本高
  2. 大表全放内存会撞上浏览器给 WASM 的内存上限
OPFS(Origin Private File System)提供「按源隔离的私有文件系统」,
DuckDB 可以把数据库文件与 Parquet 直接落盘到 OPFS。
// 把文件写入 OPFS,再让 DuckDB 从 OPFS 读
const root = await navigator.storage.getDirectory();
const fh = await root.getFileHandle('sales.parquet', { create: true });
await db.registerFileHandle('sales.parquet', fh,
  duckdb.DuckDBDataProtocol.FILE, true);

6.2 同步访问与限制

OPFS 的两个 API:
  createSyncAccessHandle()  只能在 Worker 里用,同步读写、性能最好
  createWritable()          主线程可用,异步、吞吐略低
配额:受 Storage API 的配额限制(通常为磁盘可用空间的百分比),
      写满会抛 QuotaExceededError,必须捕获并降级。

持久化不是「写了就稳」:浏览器可能在存储压力下清理来源数据,关键数据集仍需服务端作为真源。

一句话总结:OPFS 让内存态数据库可持久化,Worker 里用同步句柄性能最好;它受配额限制且可能被清理,不能当作唯一数据源。


7. 查询与结果传回

7.1 传回方式对比

方式拷贝次数适用
Arrow Table0(共享内存)大结果、需继续计算
toArray()1需要 JS 对象遍历
JSON2+极小结果、调试
const table = await conn.query(sql);       // 零拷贝拿到 Arrow Table
const rows = table.toArray();              // 显式拷贝成 JS 对象数组
console.log(rows[0].region, rows[0].amount);

7.2 避免二次序列化

反模式:查询 → Arrow → JSON.stringify → 前端解析
  白白付两次序列化成本,还把列式优势丢光

正模式:查询 → Arrow → 直接喂给图表库
  多数图表库支持列式数据,或只需 toArray() 一次的浅转换

把结果尽量留在列式形态里消费。只有当 UI 组件强制要求行式对象时,才付 toArray() 的一次拷贝。

一句话总结:结果优先以 Arrow Table 零拷贝传回;toArray() 是一次显式拷贝,仅在图库要求行式时使用,绝不做 Arrow→JSON 的二次序列化。


8. 内存上限与分块

8.1 上限来源

WASM 32 位地址空间硬上限 4 GiB,但浏览器实际给的更少:
  桌面 Chrome 单标签页通常可用 1~2 GiB 线性内存
  移动端 Safari 明显更紧,大表极易 OOM
超限表现:memory.grow 返回 -1 → 分配失败 → 查询报错或实例崩溃

8.2 分块策略

-- 用 LIMIT/OFFSET 或按分区键分块扫描,避免一次性物化全表
SELECT region, sum(amount) FROM parquet_scan('sales.parquet')
WHERE year = 2026
GROUP BY region
LIMIT 100000;
四种降内存手段:
  1. 列裁剪 + 谓词下推,从源头减少要读的列与行
  2. 流式聚合(DuckDB 会自动做),避免中间结果全物化
  3. 按分区键分块查询,每块跑完即释放
  4. 结果直接落 OPFS,不全部留在内存

一句话总结:浏览器给 WASM 的内存远小于 4 GiB,移动端尤其紧张;靠列裁剪、流式聚合、分块查询与落盘四条手段把峰值内存压住。


9. 与后端引擎的取舍

9.1 对比

维度浏览器内 DuckDB-WASM后端查询引擎
数据位置不出浏览器需上传或已在服务端
延迟无网络往返受网络与服务端影响
数据规模受内存限制可横向扩展
隐私最强依赖合规措施
运维零后端需集群与运维

9.2 混合架构

推荐形态:边缘聚合 + 服务端细算
  1. 浏览器先对本地/已下载数据做粗筛与聚合,减少回传量
  2. 只把「无法本地完成」的聚合请求发往后端
  3. 大表用 Parquet + 列裁剪,按需从 CDN 拉取分片
判据:单用户数据量 < 数百 MB 且隐私敏感 → 全前端;
      需要跨用户全量数据 → 必须后端。

不要把 WASM 分析当作后端的替代品。它的定位是「把能本地化的计算本地化」,而不是「取消后端」。

一句话总结:浏览器内引擎强在隐私与零往返、弱在规模;最优形态是「本地粗算 + 后端细算」的混合架构,而非二选一。


10. 落地清单与测量

10.1 测量指标

必测五项:
  1. 首次可查询时间(下载 bundle + 实例化 + 首次查询)
  2. 单次查询 P50 / P99 延迟(分冷热)
  3. 峰值线性内存(决定是否会 OOM)
  4. 数据下载字节数(Parquet 列裁剪后的实际传输量)
  5. Worker 与主线程的 CPU 占用分布

10.2 上线清单

[ ] bundle 按 SIMD/threads 能力选择,避免把多线程版发给不支持的浏览器
[ ] 查询走 Worker,主线程只做 UI
[ ] 结果用 Arrow Table,避免 Arrow→JSON 二次序列化
[ ] 所有 malloc / 查询失败路径有降级,OOM 不崩溃
[ ] OPFS 写入捕获 QuotaExceededError 并降级为内存态
[ ] 移动端单独压测内存上限
[ ] 明确数据不出浏览器的合规声明与验证方法

一句话总结:先测「首次可查询 / 查询 P99 / 峰值内存 / 传输字节 / CPU 分布」五项,再按清单逐项加固;移动端内存与 OPFS 配额是两个最容易被忽略的线上风险。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「wasm」更多文章

  1. WASM 模块测试与模糊测试:从单元测试到差分验证
  2. 浏览器扩展中的 WASM:MV3 约束、CSP 与生命周期实践
  3. WASM 流式编译与实例化优化:从首字节到可执行