Laravel大量データ処理の決定版|chunk・chunkById・lazy・cursorの使い分けとメモリ最適化

実装・応用テクニック

Laravelでデータベースを扱う際、数百件から数千件程度の小規模なデータであれば User::all() や User::where(...)->get() を使って手軽に全件取得しても問題ありません。しかし、実務の本番環境においてデータ件数が数万件、数十万件、あるいは数百万件へとスケールしたとき、これまで正常に動作していたコードが突如として 致命的な障害 を引き起こします。

代表的な障害が、PHPのメモリ上限に達してプロセスがクラッシュする 「メモリ枯渇エラー(Allowed memory size exhausted / OOMエラー)」 です。Eloquentは取得したレコード1件ごとにモデルインスタンスを生成し、属性値やリレーション、ダーティチェック用の配列をメモリ上に保持するため、数十万件のレコードを一括取得すると数百MBから数GBのメモリを一瞬で消費してしまいます。

さらに恐ろしいのが、バッチ更新や論理削除処理の際に発生する 「OFFSET更新スキップ事故」 です。「メモリ枯渇を防ぐために chunk() を使って100件ずつ更新したはずなのに、なぜか全体の約半分のデータが未処理のまま放置されてしまう」という、初見では原因特定が困難なサイレントバグに多くのエンジニアが直面します。

📌 本記事でわかること

  • chunk・chunkById・lazy・cursor 4大メソッドの内部動作と特性の違い
  • chunk() 内でUPDATE/DELETEを実行した際にレコードがスキップされるメカニズムと図解
  • シーク方式(Seek Method)により更新スキップを完全に防ぐ chunkById() の正しい実装
  • PHPジェネレータ(yield)と連携して高階コレクション操作を可能にする LazyCollection(lazy())の活用法
  • PDOバッファなしクエリで最小メモリ消費を実現する cursor() と大容量CSVストリーミング出力
  • Eloquentのオーバーヘッド回避、DB::disableQueryLog() によるクエリログ蓄積防止、GC制御
  • バッチ処理におけるトランザクション分割(DB::transaction)と排他ロック(lockForUpdate)の実践設計

💡 クエリビルダ・データ操作関連ガイド

Laravelのデータベース操作・クエリ最適化を網羅するトピッククラスターです。本ピラー記事と合わせて以下の解説記事もぜひご参照ください。

本記事では、Laravelが提供する大量データ処理メソッドの内部動作(LIMIT/OFFSET、主キーシーク、PHPジェネレータ、PDO非バッファクエリ)を徹底解明し、シチュエーションに応じた最適な使い分けとメモリ最適化のベストプラクティスを網羅的に解説します。


  1. 【早見表】Laravel大量データ処理メソッド4種の使い分け比較
    1. chunk vs chunkById vs lazy vs cursor 特性マトリクス
    2. 使い分けのクイック判定フロー
  2. 1. chunkメソッドの基本と「OFFSET更新スキップ事故」の罠
    1. chunk() の基本構文とページネーション(LIMIT / OFFSET)の仕組み
    2. 【超重要・図解】更新処理(UPDATE/DELETE)でレコードが半分スキップされる致命的バグの再現
      1. レコードがスキップされるメカニズムのテキスト図解
    3. chunkの中で更新してはいけない理由と回避策
  3. 2. chunkByIdメソッドによる安全なシーク方式(Seek Method)
    1. chunkById() の仕組み(WHERE id > last_id ORDER BY id ASC LIMIT n)
      1. なぜ更新を行ってもスキップが絶対に起きないのか?
    2. 大量データの安全なバッチ更新・削除実装コード例
      1. 大量IDの分割処理と whereIn / whereIntegerInRaw による最適化
    3. chunkByIdを使うための必須要件(主キーまたは一意インデックスの昇順ソート制約)
  4. 3. lazy / lazyById メソッドによるLazyCollectionの活用
    1. PHPジェネレータ(yield)とLazyCollectionの連携
    2. メモリを一定に保ちながら高階コレクションメソッド(map, filter)を連鎖させる実装例
      1. 通常のCollectionとの決定的な違い
  5. 4. cursorメソッドによる最小メモリでのストリーミング処理
    1. cursor() の内部動作(PDOバッファなしクエリと1レコードずつのフェッチ)
      1. 内部メカニズム: PDOバッファなしクエリ(Unbuffered Query)
    2. 大規模CSVエクスポート処理の実装例(response()->streamDownload() 連携)
    3. cursor利用時の注意点(N+1問題の発生リスクとリレーション事前ロードの制約)
      1. 1. 致命的な N+1 問題の発生
      2. 2. 同一DB接続での「Unbuffered queries active」エラー
  6. 5. メモリ枯渇(OOMエラー)を未然に防ぐ実務テクニック
    1. Eloquentモデルのインスタンス生成オーバーヘッドを抑えるクエリビルダ(DBファサード)の併用
    2. クエリログの蓄積防止(DB::disableQueryLog() の必須化)
    3. ガベージコレクションの手動トリガー(unset() と gc_collect_cycles())
  7. 6. トランザクション・排他制御との実務組み合わせ
    1. chunk毎の小刻みなトランザクション(DB::transaction)分割による長時間ロック防止
      1. 正しい設計: チャンク単位の小刻みなトランザクション分割
    2. 排他ロック(lockForUpdate)と組み合わせた安全なバッチ処理
      1. デッドロックを防ぐためのロック順序原則
  8. 7. まとめ・クエリビルダクラスター関連記事一覧
    1. 大量データ処理の黄金ルール
    2. クエリビルダ・データ操作クラスタの関連記事
      1. 🔒 Laravel lockForUpdate()の使い方と排他ロック制御
      2. 🔄 Laravel DBトランザクション完全ガイド|デッドロック自動リトライ
      3. ⚡ Laravel whereInの使い方とwhereIntegerInRawによる大量データ最適化
      4. 🚫 Laravel whereNot/whereNotInの使い方と違い
      5. ✨ Laravel whenメソッドの使い方と条件分岐テクニック
      6. 📥 Laravelで大容量ファイルをStreamDownloadする方法
  9. 関連記事

【早見表】Laravel大量データ処理メソッド4種の使い分け比較

Laravelには、大量のレコードを分割・逐次処理するための手段として主に chunk 、 chunkById 、 lazy(lazyById) 、 cursor の4種類が用意されています。

それぞれ「発行されるSQLの挙動」「メモリ消費特性」「レコード更新時の安全性」「戻り値の型」が大きく異なります。要件に合致しないメソッドを選択すると、深刻なパフォーマンス低下やデータ更新漏れを招きます。

chunk vs chunkById vs lazy vs cursor 特性マトリクス

以下の比較表は、各メソッドの内部挙動と最適な使い分け基準をまとめたものです。

メソッド名 内部SQLメカニズム メモリ消費量(RAM) レコード更新の安全性 戻り値の型 最適なユースケース
chunk($n, $callback) LIMIT n OFFSET m
(ページ送り方式)
低〜中
(n件分のCollection)
危険(スキップ発生)
※抽出条件列の変更時
bool
(クロージャ引数はCollection)
読み取り専用処理、外部通知、更新条件に関係ないカラムの集計
chunkById($n, $callback) WHERE id > last_id LIMIT n
(主キーシーク方式)
低〜中
(n件分のCollection)
完全安全
(スキップ・無限ループなし)
bool
(クロージャ引数はCollection)
バッチ更新・削除の標準(最推奨)、大量データのステータス移行
lazy() / lazyById() 分割クエリ+PHPジェネレータ
(yield による逐次返却)
低
(1チャンク分を維持)
lazy: 危険
lazyById: 完全安全
LazyCollection コレクションメソッド(map, filter)を連鎖させたい処理、パイプライン処理
cursor() 単一クエリ+PDO非バッファ
(カーソルによる1行フェッチ)
極小(最少)
(1レコード分のみ保持)
注意
(同一接続でのクエリ制限)
LazyCollection 大規模CSV/Excelエクスポート、読み取り専用の超高速ストリーミング

使い分けのクイック判定フロー

実務でどのメソッドを選択すべきか迷った場合は、以下の判定手順に沿って決定します。

  1. レコードの更新(UPDATE)や削除(DELETE)を伴うか?
  • YES : 絶対に chunkById() または lazyById() を選択する(chunk() は厳禁)。
  • NO : 2へ進む。
  1. CSV出力やファイル書き出しなど、純粋なストリーミング処理か?
  • YES : cursor() を選択する(SQL発行が1回で済み、メモリ消費を極限まで削減可能)。
  • NO : 3へ進む。
  1. コレクションの高階メソッド(map, filter, chunk, take など)を連鎖させたいか?
  • YES : lazy() (更新を伴う場合は lazyById())を選択する。
  • NO : 通常のバッチ処理や通知送信であれば chunk() で十分。

1. chunkメソッドの基本と「OFFSET更新スキップ事故」の罠

chunk() は、Laravelの初期から存在する最も知名度の高い大量データ処理メソッドです。指定した件数ごとにレコードを分割取得し、クロージャ内で1チャンクずつ処理します。

chunk() の基本構文とページネーション(LIMIT / OFFSET)の仕組み

まずは基本的な書き方と、裏側で発行されるSQLを確認します。

use AppModelsUser;
use IlluminateSupportCollection;

// 1,000件ずつ分割して取得
User::where('is_active', true)->chunk(1000, function (Collection $users) {
    foreach ($users as $user) {
        // 各ユーザーに対する処理(メール送信など)
        Mail::to($user->email)->queue(new MonthlyNewsletter($user));
    }
});

このコードを実行すると、Laravel内部では以下のようなSQLが順番に発行されます。

-- 1回目のループ (OFFSET 0)
SELECT * FROM `users` WHERE `is_active` = 1 LIMIT 1000 OFFSET 0;

-- 2回目のループ (OFFSET 1000)
SELECT * FROM `users` WHERE `is_active` = 1 LIMIT 1000 OFFSET 1000;

-- 3回目のループ (OFFSET 2000)
SELECT * FROM `users` WHERE `is_active` = 1 LIMIT 1000 OFFSET 2000;

このように、chunk() の内部実装は典型的な 「LIMIT / OFFSET によるページネーション」 です。指定した件数分だけメモリ上にCollectionインスタンスをロードし、クロージャの処理が終わるとメモリを解放して次のオフセットへと進みます。

途中で処理を中断したい場合は、クロージャ内で return false; を返すとループが即座に停止します。

User::chunk(500, function ($users) {
    // 条件を満たしたらバッチ全体を中断
    if (some_error_condition()) {
        return false;
    }
});

【超重要・図解】更新処理(UPDATE/DELETE)でレコードが半分スキップされる致命的バグの再現

読み取り専用の処理であれば chunk() は問題なく動作します。しかし、 「取得条件に関係するカラムをクロージャ内で更新(または削除)した場合」 、極めて深刻なデータ処理漏れが発生します。

典型的なアンチパターンの例を見てみましょう。

// 【危険】未処理(status = 0)の注文を100件ずつ取得して処理済(status = 1)に更新
Order::where('status', 0)->chunk(100, function ($orders) {
    foreach ($orders as $order) {
        $order->update(['status' => 1]); // ここでstatusを変更!
    }
});

一見すると何の不都合もないコードに見えます。しかし、これを実行すると 全体の約50%のレコードが更新されずに未処理のまま終了 します。

レコードがスキップされるメカニズムのテキスト図解

なぜ半分のレコードがスキップされてしまうのか、10件のデータを2件ずつのチャンクで処理する具体例で図解します。

【初期状態】status = 0 の未処理レコードが 10 件存在
Index (全体) : [0]  [1]  [2]  [3]  [4]  [5]  [6]  [7]  [8]  [9]
Order ID     : #1   #2   #3   #4   #5   #6   #7   #8   #9   #10
Status       :  0    0    0    0    0    0    0    0    0    0

--------------------------------------------------------------------------------
◆ 1 回目のクエリ: WHERE status = 0 LIMIT 2 OFFSET 0
取得されるデータ: #1, #2 (Index 0, 1)
クロージャ処理  : #1 と #2 を status = 1 に更新!

【1 回目処理後の DB 状態】
status = 0 に合致するレコード群が前方に「繰り上がり」ます!
新 Index     : [0]  [1]  [2]  [3]  [4]  [5]  [6]  [7]
Order ID     : #3   #4   #5   #6   #7   #8   #9   #10
(※ #1, #2 は status=1 になったため、WHERE 条件から除外される)

--------------------------------------------------------------------------------
◆ 2 回目のクエリ: WHERE status = 0 LIMIT 2 OFFSET 2  ← OFFSET が進む!
取得されるデータ: Index 2, 3 のデータ
ここで取得されるのは... なんと 【 #5, #6 】!
★ 警告: 【 #3, #4 】(新 Index 0, 1)が完全にスキップされた!

クロージャ処理  : #5 と #6 を status = 1 に更新!

--------------------------------------------------------------------------------
◆ 3 回目のクエリ: WHERE status = 0 LIMIT 2 OFFSET 4
取得されるデータ: Index 4, 5 のデータ
ここで取得されるのは... 【 #9, #10 】!
★ 警告: 【 #7, #8 】が完全にスキップされた!

--------------------------------------------------------------------------------
【最終結果】
処理された ID   : #1, #2, #5, #6, #9, #10 (計 6 件)
未処理のまま放置 : #3, #4, #7, #8         (計 4 件がスキップ!)

このように、クロージャ内で status を更新したことで、未処理データのインデックス位置が前方にシフトします。にもかかわらず chunk() は「次は OFFSET 2」「次は OFFSET 4」とページを進めてしまうため、前に繰り上がった奇数番目のグループがごっそり抜け落ちてしまうのです。

このバグはテスト環境でデータ件数が少ない場合(1チャンク未満の場合)には表面化せず、本番環境で大量データを投入した瞬間に発覚するため、甚大なインシデントに直結します。

chunkの中で更新してはいけない理由と回避策

「では OFFSET 0 で回し続ければよいのではないか?」と考えるエンジニアもいます。

確かに while (Order::where('status', 0)->exists()) のように LIMIT 100 OFFSET 0 をループさせる実装も可能ですが、万が一1件でも更新に失敗するレコード(バリデーションエラーや例外)が存在した場合、そのレコードが永久に条件にヒットし続け、 本番バッチが無限ループに陥る という別の致命的リスクを抱えることになります。

したがって、レコードの更新や削除を伴う処理において chunk() を使用することは 完全なアンチパターン です。

大量データのバッチ更新には、次に解説する chunkById() を使用するのが Laravel における絶対的な標準ルールです。


2. chunkByIdメソッドによる安全なシーク方式(Seek Method)

chunkById() は、前述の「OFFSET更新スキップ問題」を根本から解決するために Laravel に導入されたメソッドです。

chunkById() の仕組み(WHERE id > last_id ORDER BY id ASC LIMIT n)

chunkById() は、OFFSET を一切使用しません。その代わりに、レコードの主キー(通常は id)を追跡し、 「直前に処理したチャンクの最大IDよりも大きいレコードを取得する(シーク方式 / Keyset Pagination)」 というアルゴリズムで動作します。

内部で発行されるSQLの遷移を見てみましょう。

-- 1回目のループ: 最初の100件を取得
SELECT * FROM `orders`
WHERE `status` = 0
ORDER BY `id` ASC
LIMIT 100;
-- (取得した100件の中で最大の id が 100 だったとする)

-- 2回目のループ: id > 100 の条件を付与して次の100件を取得!
SELECT * FROM `orders`
WHERE `status` = 0 AND `id` > 100
ORDER BY `id` ASC
LIMIT 100;
-- (取得した100件の中で最大の id が 250 だったとする)

-- 3回目のループ: id > 250 の条件を付与して取得!
SELECT * FROM `orders`
WHERE `status` = 0 AND `id` > 250
ORDER BY `id` ASC
LIMIT 100;

なぜ更新を行ってもスキップが絶対に起きないのか?

chunkById() は「何番目のレコードか(OFFSET)」ではなく、 「IDがいくら以降か(id > last_id)」 という絶対的な位置情報で次のデータを取得します。

たとえクロージャ内で前のチャンクのデータを status = 1 に更新したり delete() で削除したりしても、未処理レコードの id 自体は変わりません。そのため、データが前に繰り上がろうとも、次のクエリは正確に id > last_id から始まる未処理データを漏れなく捉えることができます。

大量データの安全なバッチ更新・削除実装コード例

以下は、chunkById() を用いた安全なバッチ更新の実装例です。

use AppModelsOrder;
use IlluminateSupportCollection;
use IlluminateSupportFacadesLog;

// 未処理の注文を1,000件ずつ安全に更新
Order::where('status', 0)
    ->chunkById(1000, function (Collection $orders) {
        foreach ($orders as $order) {
            $order->status = 1;
            $order->processed_at = now();
            $order->save();
        }
        
        Log::info("Processed chunk up to Order ID: {$orders->last()->id}");
    });

大量IDの分割処理と whereIn / whereIntegerInRaw による最適化

各レコードを1件ずつ save() すると、1,000件の更新で1,000回の UPDATE クエリが発行されてしまい、ネットワーク往復とDB負荷が増大します。

チャンク内のIDリストを抽出し、一括更新を行いたい場合は、以下のようにID配列を活用します。

Order::where('status', 0)
    ->chunkById(1000, function (Collection $orders) {
        $ids = $orders->pluck('id')->all();
        
        // チャンク単位でまとめてステータスを一括更新
        Order::whereIntegerInRaw('id', $ids)->update([
            'status' => 1,
            'processed_at' => now(),
        ]);
    });

ここで注目したいのが whereIn ではなく whereIntegerInRaw() の利用です。

通常の whereIn は数千件のIDに対して数千個のプリペアドステートメントプレースホルダ(?)をバインドするため、PDOのバッファやメモリを消費します。数値IDの配列であることが保証されている場合は、whereIntegerInRaw() を使用することでバインド処理をバイパスし、メモリ消費とクエリパース時間を大幅に削減できます。

詳しいメカニズムやSQLプレースホルダ制限の回避テクニックについては、Laravel whereInの使い方とwhereIntegerInRawによる大量データ最適化 で詳しく解説しています。

また、特定条件に一致しないレコードのみを対象とする除外バッチを構築したい場合は、Laravel whereNot/whereNotInの使い方と違い を組み合わせることで、直感的かつ高速な除外条件を定義できます。

さらに、バッチ実行時の引数や環境フラグに応じて処理対象クエリを動的に切り替えたいケースでは、Laravel whenメソッドの使い方と条件分岐テクニック を活用すると、クエリビルダのチェーンを崩さずにスマートな条件分岐が実現できます。

chunkByIdを使うための必須要件(主キーまたは一意インデックスの昇順ソート制約)

chunkById() を使用する際には、以下の技術的制約を必ず理解しておく必要があります。

  1. 主キーまたは一意(UNIQUE)な整数カラムの昇順ソートが前提

chunkById() はデフォルトで id カラムを ORDER BY id ASC でソートします。もし対象テーブルの主キーが id 以外の名前(例: order_id)である場合は、第3引数で明示的にカラム名を指定しなければなりません。
`php
Order::where(‘status’, 0)->chunkById(1000, function ($orders) {
// 処理
}, ‘order_id’);
`

  1. 自前の orderBy() を指定してはいけない

chunkById() のクエリに開発者が手動で orderBy('created_at', 'desc') などを付加すると、ORDER BY created_at DESC, id ASC のような複合ソートになり、シーク条件 id > last_id とソート順が矛盾して意図しない動作を引き起こします。ソート順を制御したい場合は、後述の cursor() などの別手法を検討してください。

  1. UUID(文字列主キー)を使用している場合の注意

Laravelの chunkById() は標準で >(大なり)演算子による比較を行います。UUID(文字列)の場合、辞書順ソートによるインデックススキャンが走るため、適切なインデックスが貼られていないとパフォーマンスが劣化する可能性があります。


3. lazy / lazyById メソッドによるLazyCollectionの活用

Laravel 6で導入された LazyCollection は、PHPの ジェネレータ(Generators / yield) 機能を活用して、超省メモリでのコレクション操作を可能にした画期的な機能です。

その入り口となるのが lazy() および lazyById() メソッドです。

PHPジェネレータ(yield)とLazyCollectionの連携

通常の Collection(collect() や get())は、すべてのデータをメモリ上の1つの巨大な配列として保持します。そのため、100万件のデータを操作しようとすると、配列のメモリ確保だけで数十MB〜数百MBを消費します。

一方、LazyCollection は 「必要になったその瞬間にだけ、1レコードずつデータを生成(yield)する」 という遅延評価(Lazy Evaluation)の仕組みを持っています。

use AppModelsUser;

// この時点では、まだデータベースへのクエリすら実行されていない!
$lazyUsers = User::lazy(1000); 

// foreachでループを回し始めた瞬間に、最初の1,000件を取得するクエリが走る
foreach ($lazyUsers as $user) {
    echo $user->name; // 1件ずつ処理
}

lazy(1000) は、裏側では chunk(1000) と同様に1,000件ずつクエリを分割発行していますが、呼び出し元には LazyCollection のインスタンスとして返却されます。

開発者は「分割クエリが発行されていること」を一切意識することなく、あたかも全件のコレクションを普通に扱っているかのようにコードを記述できます。

⚠️ 警告

lazy() は内部で chunk() と同じ LIMIT/OFFSET を使用します。そのため、 ループ内で抽出条件カラムを更新すると、chunk() と同様にデータスキップ事故が発生します 。 レコードの更新を伴う処理で遅延コレクションを使いたい場合は、必ず lazyById() を使用してください。

メモリを一定に保ちながら高階コレクションメソッド(map, filter)を連鎖させる実装例

LazyCollection の最大の魅力は、通常のLaravelコレクションでおなじみの高階メソッド(map, filter, reduce, take, pluck など)が メモリ消費量を極小に保ったままチェーンできる 点にあります。

以下のコードは、10万件のユーザーから特定の条件に合致するデータを抽出し、整形して外部APIに送信するパイプライン処理の例です。

use AppModelsUser;
use IlluminateSupportFacadesHttp;

// lazyById() で安全にシークしつつ LazyCollection を取得
User::where('is_subscribed', true)
    ->lazyById(500)
    ->filter(function (User $user) {
        // メモリ上に1件ずつ展開してフィルタリング
        return $user->hasValidPaymentMethod();
    })
    ->map(function (User $user) {
        // 必要なペイロードに変換
        return [
            'external_id' => $user->uuid,
            'email'       => $user->email,
            'tier'        => $user->membership_tier,
        ];
    })
    ->take(10000) // 最初の10,000件に到達したらクエリ発行を自動打ち切り!
    ->each(function (array $payload) {
        // 外部APIへ送信
        Http::timeout(5)->post('https://api.example.com/sync', $payload);
    });

通常のCollectionとの決定的な違い

もし上記の処理を通常の get() や Collection で記述した場合、以下の問題が発生します。

  1. 10万件すべてのEloquentモデルが一括でメモリに展開される(約150MB消費)。
  2. filter() を呼ぶと、フィルタ後の新しいCollection配列がメモリ上に複製される。
  3. map() を呼ぶと、さらに新しい配列が複製される。

これに対して LazyCollection では、 要素が1件ずつパイプラインの中を流れていきます 。filter を通過した1件だけが map に渡され、そのまま each で消費されて即座に破棄されます。

そのため、対象データが10万件であっても100万件であっても、 PHPプロセスのメモリ使用量は常に数MBの一定値からピクリとも増えません 。

さらに、途中で take(10000) に達した時点でジェネレータが終了するため、残りの9万件に対する無駄なSQLクエリの発行自体が自動的にスキップされます。


4. cursorメソッドによる最小メモリでのストリーミング処理

数ある大量データ処理メソッドの中で、 最もメモリ消費量が少なく、最も高速 なのが cursor() メソッドです。

cursor() の内部動作(PDOバッファなしクエリと1レコードずつのフェッチ)

chunk() や lazy() は、指定件数ごとに SELECT ... LIMIT n OFFSET m や WHERE id > last_id といったSQLを 何度も繰り返しデータベースに発行 します。

これに対し、cursor() は SQLの発行が最初から最後まで「たったの1回」 です。

use AppModelsLogEntry;

// 発行されるSQLは1回のみ!
$cursor = LogEntry::where('created_at', '>=', now()->subDays(30))->cursor();

foreach ($cursor as $entry) {
    // 1行ずつPDOからフェッチされて処理される
    processLog($entry);
}

内部メカニズム: PDOバッファなしクエリ(Unbuffered Query)

通常、PHPのPDO(MySQLドライバ等)は、発行したクエリの結果セット全体をデータベースサーバから一度にすべてローカルメモリにダウンロードしてバッファリングします。これが get() でメモリが枯渇する原因です。

しかし、cursor() を呼び出すと、LaravelはPDOに対して バッファなしクエリ(Unbuffered Query) を要求します。データベースサーバとの間にカーソル(ストリーム)を確立し、PHP側が foreach でループを進めるたびに、PDOの fetch() を内部で呼び出して 「ネットワークソケットから1行だけフェッチしてEloquentモデル化」 します。

1行の処理が終わると古いモデルインスタンスは破棄され、次の1行がフェッチされます。したがって、1,000万件のテーブルを全走査しても、PHPプロセスのメモリ消費量は 「1レコード分(数KB)」 のまま一定に保たれます。

大規模CSVエクスポート処理の実装例(response()->streamDownload() 連携)

cursor() の特性が最も輝く実務ユースケースが、 管理画面からの大規模CSVエクスポート機能 です。

数十万件のCSVを生成する際、ローカルディスクに一時ファイルを作成したり、メモリ上にCSV文字列を結合したりすると、サーバーのディスク枯渇やメモリ枯渇を招きます。

Laravelの response()->streamDownload() と cursor() を組み合わせることで、 メモリ消費数MB、ディスク消費ゼロ、ダウンロード即時開始 の最高峰CSV出力が実現できます。

namespace AppHttpControllers;

use AppModelsOrder;
use SymfonyComponentHttpFoundationStreamedResponse;

class OrderExportController extends Controller
{
    /**
     * 数十万件の注文履歴を即座にストリーミングCSVダウンロード
     */
    public function export(): StreamedResponse
    {
        $fileName = 'orders_export_' . date('Ymd_His') . '.csv';

        return response()->streamDownload(function () {
            // PHPの出力バッファを直接開く
            $output = fopen('php://output', 'w');

            // Excel文字化け防止のためのBOMを出力
            fwrite($output, "xEFxBBxBF");

            // CSVヘッダー行の書き込み
            fputcsv($output, ['注文ID', '顧客名', '注文金額', '注文日時', 'ステータス']);

            // cursor() で1レコードずつストリーミング取得
            $orders = Order::select(['id', 'customer_name', 'total_amount', 'created_at', 'status'])
                ->orderBy('id', 'asc')
                ->cursor();

            foreach ($orders as $order) {
                fputcsv($output, [
                    $order->id,
                    $order->customer_name,
                    number_format($order->total_amount),
                    $order->created_at->format('Y-m-d H:i:s'),
                    $order->status_label,
                ]);

                // 定期的に出力バッファをフラッシュしてクライアントへ即座に送信
                flush();
            }

            fclose($output);
        }, $fileName, [
            'Content-Type'        => 'text/csv; charset=UTF-8',
            'Content-Disposition' => "attachment; filename="{$fileName}"",
            'Pragma'              => 'no-cache',
            'Cache-Control'       => 'must-revalidate, post-check=0, pre-check=0',
            'Expires'             => '0',
        ]);
    }
}

この実装により、クライアントはボタンをクリックしたコンマ数秒後にファイルのダウンロードが開始され、サーバー側はどれほど大量のデータであっても負荷なく処理できます。

詳しいストリーミングダウンロードのヘッダー設定やS3連携のベストプラクティスについては、Laravelで大容量ファイルをStreamDownloadする方法 を合わせて確認してください。

cursor利用時の注意点(N+1問題の発生リスクとリレーション事前ロードの制約)

圧倒的な省メモリを誇る cursor() ですが、実務で使う際には 2つの重大な落とし穴 が存在します。

1. 致命的な N+1 問題の発生

通常の get() であれば、以下のように with('items') でリレーションをイーガーロード(Eager Loading)すると、親テーブルのクエリ1回と、全子テーブルのクエリ1回(計2回)でデータを取得できます。

// 通常の get() の場合: クエリは計2回
$orders = Order::with('items')->get();

しかし、cursor() で with() を使用した場合、親レコードが1件ずつストリーミングフェッチされるため、Laravelは 「各レコードをフェッチするたびに子リレーションを取得するクエリを発行」 してしまいます。

// 【危険】cursor() でのイーガーロード
$orders = Order::with('items')->cursor();

foreach ($orders as $order) {
    // 注文が10万件あれば、10万回の SELECT * FROM order_items WHERE order_id = ? が発行される!
    echo $order->items->count();
}

10万件の注文があれば、10万回のSQLが発行されてデータベースが過負荷でダウンします。

【解決策】 : リレーション先のデータが必要な場合は、cursor() ではなく chunkById() を使用してください。chunkById() であれば、各チャンク(例: 1,000件ごと)の単位で子レコードが正しくまとめられて WHERE order_id IN (...) でイーガーロードされるため、N+1問題を回避できます。

2. 同一DB接続での「Unbuffered queries active」エラー

MySQLなどの一部のデータベースエンジンでは、バッファなしクエリでカーソルを開いている最中に、 同じデータベース接続を使って別のクエリを実行することができません 。

もし cursor() のループ内で別のモデルを保存したり、別のクエリを発行したりすると、以下のPDO例外が発生します。

SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active.
Consider using PDOStatement::fetchAll(), or, if your script is only using the mysqlnd library, PDO::MYSQL_ATTR_USE_BUFFERED_QUERY.

【解決策】 :

  • cursor() は 「純粋な読み取り(CSVエクスポートや外部転送)」 に限定する。
  • ループ内でDB書き込みや別テーブルの検索が必要な場合は、 chunkById() を利用する。

5. メモリ枯渇(OOMエラー)を未然に防ぐ実務テクニック

大量データ処理において、適切なメソッドを選択することに加えて、PHPとLaravel特有の 「隠れたメモリ消費要因」 を排除するテクニックが必須となります。

Eloquentモデルのインスタンス生成オーバーヘッドを抑えるクエリビルダ(DBファサード)の併用

Eloquentモデルは非常に高機能ですが、その反面、オブジェクト1つあたりのメモリ消費が大きいです。

  • 属性(attributes)配列
  • 元の属性(original)配列(変更検知用)
  • キャスト定義、リレーション、イベントリスナーなどのメタデータ

1件あたり数KB〜十数KBのメモリを消費します。100万件を処理する場合、モデルインスタンスの生成と破棄だけでもガベージコレクションに極めて大きな負荷がかかります。

もし「ビジネスロジックやミューテタが不要で、特定のカラムを読み取ってCSVに出力するだけ」というケースであれば、 Eloquentモデルを介さず DB ファサード(クエリビルダ)を直接利用する のが極めて有効です。

use IlluminateSupportFacadesDB;

// Eloquentではなくクエリビルダを使用
DB::table('orders')
    ->select(['id', 'customer_name', 'total_amount', 'created_at'])
    ->orderBy('id')
    ->cursor()
    ->each(function ($order) {
        // $order は純粋な stdClass オブジェクト!
        // Eloquentのオーバーヘッドがゼロになり、処理速度が約2〜3倍、メモリ消費が激減する
    });

クエリビルダが返却するのは軽量な stdClass オブジェクトであるため、Eloquentモデルに比べてCPUサイクルとメモリ割り当てが劇的に削減されます。

クエリログの蓄積防止(DB::disableQueryLog() の必須化)

長時間のArtisanバッチやキューワーカーで最も頻発する「原因不明のメモリリーク」が、 Laravelのクエリログ蓄積 です。

ローカル開発環境や、一部のミドルウェア・サービスプロバイダで DB::enableQueryLog() が呼ばれている場合、Laravelは 発行されたすべてのSQL文字列、バインドパラメータ、実行時間 をメモリ上の配列($queryLog)に永久に保存し続けます。

1万回、10万回とクエリが発行されるたびにこの配列が肥大化し、最終的に Allowed memory size of ... exhausted でプロセスが強制終了します。

大量データを扱うコマンドやジョブの冒頭では、必ず DB::disableQueryLog() を呼び出す習慣を徹底してください。

namespace AppConsoleCommands;

use IlluminateConsoleCommand;
use IlluminateSupportFacadesDB;

class ProcessMassiveDataCommand extends Command
{
    protected $signature = 'data:process-massive';

    public function handle(): int
    {
        // 【最重要】バッチ処理開始時に必ずクエリログを無効化する
        DB::disableQueryLog();

        // 大量データ処理の実行
        // ...

        return self::SUCCESS;
    }
}

ガベージコレクションの手動トリガー(unset() と gc_collect_cycles())

PHPは参照カウント方式と循環参照コレクタによるガベージコレクション(GC)を採用しています。

しかし、PHPの長寿命プロセス(CLIコマンドやQueueワーカー)で巨大なループを回していると、変数がスコープを抜けてもメモリが即座にOSへ返却されず、GCがトリガーされる前にメモリ制限(memory_limit)に到達してしまうことがあります。

特に、Eloquentモデル同士が相互参照(循環参照)している場合、通常のスコープアウトではメモリが解放されません。

大量ループ内では、定期的に変数を明示的に破棄し、GCを手動で強制実行するのが安全です。

$counter = 0;

Order::chunkById(500, function ($orders) use (&$counter) {
    foreach ($orders as $order) {
        // 個別処理
        $order->process();
        $counter++;
    }

    // チャンク変数の参照を解除
    unset($orders);

    // 1,000件ごとにガベージコレクションを強制実行
    if ($counter % 1000 === 0) {
        gc_collect_cycles();
        
        $memoryMb = round(memory_get_usage(true) / 1024 / 1024, 2);
        Log::info("Memory usage at {$counter} records: {$memoryMb} MB");
    }
});

memory_get_usage(true) で実際のシステム割り当てメモリ量を監視ログに出力しておくことで、メモリリークが発生していないかを客観的に検証できます。


6. トランザクション・排他制御との実務組み合わせ

大量データのバッチ処理において、「データの不整合を防ぐためのトランザクション」と「他プロセスとの競合を防ぐ排他制御」をどのように組み合わせるかは、システム全体の安定稼働を左右する極めて重要な設計ポイントです。

chunk毎の小刻みなトランザクション(DB::transaction)分割による長時間ロック防止

初心者がやってしまいがちな最も危険な設計が、 「バッチ処理全体を1つの巨大なトランザクションで囲ってしまうこと」 です。

// 【極めて危険なアンチパターン】全体を1つのトランザクションで囲む
DB::transaction(function () {
    Order::where('status', 0)->chunkById(1000, function ($orders) {
        foreach ($orders as $order) {
            $order->update(['status' => 1]);
        }
    });
});

このコードを実行すると、以下の致命的な問題が発生します。

  1. データベースのUndoログ(ロールバックセグメント)の肥大化 : 数十万件の変更履歴が保持され続け、DBサーバーのディスクとメモリを急激に圧迫します。
  2. 行ロック・テーブルロックの長期占有 : バッチが処理している膨大な行が長時間ロックされ、Web画面側からの通常ユーザーの注文や決済リクエストがロック待ち(Lock wait timeout exceeded)で全滅します。
  3. エラー時の巻き戻しリスク : 99万件目まで正常に処理された後に1件のエラーが発生しただけで、数時間を要した処理がすべてロールバックされ、最初からやり直しになります。

正しい設計: チャンク単位の小刻みなトランザクション分割

大量データのバッチ処理では、 「チャンク(100〜1,000件程度)ごとにトランザクションを区切ってコミットする」 のが鉄則です。

use AppModelsOrder;
use IlluminateSupportFacadesDB;
use IlluminateSupportFacadesLog;

Order::where('status', 0)
    ->chunkById(500, function ($orders) {
        // 各チャンクごとに独立したトランザクションを実行
        DB::transaction(function () use ($orders) {
            foreach ($orders as $order) {
                // 関連テーブルへの複数書き込み
                $order->update(['status' => 1]);
                $order->history()->create(['action' => 'batch_processed']);
            }
        }, 3); // 第2引数でデッドロック発生時の自動リトライ回数を指定

        Log::info("Committed batch chunk up to ID: {$orders->last()->id}");
    });

このようにチャンク単位でコミットすることで、ロック保持時間が数百ミリ秒程度に抑えられ、Web側のトランザクションを邪魔しません。万が一エラーが発生しても、直前のチャンクまでは正常にDBにコミットされているため、リカバリも極めて容易です。

トランザクションのデッドロック自動リトライ機能(attempts 引数)や手動ロールバックの詳細なエラーハンドリングについては、Laravel DBトランザクション完全ガイド|デッドロック自動リトライ で詳しく解説しています。

排他ロック(lockForUpdate)と組み合わせた安全なバッチ処理

夜間バッチや非同期ジョブで「ユーザーのポイント付与」や「請求締め処理」を行う場合、バッチがデータを読み取って計算している最中に、ユーザー自身が画面側で操作を行って値が書き換わってしまう レースコンディション(競合状態) が発生します。

これを防止するためには、悲観的排他ロックである lockForUpdate() を適用します。

use AppModelsAccount;
use IlluminateSupportFacadesDB;

// 未請求のアカウントをシークしながら排他ロック
Account::where('billing_status', 'unbilled')
    ->chunkById(100, function ($accounts) {
        // チャンク内でトランザクションを開始
        DB::transaction(function () use ($accounts) {
            $accountIds = $accounts->pluck('id')->all();

            // lockForUpdate() で対象行を厳格にロックして再取得
            $lockedAccounts = Account::whereIntegerInRaw('id', $accountIds)
                ->lockForUpdate()
                ->get();

            foreach ($lockedAccounts as $account) {
                $account->calculateMonthlyFee();
                $account->billing_status = 'billed';
                $account->save();
            }
        });
    });

デッドロックを防ぐためのロック順序原則

排他ロックを併用する際は、 「必ず主キーの昇順(ASC)でロックを取得する」 ことがデッドロックを防ぐ絶対的な鉄則です。

chunkById() は常に ORDER BY id ASC で処理を進めるため、複数ワーカーが同時に並行処理を行ってもロック取得順序が統一され、デッドロックの発生確率を最小化できます。

排他ロック(lockForUpdate)と共有ロック(sharedLock)の使い分けや、デッドロックを回避するインデックス設計の詳細は、Laravel lockForUpdate()の使い方と排他ロック制御 で徹底解説しています。


7. まとめ・クエリビルダクラスター関連記事一覧

Laravelで大量データを安全かつ高速に処理するための最重要ポイントを振り返りましょう。

大量データ処理の黄金ルール

  1. 更新や削除を行うバッチ処理では、絶対に chunk() を使わない。必ず chunkById() を使う。
  • chunk() は OFFSET が進むことで未処理データが前に繰り上がり、レコードの約半分がスキップされる致命的バグを引き起こす。
  1. コレクションのパイプライン処理(map, filter)を行いたいなら LazyCollection(lazyById)を活用する。
  • PHPジェネレータ(yield)により、メモリ消費量を一定に保ちながら関数型の流れるようなコードが書ける。
  1. CSVエクスポートや純粋な読み取りストリーミングには cursor() が最速・最軽量。
  • SQL発行は1回のみ、PDO非バッファクエリで1レコードずつフェッチするためメモリ消費は最小。ただしN+1問題と追加クエリ実行には注意する。
  1. 大量処理の現場では DB::disableQueryLog() とクエリビルダ(DB::table)を賢く併用する。
  • クエリログの蓄積による隠れメモリリークを排除し、Eloquentのインスタンス生成オーバーヘッドを抑える。
  1. トランザクションは全体を囲まず、チャンク単位で小刻みにコミットする。
  • ロック保持時間を最小化し、デッドロックとUndoログ肥大化を防止する。

クエリビルダ・データ操作クラスタの関連記事

クエリビルダやデータベース操作の専門知識をさらに深めるために、以下の関連記事もぜひご活用ください。

🔒 Laravel lockForUpdate()の使い方と排他ロック制御

悲観的排他ロック(SELECT … FOR UPDATE)の基礎から在庫引き当ての二重更新防止コードまで詳しく解説。

🔄 Laravel DBトランザクション完全ガイド|デッドロック自動リトライ

DB::transactionの基本構文から第2引数(attempts)による自動再試行、手動トランザクションまで完全網羅。

⚡ Laravel whereInの使い方とwhereIntegerInRawによる大量データ最適化

大量ID検索時のSQLプレースホルダ制限対策と、数値ID高速バインディングの極意を解説。

🚫 Laravel whereNot/whereNotInの使い方と違い

直感的な否定条件の記述、whereNotInやwhereDoesntHaveとの使い分けを徹底比較。

✨ Laravel whenメソッドの使い方と条件分岐テクニック

if文のネストを排除し、クエリビルダチェーンの中でスマートに条件分岐を組み立てる実装テクニック。

📥 Laravelで大容量ファイルをStreamDownloadする方法

メモリを消費しないストリーミングレスポンスの実装法、S3連携、大容量ファイル出力のベストプラクティス。

レン (Wren)

こんにちは。レンです。

Laravelのコードの森に住んでいる、小さな案内役です。
ルーティングの枝やクラスの影を歩きながら、コードの流れや仕組みを眺めています。

このサイトでは、Laravelの基本から実装のコツまで、開発で役立つポイントを静かに整理しています。
難しいことを増やすのではなく、コードの見通しが少し良くなるヒントを届けるのが役目です。

「この処理はどこに書くのがいいのか」
「Laravelではどう考えると整理できるのか」

そんな疑問に、小さなメモを残すような気持ちで記事を書いています。

コードを書いている途中で迷ったとき、
このサイトが少し立ち止まって整理できる場所になればうれしいです。

レン (Wren)をフォローする

コメント