PHPで大容量ファイルを処理する際のメモリ不足問題を解決する実装パターンを解説。ストリーム処理・ジェネレータ・チャンク分割など現場で使える手法を紹介します。
「メモリ不足でスクリプトが死ぬ」——あなたも経験ありませんか?
PHPでCSVや画像、ログファイルを扱うコードを書いていると、ある日突然こんなエラーに直面することがあります。
Fatal error: Allowed memory size of 134217728 bytes exhausted
ローカル環境では問題なく動いていたのに、本番の大容量ファイルを処理した途端にスクリプトがクラッシュ。焦ってメモリ上限を memory_limit = 512M に引き上げて一時凌ぎをした経験がある方も多いのではないでしょうか。
実はこれ、「とりあえず動いている」だけで、根本的な問題は解決されていません。ファイルが大きくなればいずれまた同じエラーが出ますし、サーバー全体のパフォーマンスを圧迫するリスクも残ります。
この記事では、ファイルサイズに依存しないメモリ効率の良いPHP実装パターンを、実際の案件経験をもとに解説します。
なぜPHPはメモリ不足になるのか——問題の根本を理解する
まず原因を整理しましょう。PHPの典型的なファイル処理コードがなぜメモリを食うのか、以下の例を見てください。
// NG例: ファイル全体をメモリに読み込んでしまう
$lines = file('/path/to/large_file.csv'); // 全行を配列として保持
foreach ($lines as $line) {
// 処理...
}
file() 関数は、ファイル全体を一度に配列として読み込みます。100MBのCSVなら100MB以上のメモリを使用します。 同様に file_get_contents() も全データをメモリ上に展開するため、大容量ファイルには向きません。
PHPのデフォルト memory_limit は多くの環境で128MBに設定されています。50万行のCSVや高解像度画像を扱うシステムでは、この上限をあっさり超えてしまうわけです。
グラフを見れば一目瞭然です。ストリーム処理を使うと、ファイルサイズが増えてもメモリ使用量はほぼ一定に保てます。
現場で使える実装パターン3選
パターン① ストリーム処理でCSVを1行ずつ読む
最もシンプルかつ効果的な方法です。fopen() + fgets() または fgetcsv() を使い、1行読んでは処理し、メモリから解放するサイクルを繰り返します。
// 良い例: 1行ずつストリーム処理
$handle = fopen('/path/to/large_file.csv', 'r');
if ($handle === false) {
throw new RuntimeException('ファイルを開けませんでした');
}
try {
while (($row = fgetcsv($handle, 4096)) !== false) {
// 1行分のデータだけメモリ上に存在する
processRow($row);
}
} finally {
fclose($handle); // 必ずクローズする
}
ポイントは try/finally ブロックで必ずファイルハンドルを閉じることです。例外が発生しても確実に fclose() が実行されます。
パターン② PHPジェネレータで再利用可能なイテレータを作る
同じCSV処理をいろんな箇所から呼び出す場合、ジェネレータを使うとロジックを綺麗に分離できます。
// ジェネレータ関数: メモリ効率よく行を返す
function readCsvRows(string $filePath): Generator
{
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw new RuntimeException("ファイルが開けません: {$filePath}");
}
try {
// ヘッダー行をスキップ
fgetcsv($handle);
while (($row = fgetcsv($handle, 4096)) !== false) {
yield $row; // 1行だけ返して一時停止
}
} finally {
fclose($handle);
}
}
// 呼び出し側: 通常のforeachで使える
foreach (readCsvRows('/path/to/data.csv') as $row) {
// $rowは1行分のみ。全体はメモリに乗っていない
importToDatabase($row);
}
yield キーワードにより、関数は1行処理するたびに「一時停止 → 呼び出し元へ返す → 再開」を繰り返します。10万行のファイルを処理しても、常に数行分のデータしかメモリを使いません。
パターン③ 大容量ファイルのチャンク分割書き込み
ファイルへの書き込み処理も同様です。大量データをDBから取得してCSVに書き出す場合、一括取得は禁物です。
// NG: 全件取得してから書き込み
$allData = $pdo->query('SELECT * FROM orders')->fetchAll(); // メモリ爆発の危険
file_put_contents('output.csv', formatAsCsv($allData));
// 良い例: チャンクで分割して処理
$handle = fopen('output.csv', 'w');
$offset = 0;
$chunkSize = 1000; // 一度に処理する件数
try {
$stmt = $pdo->prepare(
'SELECT * FROM orders LIMIT :limit OFFSET :offset'
);
do {
$stmt->bindValue(':limit', $chunkSize, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
fputcsv($handle, $row);
}
$offset += $chunkSize;
} while (count($rows) === $chunkSize);
} finally {
fclose($handle);
}
Laravelを使っているなら chunk() メソッドがこの処理を内包しています。
// Laravelのchunk()を使う場合
Order::chunk(1000, function ($orders) use ($handle) {
foreach ($orders as $order) {
fputcsv($handle, $order->toArray());
}
});
よくある失敗パターンと対処法
あるクライアントでの実例——30万行CSV処理の改善
神奈川の卸売業者様から「商品マスタCSVのインポートが途中でエラーになる」というご相談をいただいたことがあります。30万行・約80MBのCSVを file() で読み込んでいたため、memory_limit 128M の壁に毎回ぶつかっていました。
対応策として取ったのは次の3点です。
fgetcsv()によるストリーム処理への書き換え- バリデーションエラーの別ファイル書き出し(配列蓄積をやめる)
- LaravelのQueueジョブ化による非同期処理
結果として、メモリ使用量は最大時でも約8MBに収まり、処理も安定して完走するようになりました。インポート完了後に管理者へメール通知する仕組みも合わせて実装し、担当者の作業負担も大幅に軽減されました。
「コードを少し書き直すだけでこんなに変わるんですね」と驚いていただけたことが印象に残っています。
開発・運用でお困りなら
システム開発
設計から運用まで、堅牢なシステムを構築します
※ 通常1営業日以内にご返信します
まとめと次のステップ
PHPのメモリ不足問題は、「ファイルを全部読んでから処理する」設計そのものが原因です。ストリーム処理・ジェネレータ・チャンク分割の3パターンを使い分けることで、ファイルサイズに依存しない堅牢なシステムを構築できます。
まず既存コードを見直してみてください。file() や file_get_contents() でCSVや大容量テキストを読んでいる箇所があれば、今日から改善のスタートラインに立てます。
Fivenine Designからひとこと
「動いてはいるが、実は時限爆弾を抱えている」コードは珍しくありません。特にデータ量が増えてきた段階で一気に問題が顕在化するケースが多いです。
既存システムのパフォーマンス改善・コードレビュー・Laravel/WordPressを使った安定した処理基盤の構築など、バックエンド開発でお困りのことがあれば、神奈川拠点の私たちFivenine Designにお気軽にご相談ください。20年以上の実績で、貴社のシステムを安心・安定して動かすお手伝いをします。