はじめに
今回は私が新卒研修で学んだN+1問題について学んだことをアウトプットしていきます。
この記事の対象者としては
- N+1問題について知らない方
- Laravel初心者の方
を想定しております。修正点やアドバイスなどございましたら、ご意見ください!
N+1問題とは
SQLのクエリを発行して情報を集めていく必要がありますが、その際、余分にたくさんのクエリを発行してしまい、少なからず表示速度等に悪影響を出してしまうことを指します。親レコードを1つ取得し、その後、各子レコードを個別に取得するため、合計でN+1回のクエリが実行されます。ここでNは子レコードの数を指します。
具体例
具体的な例を挙げてみましょう。仮に、あるECサイトの注文履歴を表示するというケースを考えます。この場合、以下の3つのテーブルが関与します
- orders テーブル:各注文に関する情報を保持
- items テーブル:販売商品の情報を保持
- order_items テーブル:各注文がどの商品を含んでいるかを記録(ordersとitemsの中間テーブル)
+-----------------+ +-------------+ +-------------+
| Orders | | Order_items | | Items |
|-----------------| |-------------| |-------------|
| id |-----| order_id | | id |
| | | item_id |-----| name |
| | | | | |
+-----------------+ +-------------+ +-------------+
まずは、N+1問題を含んだコードを書いていきます。
public static function test1()
{
$orders = Order::all();
Log::info('====test1====');
foreach ($orders as $order) {
$items = $order->orderItems()->select('item_id')->get();
foreach ($items as $item) {
$item = Item::select('name')->where('id', $item->item_id)->first();
Log::info($item->name);
}
}
}
このコードは、全ての注文とその注文に関連する全てのアイテムを取得し、各アイテムの名前をログに出力するものです。1つの注文とその注文に含まれる全てのアイテムを取得するために、注文の数に応じた多数のクエリが実行されます。
===test1===
総クエリ時間: 8.81秒 + 1.99秒 + 114.13秒 + 23.81秒 + 14.34秒 = 163.08秒
クエリ数: 26 (1のクエリ数 + 9のクエリ数 + 9のクエリ数 + 6のクエリ数 + 1のクエリ数 = 26)
===test1===
[2023-05-31 16:50:43] local.INFO: Query Time:14.34s] select * from `items` where 0 = 1
[2023-05-31 16:50:54] local.INFO: Query Time:8.81s] select * from `users` where `id` = ? limit 1
[2023-05-31 16:50:54] local.INFO: Query Time:1.99s] select * from `orders`
[2023-05-31 16:50:54] local.INFO: Query Time:3.91s] select `item_id` from `order_items` where `order_items`.`order_id` = ? and `order_items`.`order_id` is not null
[2023-05-31 16:50:54] local.INFO: Query Time:2.02s] select `name` from `items` where `id` = ? limit 1
[2023-05-31 16:50:54] local.INFO: Query Time:1.8s] select `item_id` from `order_items` where `order_items`.`order_id` = ? and `order_items`.`order_id` is not null
[2023-05-31 16:50:54] local.INFO: Query Time:1.49s] select `name` from `items` where `id` = ? limit 1
[2023-05-31 16:50:54] local.INFO: Query Time:1.2s] select `item_id` from `order_items` where `order_items`.`order_id` = ? and `order_items`.`order_id` is not null
[2023-05-31 16:50:54] local.INFO: Query Time:1.18s] select `name` from `items` where `id` = ? limit 1
(省略)
次にN+1問題をイーガーロードという手法でwithメソッドを使用し解消していきます。
public static function test2()
{
$orders = Order::with(['orderItems' => function ($query) {
$query->select('order_id', 'item_id');
}, 'orderItems.item' => function ($query) {
$query->select('id', 'name');
}])->get();
Log::info('====test2====');
foreach ($orders as $order) {
foreach ($order->orderItems as $item) {
Log::info($item->item->name);
}
}
}
上記のコードでは必要な全てのOrderItemとそれぞれのItemが一度のクエリで取得され、N+1問題が解消されます
===test2===
総クエリ時間: 11.23秒 + 3.03秒 + 2.84秒 + 1.36秒 = 18.46秒
クエリ数: 4
[2023-05-31 16:50:04] local.INFO: Query Time:11.23s] select * from `users` where `id` = ? limit 1
[2023-05-31 16:50:04] local.INFO: Query Time:3.03s] select * from `orders`
[2023-05-31 16:50:04] local.INFO: Query Time:2.84s] select `order_id`, `item_id` from `order_items` where `order_items`.`order_id` in (271, 272, 273, 276, 277, 278, 279, 280, 281)
[2023-05-31 16:50:04] local.INFO: Query Time:1.36s] select `id`, `name` from `item` where `items`.`id` in (4, 6, 7, 8, 9, 10, 33, 49, 97)
これによりクエリの数が大幅に削減され、パフォーマンスが向上します。
まとめ
今回はN+1問題について研修の中で学んだことをまとめました。
N+1問題はORMを使用する際に避けられない問題ですが、理解と適切な対策を行うことでパフォーマンスの低下を防ぐことが可能ですLaravelのEloquent ORMは開発を大いに容易にしますが、これがN+1問題の原因となることもあります。
参考記事