DuckDB-Wasm 可以直接打开浏览器来源私有文件系统(OPFS)中的数据库文件。本地分析数据因此能够跨页面刷新和浏览器重启保存;要正确使用它,还需要理解事务日志、检查点,以及数据何时真正写入数据库主文件。
2021 年 DuckDB-Wasm 刚发布时,数据库还不能持久保存:所有内容都位于 Wasm 堆内存中,关闭标签页后就会消失。为了保存数据,应用需要把表序列化为 Parquet,将字节存进 IndexedDB,并在下次页面加载时重新注册。这条路径可行,但必须由应用层实现,DuckDB-Wasm 没有提供开箱即用的支持。
现代浏览器自 2023 年 3 月起陆续提供了 OPFS。这是按来源隔离、运行在沙箱内的文件系统,支持随机位置读写。根据 DuckDB 的 OPFS 文档,DuckDB-Wasm 可以将其用作存储后端:在 opfs:// 路径打开的数据库,能够在刷新页面、重启浏览器后再次打开。原文作者测试的版本是 1.32.0 和 1.33.1-dev64.0。
下面的调用会在 OPFS 中打开数据库文件:
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});
得到的是普通 .duckdb 文件,配合预写日志和检查点保存状态。
版本提醒:原文在 2026 年 9 月 18 日记录,npm 的
latest当时指向 1.33.1-dev57.0。该构建会创建 OPFS 文件,却不会写入文件,数据因而无法持久保存。原因是它把路径规范化为只有一个斜杠的opfs:/analytics.duckdb,与 OPFS 句柄不再匹配。原文建议固定使用 1.32.0,或使用包含 1.33.1-dev64.0 及后续修复的构建。这个提醒描述的是当时的版本状态,安装时应再次核对具体版本,不能将它当作当前latest标签的状态说明。
打开数据库
初始化过程与普通 DuckDB-Wasm 应用相同:选择构建包,启动 Worker,再实例化数据库。新增的步骤是调用 open。模块导入会解析到已安装的版本,getJsDelivrBundles() 会获取与其对应的 Worker 与 .wasm 文件。因此,要安装持久化功能正常的版本;原文使用的固定版本安装命令是 npm install @duckdb/duckdb-wasm@1.32.0,也列出了 @next 作为当时的另一选择。
import * as duckdb from '@duckdb/duckdb-wasm';
const bundles = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(bundles);
// Worker scripts must be same-origin, so wrap the CDN worker URL in a Blob
const workerUrl = URL.createObjectURL(
new Blob([`importScripts("${bundle.mainWorker}");`], {
type: 'text/javascript'
})
);
const worker = new Worker(workerUrl);
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
URL.revokeObjectURL(workerUrl);
// NEW: open a persistent database in OPFS instead of the default :memory:
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});
const conn = await db.connect();
await conn.query(`
CREATE TABLE IF NOT EXISTS transactions (
id BIGINT,
ts TIMESTAMP,
merchant VARCHAR,
category VARCHAR,
amount DECIMAL(10, 2)
);
`);
await conn.query(`INSERT INTO transactions VALUES (1, now(), 'Coolblue', 'electronics', 49.95)`);
await conn.query('CHECKPOINT');
const result = await conn.query('SELECT count(*) AS n FROM transactions');
console.log(result.toArray()[0].n);
这个示例先创建交易表、插入一行,再执行检查点。原文说明,在相同来源刷新页面后再运行同一段代码,CREATE TABLE IF NOT EXISTS 会找到已有的表,不再新建;插入语句增加第二行,计数结果应为 2。这里不需要同步步骤、导出流程,也不需要在 localStorage 中记录状态。opfs:// 前缀让 DuckDB-Wasm 的文件系统层,相对于当前来源的私有文件系统解析路径,而不是使用内存中的 Emscripten 文件系统。
打开数据库会在 OPFS 中创建数据库文件及其 .wal 文件。从 1.33.1-dev64.0 起,相关构建还会创建两个空的辅助文件:.wal.checkpoint 和 .wal.recovery,供 DuckDB 在检查点过程中使用。.duckdb 文件采用普通 DuckDB 数据库格式;通过下文方法从 OPFS 取出后,可以用 DuckDB 命令行或 Python 客户端打开。
保存数据文件
相同的前缀也适用于数据文件。常见做法是只下载一次远程数据集,将其保存在持久数据库中,再把派生结果作为 Parquet 文件缓存在 OPFS 中。下面使用 DuckDB 网页 Shell 提供的 TPC-H orders 表:比例因子为 0.01,大约有 1,500 行。
await conn.query(`
CREATE TABLE IF NOT EXISTS orders AS
SELECT * FROM 'https://shell.duckdb.org/data/tpch/0_01/parquet/orders.parquet';
`);
await conn.query('CHECKPOINT');
DuckDB-Wasm 通过 HTTP 范围请求读取远程文件。表使用 IF NOT EXISTS 创建,因此第一次页面加载时才需要获取该文件;后续加载时,已有表从 OPFS 读取,不再向 shell.duckdb.org 请求同一份数据。
可以用浏览器开发者工具的 Network 面板检查这一点:第一次加载能看到范围请求,后续刷新时不应再出现这些数据请求。也可以观察 DuckDB-Wasm 日志:传给 AsyncDuckDB 的 ConsoleLogger 会记录每次 HTTP 读取。刷新后没有这些读取记录,才有依据判断相应数据读取走的是 OPFS。
数据保存到本地后,可以把聚合结果写入 OPFS 中的 Parquet 文件,之后再读取:
COPY (
SELECT o_orderpriority AS priority,
date_trunc('month', o_orderdate) AS month,
sum(o_totalprice) AS total
FROM orders
GROUP BY ALL
) TO 'opfs://cache/monthly_totals.parquet';
SELECT * FROM 'opfs://cache/monthly_totals.parquet';
cache/ 这样的子目录会按需创建。OPFS 文件对 DuckDB 来说是普通文件路径,因此通配符、read_csv 和其他读取器仍可正常使用。不过,在 SQL 中读写 opfs:// 路径,需要下面介绍的 open() 选项,或手动注册文件。
文件处理模式
设置 opfs: { fileHandling: 'auto' } 后,DuckDB-Wasm 会扫描每条语句,寻找单引号包裹的 'opfs://...' 字面量,在执行前注册这些文件;必要时也会创建文件和缺失的目录,执行后释放句柄。这个选项只有在数据库本身通过 opfs:// 路径打开时才生效。不使用自动模式时,数据库以外的每个文件都需要手动注册:
// Option 1: automatic registration of opfs:// paths found in SQL
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
opfs: { fileHandling: 'auto' },
});
// Option 2: manual registration (the default)
await db.open({
path: 'opfs://analytics.duckdb',
accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
});
await db.registerOPFSFileName('opfs://cache/monthly_totals.parquet');
// ... run queries against it ...
await db.dropFile('opfs://cache/monthly_totals.parquet');
自动模式适合一次性读取。手动模式需要更多代码,但可以避免每条语句都重新获取 OPFS 访问句柄;对频繁运行小查询的应用,这项开销会累积。一个文件同一时间只能由一个句柄持有,因此 DuckDB 文档建议:在另一个连接或数据库实例打开该文件之前,先用 db.dropFile() 释放已注册文件。
持久性与检查点
DuckDB-Wasm 写入 OPFS 的机制与原生 DuckDB 写入本地磁盘类似:使用预写日志(WAL)和定期检查点。区别在于,浏览器标签页很少能够可靠地正常退出;适合桌面进程的默认行为,可能让浏览器下次打开数据库时变慢。
已提交事务先追加到 analytics.duckdb.wal,数据库主文件在检查点时更新。WAL 超过 checkpoint_threshold 时会自动执行检查点;原文所述默认阈值为 16 MB。正常关闭数据库,或手动运行 CHECKPOINT,也会触发检查点。
桌面进程通常能够正常关闭。浏览器则可能在用户关闭标签页、手机终止后台页面、笔记本合盖时停止;这些情况都不能保证运行应用的退出代码。因此,要注意两件事。
首先,在重要写入完成后执行 CHECKPOINT。DuckDB 文档明确说明,CHECKPOINT 会将写入刷新至 OPFS。事务虽然会追加到 WAL,并在下一次打开时重放,但标签页可能随时被终止;执行检查点能把相应数据更新到主文件。
其次,按批次执行检查点,而不是每条语句都执行。WAL 很大时,下次打开数据库必须先重放日志,第一条查询也会因此延后。交互式应用可以在一批用户编辑完成后执行检查点:
await conn.query('INSERT INTO transactions VALUES (...)');
await conn.query('CHECKPOINT');
上面第一行是原文的结构示意,(...) 需要替换为实际列值,不能原样作为可运行的插入语句执行。
如果不想追踪写入批次,可以在连接后将检查点阈值设为零。这样 DuckDB 会在每条语句后执行检查点,代价是牺牲部分写入吞吐量:
await conn.query(`SET checkpoint_threshold = '0KB'`);
正常结束数据库会话的顺序如下:
await conn.query('CHECKPOINT');
await conn.close();
await db.terminate();
标签页在事务中途被终止的行为,以及如何在多个标签页间共享数据库,原文留给后续文章讨论;这里的示例不解决这两个问题。
还要考虑 DuckDB 之下的另一层持久性:OPFS 属于浏览器存储,并不是永久保存的硬性保证。磁盘空间紧张、来源长时间没有访问时,浏览器可能回收数据;用户也可以通过站点设置主动清除它。OPFS 适合加快启动和保存工作状态,不能充当不可丢失数据的唯一副本。
对需要可靠保存的数据,应将可信主副本放在稳定位置,再同步回去。例如,使用 DuckLake 目录,或通过 s3:// 路径使用对象存储上的普通文件。
导出与备份
用户可能需要将数据移至另一台设备、创建备份,或使用其他工具打开。原文指出,DuckDB-Wasm 本身尚不能直接将文件移入、移出 OPFS;但数据库采用标准 DuckDB 文件格式,浏览器 OPFS API 可以把它读取为字节:
await conn.query('CHECKPOINT');
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle('analytics.duckdb');
const file = await handle.getFile();
// Offer as a download, upload to your backend, etc.
const url = URL.createObjectURL(file);
url 是创建出的对象 URL;这段示例本身还没有弹出下载或上传文件。应用需要另外接入下载按钮或上传逻辑,并在不再使用对象 URL 后释放它。
也可以通过 SQL 导出为 Parquet:
COPY transactions
TO 'opfs://export/transactions.parquet'
(FORMAT parquet, COMPRESSION zstd);
配合 DuckDB 的 Parquet 支持,应用可以先在浏览器中整理、清洗数据,再上传到服务器。反向流程也成立:应用分发一个预先构建好的 .duckdb 文件,在首次启动时复制进 OPFS 后打开。用户便能够直接使用本地数据集,无需再执行一次导入。
实际使用时的三个要点
持久保存曾长期是 DuckDB-Wasm 的主要限制。OPFS 让它能够在浏览器中打开数据库文件,将提交事务写入 WAL,执行检查点,并在刷新后重新打开同一数据库。
使用时应在每一批写入后执行 CHECKPOINT,提供下载数据库文件的方式,并在交付前仔细检查官方文档中的限制:一个文件只能持有一个访问句柄;从 SQL 重命名 OPFS 文件时,源文件和目标文件都必须已经注册。
这为本地优先分析应用提供了跨会话保存数据的途径,也减少了自行编写 IndexedDB 封装、序列化与恢复逻辑的需要。可以试着在自己的应用中使用,并在 GitHub 或 Discord 分享成果。
仍需为重要数据准备备份和同步机制。本稿对示例代码进行了静态逐字核对,没有在浏览器中执行,也没有验证所述构建版本的运行行为。
来源与许可
作者:Carlo Piovesan、Geertjan Wielenga。原文:Persistent Databases in the Browser with DuckDB-Wasm and OPFS,2026-09-18。正文依原文完整汉化,并补充了版本时间、占位语句和对象 URL 的使用说明;代码保留原文。
原文所在 duckdb-web 仓库采用 MIT 许可。以下保留原版权及许可通知:
Copyright 2018-2025 Stichting DuckDB Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.











暂无评论内容