0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

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

CleanShot 2026-01-22 at 15.32.20.jpg

「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万件の謎も解明できた。

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

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?