📘

# 桐をWebで蘇らせる ― DataDrawers開発記-第4回

に公開

第4回: 124万件の壁 - メモリ不足との戦い

「10万件くらい簡単でしょ」という甘い見積もり

IndexedDBからSQLiteへの移行が完了し、基本的なデータインポート機能も実装できた。テストでは1万件、5万件と順調にインポートできていたので、「大量データもいけるだろう」と高を括っていた。

ある日、実際の業務データである「指導要録」テーブルをインポートしようと試みた。このテーブルには124万703件のレコードが格納されている。教育機関で長年蓄積されたデータだ。

「まあ、10万件の延長線でしょ。ちょっと時間はかかるかもしれないけど」

そう思いながらインポートボタンをクリックした。これが、数日間に及ぶ格闘の始まりだった。


問題1: トランザクションタイムアウトの洗礼

最初のエラーは意外なところからやってきた。

Transaction already closed: A query cannot be executed on an expired transaction

Prismaを使ってSQLiteに大量データを保存する際、トランザクション処理を使っていた。しかし、このトランザクションにはデフォルトで5秒のタイムアウトが設定されており、大量データの処理がそれを超えると例外がスローされる。

当初の実装は以下のような形だった:

// lib/sqlite-client.ts (初期実装)
export async function insertTableData(tableId: string, rows: any[]) {
  const CHUNK_SIZE = 1000;

  for (let i = 0; i < rows.length; i += CHUNK_SIZE) {
    const chunk = rows.slice(i, i + CHUNK_SIZE);

    // 暗黙的なトランザクション(5秒タイムアウト)
    await prisma.tableDataRow.createMany({
      data: chunk.map(rowData => ({ tableId, rowData }))
    });
  }
}

エラーログを見ると、3万件あたりでタイムアウトしていた。解決策は単純で、トランザクションのタイムアウトを延長すればよい:

await prisma.$transaction(operations, {
  timeout: 60000 // 60秒に延長
});

これで一時的に先に進めた。しかし、これは氷山の一角に過ぎなかった。


問題2: 90万件でサーバーがダウン

タイムアウトを延長してからは、順調にインポートが進むようになった。

進捗: 100,000 / 1,240,703 (8%)
進捗: 200,000 / 1,240,703 (16%)
進捗: 300,000 / 1,240,703 (24%)
...
進捗: 800,000 / 1,240,703 (64%)
進捗: 890,000 / 1,240,703 (71%)

「よし、あと少しだ」と思った矢先、サーバーが突然無言で落ちた。

Next.jsの開発サーバーが完全に停止し、ブラウザには「接続できません」というエラーが表示された。ターミナルを見ても、特にエラーメッセージは出ていない。単に、プロセスが消えていた。

再度試しても、同じように90万件付近で停止する。何度やっても同じ。

「何が起きているんだ...?」

メモリ使用量をモニタリングしてみると、Node.jsプロセスが2GB近くまで膨れ上がっていた。そして、原因が判明した。

全データを一度にメモリに読み込んでいた

当時のコードは、SQL Serverから全データを一括で取得し、それをメモリに保持してからSQLiteに保存していた:

// lib/import-job-processor.ts (失敗版)
export async function processImportJob(jobData: ImportJobData) {
  const { tableName, tableId } = jobData;

  // ❌ 124万件全部をメモリに読み込む
  const allData = await fetchTableData(tableName);

  // SQLiteに保存
  for (let i = 0; i < allData.length; i += CHUNK_SIZE) {
    const chunk = allData.slice(i, i + CHUNK_SIZE);
    await insertTableData(tableId, chunk);
  }
}

124万件のデータをJavaScriptのオブジェクト配列として保持すると、1件あたり数百バイト〜数KB必要になる。仮に1件500バイトとすると:

1,240,703件 × 500 bytes ≈ 620 MB

さらに、Node.jsはオブジェクトのメタデータやガベージコレクションのオーバーヘッドで実際にはその数倍のメモリを消費する。結果として、2GB以上のメモリを使い切り、クラッシュしていた。

「これは、ページネーションで分割取得するしかない」


問題3: 77万件の謎の壁

メモリ不足を解決するため、SQL Serverから一度に全データを取得するのではなく、ページネーション方式に変更した。

// lib/sql-client.ts (ページネーション版)
export async function fetchTableDataPaginated(
  tableName: string,
  offset: number,
  limit: number
) {
  const escapedTableName = escapeTableName(tableName);

  const query = `
    SELECT * FROM (
      SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) as RowNum
      FROM ${escapedTableName}
    ) AS Numbered
    WHERE RowNum BETWEEN ${offset + 1} AND ${offset + limit}
  `;

  const pool = await sql.connect(sqlConfig);
  const result = await pool.request().query(query);

  return result.recordset;
}

SQL Serverには OFFSET ... FETCH 構文もあるが、当時は ROW_NUMBER() を使うのが一般的だと思っていた。この方法で、5万件ずつ取得しながらSQLiteに保存するように変更した。

// lib/import-job-processor.ts (ページネーション版)
export async function processImportJob(jobData: ImportJobData) {
  const { tableName, tableId } = jobData;
  const FETCH_CHUNK_SIZE = 50000;

  // 総件数を取得
  const totalCount = await getTableCount(tableName);

  let offset = 0;
  while (offset < totalCount) {
    // ✅ 5万件ずつ取得
    const chunk = await fetchTableDataPaginated(tableName, offset, FETCH_CHUNK_SIZE);

    // SQLiteに保存
    await insertTableData(tableId, chunk);

    offset += chunk.length;
    console.log(`進捗: ${offset} / ${totalCount} (${Math.floor(offset / totalCount * 100)}%)`);
  }
}

「これで完璧だ」

そう思って実行すると、確かにメモリ使用量は安定した。しかし、新たな謎の壁が現れた。

進捗: 50,000 / 1,240,703 (4%)
進捗: 100,000 / 1,240,703 (8%)
...
進捗: 700,000 / 1,240,703 (56%)
進捗: 750,000 / 1,240,703 (60%)
進捗: 770,000 / 1,240,703 (62%)

(ここで停止。プログレスが一切進まない)

77万件付近で必ず停止する。何度試しても同じ。タイムアウトエラーも出ない。ただ、沈黙したままフリーズする。

SQL Serverの調査

まず、SQL Server側の問題を疑った。Dockerでホストしている SQL Server 2022 のメモリ使用状況を確認:

$ docker stats

CONTAINER ID   NAME        CPU %   MEM USAGE / LIMIT     MEM %
abc123defg     sqlserver   8.5%    1.93GiB / 4GiB       48.25%

メモリは十分余裕がある。次に、SQL Serverに直接ログインして、問題のクエリを手動で実行してみた:

-- 77万〜78万件のレンジを取得
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) as RowNum
  FROM [dbo].[指導要録]
) AS Numbered
WHERE RowNum BETWEEN 770001 AND 780000

結果: 10,000件が正常に取得される。所要時間: 約2秒。

「SQL Server側は問題ない...」

ログの追加と観察

Node.js側のコードに詳細なログを追加して、どこで止まっているのかを特定することにした:

console.log(`[${new Date().toISOString()}] クエリ開始: offset=${offset}`);
const startTime = Date.now();

const chunk = await fetchTableDataPaginated(tableName, offset, FETCH_CHUNK_SIZE);

const elapsed = Date.now() - startTime;
console.log(`[${new Date().toISOString()}] クエリ完了: ${chunk.length}件 (${elapsed}ms)`);

ログを見ると、77万件のクエリは送信されているが、レスポンスが返ってこないことが判明した。mssql パッケージのタイムアウト設定を確認すると、デフォルトは15秒だった。しかし、それも過ぎているのにエラーが出ない。

「まるで、ネットワークの奥底に消えたかのようだ」

ROW_NUMBER() の不安定性

調べていくうちに、ある Stack Overflow の投稿を見つけた:

ROW_NUMBER() is non-deterministic when ORDER BY is not fully specified

大量データに対して ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) のような不定なソート順を使うと、SQL Serverの内部処理が不安定になり、途中でタイムアウトやフリーズが発生することがある。

「これだ!」

原因は、ソート順が不定の ROW_NUMBER() が大量データで破綻していた。77万件という中途半端な数字は、SQL Serverの内部メモリやページング処理の閾値だったのかもしれない。

解決策は、ROW_NUMBER() を使わず、SQL Server 2012以降で導入された OFFSET ... FETCH 構文を使うことだった:

SELECT * FROM [dbo].[指導要録]
ORDER BY (SELECT NULL)
OFFSET 770000 ROWS
FETCH NEXT 50000 ROWS ONLY

これは、内部的により効率的な方法でページネーションを実装しており、大量データでも安定している。

しかし、この時点ではまだコードを変更していなかった。なぜなら、もっと大きな問題が発覚していたからだ。


教訓と次への準備

この段階で学んだことは:

  1. 大量データは一度にメモリに載せない
    • ページネーションは必須
    • ストリーム処理を検討すべき
  2. ROW_NUMBER() は大量データで危険
    • OFFSET ... FETCH を使うべき
    • ソート順は明示的に指定する
  3. エラーハンドリングの重要性
    • タイムアウトは明示的に設定
    • ログは詳細に出力
    • 沈黙の失敗(silent failure)が最も厄介

90万件の壁は超えられた。77万件の謎も解明できた。

しかし、まだ先に進めなかった。なぜなら、開発サーバー自体が自動的に再起動してしまうという、さらに厄介な問題が待ち構えていたからだ。

参考リンク

Discussion