タイムアウト・メモリ上限・例外握りつぶし——PHPバッチ処理が黙って止まる3大原因を実案件事例をもとに解説。ログの読み方から修正コードまで実践的に紹介します。
「バッチが終わってないのに、エラーも出ていない」
こんな経験はありませんか?
- 夜間バッチを仕掛けたのに、翌朝見たら途中のデータしか処理されていない
- cronのログには「正常終了」と書いてあるのに、明らかに件数が足りない
- 手動で実行すると動くのに、定期実行だと止まる
原因を調べようにも、エラーメッセージが一切出ていない——これがPHPバッチ処理の「静かな失敗」です。Webリクエストと違い、バッチ処理はユーザーの目に触れないため、問題が発覚するまでに時間がかかります。しかも、止まり方によって原因が異なるため、正しく見分けないと見当違いの対処をしてしまうことになります。
本記事では、PHPバッチ処理が途中で止まる主な3つの原因——タイムアウト・メモリ上限・例外の握りつぶし——を、実案件での事例をもとに解説します。「どれが原因か判断できない」という状態から脱するための実践的な診断手順と修正コードを紹介しますので、ぜひ最後まで読んでください。
なぜPHPバッチは「黙って止まる」のか
PHPはもともとWebリクエストを処理するために設計された言語です。そのため、デフォルトの設定がWebアプリケーションに最適化されており、長時間・大量データを扱うバッチ処理には向いていない側面があります。
具体的には、以下の3つのデフォルト制約が問題になります。
弊社でこれまで対応してきた案件を振り返ると、タイムアウトが最多で約45%、次いでメモリ上限が35%、例外の握りつぶしが20%という感覚値です。ただし厄介なのは、これらが複合して起きているケースも少なくない点です。
原因ごとの見分け方と修正手順
① タイムアウト——「正常終了なのに未処理データが残る」
PHPの max_execution_time は、デフォルトで 30秒 に設定されています。Webサーバー経由で実行するバッチはこの制限に引っかかります。一方、CLIから実行した場合は0(無制限)になりますが、Laravelのスケジューラやcronで呼び出す際の設定ミスで30秒制限が残るケースがあります。
診断方法: エラーログに以下のようなメッセージがあればタイムアウトです。
Fatal error: Maximum execution time of 30 seconds exceeded
ただし、この行がログに残らないケースもあります。そのときは、処理の冒頭と末尾にログを出力して「どこまで到達したか」を確認します。
<?php
// バッチ冒頭でタイムアウト上限を明示的に解除
set_time_limit(0);
ini_set('max_execution_time', 0);
$logger->info('バッチ開始', ['time' => date('Y-m-d H:i:s')]);
foreach ($records as $record) {
// 処理ごとに進捗ログを出力
$logger->info('処理中', ['id' => $record->id]);
processRecord($record);
}
$logger->info('バッチ完了', ['time' => date('Y-m-d H:i:s')]);
あるECサイトクライアントの事例では、毎晩実行している在庫同期バッチが「1,200件中800件で止まる」という問題がありました。ログを仕込んだところ、正確に実行から30秒後にプロセスが終了していることが判明。CLI実行に切り替え、set_time_limit(0) を追加することで解消しました。
② メモリ上限——「件数が多いほど早く止まる」
メモリ問題のサインは「処理件数が増えるほど止まるタイミングが早くなる」ことです。デフォルトの memory_limit は128MBが多く、大量レコードを一括取得するとあっという間に枯渇します。
よくあるアンチパターン:
// ❌ 全件を一度にメモリに展開する
$records = DB::table('orders')->get(); // 10万件→即OOM
修正後:チャンク処理で分割取得
// ✅ chunk()で分割処理
DB::table('orders')->orderBy('id')->chunk(500, function ($records) use ($logger) {
foreach ($records as $record) {
processOrder($record);
}
// 処理後にガベージコレクションを促す
gc_collect_cycles();
$logger->info('500件処理完了', ['memory' => memory_get_usage(true)]);
});
Laravelを使っている場合、cursor() メソッドも有効です。チャンクとの違いは、カーソルはジェネレータを使って1件ずつストリーミングするため、メモリ効率がさらに高くなります。
// ✅ cursor()で1件ずつ処理(大量データに最適)
foreach (DB::table('orders')->cursor() as $record) {
processOrder($record);
}
③ 例外の握りつぶし——「エラーがないのに処理が飛ばされる」
最も見つけにくい原因がこれです。try-catchで例外をキャッチしているのに、中身が continue や何もしない空ブロックになっているケース。エラーが出ないまま処理がスキップされます。
// ❌ 例外を握りつぶしている典型例
foreach ($records as $record) {
try {
processRecord($record);
} catch (Exception $e) {
// 何もしない(握りつぶし)
continue;
}
}
修正:最低限ログに残す
// ✅ 例外をログに記録しつつ処理を継続
$errorCount = 0;
foreach ($records as $record) {
try {
processRecord($record);
} catch (Exception $e) {
$errorCount++;
$logger->error('レコード処理エラー', [
'id' => $record->id,
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// エラー率が高ければ異常終了させる
if ($errorCount > 100) {
throw new RuntimeException('エラー件数が上限を超えました');
}
}
}
3つの原因を素早く判断するフローチャート
flowchart TD
A[バッチが途中で止まった] --> B{エラーログに\n記述がある?}
B -->|ある| C{メッセージの内容}
C -->|Maximum execution time exceeded| D[タイムアウト\nset_time_limit対応]
C -->|Allowed memory size exhausted| E[メモリ上限\nchunk/cursor対応]
C -->|その他のException| F[例外の伝播確認\nログ実装を見直す]
B -->|ない| G{処理件数と\n停止タイミングは?}
G -->|常に同じ時間で止まる| D
G -->|件数が多いほど早く止まる| E
G -->|特定のレコードで飛ばされる| H[例外握りつぶし\ntry-catchを見直す]よくある失敗パターンと対処法
失敗① 本番だけで発生する問題を開発環境で再現できない
ローカルはデータ件数が少ないため、メモリもタイムアウトも問題になりません。「本番で試して初めて気づく」というケースが多く、デバッグのサイクルが遅くなります。
対策: ステージング環境に本番相当のデータ量を用意する。または、バッチの冒頭で現在のメモリ上限・タイムアウト設定をログに出力しておき、環境差異を検知しやすくする。
$logger->info('実行環境情報', [
'memory_limit' => ini_get('memory_limit'),
'max_execution_time' => ini_get('max_execution_time'),
'php_sapi' => PHP_SAPI, // 'cli' か 'fpm-fcgi' かを確認
]);
失敗② set_time_limit(0) を入れたが効かない
Webサーバー(Apache/Nginxのphp-fpm)の設定が優先されるケースがあります。php.ini の max_execution_time がサーバー設定でオーバーライドされていると、スクリプト内の変更が無効になります。
対策: phpinfo() や ini_get() で実際に適用されている値を確認する。CLI実行への移行、またはphp-fpmのプールごとの設定変更を検討する。
失敗③ chunk()を使ったのにメモリが減らない
Eloquentのリレーションをwith()でイーガーロードしている場合、chunk()を使ってもリレーション分のオブジェクトが蓄積することがあります。また、イベントリスナーやオブザーバーがモデルを保持し続けるケースも。
対策: EloquentではなくDB::table()(クエリビルダ)を使って生の配列で取得する。モデルが必要な場合はwithoutEvents()でオブザーバーを無効化する。
開発・運用でお困りなら
システム開発
設計から運用まで、堅牢なシステムを構築します
※ 通常1営業日以内にご返信します
まとめと次のステップ
PHPバッチ処理の「静かな失敗」は、原因を正しく見極めることが解決への最短ルートです。「エラーが出ない=正常」ではなく、「ログを意図的に設計する」ことが信頼性の高いバッチの第一歩です。
弊社では、Laravelを用いたバッチ処理の設計・リファクタリングを多数手がけており、「動いているけど不安」という既存バッチのコードレビューにも対応しています。「自社のバッチが本当に正常に動いているか確かめたい」という方は、お気軽にご相談ください。