DuckDB-Wasm:浏览器中高效的分析型 SQL
摘要:DuckDB-Wasm 是一个用于浏览器的进程内分析型 SQL 数据库。它由 WebAssembly 提供支持,能流畅地处理 Arrow 格式,通过文件系统 API 或 HTTP 请求读取 Parquet、CSV 和 JSON 文件,并已在 Chrome、Firefox、Safari 和 Node.js 上进行过测试。您可以在浏览器中访问 shell.duckdb.org 或在 Observable 上试用。
DuckDB-Wasm 速度飞快!如果您想了解性能数据,请前往我们的基准测试页面:shell.duckdb.org/versus。
浏览器中的高效分析
网页浏览器已经发展成为一个通用计算平台,甚至可以在您的汽车中运行。它的兴起伴随着对浏览器编程语言 JavaScript 越来越高的要求。JavaScript 首先被设计为非常灵活,但这以牺牲与 C++ 等原生语言相比的较低处理效率为代价。当考虑更复杂数据分析任务的执行时间时,这一点尤其明显,这些任务的执行时间通常比原生执行慢几个数量级。因此,过去此类分析任务通常被推送到服务器上,这将任何客户端处理与额外的互联网往返通信绑定在一起,并引入了自身的可伸缩性问题。
4 年前,随着 WebAssembly 的引入,浏览器的处理能力得到了极大的提升
WebAssembly(简称 Wasm)是一种用于基于栈式虚拟机的二进制指令格式。Wasm 被设计为编程语言的可移植编译目标,支持在 Web 上部署客户端和服务器应用程序。
Wasm 栈式机器被设计为以大小和加载时间高效的二进制格式编码。WebAssembly 旨在通过利用各种平台上可用的通用硬件能力,以接近原生速度执行。
四年之后,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
。方法 insertCSVFromPath
和 insertJSONFromPath
进一步提供了方便的方式,可以使用额外的类型设置(如列类型)导入 CSV 和 JSON 文件。最后,方法 insertArrowFromIPCStream
(可选地通过 insertArrowTable
、insertArrowBatches
或 insertArrowVectors
)直接将原始 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.stringify
和 JSON.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 文件系统使用特定于环境的运行时从多个可能的位置读取文件。
根据上下文,Parquet 文件可能位于本地设备、远程服务器或用户预先注册的缓冲区中。我们特意将这三种情况同等对待,以统一外部数据的检索和处理。这不仅简化了分析,还支持更高级的功能,例如部分消费结构化文件格式。例如,Parquet 文件由多个行组组成,这些行组以列主序方式存储数据。因此,我们可能不需要为查询下载整个文件,而只需下载所需的字节。
例如,像 SELECT count(*) FROM parquet_scan(...)
这样的查询,仅凭文件元数据就可以进行评估,即使是数 TB 大的远程文件,也能在几毫秒内完成。另一个更通用的例子是带有 LIMIT
和 OFFSET
限定符的分页扫描,例如 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 异常,没有任何钩子调用。
这种碎片化的功能空间是一个暂时的挑战,一旦像异常处理、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-Wasm、sql.js、Arquero 和 Lovefield 以 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 数据库的可伸缩性和交互性。