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件が消失した。

復旧手順:

  1. Stripeのwebhookログから、消失した注文のpayment_intent_idを特定
  2. Stripe APIから決済情報を取得
  3. 手動でINSERT文を作成してデータを復元

この事故以降、saveDbの間隔を5分から1分に短縮し、さらにすべてのWRITE操作の直後にも即時保存するようにした。

sql.jsは個人開発の最強の武器だが、「データは消える」という前提で設計しなければならない。楽観的なアーキテクチャには、悲観的なバックアップ戦略を。

← ブログ一覧に戻る