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を実装する