2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Laravel における with の使い所 / 使うべきでない所

Last updated at Posted at 2020-09-10

下記のようなコードがあると仮定します。

$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 問題が起こることがあります。
withload は 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;
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?