DuckDB-Wasm:浏览器中高效的分析型 SQL

André Kohn 和 Dominik Moritz
2021-10-29 · 15 分钟

摘要:DuckDB-Wasm 是一个用于浏览器的进程内分析型 SQL 数据库。它由 WebAssembly 提供支持,能流畅地处理 Arrow 格式,通过文件系统 API 或 HTTP 请求读取 Parquet、CSV 和 JSON 文件,并已在 Chrome、Firefox、Safari 和 Node.js 上进行过测试。您可以在浏览器中访问 shell.duckdb.org 或在 Observable 上试用。

DuckDB-Wasm logo

DuckDB-Wasm 速度飞快!如果您想了解性能数据,请前往我们的基准测试页面:shell.duckdb.org/versus

浏览器中的高效分析

网页浏览器已经发展成为一个通用计算平台,甚至可以在您的汽车中运行。它的兴起伴随着对浏览器编程语言 JavaScript 越来越高的要求。JavaScript 首先被设计为非常灵活,但这以牺牲与 C++ 等原生语言相比的较低处理效率为代价。当考虑更复杂数据分析任务的执行时间时,这一点尤其明显,这些任务的执行时间通常比原生执行慢几个数量级。因此,过去此类分析任务通常被推送到服务器上,这将任何客户端处理与额外的互联网往返通信绑定在一起,并引入了自身的可伸缩性问题。

4 年前,随着 WebAssembly 的引入,浏览器的处理能力得到了极大的提升

WebAssembly(简称 Wasm)是一种用于基于栈式虚拟机的二进制指令格式。Wasm 被设计为编程语言的可移植编译目标,支持在 Web 上部署客户端和服务器应用程序。

Wasm 栈式机器被设计为以大小和加载时间高效的二进制格式编码。WebAssembly 旨在通过利用各种平台上可用的通用硬件能力,以接近原生速度执行。

(参考:https://webassembly.net.cn/)

四年之后,WebAssembly 革命正全面展开,首批实现已在四大主流浏览器中推出。它已经为我们带来了游戏引擎、完整的 IDE,甚至还有 Photoshop 的浏览器版本。今天,我们加入了这一行列,首次发布了 npm 库 @duckdb/duckdb-wasm

作为一种进程内分析型数据库,DuckDB 有着显著加速浏览器中 OLAP 工作负载的难得机会。我们相信市场需要一个全面且自包含的数据分析库。DuckDB-wasm 会自动将您的查询卸载到专用的 worker 线程,并根据纯 SQL 输入从本地文件系统或 HTTP 服务器读取 Parquet、CSV 和 JSON 文件。在这篇博客文章中,我们希望介绍这个库,并展示我们迈向浏览器原生 OLAP 数据库之旅中的挑战。

DuckDB-Wasm 尚未稳定。您在此版本中可能会遇到一些不完善之处和错误。请通过 GitHub 与我们分享您的想法。

如何导入数据?

让我们来看一些例子。DuckDB-Wasm 提供了多种加载数据的方式。首先,像 INSERT INTO sometable VALUES (1, 'foo'), (2, 'bar') 这样的原始 SQL 值子句易于编写,并且只依赖于纯 SQL 文本。另外,像 CREATE TABLE foo AS SELECT * FROM 'somefile.parquet' 这样的 SQL 语句会查询我们集成的 Web 文件系统,以在本地、远程或从缓冲区解析 somefile.parquet。方法 insertCSVFromPathinsertJSONFromPath 进一步提供了方便的方式,可以使用额外的类型设置(如列类型)导入 CSV 和 JSON 文件。最后,方法 insertArrowFromIPCStream(可选地通过 insertArrowTableinsertArrowBatchesinsertArrowVectors)直接将原始 IPC 流字节复制到 WebAssembly 流解码器中。

以下示例展示了将数据导入 DuckDB-Wasm 的不同选项

// Data can be inserted from an existing arrow.Table
await c.insertArrowTable(existingTable, { name: "arrow_table" });
// ..., from Arrow vectors
await c.insertArrowVectors({
    col1: arrow.Int32Vector.from([1, 2]),
    col2: arrow.Utf8Vector.from(["foo", "bar"]),
}, {
    name: "arrow_vectors"
});
// ..., from a raw Arrow IPC stream
const c = await db.connect();
const streamResponse = await fetch(`someapi`);
const streamReader = streamResponse.body.getReader();
const streamInserts = [];
while (true) {
    const { value, done } = await streamReader.read();
    if (done) break;
    streamInserts.push(c.insertArrowFromIPCStream(value, { name: "streamed" }));
}
await Promise.all(streamInserts);

// ..., from CSV files
// (interchangeable: registerFile{Text,Buffer,URL,Handle})
await db.registerFileText(`data.csv`, "1|foo\n2|bar\n");
// ... with typed insert options
await db.importCSVFromPath('data.csv', {
    schema: 'main',
    name: 'foo',
    detect: false,
    header: false,
    delimiter: '|',
    columns: {
        col1: new arrow.Int32(),
        col2: new arrow.Utf8(),
    }
});

// ..., from JSON documents in row-major format
await db.registerFileText("rows.json", `[
    { "col1": 1, "col2": "foo" },
    { "col1": 2, "col2": "bar" },
]`);
// ... or column-major format
await db.registerFileText("columns.json", `{
    "col1": [1, 2],
    "col2": ["foo", "bar"]
}`);
// ... with typed insert options
await db.importJSONFromPath('rows.json', { name: 'rows' });
await db.importJSONFromPath('columns.json', { name: 'columns' });

// ..., from Parquet files
const pickedFile: File = letUserPickFile();
await db.registerFileHandle("local.parquet", pickedFile);
await db.registerFileURL("remote.parquet", "https://origin/remote.parquet");

// ..., by specifying URLs in the SQL text
await c.query(`
    CREATE TABLE direct AS
        SELECT * FROM 'https://origin/remote.parquet'
`);
// ..., or by executing raw insert statements
await c.query(`INSERT INTO existing_table
    VALUES (1, "foo"), (2, "bar")`);

如何导出数据?

数据加载完成后,DuckDB-Wasm 可以通过两种不同的方式运行查询,这两种方式在结果具体化方面有所不同。首先,方法 query 会运行查询直到完成,并将结果作为单个 arrow.Table 返回。其次,方法 send 通过 arrow.RecordBatchStreamReader 惰性获取查询结果。这两种方法都是通用的,并允许在 TypeScript 中获取类型化的结果。

// Either materialize the query result
await conn.query<{ v: arrow.Int32 }>(`
    SELECT * FROM generate_series(1, 100) t(v)
`);
// ..., or fetch the result chunks lazily
for await (const batch of await conn.send<{ v: arrow.Int32 }>(`
    SELECT * FROM generate_series(1, 100) t(v)
`)) {
    // ...
} 

另外,您可以使用以下方式为参数化查询准备语句:

// Prepare query
const stmt = await conn.prepare<{ v: arrow.Int32 }>(
    `SELECT (v + ?) AS v FROM generate_series(0, 10000) t(v);`
);
// ... and run the query with materialized results
await stmt.query(234);
// ... or result chunks
for await (const batch of await stmt.send(234)) {
    // ...
}

在我看来它就是 Arrow

DuckDB-Wasm 使用 Arrow 作为数据导入和所有查询结果的数据协议。Arrow 是一种对数据库友好的列式格式,以列向量块(称为记录批次)的形式组织,并支持零拷贝读取,开销很小。npm 库 apache-arrow 在浏览器中实现了 Arrow 格式,并已被其他数据处理框架(如 Arquero)使用。因此,Arrow 不仅使我们省去了在 JavaScript 中实现 SQL 类型逻辑的麻烦,也使我们与现有工具兼容。

为什么不使用普通的 JavaScript 对象?

WebAssembly 是隔离且内存安全的。这种隔离是其核心特性之一,并驱动了 DuckDB-Wasm 中的基本设计决策。例如,WebAssembly 在传统 JavaScript 堆内存与自身之间引入了一道屏障。跨越这道屏障很困难,因为 JavaScript 必须处理原生函数调用、内存所有权和序列化性能。像 C++ 这样的语言会使情况更糟,因为它们依赖于通过 FFI 无法获得的智能指针。这给我们留下了两种选择:要么将内存所有权传递给 WebAssembly 实例中的静态单例,要么通过 C 风格的 API 在 JavaScript 中维护内存——而 JavaScript 这种语言对于 RAII 习语 的可靠实现来说过于动态。内存隔离迫使我们在将数据传递给 WebAssembly 实例之前对其进行序列化。浏览器可以使用 JSON.stringifyJSON.parse 函数将 JavaScript 对象原生序列化为 JSON 并从 JSON 反序列化,但这比例如复制原始原生数组要慢。

Web 文件系统

DuckDB-Wasm 集成了专为 WebAssembly 设计的文件系统。DuckDB 本身构建在一个虚拟文件系统之上,该系统将读取 Parquet 文件等高级任务与操作系统特定的低级文件系统 API 解耦。我们在 DuckDB-Wasm 中利用这种抽象,以根据不同的 WebAssembly 环境定制文件系统实现。

下图展示了我们当前 Web 文件系统的运行情况。序列图展示了用户运行一个扫描单个 Parquet 文件的 SQL 查询。该查询首先通过 JavaScript API 卸载到专用的 Web Worker。在那里,它被传递给 WebAssembly 模块,该模块处理查询直到执行命中 parquet_scan 表函数。然后,此表函数使用缓冲文件系统读取文件,该文件系统进而在 Web 文件系统上发出分页读取。然后,此 Web 文件系统使用特定于环境的运行时从多个可能的位置读取文件。

Example Web Filesystem shown visually

根据上下文,Parquet 文件可能位于本地设备、远程服务器或用户预先注册的缓冲区中。我们特意将这三种情况同等对待,以统一外部数据的检索和处理。这不仅简化了分析,还支持更高级的功能,例如部分消费结构化文件格式。例如,Parquet 文件由多个行组组成,这些行组以列主序方式存储数据。因此,我们可能不需要为查询下载整个文件,而只需下载所需的字节。

例如,像 SELECT count(*) FROM parquet_scan(...) 这样的查询,仅凭文件元数据就可以进行评估,即使是数 TB 大的远程文件,也能在几毫秒内完成。另一个更通用的例子是带有 LIMITOFFSET 限定符的分页扫描,例如 SELECT * FROM parquet_scan(...) LIMIT 20 OFFSET 40,或者带有选择性筛选谓词的查询,其中可以根据元数据统计信息跳过整个行组。这些部分文件读取并非开创性新奇,今天也可以在 JavaScript 中实现,但借助 DuckDB-Wasm,这些优化现在由 SQL 查询的语义驱动,而不是由精细调整的应用程序逻辑驱动。

注意:不幸的是,可用文件 API 之间的共同点并不多。这限制了我们可以在浏览器中提供的功能。例如,DuckDB 数据库的本地持久化将是一个具有重要影响的功能,但这需要一种能够同步读写用户提供文件或 IndexedDB 的方式。我们将来可能能够绕过这些限制,但这仍在进行中的研究范围内。

高级功能

WebAssembly 1.0 已在所有主流浏览器中推出。WebAssembly 社区组早在 2017 年 11 月就确定了该第一个版本的设计,现在被称为 WebAssembly MVP。此后,开发持续进行,已有八项额外功能被添加到标准中,并且至少有五个提案正在进行中。

这种快速的发展为库作者带来了挑战和机遇。一方面,不同的功能进入浏览器的速度不同,导致 MVP 后功能的空间碎片化。另一方面,功能可以带来显著的性能提升,因此在追求最高性能时不可或缺。

对于 DuckDB-Wasm 而言,最具前景的功能是异常处理,它在 Chrome 95 中已默认启用。DuckDB 和 DuckDB-Wasm 都用 C++ 编写,并使用异常来处理错误情况。DuckDB 不将异常用于一般的控制流,而是用于自动将错误向上层传播到顶级计划驱动程序。在原生环境中,这些异常被实现为“零成本异常”,因为它们在被抛出之前不会产生任何开销。然而,对于 WebAssembly MVP 而言,这不再可能,因为编译器工具链 Emscripten 必须通过 JavaScript 模拟异常。如果没有 WebAssembly 异常,DuckDB-Wasm 会通过 JavaScript 钩子调用抛出函数,该钩子可以捕获通过 JavaScript aborts 模拟的异常。下图中显示了这些钩子调用的示例。两个堆栈跟踪都源自 DuckDB-Wasm 中对 Parquet 文件的一次分页读取。左侧显示了使用 WebAssembly MVP 的堆栈跟踪,并需要通过 wasm-to-js-i* 函数进行多次调用。右侧堆栈跟踪使用 WebAssembly 异常,没有任何钩子调用。

Exception handling shown visually

这种碎片化的功能空间是一个暂时的挑战,一旦像异常处理、SIMD 和批量内存操作等高影响力功能普遍可用,这个问题就会解决。在此期间,我们将发布为不同功能集编译的多个 WebAssembly 模块,并使用动态浏览器检查为您自适应地选择最佳捆绑包。

以下示例展示了如何使用手动或 JsDelivr 捆绑包实例化 DuckDB-Wasm 的异步版本

// Import the ESM bundle (supports tree-shaking)
import * as duckdb from '@duckdb/duckdb-wasm/dist/duckdb-esm.js';

// Either bundle them manually, for example as Webpack assets
import duckdb_wasm from '@duckdb/duckdb-wasm/dist/duckdb.wasm';
import duckdb_wasm_next from '@duckdb/duckdb-wasm/dist/duckdb-next.wasm';
import duckdb_wasm_next_coi from '@duckdb/duckdb-wasm/dist/duckdb-next-coi.wasm';
const WEBPACK_BUNDLES: duckdb.DuckDBBundles = {
    asyncDefault: {
        mainModule: duckdb_wasm,
        mainWorker: new URL('@duckdb/duckdb-wasm/dist/duckdb-browser-async.worker.js', import.meta.url).toString(),
    },
    asyncNext: {
        mainModule: duckdb_wasm_next,
        mainWorker: new URL('@duckdb/duckdb-wasm/dist/duckdb-browser-async-next.worker.js', import.meta.url).toString(),
    },
    asyncNextCOI: {
        mainModule: duckdb_wasm_next_coi,
        mainWorker: new URL(
            '@duckdb/duckdb-wasm/dist/duckdb-browser-async-next-coi.worker.js',
            import.meta.url,
        ).toString(),
        pthreadWorker: new URL(
            '@duckdb/duckdb-wasm/dist/duckdb-browser-async-next-coi.pthread.worker.js',
            import.meta.url,
        ).toString(),
    },
};
// ..., or load the bundles from jsdelivr
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();

// Select a bundle based on browser checks
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
// Instantiate the asynchronus version of DuckDB-Wasm
const worker = new Worker(bundle.mainWorker!);
const logger = new duckdb.ConsoleLogger();
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

您还可以使用 Web Shell 命令 .features 在浏览器中测试功能和所选的捆绑包。

多线程

2018 年,Spectre 和 Meltdown 漏洞在互联网上引发了毁灭性的冲击波。如今,我们正在面对这些事件的余波,尤其是在运行任意用户代码的软件中——例如网络浏览器。在这些漏洞公布后不久,所有主要浏览器供应商都限制了 SharedArrayBuffers 的使用,以防止危险的时序攻击。SharedArrayBuffers 是可以在 Web Worker 之间共享以实现全局状态的原始缓冲区,也是浏览器特定消息传递的替代方案。这些限制对 WebAssembly 模块产生了不利影响,因为 SharedArrayBuffers 对于在 WebAssembly 中实现 POSIX 线程是必需的。

如果没有 SharedArrayBuffers,WebAssembly 模块可以在专用的 Web Worker 中运行以解除主事件循环的阻塞,但无法在同一实例中生成额外的 Worker 进行并行计算。因此,默认情况下,我们无法在 Web 中释放 DuckDB 的并行查询执行能力。然而,浏览器供应商最近已开始为跨源隔离的网站重新启用 SharedArrayBuffers。如果网站的主文档包含以下 HTTP 头,则该网站是跨源隔离的:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

这些头部将指示浏览器 A) 将顶级文档与其自身来源之外的其他顶级文档隔离,以及 B) 阻止该文档发出任意跨源请求,除非请求的资源明确选择加入。这两项限制对网站影响深远,因为许多第三方数据源目前尚未提供这些头部,而且顶级隔离目前阻碍了与例如 OAuth 弹出窗口的通信(有计划解除此限制)。

因此,我们假设 DuckDB-Wasm 的大多数用户将在非隔离网站上找到。然而,我们正在尝试为隔离网站提供带有后缀 -next-coi 的专用捆绑包,并将密切关注我们用户的未来需求。

Web Shell

我们还在 shell.duckdb.org 随库发布了一个由 DuckDB-Wasm 驱动的 Web Shell。使用以下 Shell 命令以 0.01 的规模因子查询远程 TPC-H 文件。当查询您自己的文件时,请确保正确设置 CORS 头,否则您的浏览器将阻止这些请求。您也可以使用 .files 命令注册来自本地文件系统的文件。

.timer on

SELECT count(*)
FROM 'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/lineitem.parquet';

SELECT count(*)
FROM 'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/customer.parquet';

SELECT avg(c_acctbal)
FROM 'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/customer.parquet';

SELECT *
FROM 'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/orders.parquet'
LIMIT 10;

SELECT n_name, avg(c_acctbal)
FROM
    'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/customer.parquet',
    'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/nation.parquet'
WHERE c_nationkey = n_nationkey
GROUP BY n_name;

SELECT *
FROM
    'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/region.parquet',
    'https://blobs.duckdb.org/data/tpch-sf0.01-parquet/nation.parquet'
WHERE r_regionkey = n_regionkey;

评估

下表展示了使用库 DuckDB-Wasmsql.jsArqueroLovefield 以 0.5 规模因子执行某些 TPC-H 查询的时间。您可以在“DuckDB-Wasm 对比 X”页面上找到包含所有 TPC-H 查询、更多规模因子和微基准测试的更深入讨论。

查询 DuckDB-Wasm sql.js Arquero Lovefield
1 0.855 秒 8.441 秒 24.031 秒 12.666 秒
3 0.179 秒 1.758 秒 16.848 秒 3.587 秒
4 0.151 秒 0.384 秒 6.519 秒 3.779 秒
5 0.197 秒 1.965 秒 18.286 秒 13.117 秒
6 0.086 秒 1.294 秒 1.379 秒 5.253 秒
7 0.319 秒 2.677 秒 6.013 秒 74.926 秒
8 0.236 秒 4.126 秒 2.589 秒 18.983 秒
10 0.351 秒 1.238 秒 23.096 秒 18.229 秒
12 0.276 秒 1.080 秒 11.932 秒 10.372 秒
13 0.194 秒 5.887 秒 16.387 秒 9.795 秒
14 0.086 秒 1.194 秒 6.332 秒 6.449 秒
16 0.137 秒 0.453 秒 0.294 秒 5.590 秒
19 0.377 秒 1.272 秒 65.403 秒 9.977 秒

未来研究

我们相信 WebAssembly 揭示了客户端和服务器之间共享查询处理迄今为止休眠的潜力。将计算推向客户端可以消除昂贵的服务器往返通信,从而提高浏览器内分析的交互性和可伸缩性。我们还相信,DuckDB-Wasm 的发布可能是迈向更通用数据平面的第一步,该平面将跨越多个层,包括传统数据库服务器、客户端、CDN Worker 和计算存储。作为一种进程内分析型数据库,DuckDB 可能是分布式查询计划的理想驱动器,能够以低成本提高 SQL 数据库的可伸缩性和交互性。