Laravel・PHP開発

Laravelのcursorメソッドによるメモリ削減:10万件のデータ処理で検証した挙動と実装

本番データベースをまるごと読み出してCSVに書き出すバッチが、深夜に突然止まる。ログに残るのは、PHPの Allowed memory size exhausted。処理対象が10万件程度だからと安心していても、Eloquentの get() が全レコードをモデル化してから返す実装である以上、メモリ上限はあっさり踏み抜く。…

Laravelのcursorメソッドによるメモリ削減:10万件のデータ処理で検証した挙動と実装

レコードの件数だけを見てはいけない。カラム数、文字列の長さ、Eloquentモデルが持つ属性や内部情報、リレーション、ログの蓄積まで含めると、データベース上のサイズとPHPプロセスが消費するメモリは別物になる。

この問題に対する定番の答えが cursor() だ。ジェネレータを使って結果を遅延評価し、ループのたびに1件ずつモデルを取り出す。全件を Collection に載せないため、理屈の上では get() より大幅に軽くなる。

ただし、cursor() に置き換えれば必ずメモリが減るわけではない。PDOのバッファ設定、リレーションの扱い、ループの書き方によっては、軽くなったはずの処理が別の場所で膨らむ。ここでは10万件の処理を例に、get()cursor()chunk() の違いと、実運用で踏みやすい落とし穴を整理する。

get()とcursor()のメモリ消費構造:なぜ全件取得は危険なのか

まず、get() が何をしているかを単純化して考える。

User::query()->get() を実行すると、データベースにSQLを送り、返ってきた結果をEloquentがモデルへ変換する。そのモデルは配列として Collection に格納され、呼び出し側へ返される。つまり、get() が戻ってきた時点で、対象レコードは基本的に全件メモリ上に存在している。

その後で次のような処理をしても、メモリの節約にはならない。

User::query()->get()->each(fn ($user) => $this->export($user));

each() は1件ずつコールバックを呼ぶが、get() の時点ですでに全件取得が終わっているからだ。「ループは1件ずつだから大丈夫」と考えやすいところだが、問題はループの中ではなく、その手前にある。

Eloquentモデルはデータベースの1行より重い

データベースの1行が数百バイトだからといって、PHP上のモデルも同じサイズになるわけではない。Eloquentモデルには、取得した属性だけでなく、変更前の属性を保持する領域、キャスト情報、リレーション、イベント処理に関わる状態などが存在する。

もちろん、実際の使用量はLaravelのバージョン、PHPのバージョン、属性の内容、モデルの実装によって変わる。ここで一律に「1件あたり何バイト」と断定するのは危険だ。ただ、10万件をEloquentモデルとして一度に展開すれば、アプリケーションの処理内容によってはメモリ上限に近づく、という見積もりは持っておいた方がいい。

特に危険なのは、取得したコレクションをさらに加工するケースだ。

  • map() で別の配列を作る
  • groupBy() で分類する
  • toArray()json_encode() で変換する
  • 書き出し用の配列を別に保持する
  • デバッグ用に取得結果をログへ渡す
  • 取得したモデルからリレーションを追加で読み込む

このような処理では、元の Collection だけでなく、加工後の配列や関連データも同時に残る。get() 自体がぎりぎり動いていたとしても、後段の処理でメモリが跳ねることは珍しくない。

cursor()は「取得」と「消費」を分ける

一方、cursor() は結果をすべて配列にしてから返すのではなく、必要になったタイミングで次のレコードを取得する。

User::query()->cursor() の戻り値は LazyCollection であり、その内部ではジェネレータが使われる。foreach が次の値を要求したときに、データベースの結果から次の行を取り出し、Eloquentモデルへ変換する。呼び出し側がまだ次の値を要求していなければ、後続のレコードはPHP側のモデルにならない。

この違いを表にすると、次のようになる。

観点get()cursor()
戻り値CollectionLazyCollection
モデル化のタイミング呼び出し時に全件ループで消費した時点
PHP側に保持される件数基本的に全件現在処理している件数が中心
1本のSQLで読むか1本1本
with() によるEager Loading利用できるそのままでは利用しにくい
複数回のループ同じコレクションを再利用できる再実行や再クエリが必要
途中で配列化した場合すでに大きい遅延の利点が失われる

cursor() の利点は、単に「戻り値が軽い」ことではない。結果を一度に保持せず、取得したデータをその場で処理して捨てられることにある。

cursor()のメモリ削減は、メソッド名だけで成立するものではない。取得したレコードを保持せず、その場で消費し続けるコードと組み合わせて初めて効く。

たとえばCSVへ順番に書き出す処理なら、cursor() との相性は良い。1件を読み込み、必要な変換を行い、ファイルへ書き出し、次へ進む。逆に、全件を別の配列へ詰め直すなら、入口を cursor() に変えただけで、最終的なメモリ使用量は元に戻る。

10万件のデータ処理で見るパフォーマンス比較:メモリと実行時間の検証結果

10万件規模の users テーブルを処理した公開検証の一例では、次のような差が示されている。

メソッドメモリ使用量処理時間
get()約132MB約0.8秒
cursor()約45MB約0.5秒
chunk(100)約10MB約19.9秒
chunk(1000)約12MB約2.3秒

これは特定のLaravel、PHP、データベース、テーブル構造、実行環境における一例であり、どの環境でも同じ数字になるわけではない。メモリ使用量は取得するカラムの幅やモデルの実装で変わり、実行時間はインデックス、ディスク、データベースとの距離、処理内容で変わる。

それでも、各メソッドの性格を比較する材料にはなる。

get() は処理時間が短く見える一方、全件をメモリへ載せる。対象件数が増えたり、モデルにリレーションを追加したりすると、メモリ上限が先に問題になる。

cursor() は1本のクエリを長く読みながら、PHP側では結果を逐次消費する。ループ内の処理が単純なら、メモリと処理時間のバランスが良い。ただし、データベースドライバが結果セットを内部でバッファしている場合は、この見え方が変わる。

chunk() は一定件数ごとにクエリを発行する。1回あたりのメモリ使用量を抑えやすい反面、分割数が増えるほどクエリ回数が増える。chunk(100) なら10万件を処理するために、単純計算で多くのクエリが必要になる。1回の処理は軽くても、ネットワーク通信、SQLの解析、結果の待ち時間が積み重なる。

メモリだけで勝者を決めると判断を誤る

大量データ処理では、メモリ使用量の最小値だけを見てしまいがちだ。しかし、バッチの実行時間が長くなると、別の問題が発生する。

  • データベース接続を長時間占有する
  • トランザクションを長く保持してしまう
  • Queueワーカーのタイムアウトにかかる
  • 同じテーブルへの更新と競合する
  • デプロイやメンテナンスの時間帯に処理が残る
  • 途中失敗時の再実行範囲が大きくなる

chunk() がメモリ面で優れていても、クエリを大量に発行することで実行時間が伸びるなら、運用上は扱いにくくなる。逆に cursor() はメモリが少なくても、1本の結果セットを長時間保持する設計になるため、データベース側の制約を確認しなければならない。

処理時間を測る場合は、単純に microtime(true) で全体を囲むだけでなく、少なくとも次を分けて見ると原因を追いやすい。

1. クエリの発行から最初の行を受け取るまで

2. 1件あたりの変換や書き出しにかかる時間

3. 10万件を最後まで読み終える時間

4. ファイル出力や外部API呼び出しなど、DB以外の待ち時間

5. ループ終了後にメモリが解放されるまでの状態

メモリは memory_get_usage()memory_get_peak_usage() で確認できる。ただし、PHPから見えるメモリと、PDOやデータベースサーバが使うメモリは別に測る必要がある。PHPの値だけを見て「安全」と判断するのは早い。

PDOバッファクエリの落とし穴:cursor()を使ってもメモリが減らない理由

cursor() を導入したのに、メモリ使用量が期待ほど下がらない。これはLaravelの遅延コレクションが壊れているのではなく、データベースドライバ側のバッファリングが原因かもしれない。

アプリケーション側では fetch() を1件ずつ呼び出していても、PDOのドライバがSQL実行時に結果セットをまとめて保持していれば、データベースからPHPプロセスまでのどこかに全件分のデータが存在することになる。

MySQL向けのPDO接続では、バッファクエリが使われる構成が一般的だ。バッファクエリでは、結果をドライバ側で受け取ってから、アプリケーションが順番に読み出す。アプリケーションコードの見た目はストリーミングでも、ドライバ側では全件を保持している可能性がある。

このとき、cursor() に期待できるのは「Eloquentの全モデルをPHP配列へ積まない」効果であって、結果セット全体がどこにも保持されないことではない。

非バッファクエリには別の制約がある

MySQLで非バッファクエリを使う場合は、接続設定の optionsPDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false を渡す方法がある。設定の記述自体は難しくないが、これを有効にすればすべて解決するわけではない。

非バッファで結果を読み出している途中は、同じ接続で別のクエリを実行しにくくなる。結果セットを最後まで消費する前に、ループ内から同じ接続へ別のSQLを投げると、未読の結果が残っているためエラーになることがある。

ここで問題になりやすいのが、ループ内でのリレーション参照だ。

foreach (User::cursor() as $user) { $user->profile; }

このようなコードでは、profile が未ロードなら、各ユーザーの処理中に追加のSELECTが発行される。バッファ設定と接続の状態によっては、同じ接続上で結果を読みながら別クエリを投げる構造になってしまう。

したがって、非バッファクエリを使う場合は、次のような設計を先に確認する必要がある。

  • ループ内で同じDB接続へクエリを発行しないか
  • リレーションの遅延ロードが発生しないか
  • モデルイベントやアクセサが内部でクエリを発行しないか
  • 1つの処理が終わる前に別の接続を必要としないか
  • QueueやCLIで長時間接続を保持して問題がないか

MySQL、PostgreSQL、SQLiteではドライバと結果セットの扱いが同じではない。開発環境がSQLite、本番がMySQLという構成なら、開発環境で cursor() が問題なく動いたことは、本番のメモリ挙動や同時クエリの安全性を保証しない。

cursor()で減るのは、まずPHP側のモデル保持量だ。PDOの結果セットやデータベース側のバッファまで自動的に消えるわけではない。

何を測るべきか

メモリ削減を確認するなら、実行環境をそろえた上で、少なくとも get()cursor() の両方を比較する。取得カラム、WHERE条件、モデルのキャスト、ループ内の処理を変えたまま比較すると、メソッドの差なのか処理内容の差なのか分からなくなる。

また、次のようなコードは cursor() の遅延性を壊しやすい。

  • cursor()->all() で全件を配列化する
  • cursor()->collect() で通常のコレクションへ変換する
  • iterator_to_array() で全要素を保持する
  • ループの外側で結果を蓄積する
  • sort()groupBy() のために全件をメモリへ集める

データを並べ替えたり、全件を突き合わせたりする処理は、そもそもストリーミングと相性が悪い。必要な処理をSQL側へ寄せる、作業単位を分割する、一時テーブルを使うなど、別の設計を検討した方がよい場合もある。

LazyCollectionの制約とEager Loading:リレーションを扱う際の設計判断

cursor()LazyCollection を使うと、取得処理は軽くなる。しかし、Eloquentを使う大きな理由の一つであるリレーションの扱いには制約が出る。

通常のEloquentでは、User::with('posts')->get() のように書けば、親モデルを取得した後、関連する子モデルをまとめて読み込める。これがEager Loadingだ。親を1件ずつ処理しながら、その都度子を取りに行くN+1問題を避けるための基本的な方法である。

一方、cursor() は親モデルを全件そろえる前提ではない。親を1件ずつ流しながら、全親のIDを集めて関連データをまとめて取得する、という with() の仕組みと噛み合わない。そのため、一般的な with() をそのまま cursor() に組み合わせることはできない。

リレーションを使うときの三つの選択肢

リレーションが必要な場合は、次のどれを優先するかを決めることになる。

1. 親のカーソル処理と、関連データの取得を分ける

2. 一定件数ごとに親と子をまとめて取得する

3. リレーションを使わず、必要な値をSQLのJOINやサブクエリで取得する

1つ目は、関連するIDだけを先に扱い、別のクエリで必要なデータをまとめて取得する方法だ。たとえばユーザーのIDを一定数だけ集め、そのIDを使って whereIn でプロフィールを取得する。全件分の関連データは保持しないため、設計次第ではメモリを抑えられる。

2つ目は chunk()lazy() を使う方法だ。一定数の親を取得し、その範囲でEager Loadingを行う。親と子の関係を保ったまま処理しやすく、コードの読みやすさも維持しやすい。1件ずつのクエリにはならないため、ループ内での遅延ロードより安全である。

3つ目は、そもそもEloquentモデルとして関連先を持ってくる必要があるのかを見直す方法だ。CSV出力で必要なのがユーザー名とプロフィールの一部だけなら、JOINして必要なカラムだけ取得する方が合理的なことがある。モデルの便利さを使う必要がない処理で、複数のモデルとリレーションを組み立て続けると、メモリもクエリも増える。

cursor()に変えた後のN+1は見えにくい

get()with() の組み合わせから、単純に cursor() へ置き換えると、これまで抑えられていたEager Loadingが外れてしまうことがある。

親を1件取得するたびに $user->company$order->items に触れているなら、ループの裏側で追加クエリが発生していないか確認したい。開発環境で件数が少ないと気づきにくいが、大量処理ではクエリ回数がそのまま実行時間になる。

クエリログを有効にして確認する方法もあるが、ログ自体が大量にメモリへ蓄積される設定には注意が必要だ。長時間のバッチでクエリログをため続けると、cursor() 以前の場所でメモリを使い切る。観測のための設定が、測定対象を変えてしまう典型的な例である。

chunk()との使い分け:メモリ効率と実行速度のトレードオフ

大量データ処理で使われる代表的な方法を整理すると、次のようになる。

  • get():全件を取得し、通常の Collection として扱う
  • cursor():1本のクエリを読みながら、モデルを逐次処理する
  • chunk():指定件数ごとにクエリを分けて処理する
  • lazy():チャンク単位の取得を、遅延コレクションとして扱う
  • chunkById():IDなどのキーを基準に、範囲を進めながら処理する

get() はコードが最も単純だ。全件を比較したい、何度も同じ結果を参照したい、データ量が十分に小さいという条件なら、無理に遅延処理へ変える必要はない。

cursor() は、読み取ったレコードをその場で処理できる場合に向いている。ファイルへの逐次出力、単純な変換、外部システムへの送信など、前のレコードを保持しなくても進められる処理が典型だ。

chunk() は、各バッチでリレーションをEager Loadしたい場合に使いやすい。1回のクエリで扱う件数を制限しながら、チャンク内では通常のEloquentコレクションとして操作できる。

ただし、chunk() のページング方法には注意が要る。一般的な chunk() はページを進めるために OFFSETLIMIT を使う。先頭付近では問題なくても、オフセットが深くなると、データベースが多くの行を読み飛ばしてから必要な行を返すことがある。データ量やインデックスによっては、後半ほど処理が重くなる。

更新しながら読むならchunk()にも注意する

chunk() で取得した行をループ内で更新する場合、条件や並び順によっては行を飛ばしたり、同じ行を別のチャンクで扱ったりすることがある。処理対象のフラグを更新してWHERE条件から外すような実装では、OFFSET方式と相性が悪い。

この用途では、主キーなどの安定したキーを基準に進める chunkById() の方が安全になりやすい。処理済みのキーを記録しながら、前回の最後のIDより大きい行を取得する方式なら、途中で対象行の状態が変わってもページ位置がずれにくい。

ただし、キーが連続している必要はない。重要なのは、比較に使うカラムが安定していて、適切なインデックスがあり、処理中にその並びの前提を壊さないことだ。主キー以外を使う場合は、同値が存在するときの順序や、NULLの扱いまで確認したい。

選択を決めるときの実務的な見方

状況向いている方法理由
少量のデータを複数回使うget()実装が単純で再利用しやすい
取得した行を1件ずつ書き出すcursor()PHP側に全件を保持しない
チャンクごとにリレーションを使うchunk() または lazy()範囲内でEager Loadingしやすい
処理中に対象行を更新するchunkById()OFFSETによる位置ずれを避けやすい
全件を集計・並べ替えするSQL側の集計や別設計PHPへ全件を集める必要を減らせる

ここで「最もメモリが少ない方法」を探し始めると、判断を誤りやすい。必要なのは、処理の性質に合う方法だ。

リレーションがほぼ不要で、読み取ったデータをその場で捨てられるなら cursor()。関連データをまとまりとして扱いたいなら chunk()lazy()。行の更新を伴うなら chunkById()。データ量が小さく、可読性を優先できるなら get()。このくらいの切り分けから始めると、過剰な最適化になりにくい。

実装時に確認したいメモリの増え方

cursor() を導入した後もメモリが増え続ける場合は、メソッドそのものよりループ内の処理を疑う。

たとえば、次のような処理は逐次処理に見えて、実際にはデータを保持している。

  • 処理済みのIDを無制限に配列へ追加している
  • 生成したCSVの行を文字列へ連結し続けている
  • 例外や警告の内容を配列へ保存している
  • 送信前のデータをまとめてキューへ積んでいる
  • モデルを別オブジェクトのプロパティへ参照し続けている
  • 画像やファイルの内容を閉じずに扱っている

PHPのガベージコレクションが働いても、参照が残っていればオブジェクトは解放されない。ループの最後で変数を null にすれば必ず解決するわけではないが、長寿命の配列やクロージャがモデルを捕まえていないかを見るきっかけにはなる。

また、LaravelのQueueワーカーはリクエスト終了時にプロセスが破棄される通常のWebリクエストとは違い、同じPHPプロセスが複数のジョブを処理する。1件の処理で大きく増えたメモリが、次のジョブで完全に元へ戻るとは限らない。cursor() を使ったジョブでも、ジョブ単位のメモリ上限、タイムアウト、失敗時の再試行を含めて設計する必要がある。

外部APIへ1件ずつ送信する処理なら、DBのカーソルを開いたままネットワーク待ちを続ける構成も考えものだ。1行の処理に時間がかかるなら、短いチャンクでIDを取り出してジョブへ分割する方が、DB接続を長時間占有せずに済む場合がある。メモリ削減だけを目的に cursor() を選ぶと、別のボトルネックを作ることがある。

落とし所:コードだけでなく、接続と運用まで見る

get() から cursor() への変更は、メモリ問題を解く有力な一手だ。ただし、それはPHP側で全件のモデルを保持しないための変更に過ぎない。

実際に確認すべき範囲はもう少し広い。

1. get()toArray() で全件を再び配列化していないか確認する

2. PDOのバッファクエリ設定と、本番ドライバの挙動を確認する

3. ループ内でリレーションの遅延ロードが起きていないか確認する

4. 同じ接続で別クエリを発行する構造になっていないか確認する

5. chunk() のOFFSETが深くなったときの実行計画を見る

6. 更新を伴う処理では chunkById() を含めて比較する

7. Queueワーカーのメモリ、タイムアウト、再試行を別々に設定する

8. PHPだけでなく、データベース接続数と実行時間も監視する

10万件程度の一度きりのスクリプトなら、取得するカラムを絞った get() で十分なこともある。コードを複雑にしてまで遅延処理へ変える必要はない。

日次バッチで10万件から数百万件を処理するなら、cursor()lazy()chunk()chunkById() を、リレーションと更新の有無で選ぶ。特に読み取り専用で単純な変換を行う処理は cursor() が候補になるが、PDOの設定を確認せずに本番へ出すのは避けたい。

処理量がさらに増え、1回のジョブが長時間になるなら、最初からジョブ分割や専用ワーカーを考える。1本の長いカーソルで最後まで走り切る設計は、途中失敗時の再開が難しい。一定のID範囲やチャンク単位で処理できれば、失敗した範囲だけをやり直せる。

cursor() は便利だが、魔法の蓋ではない。PHPのジェネレータ、Eloquentのハイドレーション、PDOの結果セット、データベースの実行計画、Queueワーカーの寿命。そのどこでメモリを使っているのかを切り分けて初めて、数字は意味を持つ。

10万件の処理で get() が落ちたなら、まず cursor() を試す価値はある。ただし、変えるのはメソッド名だけではない。処理中にデータを保持しないこと、リレーションを無邪気に呼ばないこと、ドライバのバッファ設定を見ること。この三つまで確認して、ようやく「Laravel cursor メモリ 削減」と呼べる状態になる。

Related reading: Eloquentとクエリビルダの境界線:個人開発での速度と保守性の両立 and LaravelのN+1問題を確実に防ぐ:Lazy Loading制限の導入と本番運用の設計.

よくある質問

Laravelのget()とcursor()はメモリ使用量にどう違いがありますか?
get()は取得時点で対象レコードを基本的に全件Eloquentモデル化し、Collectionに保持します。cursor()はLazyCollectionとジェネレータを使い、ループで必要になったレコードを順次処理するため、PHP側の保持量を抑えやすくなります。
10万件の処理ではget()とcursor()のどちらが速いですか?
公開検証の一例では、get()は約0.8秒、cursor()は約0.5秒でした。ただし、実行時間はLaravelやPHP、データベース、インデックス、実行環境、処理内容によって変わります。
cursor()を使ってもメモリが減らないのはなぜですか?
PDOのドライバが結果セットをバッファリングしていると、アプリケーションが1件ずつ読み出していても、ドライバ側で全件分のデータを保持する可能性があります。cursor()で抑えられるのは、主にPHP側で全モデルを配列へ積むことです。
cursor()でリレーションをEager Loadingできますか?
一般的なwith()をcursor()にそのまま組み合わせることはできません。リレーションが必要な場合は、一定件数ごとにchunk()やlazy()で取得するか、JOINやサブクエリで必要な値を取得する方法があります。
処理中にデータを更新する場合はchunk()とchunkById()のどちらが向いていますか?
処理中に対象行を更新する場合は、OFFSET方式のchunk()よりも、安定したキーを基準に範囲を進めるchunkById()の方が安全になりやすいです。chunk()では条件や並び順によって、行を飛ばしたり同じ行を別のチャンクで扱ったりする可能性があります。

参考情報