PhpSpreadsheetで大きなExcel出力がメモリ不足になる時の対処法

概要

PhpSpreadsheet を使った Excel 出力で、行数が数万〜数十万行規模になると Allowed memory size of ... bytes exhausted でプロセスが落ちることがあります。原因は、PhpSpreadsheet が既定で全セルをメモリ上のオブジェクトとして保持する設計にあります。

この記事では、なぜメモリを食い潰すのかという仕組みと、実際に効いた対策を優先度順にまとめます。結論から言うと、「本当に見た目の装飾が必要な帳票なのか、それとも生データの一括ダウンロードなのか」を切り分けることが最初の分岐点です。

環境

  • PHP 8.x
  • PhpSpreadsheet 1.x / 2.x(phpoffice/phpspreadsheet)
  • 出力形式は .xlsx(Xlsx Writer)
  • 想定データ: 5万〜30万行、10〜30列程度

発生した問題

数千行までは問題なく出ていた Excel 出力が、データ量が増えたタイミングで以下のようなエラーで落ちるようになりました。

PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted
(tried to allocate 20480 bytes) in .../Cell/Cell.php on line ...

memory_limit を上げると一時的に通ることもありますが、データが増えるたびに限界が来るため、根本対策にはなりません。

原因

PhpSpreadsheet はセル1つ1つを PHP オブジェクトとして管理します。セルには値だけでなく、書式・スタイル参照・データ型などのメタ情報が紐づくため、1セルあたり数百バイト〜1KB程度を消費します。

ざっくり見積もると次のようになります。

10万行 × 20列 = 200万セル
200万セル × 1KB ≒ 約2GB

デフォルトの memory_limit(128MB など)を軽く超えるのは当然で、memory_limit を上げる方向で追いかけると青天井になります。ポイントは「セルをメモリに全部載せる」構造そのものを避けることです。

対応方法

対策1: セル値をディスク/圧縮キャッシュに逃がす(最小の変更)

PhpSpreadsheet には、セルデータをメモリ以外に保持するキャッシュ機構があります。ライブラリのコードにはほとんど手を入れず、保持先を差し替えるだけでピークメモリを下げられます。

バージョンによって API が異なる点に注意してください。

  • 1.x 系では PhpOffice\PhpSpreadsheet\Settings::setCache()(PSR-16 準拠のキャッシュ実装を渡す)
  • 古い系統では CachedObjectStorageFactory(現行では非推奨/削除)

現行系では PSR-16 の Cache 実装(ファイルキャッシュ等)を渡す形になります。ディスクI/Oが増えるため速度とのトレードオフになりますが、「まず落ちないようにしたい」という段階では有効です。

use PhpOffice\PhpSpreadsheet\Settings;

// PSR-16 準拠のキャッシュ実装を渡す
Settings::setCache($psr16Cache);

対策2: 装飾不要ならストリーミング系ライブラリに切り替える(最も効く)

生データのダウンロード(フィルタ済みの一覧を CSV/Excel で落とす等)で、セルの色付け・結合・数式が不要なら、そもそも PhpSpreadsheet を使わないのが最も確実です。

行を1行ずつ書き出してメモリに溜めない「ストリーミング型」のライブラリに切り替えると、ピークメモリはデータ量にほぼ依存しなくなります。

  • box/spout(後継の openspout) — 行単位でストリーミング書き込み。メモリ使用量がほぼ一定
  • CSV での出力 — Excel が必須でなければ最軽量

openspout での書き出しイメージ:

use OpenSpout\Writer\XLSX\Writer;
use OpenSpout\Common\Entity\Row;

$writer = new Writer();
$writer->openToFile('/tmp/export.xlsx');

$writer->addRow(Row::fromValues(['ID', '名前', '登録日']));

foreach ($this->fetchRowsLazily() as $record) {
    $writer->addRow(Row::fromValues([
        $record['id'],
        $record['name'],
        $record['created_at'],
    ]));
}

$writer->close();

行を1件書いたらオブジェクトを保持し続けないため、30万行でもメモリはほぼ横ばいで済みます。

対策3: 装飾が必要なら「取得側」を遅延させる

「合計行に色をつけたい」「一部セルを結合したい」など、どうしても PhpSpreadsheet の機能が必要なケースもあります。この場合は書き出し側を変えられないので、データ取得側でメモリを溜めないことが重要になります。

  • fromArray() に巨大配列を一気に渡さない(配列自体がメモリを食う)
  • ORM の全件 get() ではなく、cursor() / lazy() 等のカーサ取得で1行ずつ流す
  • 1行ずつ setCellValue() していく
$sheet = $spreadsheet->getActiveSheet();
$row = 1;

// 全件を配列に載せる get() ではなく cursor() で1件ずつ
foreach (Order::query()->cursor() as $order) {
    $sheet->setCellValue("A{$row}", $order->id);
    $sheet->setCellValue("B{$row}", $order->customer_name);
    $row++;
}

ただし、この方法でも「書き出し済みのセル」は PhpSpreadsheet 内に残るため、対策1(キャッシュ逃がし)と併用するのが現実的です。

対策4: 非同期化してリクエスト内で処理しない

数十万行の出力を Web リクエストのタイムアウト内に収めようとすること自体が無理筋なケースがあります。

  • ジョブキューに投げてバックグラウンドで生成
  • 生成完了後にダウンロードリンク or S3 の署名付きURLを通知
  • リクエストは「受付だけ」で即レスポンスを返す

この構成にすると、メモリだけでなく実行時間・タイムアウトの問題もまとめて解消できます。

確認方法

対策の効果は、ピークメモリを実測して確認します。

$before = memory_get_peak_usage(true);

// ...出力処理...

$after = memory_get_peak_usage(true);
error_log(sprintf(
    'peak memory: %.1f MB',
    $after / 1024 / 1024
));
  • 対策前は行数に比例してピークが増える
  • ストリーミング化後は行数を増やしてもピークがほぼ一定になる

この「行数を変えてもピークが横ばいになるか」が、正しく対処できたかの決定的な確認ポイントです。

注意点

  • memory_limit の引き上げは対症療法。データが増えれば再発するので、恒久対策と混同しない
  • PhpSpreadsheet のキャッシュ API はメジャーバージョンで変わっている。使っているバージョンの公式ドキュメントを必ず確認する
  • ストリーミング系に切り替えると、セル結合・条件付き書式・グラフなど一部機能は使えなくなる。要件と天秤にかける
  • .csv は文字コード(BOM 付き UTF-8 か Shift_JIS か)で Excel での文字化けが起きやすい。開く先の環境を確認する

まとめ

  • PhpSpreadsheet のメモリ枯渇は「全セルをオブジェクトで保持する」構造が原因
  • まず「装飾が必要な帳票か、生データか」を切り分ける
  • 生データなら openspout / CSV のストリーミング出力に切り替えるのが最も効く
  • 装飾が必要なら、キャッシュ逃がし+カーサ取得+非同期化を組み合わせる
  • 対策の成否は「行数を増やしてもピークメモリが横ばいか」で判定する

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。