- カテゴリー
- ブラウザDB・ストレージ
- 公開日
- 2026.09.22
Contents
はじめに
SQLite WASMでデータベースをOPFSへ保存しようとすると、公式ドキュメントには複数のVFSが登場します。
opfsopfs-sahpoolopfs-wl
どれもOPFSを利用しますが、内部のロック方法、必要なHTTPヘッダー、同時接続、性能特性が異なります。
この記事では3種類を比較し、用途別に選ぶための基準と、同じ端末でベンチマークする方法を解説します。
対象は、SQLite WASMをWorkerから使った経験があり、保存方式を選びたい方です。SQLとWorkerの実装が初めての場合は、先にTODOアプリの構成を理解してください。この記事の短いコードは、初期化済みのsqlite3を使うWorker内の抜粋であり、ページのConsoleへそのまま貼るコードではありません。後半に単独で比較を試せる手順を示します。
VFSとは
SQLiteのVFS(Virtual File System)は、SQLiteがOSのファイルやロック、時刻、乱数などへアクセスするための抽象層です。
通常のデスクトップ版SQLiteはOSのファイルシステムを利用します。ブラウザでは同じファイルAPIを直接使えないため、SQLite WASM用VFSがOPFSとの橋渡しをします。
SQLite SQLエンジン
↓
SQLite VFS
↓
OPFS / Web Locks / SyncAccessHandle
↓
ブラウザストレージ
VFSを変えても、CREATE TABLEやSELECTなどのSQL構文が変わるわけではありません。ファイル保存と並行処理の仕組みが変わります。
3種類の比較
| 項目 | opfs | opfs-sahpool | opfs-wl |
|---|---|---|---|
| 主な特徴 | 標準的なOPFS VFS | SyncAccessHandleをプール | Web Locksでロック |
| COOP/COEP | 必要 | 不要 | 必要 |
| 複数接続 | 競合制御付きで可能 | 同じプールを扱うコンテキストは1つ | 競合制御付きで可能 |
| ファイル名 | OPFS上で比較的透過的 | 内部メタデータで管理 | opfsと同様 |
| 性能傾向 | 標準 | 公式文書ではOPFS選択肢中で高性能 | opfsと同程度と説明 |
| 追加要件 | Cross-Origin Isolation | プール容量設計 | Atomics.waitAsync() |
| 主な用途 | 複数タブ・一般用途 | 単一接続・バッチ処理 | 公平なロック待ちが必要な並行処理 |
この表の性能は固定順位ではありません。SQLite公式ドキュメントも、端末や実行ごとの変動が大きく、opfsとopfs-wlの明確な性能勝者は確認できていないと説明しています。
標準のopfs VFS
opfs VFSは、SQLite WASMで長く使われてきた標準的なOPFS保存方式です。
const db = new sqlite3.oo1.OpfsDb("/app.sqlite3", "c");
利点
- 複数タブや複数Workerからの接続を扱える
- SQLite側のファイル名とOPFS上の構成が比較的分かりやすい
- 公式サンプルや利用例が多い
注意点
- COOP/COEPヘッダーが必要
- 外部リソース読み込みへ影響する
- 並行書き込みでは
SQLITE_BUSYを正しく処理する必要がある
複数接続に対応していることは、同時書き込みが常に待ち時間なく成功することを意味しません。SQLiteのロック競合時にはSQLITE_BUSYが正常な結果として起こり得ます。
opfs-sahpool VFS
opfs-sahpoolは、OPFSのSyncAccessHandleをあらかじめプールして利用する方式です。
const pool = await sqlite3.installOpfsSAHPoolVfs({
initialCapacity: 6,
directory: ".todo-pool",
});
const db = new pool.OpfsSAHPoolDb("/todo.sqlite3");
利点
- COOP/COEPヘッダーを必要としない
- 公式ドキュメントではOPFS方式の中で最も高い性能を持つ選択肢として説明される
- バッチ挿入など大量ファイルI/Oで差が出やすい
注意点
- 同じプールを複数のブラウジングコンテキストから同時に初期化できない
- DB名と実際のOPFSファイル名が一対一で見えない
- パスは絶対パスとして指定する
- プール容量を見積もる必要がある
initialCapacityは単純にDBファイル数と同じではありません。ジャーナルなどのファイルも必要になるため、公式ドキュメントでは最低でも想定DB数の2倍以上を考慮するよう説明されています。
ここでの制限は「DBが1個しか使えない」という意味ではありません。同じプールを別タブや別Workerで同時に初期化できない、という意味です。1つのWorkerからプール内の複数DBを扱うことはできます。
1タブだけで大量データを処理し、サイト全体へCOOP/COEPを設定したくない場合の有力候補です。
opfs-wl VFS
opfs-wlはSQLite 3.53.0で追加されたVFSで、ファイルロックへWeb Locks APIを使います。
if (!sqlite3.oo1.OpfsWlDb) {
throw new Error("opfs-wlを利用できません。");
}
const db = new sqlite3.oo1.OpfsWlDb("/app.sqlite3", "c");
利点
- ブラウザが管理するWeb Locksを利用する
- ロック要求をFIFOで待たせられ、標準
opfsより公平な待機になりやすい - 複数接続を扱える
注意点
- 標準
opfsと同じくCOOP/COEPが必要 Atomics.waitAsync()が必要- 古いブラウザではVFSがインストールされない
- 公平なロックと、個々の処理が高速であることは別
opfsとの混在接続は公式に積極サポートされていない
同じデータベースへopfs接続とopfs-wl接続を混在させる構成は避け、アプリ単位でVFSを統一します。
用途別の選び方
単一タブで大量データを処理する
opfs-sahpoolを最初の候補にします。COOP/COEPが不要で、バッチ処理性能を重視できます。
複数タブから同じDBへ接続する
opfsまたはopfs-wlを検討します。SQLITE_BUSY、トランザクション時間、書き込み頻度を含めてテストします。
既存サイトへCross-Origin Isolationを追加できない
opfs-sahpoolを検討します。ただし同一プールの同時接続制限が要件と合うかを確認します。
ロック待ちの公平性を重視する
対応ブラウザを限定できるならopfs-wlを検討します。SQLite 3.53.0で追加された新しい方式なので、実環境で十分に検証します。
同じ条件でベンチマークする
最初に、各VFSが実際にインストールされたかを確認します。ブラウザがOPFS APIを持っていても、必要なヘッダーやAtomics.waitAsync()がなければ対応クラスは追加されません。
const availability = {
opfs: typeof sqlite3.oo1.OpfsDb === "function",
opfsWl: typeof sqlite3.oo1.OpfsWlDb === "function",
sahPool: typeof sqlite3.installOpfsSAHPoolVfs === "function",
};
次のような処理を各VFSで実行します。
この関数はbenchmarkテーブルを削除して作り直します。必ず使い捨ての検証用DBで実行し、既存アプリのDBを渡さないでください。
function runWriteBenchmark(db, rowCount = 10000) {
db.exec("DROP TABLE IF EXISTS benchmark");
db.exec("CREATE TABLE benchmark(id INTEGER PRIMARY KEY, value TEXT NOT NULL)");
const startedAt = performance.now();
db.transaction(() => {
const statement = db.prepare("INSERT INTO benchmark(value) VALUES (?)");
try {
for (let index = 0; index < rowCount; index += 1) {
statement.bind([`value-${index}`]).stepReset();
}
} finally {
statement.finalize();
}
});
return performance.now() - startedAt;
}
自分の端末で比較を実行する
Node.js 22.12以上の22系、または24系とnpmを用意し、通常モードのChromeで試します。新しいフォルダーで以下を実行してください。
mkdir sqlite-vfs-lab
cd sqlite-vfs-lab
npm init -y
npm install @sqlite.org/sqlite-wasm@3.53.0-build1
npm install --save-dev vite@7.3.6
vite.config.jsを作成します。COOP/COEPは3種類を同じページで試すために設定しており、opfs-sahpool単独には不要です。
import { defineConfig } from "vite";
export default defineConfig({
server: {
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
optimizeDeps: { exclude: ["@sqlite.org/sqlite-wasm"] },
});
次にindex.htmlを作ります。
<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SQLite VFS比較</title>
<style>body{margin:24px;font-family:system-ui}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style>
<h1>SQLite VFS比較</h1>
<pre id="result" role="status">実行中です。</pre>
<script type="module">
const output = document.querySelector("#result");
const worker = new Worker(new URL("./benchmark.worker.js", import.meta.url), { type: "module" });
worker.addEventListener("message", ({ data }) => {
output.textContent = JSON.stringify(data, null, 2);
worker.terminate();
});
worker.addEventListener("error", (event) => {
output.textContent = `Workerエラー: ${event.message}`;
});
</script>
</html>
最後にbenchmark.worker.jsを作ります。次のコードのコメント位置に、前掲のrunWriteBenchmark関数全体をコピーしてください。DB名はVFSごとに分け、既存のTODOアプリとは別の検証用DBを使います。
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
// ここへ前掲の function runWriteBenchmark(...) { ... } 全体を貼り付ける
try {
const sqlite3 = await sqlite3InitModule();
const pool = await sqlite3.installOpfsSAHPoolVfs({
initialCapacity: 6,
directory: ".vfs-comparison-lab",
});
const results = [];
for (const [name, DbClass] of [
["opfs", sqlite3.oo1.OpfsDb],
["opfs-wl", sqlite3.oo1.OpfsWlDb],
["opfs-sahpool", pool.OpfsSAHPoolDb],
]) {
if (typeof DbClass !== "function") {
results.push({ name, skipped: "必要なAPIまたはヘッダーがありません" });
continue;
}
const db = new DbClass(`/vfs-lab-${name}.sqlite3`, "c");
try {
const firstMs = runWriteBenchmark(db);
const repeatMs = runWriteBenchmark(db);
const rows = db.selectValue("SELECT count(*) FROM benchmark");
results.push({ name, firstMs, repeatMs, rows });
} finally {
db.close();
}
}
self.postMessage({ ok: true, results });
} catch (error) {
self.postMessage({ ok: false, error: String(error) });
}
npx vite --host 127.0.0.1を実行し、ターミナルのLocalに表示されたURLを開きます(通常はhttp://127.0.0.1:5173/)。HTMLを直接開かず、サーバーはCtrl+Cで停止するまで起動しておきます。
対応環境ではok: true、3種類の結果、各rows: 10000が表示されます。firstMsとrepeatMsは挿入トランザクションの所要ミリ秒で、初期化時間とテーブル作成時間は含みません。1回だけの結果で速度順位を決めず、他の重い処理を止めて複数回比較してください。この最小例は並行接続の公平性を測るものではありません。
ok: falseならエラー文とConsoleを確認します。同じページを複数タブで開いている場合は、他の検証タブを閉じて再読み込みしてください。skippedならヘッダーとブラウザの必要APIを確認します。
トランザクションを使う
1行ごとに独立したトランザクションを確定すると、ファイルI/Oの影響が極端に大きくなります。実用的なバッチ処理として比較するため、複数INSERTを1トランザクションへまとめます。
ウォームアップを分ける
VFS初期化、WASMコンパイル、初回ファイル作成は一度だけの費用です。次を別々に記録します。
- SQLiteモジュール初期化時間
- VFSインストール時間
- DBオープン時間
- 初回バッチ時間
- 2回目以降のバッチ時間
- SELECT時間
データを毎回初期化する
前回のDB、WAL、ジャーナル、キャッシュが残ると条件が変わります。ベンチマーク用ディレクトリを分け、削除対象を正確に確認してから初期化します。
並行処理を確認する
複数タブテストでは、単に2タブで開くだけでなく、同じDBへ短いトランザクションを繰り返します。
確認項目は次のとおりです。
SQLITE_BUSYの発生回数- 再試行後の成功率
- 最大待ち時間
- 片方のタブだけが処理し続ける飢餓状態がないか
- タブを強制終了した後にロックが回復するか
- データ件数と制約が正しいか
busy_timeoutを設定すればすべて解決するとは限りません。長い書き込みトランザクションを避け、アプリ側で再試行回数と失敗表示を設計します。
VFS自動選択は慎重に行う
次のように、利用可能という理由だけで毎回異なるVFSへ切り替える設計は危険です。
// 保存済みDBと異なるVFSへ切り替わる可能性がある悪い例
const selected = sqlite3.oo1.OpfsWlDb ?? sqlite3.oo1.OpfsDb;
VFSによってファイル表現や保存場所が異なる場合があります。以前のバージョンで作ったDBが見えなくなったり、別DBを新規作成したりしないよう、選択したVFSをアプリのデータ形式の一部として管理します。
変更する場合は移行手順、バックアップ、ロールバックを用意します。
動作確認
共通
- DB作成、INSERT、SELECT、UPDATE、DELETEが成功する
- ページ再読み込み後にデータが残る
- トランザクション中の例外でロールバックされる
- サイトデータ削除後の初期状態を扱える
sahpool
- COOP/COEPなしで初期化できる
- 同じプールを2タブから開いたときのエラーを表示する
- プール容量不足を正常扱いしない
opfs/opfs-wl
- 複数タブの競合を再現する
SQLITE_BUSYを記録して再試行する- 対応していない
Atomics.waitAsync()環境でopfs-wlを選ばない
関連記事
まとめ
SQLite WASMのOPFS VFSは、保存先が同じOPFSでも設計上のトレードオフが異なります。
- 複数接続と実績を重視するなら
opfs - 単一接続の速度とヘッダー不要を重視するなら
opfs-sahpool - Web Locksによる公平な並行ロックを試すなら
opfs-wl
最速という説明だけで選ばず、タブ数、ヘッダー、ブラウザ範囲、データ移行を含めて決定してください。


コメント