上級 api

Wasm経由でブラウザ内SQLite

はじめに

ブラウザ内で完全なSQLデータベースを実行するのは不可能に思えますが、WebAssemblyにコンパイルされたSQLiteがそれを現実にします。このレッスンでは核となる概念を理解するために純粋なRustで簡略化したインメモリSQLエンジンを構築し、実際のSQLiteがWasmでどのように動作するかを解説します。

なぜブラウザ内SQLiteなのか?

従来のウェブアプリはデータクエリをすべてサーバーに送信します:

従来の方式:
┌──────────┐  HTTPリクエスト ┌──────────┐  SQLクエリ  ┌──────────┐
│  ブラウザ  │──────────────>│  サーバー  │────────────>│データベース│
│           │<──────────────│           │<────────────│          │
│           │  HTTPレスポンス│           │  結果セット │          │
└──────────┘               └──────────┘              └──────────┘
   レイテンシ: クエリあたり50-200ms

WasmでSQLiteを使う場合:
┌──────────────────────────────┐
│  ブラウザ                     │
│  ┌──────────┐  ┌───────────┐│
│  │ アプリ(JS)│──│SQLite Wasm││
│  │          │  │(インメモリ) ││
│  └──────────┘  └───────────┘│
│   レイテンシ: クエリあたり<1ms│
└──────────────────────────────┘

ユースケース:

  • オフラインファーストアプリ -- ネットワークなしで完全なSQL機能
  • ローカルデータ処理 -- クライアント側で大きなデータセットをフィルタ/ソート/集計
  • プライバシー重視のアプリ -- データがデバイスの外に出ない
  • プロトタイピング -- 開発中にバックエンドが不要

SQLエンジンの仕組み

簡略化したエンジンは4つのコア操作を実装しています:

SQL操作          │  メソッド        │  説明
─────────────────┼─────────────────┼──────────────────────────
CREATE TABLE     │  create_table() │  テーブルのカラムを定義
INSERT INTO      │  insert()       │  値を持つ行を追加
SELECT ... WHERE │  select()       │  行の読み取り、フィルタ、射影
UPDATE ... WHERE │  update()       │  条件に合う行を変更
DELETE ... WHERE │  delete()       │  条件に合う行を削除

データモデル

Database
└── tables: HashMap<String, Table>
    └── Table
        ├── name: String
        ├── columns: Vec<String>
        └── rows: Vec<Row>
            └── Row = HashMap<String, Value>
                └── Value: Integer(i64) | Text(String) | Null

各行はHashMap<String, Value>であり、カラムアクセスはO(1)ですが、カラム型レイアウトよりメモリ使用量が多くなります。プロダクション向けデータベースはより効率的な表現を使用します。

クエリ実行パイプライン

実際のSQLデータベースは複数のステージでクエリを処理します:

SQL文字列
    │
    ▼
┌──────────┐
│  字句解析 │  トークンに分割: SELECT, *, FROM, users, WHERE, ...
└────┬─────┘
     ▼
┌──────────┐
│  構文解析 │  抽象構文木(AST)を構築
└────┬─────┘
     ▼
┌──────────┐
│ プランナー│  実行戦略を選択(どのインデックスを使うか?)
└────┬─────┘
     ▼
┌──────────┐
│ エグゼキュータ│  テーブルスキャン、フィルタ適用、カラム射影
└────┬─────┘
     ▼
結果セット

簡略化したエンジンでは字句解析/構文解析をスキップしてメソッドを直接呼び出しますが、実行ロジック(スキャン、フィルタ、射影)は同じです。

インデックス作成:クエリを高速化する

インデックスなしでは、すべてのクエリがすべての行をスキャンする必要があります(フルテーブルスキャン)。インデックスは高速な検索を可能にするソート済みのデータ構造です:

フルテーブルスキャン(インデックスなし):
  SELECT * FROM users WHERE age = 30
  → 行1をチェック: age=30? はい ✓
  → 行2をチェック: age=25? いいえ
  → 行3をチェック: age=35? いいえ
  → 行4をチェック: age=28? いいえ
  時間: O(n) — すべての行をチェックする必要がある

ageにB-treeインデックスがある場合:
         [28]
        /    \
     [25]   [30, 35]

  → ツリーを探索: 30 > 28 → 右へ → 30を発見
  時間: O(log n) — ほとんどの行をスキップ
行数 フルスキャン B-treeインデックス
100 100回チェック 約7回チェック
10,000 10,000回チェック 約14回チェック
1,000,000 1,000,000回チェック 約20回チェック

トランザクション:ACID保証

SQLiteはWasm内でもACIDトランザクションを提供します:

BEGIN TRANSACTION;
  INSERT INTO accounts (id, balance) VALUES (1, 1000);
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 3つの操作がすべて成功するか、すべてが取り消される
  • 原子性(Atomicity) -- すべての操作が成功するか、すべてがロールバックされる
  • 一貫性(Consistency) -- データベースの制約が常に維持される
  • 分離性(Isolation) -- 同時実行トランザクションが互いに干渉しない
  • 永続性(Durability) -- コミットされたデータはクラッシュ後も存続する(Wasmではストレージに永続化)

データの永続化:IndexedDBとOPFS

Wasm SQLiteデータベースはデフォルトでインメモリです。ページ再読み込み後もデータを保持するにはストレージバックエンドが必要です:

IndexedDBバックエンド

┌──────────┐      ┌──────────┐      ┌────────────┐
│ SQLite   │ VFS  │ JSシム    │      │ IndexedDB  │
│ (Wasm)   │─────>│(アダプタ)  │─────>│ (ブラウザ)  │
│          │      │          │      │            │
└──────────┘      └──────────┘      └────────────┘

SQLiteは仮想ファイルシステム(VFS)レイヤーを使用します。Wasmでは、このVFSがJavaScriptで実装され、IndexedDBへページの読み書きを行います。

OPFSバックエンド(モダンブラウザ)

Origin Private File System(OPFS)は真のファイルシステムアクセスを提供します:

┌──────────┐      ┌──────────┐
│ SQLite   │ VFS  │  OPFS    │
│ (Wasm)   │─────>│(ネイティブ)│
│          │      │ ファイルI/O│
└──────────┘      └──────────┘

OPFSはWeb Worker内での同期読み取りをサポートするため、SQLiteの同期I/Oモデルと一致し、IndexedDBより高速です。

ストレージバックエンド 読み取り速度 書き込み速度 互換性
インメモリ 最速 最速 すべてのブラウザ
IndexedDB VFS 約2倍遅い 約5倍遅い すべてのブラウザ
OPFS VFS 約1.2倍遅い 約1.5倍遅い Chrome 102+, Firefox 111+

実際のWasm版SQLite:sql.jsと代替手段

sql.js -- ブラウザ内SQLiteの最も人気のあるソリューションです。EmscriptenでSQLiteのCコードをWasmにコンパイルしています:

const SQL = await initSqlJs();
const db = new SQL.Database();
db.run("CREATE TABLE users (id INTEGER, name TEXT)");
db.run("INSERT INTO users VALUES (1, 'Alice')");
const results = db.exec("SELECT * FROM users");

Rustの代替手段:

プロジェクト アプローチ ステータス
sql.js C → Emscripten → Wasm 成熟、広く使用
rusqlite + wasm SQLite CへのRustバインディング wasm32ターゲットで動作
gluesql 純粋RustのSQLエンジン ネイティブWasm、C依存なし
limbo SQLite互換の純粋Rust より新しい、Wasmファースト設計

パフォーマンス:SQLite Wasm vs IndexedDB

10,000行操作のベンチマーク:

┌──────────────────────┬──────────┬──────────┬──────────┐
│ 操作                  │ SQLite   │ IndexedDB│ 比率     │
│                      │ (Wasm)   │(ネイティブ)│          │
├──────────────────────┼──────────┼──────────┼──────────┤
│ INSERT(単一)        │ 0.02ms   │ 0.5ms    │ 25倍     │
│ INSERT(一括10K)     │ 15ms     │ 450ms    │ 30倍     │
│ SELECT(フルスキャン) │ 2ms      │ 35ms     │ 17倍     │
│ SELECT(インデックス付)│ 0.05ms   │ 0.8ms    │ 16倍     │
│ UPDATE(単一)        │ 0.03ms   │ 1.2ms    │ 40倍     │
│ 複雑なJOIN           │ 8ms      │ N/A*     │ --       │
│ 集計(COUNT)         │ 1ms      │ 30ms     │ 30倍     │
└──────────────────────┴──────────┴──────────┴──────────┘
* IndexedDBにはJOINサポートがないため、手動のJSコードが必要

Wasm版SQLiteはほとんどの操作でIndexedDBより劇的に高速で、JOIN、サブクエリ、ウィンドウ関数を含む完全なSQLサポートを提供します。

Wasm版SQLiteの使いどころ

シナリオ 推奨
シンプルなキー・バリューストレージ localStorageまたはIndexedDBを使用
JOINを伴う複雑なクエリ Wasm版SQLite
同期付きオフラインファースト SQLite + カスタム同期プロトコル
大規模データセット(>10MB) Wasm版SQLite(より高いパフォーマンス)
フォームデータ / 設定 IndexedDBで十分
全文検索 SQLite FTS5拡張

試してみよう

SQLエンジンを拡張してみましょう:

  • カラムで結果をソートする(昇順または降順)ORDER BYサポートを追加する
  • COUNT、SUM、AVG集計関数を実装する
  • O(log n)ルックアップのためのカラムへのシンプルなB-treeインデックスを追加する
  • 複数のWhereClause値を組み合わせてWHERE句でのAND/ORをサポートする
  • 共有カラムで行をマッチングさせて2つのテーブル間のJOINを実装する

試してみる