SQLite WASMのOPFS VFSを比較|opfs・opfs-sahpool・opfs-wlの選び方

SQLite WASM、どのVFSを選ぶ? opfs・opfs-sahpool・opfs-wlの比較を示すアイキャッチ ブラウザDB・ストレージ
カテゴリー
ブラウザDB・ストレージ
公開日
2026.09.22

はじめに

SQLite WASMでデータベースをOPFSへ保存しようとすると、公式ドキュメントには複数のVFSが登場します。

  • opfs
  • opfs-sahpool
  • opfs-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 TABLESELECTなどのSQL構文が変わるわけではありません。ファイル保存と並行処理の仕組みが変わります。

3種類の比較

項目opfsopfs-sahpoolopfs-wl
主な特徴標準的なOPFS VFSSyncAccessHandleをプールWeb Locksでロック
COOP/COEP必要不要必要
複数接続競合制御付きで可能同じプールを扱うコンテキストは1つ競合制御付きで可能
ファイル名OPFS上で比較的透過的内部メタデータで管理opfsと同様
性能傾向標準公式文書ではOPFS選択肢中で高性能opfsと同程度と説明
追加要件Cross-Origin Isolationプール容量設計Atomics.waitAsync()
主な用途複数タブ・一般用途単一接続・バッチ処理公平なロック待ちが必要な並行処理

この表の性能は固定順位ではありません。SQLite公式ドキュメントも、端末や実行ごとの変動が大きく、opfsopfs-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が表示されます。firstMsrepeatMsは挿入トランザクションの所要ミリ秒で、初期化時間とテーブル作成時間は含みません。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

最速という説明だけで選ばず、タブ数、ヘッダー、ブラウザ範囲、データ移行を含めて決定してください。

参考リンク

コメント

タイトルとURLをコピーしました