LoginSignup
37
25

More than 3 years have passed since last update.

LaravelでN+1を解決するために雰囲気でeager loadを使っている

Last updated at Posted at 2019-10-07

まえおき

laravelでリレーションを張ったモデルを扱っているとN+1問題に遭遇してしまいます。
ググっても動的プロバティは遅延ロードされる、eager loadは熱心なロードです、ってなんやねんって思ったのは私だけでしょうか。
とりあえずwith()使っとけばいいんでしょ?となんとなくコードを書いてきたので、そろそろ仕組みを知りたいなあと思ったのでコードリーディングの旅に出たのであります。
laravelのバージョンは5.8です。

設定

モデルはユーザーと本があり、1:多で結びつきます。

リレーションは以下のように設定しておきます。

User.php
public function books()
{
    return $this->hasMany('App\User');
}

動的プロパティ

動的プロバティの遅延ロードされるという特性がN+1問題を引き起こしているので、まずは動的プロバティについて追っていきます。

以下のコードを読み解いていきます。

$user = new User();
$user->books;

$user->booksはメソッドを呼んでいるわけではないので、books()は呼ばれません。

かといってbooksプロパティを定義しているわけでもないので、マジックメソッドの__get()が呼ばれます。

モデルはIlluminate\Database\Eloquent\Modelを継承しているので、そこの__get()を見てみましょう。

Illuminate\Database\Eloquent\Model
public function __get($key)
{
    return $this->getAttribute($key);
}

getAttribute()が呼ばれてますね。traitで実装されています。($keyにはbooksが入っています。)

Illuminate/Database/Eloquent/Concerns/HasAttributes.php
public function getAttribute($key)
{

    //...

    return $this->getRelationValue($key);
}
Illuminate/Database/Eloquent/Concerns/HasAttributes.php
public function getRelationValue($key)
{
    //キャッシュを実現してるとこ
    if ($this->relationLoaded($key)) {
        return $this->relations[$key];
    }

    if (method_exists($this, $key)) {
        return $this->getRelationshipFromMethod($key);
    }
}

1つ目のif文でキャッシュを実現しています。(N+1解消のときに重要です。)
今の時点では関係がないので2つ目のif文を見てみます。

method_exists($this, $key)の部分ですが、$thisが呼び出し元のオブジェクト(今回だと$user)を指しているので、$userbooks()というメソッドがあるかどうかを判定しています。

リレーションとしてbooks()を定義したのでありますね。なのでgetRelationshipFromMethod()を見ていきます。

Illuminate/Database/Eloquent/Concerns/HasAttributes.php
protected function getRelationshipFromMethod($method)
{
    $relation = $this->$method();

    //...    

    return tap($relation->getResults(), function ($results) use ($method) {
        $this->setRelation($method, $results);
    });
}

$this->$method()の部分は$user->books()ということです。結局定義したメソッドを呼んでます。

tap()はヘルパー関数で第二引数のクロージャに第一引数を渡して、第一引数を返します。

$relation->getResults()で実際にクエリを発行してDBからデータをとってきます。

クロージャの中の$this->setRelation()でリレーションとデータの紐付けをモデルにセットしています。

public function setRelation($relation, $value)
{
    $this->relations[$relation] = $value;

    return $this;
}

今回の場合だと$userrelationsプロパティに

relations['books'] = DBからとってきたデータ

という風にリレーションとデータを紐付けていることになります。

tap()関数の返り値は第一引数なので、DBからとってきたデータを返していることになります。

つまり$user->booksはDBからとってきたデータを返すだけでなく、$userrelationsプロパティに値をセットしていることになります。

説明の途中でキャッシュを実現している箇所が出てきていましたが、relationsプロバティにデータをセットすることで、2回目からは実際にクエリを発行することなくセットされたデータを返すようになっているのです。

$user->books;
$user->books;

このように書いてもクエリが発行されているのは1回目のみで2回目は発行されていないことが確認できます。

動的プロバティではアクセスされたときに初めてクエリが発行されるシステムになっています。これをlaravelでは遅延ロードと呼んでいます。

N+1問題

動的プロバティの遅延ロードという特性上、N+1問題が発生してしまいます。

$users = User::all();
foreach($users as $user) {
    $user->books;   //ループの回数だけクエリが発行される
}

同じようなクエリが発行されてします。

select * from users;
select * from books where id = 1
select * from books where id = 2
select * from books where id = 3
select * from books where id = 4
//...

そこでlaravelはeager loadという解決策を用意してくれています。(なんでpreloadじゃないんだろう...?)

$users = User::with('books')->get();

この時点でユーザーの情報だけでなく本の情報も一括でとればクエリの発行回数が少なくて済むよねって話です。

select * from users;
select * from books where id in (1, 2, 3, 4, ...);

with()を使うことによりeager loadがセットされてget()時にモデルのrelationsプロバティにデータをセットしてくれます。

relationsプロバティにデータがセットされるということは、動的プロバティとしてアクセスすればキャッシュが効くので、都度クエリが発行されることはなくなります。

$users = User::with('books')->get();
foreach($users as $user) {
    $user->books;   //キャッシュを使うのでクエリの発行はされない
}

これでめでたくN+1問題も解決してドヤ顔でコードを書けるようになりました!

まとめ

  • 動的プロバティはアクセスされたときにクエリを発行してデータをとってくる
  • なのでループ処理で動的プロバティにアクセスするとクエリ回数がすごいことになる
  • with でリレーション先のデータも一括でとってきて、relationsプロバティにセットする
  • relationsプロバティにデータがセットされていれば、動的プロバティはクエリを発行せずにそのデータを使ってくれる
37
25
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
37
25