LaravelでN+1を回避!with()を使う or 使わない?
こんにちは。レバテック開発部のきょうかです。
レバテックの社内業務用システムの保守開発を担当しています。
今回はLaravelのwith()について少し調べたのでそれについて書いていきます。
背景
とある改修を進める中で、データの取得方法について悩みました。
Laravelのwith()で取得するか、別でクエリを発行して取得をするか...🤔
どちらが適切か判断するため、実際に動作を確認しながら進めました。
今回はその記録をまとめます。
実装内容を見てみる
実装したいのは、「HogeのIDを複数取得し、それらを元に文章を作成する」という処理です。
文章の作成には、Hogeに紐づく複数のリレーションデータが必要でした。
今回追加で必要なリレーションのrelation11.latest_hoge_eventは、retrieved_atが最新のHogeEventを取得するリレーションです。
検討した実装方法のコードを見ていきます。
方法1:with()にリレーションを追加する
public function getHogeText(Request $request)
{
    // リクエストからIDを取得
    $input_all = $request->all();
    $hoge_ids = $input_all['ids'];
    // リレーションを指定してHogeのデータを取得(既存コード)
    $hoges =  Hoge::with([
        'relation1.relation2.relation3.relation4',
        'relation5.relation6.relation7.relation8',
        'relation9',
        'relation10',
+        'relation11.latest_hoge_event', // ここに追加!
    ])->whereIn('id', $hoge_ids)->get();
    // 以降の処理が続く....
}
// Relation11に設定されているリレーション
public function latest_hoge_event()
{
    return $this->hasOne(HogeEvent::class)->orderBy('retrieved_at', 'DESC');
}
方法2:with()に含めず別でイベント取得クエリを発行する
public function getHogeText(Request $request)
{
    // リクエストからIDを取得
    $input_all = $request->all();
    $hoge_ids = $input_all['ids'];
    // リレーションを指定してHogeのデータを取得(既存コード)
    $hoges =  Hoge::with([
        'relation1.relation2.relation3.relation4',
        'relation5.relation6.relation7.relation8',
        'relation9',
        'relation10',
    ])->whereIn('id', $hoge_ids)->get();
+    // ここで追加でクエリ発行!
+    // Hogeに紐づくHogeEventを取得
+    $hoge_events =  HogeEvent::select('hoge_events.*', 'hoge.id as hoge_id')
+        ->join('relation11', 'relation11.id', '=', 'hoge_events.relation11_id')
+        ->join('hoges', 'hoges.id', '=', 'relation11.hoge_id')
+        ->whereIn('hoges.id', $hoge_ids)
+        ->whereRaw('hoge_events.retrieved_at = (SELECT MAX(retrieved_at) FROM hoge_events h WHERE hoge_events.relation11_id = h.relation11_id)')
+        ->get();
    // 以降の処理が続く....
}
処理を見てみる
with()の裏側
with()はリレーションをEagerロードし、N+1 問題を回避する仕組みです。
リレーションごとにクエリが発行されます。
例えばHoge::with(['relation1.relation2.relation3'])を実行すると、以下のようなクエリが発行されます。
SELECT * FROM `relation1` WHERE `relation1`.`id` IN (?, ?, ...);
SELECT * FROM `relation2` WHERE `relation2`.`id` IN (?, ?, ...);
SELECT * FROM `relation3` WHERE `relation3`.`id` IN (?, ?, ...);
今回の例のrelation11.latest_hoge_eventの場合、with()実行時は以下のクエリが発行されます
`hoge_events` where `hoge_events`.`relation11.id` in (?, ?, ...) order by `retrieved_at` desc;
hogesに紐づくhoge_eventsをすべて取得し、メモリ上で最新のデータを選択していると考えられます。
各方法の評価
| 項目 | with() を使う | 別クエリを発行 | 
|---|---|---|
| 変更容易性 | ○ リレーション追加だけで済む | × クエリを手書きする必要がある | 
| 可読性 | ○ Laravel に慣れている人にはわかりやすい | △ クエリが複雑になりやすい | 
| パフォーマンス | △ 不要なデータも取得するためメモリ消費が増える | ○ 必要なデータのみ取得できる | 
結論
今回は処理対象データが最大200件ほどだっため、クエリによるパフォーマンス影響はそこまで大きくなさそうでした。
しかし既存コードのwith()で取得しているリレーションが多かったため、別途クエリを発行する形で実装しました。
Laravelに慣れている人だと、with()の方がリレーションがそのまま使えるため分かりやすいし使いやすそうです。
しかしwith()は便利な反面、取得データが増えすぎるとメモリ枯渇のリスクがある ため、慎重に使う必要があります。
処理対象データ量や取得するリレーション数が多い場合は、多少の可読性を犠牲にしてでもクエリ最適化を優先する必要があると感じました🤔
感想
with()ってjoinして1回のクエリでデータ取得するのだと思ってました。
便利なものって大体落とし穴があるよね〜。
参考
補足
公式ドキュメントのEagerロードの制約の箇所に記載がある通り、with()で取得する場合も工夫次第でメモリ節約できるみたいでした🙆♀️
Discussion