サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

Laravel Eloquentの高速化設計:N+1対策・大量データ処理・キャッシュ実践ガイド

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

Laravelアプリが遅いとき、Eloquentそのものが原因とは限りません。
同じリレーションを何度も取得している、全レコードを一度にモデル化している、失効条件を決めずにキャッシュしているといった設計上の問題を切り分ける必要があります。

この記事では、N+1問題、大量データ処理、キャッシュ、動的読み込みのアクセシビリティを、計測結果に基づいて改善する手順へ整理します。
コードを置き換える前に、どの症状へどの手法を使うのかを判断できる状態を目指します。

最適化の前に計測対象を固定する

性能改善では、変更前後を同じ条件で比べます。
対象の画面または処理、データ量、実行条件を固定し、クエリ数と実行時間、応答時間、ピークメモリを記録すると、改善した箇所と残った問題を区別できます。

  1. 遅さを再現できる画面、API、バッチ処理を一つ選ぶ
  2. 同じデータと操作で変更前の値を記録する
  3. クエリの重複、取得件数、メモリ使用量を確認する
  4. 一度に一つだけ変更して再計測する
  5. 正常系だけでなく、キャッシュ失効後と大量データ時も確認する
観測した症状 疑う箇所 最初の確認
似たSQLが行数に応じて増える リレーションの遅延読み込み with()で必要な関連を先に取得する
バッチ処理でメモリが増え続ける get()による一括取得 chunk()、lazy()、cursor()を比較する
同じ重い処理がリクエストごとに走る 再利用できる結果の未キャッシュ 許容できる古さと失効条件を先に決める
更新後も古い内容が表示される キャッシュキーまたは失効処理 作成、更新、削除の各経路を確認する

N+1問題をEager Loadingで防ぐ

N+1問題の仕組み

N+1問題とは、親レコードを取得する1回のクエリに加え、ループ内で各レコードのリレーションを読むたびに追加クエリが発生する状態です。
投稿が増えるほど作者情報を取るクエリも増えるため、データ量とともに処理時間が伸びやすくなります。

$posts = Post::latest()->get();

foreach ($posts as $post) {
    echo $post->author->name;
}

この例では、$post->authorへ初めてアクセスした時点で作者を遅延読み込みします。
一覧に作者名が必ず必要なら、取得前にリレーションを指定します。

Eager Loadingで必要な関連を先に取得する

$posts = Post::with('author')
    ->latest()
    ->get();

Eager Loadingは、親モデルを使う前に必要なリレーションもまとめて読み込む方法です。
一つのauthorリレーションなら、通常は投稿用と作者用のクエリに分かれ、投稿ごとの追加クエリを避けられます。

入れ子のリレーションが必要な場合は、with(['author', 'comments.user'])のように指定できます。
一方、表示しない関連まで無条件に読み込むと取得量が増えるため、画面で実際に使う関連と列に絞ります。

Lazy Loadingを使う場面

Lazy Loadingは、リレーションへアクセスした時点で取得する方法です。
単一の詳細画面で、条件によって一度だけ追加情報が必要になる場合には使えますが、一覧のループ内で使うとN+1問題を起こしやすくなります。

「後で読むからメモリが必ず減る」という意味ではありません。
結局すべてのリレーションへアクセスすれば取得量は減らず、クエリ数だけが増える場合があります。

開発環境で意図しない遅延読み込みを検出したい場合は、Laravelの遅延読み込み防止機能も利用できます。
リレーション設計全体は、関連記事のLaravelのEloquent設計とN+1対策で詳しく整理しています。

大量データでは処理方法を使い分ける

大量データをget()で取得すると、その時点で対象モデルをまとめてメモリへ展開します。
必要なのが全件の一括表示ではなく、集計、出力、更新などの逐次処理なら、処理単位を分けます。

chunkとchunkById

Post::query()->chunk(100, function ($posts) {
    foreach ($posts as $post) {
        // 読み取り中心の処理
    }
});

chunk()は、指定件数ごとに取得してコールバックへ渡します。
現在のまとまりだけを処理できるため、全件を一括取得する場合よりメモリ使用量を抑えやすくなります。

処理中に検索対象の行を更新する場合は、chunkById()を検討します。
IDを基準に次のまとまりを取得するため、更新によって検索結果の並びや対象範囲が変わる処理に向いています。

lazyとcursor

lazy()は内部で分割取得しながら、呼び出し側では連続したコレクションのように反復できます。
リレーションのEager Loadingも必要なら、with()と組み合わせたlazy()が候補になります。

foreach (Post::query()->cursor() as $post) {
    // 一件ずつモデル化して処理
}

cursor()は一つのクエリを実行し、反復時に一件ずつEloquentモデルを生成します。
ただし、リレーションをEager Loadingできないため、ループ内で関連を読む処理には向きません。

また、cursor()でもデータベースドライバの結果バッファが残る場合があります。
非常に大きなデータを扱うときは、lazy()との実測比較が必要です。

方法 取得のしかた 向く処理 注意点
chunk() 一定件数ごと 読み取り中心のバッチ 更新条件が変わる処理はchunkById()を検討
lazy() 分割取得を連続的に反復 関連も使う長い逐次処理 処理中の更新ではlazyById()も検討
cursor() 一つのクエリから一件ずつモデル化 関連を使わない読み取り処理 Eager Loading不可、結果バッファにも注意

キャッシュは失効条件から設計する

キャッシュは、取得や計算に時間がかかる結果を保存し、次回以降に再利用する仕組みです。
保存すれば終わりではなく、どの時点まで古い値を許容するか、どの更新で削除するかを決めて初めて運用できます。

Cache::rememberで取得と保存をまとめる

use Illuminate\Support\Facades\Cache;

$posts = Cache::remember(
    'posts.latest.v1',
    now()->addMinutes(10),
    function () {
        return Post::with('author')
            ->latest()
            ->take(20)
            ->get();
    }
);

remember()は、キーが存在すれば保存済みの値を返し、存在しなければクロージャを実行して結果を保存します。
第2引数は省略する「既定の60分」ではなく、保存期間または有効期限を明示する引数です。

例では単位の読み違いを避けるため、10分後の日時を渡しています。
数値を渡す場合の単位や利用できる型はLaravelのバージョンで確認し、プロジェクト内で表記方法を統一します。

キーと削除契機を対にする

キャッシュキーには、対象と条件が判別できる名前を付けます。
言語、公開範囲、ページ番号などで結果が変わる場合、それらを同じキーへ保存すると別条件の結果を返す危険があります。

Cache::forget('posts.latest.v1');

更新処理とforget()を対応させれば、期限切れを待たずに古い一覧を削除できます。
アプリケーション全体のCache::flush()は共有ストアの別データまで消す可能性があるため、対象キーまたはタグ単位の削除を優先します。

キャッシュタグとドライバの制約

$posts = Cache::tags(['posts'])->remember(
    'latest.v1',
    now()->addMinutes(10),
    fn () => Post::with('author')->latest()->take(20)->get()
);

Cache::tags(['posts'])->flush();

キャッシュタグは、関連する複数キーを同じ分類で削除するときに使えます。
ただし、Laravelではfile、database、dynamodb、storageの各ドライバがタグに対応していないため、採用前に利用中のストアを確認します。

ドライバ 向く場面 設計上の注意
file 単一サーバーで小さく始める構成 ディスクI/Oとサーバー間共有を確認し、タグは使わない
redis 複数プロセスや複数サーバーで共有する構成 Redisの監視、容量、障害時の運用が必要
array 自動テスト 永続化されないため本番の共有キャッシュには使わない

キャッシュAPIと対応ドライバの現行仕様は、実装前にLaravelのキャッシュ公式ドキュメントで確認できます。

動的読み込みでは状態通知とフォーカスを分ける

無限スクロールや「さらに読み込む」操作では、画面を見ている利用者だけでなく、スクリーンリーダーの利用者にも処理中であることを伝えます。
aria-busyは対象領域が更新中であることを示し、role=”status”は進行状況などの通知を穏やかに読み上げるための役割です。

<p id="post-list-status" role="status">
  投稿を読み込んでいます。
</p>

<section
  id="post-list"
  aria-busy="true"
  aria-describedby="post-list-status"
>
  <div class="skeleton-card" aria-hidden="true"></div>
</section>

読み込みが完了したら、対象領域のaria-busyをfalseへ変更し、statusの文言も「投稿を読み込みました」のように更新します。
見た目だけのスケルトンはaria-hidden="true"として、同じ情報を重複して読み上げないようにします。

role="status"の要素へフォーカスを移す必要はありません。
読み込み中の要素へ一律にtabindex="-1"を付けるのではなく、「さらに読み込む」ボタンを押した後もボタン上のフォーカスを保ち、新しい項目を自然なDOM順へ追加します。

画面の置き換えによって操作の文脈が変わる場合だけ、移動先を明確にしたうえでフォーカスを管理します。
実装時の判断は、関連記事のキーボード操作とフォーカス表示の整え方も参考になります。

改善を一つの流れにする

  1. 同じ操作とデータで基準値を測る
  2. 重複クエリを見つけたら、必要なリレーションだけをEager Loadingする
  3. 一括取得でメモリが増えるなら、処理内容に合わせてchunk()、lazy()、cursor()を選ぶ
  4. 同じ結果を繰り返し計算している場合だけ、許容できる古さと失効条件を決めてキャッシュする
  5. 変更後に再計測し、キーボード操作と読み上げ時の状態通知も確認する

Eloquentの高速化は、メソッドを一つ置き換える作業ではありません。
SQLの発行回数、モデル化する件数、キャッシュの鮮度、動的表示の伝わり方を別々に計測し、遅さの原因へ対応する手法を選びます。

この記事に関連する株式会社greedenの取り組み

Eloquentの最適化は、SQL、メモリ、キャッシュ更新を運用まで含めて設計すると続けやすくなります。株式会社greedenは、Webシステム開発を要件定義から保守運用まで支援し、計測に基づく改善の仕組みづくりに伴走します。
モバイルバージョンを終了