第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
これは、内部的により効率的な方法でページネーションを実装しており、大量データでも安定している。
しかし、この時点ではまだコードを変更していなかった。なぜなら、もっと大きな問題が発覚していたからだ。
教訓と次への準備
この段階で学んだことは:
-
大量データは一度にメモリに載せない
- ページネーションは必須
- ストリーム処理を検討すべき
-
ROW_NUMBER() は大量データで危険
-
OFFSET ... FETCHを使うべき - ソート順は明示的に指定する
-
-
エラーハンドリングの重要性
- タイムアウトは明示的に設定
- ログは詳細に出力
- 沈黙の失敗(silent failure)が最も厄介
90万件の壁は超えられた。77万件の謎も解明できた。
しかし、まだ先に進めなかった。なぜなら、開発サーバー自体が自動的に再起動してしまうという、さらに厄介な問題が待ち構えていたからだ。
