Sausage Erectos X sql.jsの落とし穴 ── インメモリSQLiteでハマった5つのこと
sql.jsの落とし穴──ブラウザ内SQLiteで学んだ教訓
Sausage Erectosのバックエンドは、sql.js(SQLiteのWASMビルド)をインメモリデータベースとして使用している。PostgreSQLやMySQLに比べて圧倒的にシンプルだが、本番運用で踏んだ落とし穴の数もまた圧倒的だった。
なぜsql.jsを選んだのか
個人開発で最も貴重なリソースは「時間」だ。PostgreSQLのセットアップ、接続プール管理、マイグレーション設定──これらすべてを省略できるsql.jsは、MVPを最速で出すために最適な選択だった。
import initSqlJs from 'sql.js';
const SQL = await initSqlJs();
const db = new SQL.Database();
// これだけでSQLiteが使える。接続文字列もサーバーも不要。
パフォーマンスベンチマーク
sql.jsとPostgreSQLの比較(同一データセット、1万レコード):
操作 sql.js PostgreSQL
-------------------------------------------------
単純SELECT 0.3ms 1.2ms(ネットワーク含む)
JOIN (2テーブル) 2.1ms 1.5ms
INSERT (1000件) 45ms 30ms
全文検索 (LIKE) 15ms 3ms(GINインデックス使用時)
単純なクエリではsql.jsが高速だ。ネットワークレイテンシがゼロだからだ。しかし、複雑なクエリや全文検索ではPostgreSQLの最適化が効いてくる。
メモリ使用量の監視
sql.jsのデータベースはすべてメモリ上に展開される。Node.jsのデフォルトヒープサイズ(約1.5GB)を超えると、プロセスがクラッシュする。
function logMemoryUsage() {
const used = process.memoryUsage();
console.log({
rss: `${Math.round(used.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)}MB`,
heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)}MB`,
});
}
// 定期的に監視
setInterval(logMemoryUsage, 60000);
私たちのケースでは、データベースサイズが50MBを超えたあたりから、メモリ使用量の増加が顕著になった。
バックアップ戦略──定期的なsaveDb
sql.jsの最大のリスクは「プロセスが死んだらデータが消える」ことだ。これを防ぐために、定期的なファイルシステムへの書き出しが必須になる。
function saveDb() {
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync('orders.db', buffer);
console.log(`DB saved: ${buffer.length} bytes`);
}
// 5分ごとにバックアップ
setInterval(saveDb, 5 * 60 * 1000);
// プロセス終了時にもバックアップ
process.on('SIGINT', () => {
saveDb();
process.exit(0);
});
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
saveDb(); // クラッシュ前に保存を試みる
process.exit(1);
});
マイグレーションの実装パターン
ORMなしでマイグレーションを管理するパターンを自前で実装した。
const MIGRATIONS = [
{ version: 1, sql: 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)' },
{ version: 2, sql: 'ALTER TABLE users ADD COLUMN email TEXT' },
{ version: 3, sql: 'CREATE INDEX idx_users_email ON users(email)' },
];
function migrate(db) {
db.run('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER)');
const result = db.exec('SELECT MAX(version) as v FROM schema_version');
const currentVersion = result.length > 0 ? result[0].values[0][0] || 0 : 0;
for (const m of MIGRATIONS) {
if (m.version > currentVersion) {
db.run(m.sql);
db.run('INSERT INTO schema_version VALUES (?)', [m.version]);
console.log(`Migrated to version ${m.version}`);
}
}
}
本番データロス事故と復旧
2024年某日、VPSのメモリ不足でNode.jsプロセスがOOM Killerに殺された。最後のsaveDbから12分が経過しており、その間の注文データ3件が消失した。
復旧手順:
- Stripeのwebhookログから、消失した注文のpayment_intent_idを特定
- Stripe APIから決済情報を取得
- 手動でINSERT文を作成してデータを復元
この事故以降、saveDbの間隔を5分から1分に短縮し、さらにすべてのWRITE操作の直後にも即時保存するようにした。
sql.jsは個人開発の最強の武器だが、「データは消える」という前提で設計しなければならない。楽観的なアーキテクチャには、悲観的なバックアップ戦略を。