下記のようなコードがあると仮定します。
$tweet = Tweet::findOrFail($tweet_id);
foreach ($tweet->replies as $reply) {
echo $reply->user->name, "\n";
}
モデルの実装例
class User extends Model {
protected $fillable = ['name'];
function tweets() {
return $this->hasMany(Tweet::class);
}
}
class Tweet extends Model {
protected $fillable = ['content', 'user_id', 'parent_id'];
function user() {
return $this->belongsTo(User::class);
}
function replies() {
return $this->hasMany(Tweet::class, 'parent_id');
}
function parent() {
return $this->belongsTo(Tweet::class, 'parent_id');
}
}
上記のコード例で発行される SQL は下記のようになります。
SELECT * FROM tweets WHERE id IN (?) LIMIT 1; -- Tweet::findOrFail($tweet_id)
SELECT * FROM tweets WHERE parent_id IN (?); -- $tweet->replies
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
おおっと!
replies が 6 件だったので良かったですが、これが 1000 件だったら?
N + 1 問題
replies を全件取得したあと( SQL のクエリ1回 )
SELECT * FROM tweets WHERE parent_id IN (?); -- $tweet->replies
foreach
の中で関連する user の取得が replies の数だけ発生する…( SQL のクエリ N 回 )。
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
SELECT * FROM users WHERE id IN (?); -- $reply->user
このようにクエリがたくさん発行される問題を N + 1 問題 と呼びます。
Eager ローディングを使う
ここで with
を使ってみます。
$tweet = Tweet::with('replies.user')->findOrFail($tweet_id);
foreach ($tweet->replies as $reply) {
echo $reply->user->name, "\n";
}
SELECT * FROM tweets WHERE id IN (?) LIMIT 1; -- findOrFail($tweet_id)
SELECT * FROM tweets WHERE parent_id IN (?); -- with('replies
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?, ?); -- with('replies.user')
with
を使うことでクエリ数が減りました。
with
を使うべきでない場合
すべての場合において with
を使うべきでしょうか?
$tweet = Tweet::with('user', 'replies.user')->findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
echo $tweet->user->name, "\n";
foreach ($tweet->replies as $reply) {
echo $reply->user->name, "\n";
}
} else {
echo "too many replies!\n";
}
SELECT * FROM tweets WHERE id IN (?) LIMIT 1; -- findOrFail($tweet_id)
SELECT * FROM users WHERE id IN (?); -- with('user')
SELECT * FROM tweets WHERE parent_id IN (?); -- with('replies
-- replies が 6 件だった場合…
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?, ?); -- with('replies.user')
さて今回の例において replies が 6 件だった場合
下記は false
になるので…
if ($tweet->replies->count() <= 5) {
…下記の2つのクエリが 無駄に実行 されています。
SELECT * FROM users WHERE id IN (?); -- with('user')
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?, ?); -- with('replies.user')
Lazy ローディング / Lazy Eager ローディングを使う
これを修正すると下記のようになります。
$tweet = Tweet::findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
echo $tweet->user->name, "\n"; // Lazy ローディング
$tweet->loadMissing('replies.user'); // Lazy Eager ローディング
foreach ($tweet->replies as $reply) {
echo $reply->user->name, "\n";
}
} else {
echo "too many replies!\n";
}
replies が 6 件だった場合のクエリは下記のようになり…
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM tweets WHERE parent_id = ?;
replies が 5 件だった場合のクエリは下記のようになります。
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM tweets WHERE parent_id = ?;
-- 下記の2つのクエリは必要なときだけ実行される
SELECT * FROM users WHERE id = ? LIMIT 1;
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?);
まとめ
Laravel のリレーションは必要になるまで取得されません。
そのため N + 1 問題が起こることがあります。
with
や load
は N + 1 問題 を防ぐために使いましょう。
不要な場合はできるだけ使うのを避けた方が良いです。
必要ないのに取得するため無駄にクエリが発行されるかもしれません。
参考
https://laravel.com/docs/8.x/eloquent-relationships#eager-loading
https://laravel.com/docs/8.x/eloquent-relationships#lazy-eager-loading
補足1
Laravel でない ORM の場合は
必要そうなリレーションに片っ端から with を指定する利点があるかもしれません。
Laravel で
Tweet::with('user')->findOrFail($tweet_id)
を実行すると下記の SQL が発行されますが…
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM users WHERE id IN (?);
たとえば Sequelize で
Tweet.findOne({ where: { id: tweetId }, include: [Tweet.associations.user] })
を実行すると下記の SQL が発行されます。
SELECT `tweet`.`id`,
`tweet`.`content`,
`tweet`.`created_at` AS `createdAt`,
`tweet`.`updated_at` AS `updatedAt`,
`tweet`.`user_id` AS `userId`,
`tweet`.`parent_id` AS `parentId`,
`user`.`id` AS `user.id`,
`user`.`name` AS `user.name`,
`user`.`created_at` AS `user.createdAt`,
`user`.`updated_at` AS `user.updatedAt`
FROM `tweets` AS `tweet`
LEFT OUTER JOIN `users` AS `user` ON `tweet`.`user_id` = `user`.`id`
WHERE `tweet`.`id` = ?;
補足2
今回の例の場合は、より適切な実装があります。
$tweet = Tweet::with(['replies' => fn($q) => $q->take(6)])->findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
$tweet->loadMissing('replies.user');
echo $tweet->user->name, "\n";
foreach ($tweet->replies as $reply) {
echo $reply->user->name, "\n";
}
} else {
echo "too many replies!\n";
}
php 7.3 以前の場合
$tweet = Tweet::with(['replies' => function($q) { $q->take(6); }])->findOrFail($tweet_id);
if ($tweet->replies->count() <= 5) {
$tweet->loadMissing('replies.user');
echo $tweet->user->name, "\n";
foreach ($tweet->replies as $reply) {
echo $reply->user->name, "\n";
}
} else {
echo "too many replies!\n";
}
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT * FROM tweets WHERE parent_id IN (?) LIMIT 6;
-- replies が 5 件以下だった場合 下記の2つのクエリも実行される
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?);
SELECT * FROM users WHERE id = ? LIMIT 1;
あるいは(クエリの実行数は増えますが)下記の方が良いかもしれません。
$tweet = Tweet::findOrFail($tweet_id);
if ($tweet->replies()->count() <= 5) { // replies が replies() になってます
$tweet->loadMissing('replies.user');
echo $tweet->user->name, "\n";
foreach ($tweet->replies as $reply) {
echo $reply->user->name, "\n";
}
} else {
echo "too many replies!\n";
}
SELECT * FROM tweets WHERE id = ? LIMIT 1;
SELECT COUNT(*) AS aggregate FROM tweets WHERE parent_id = ?;
-- replies が 5 件以下だった場合 下記の3つのクエリも実行される
SELECT * FROM tweets WHERE parent_id IN (?);
SELECT * FROM users WHERE id IN (?, ?, ?, ?, ?);
SELECT * FROM users WHERE id = ? LIMIT 1;