🧭

【GraphQL】バッチローダーを使って、N+1問題を解消する【Laravel・lighthouse】

2023/07/16に公開

https://zenn.dev/jordan23/articles/976e6f6a6ae18c

以前の記事で、カスタムディレクティブの定義の仕方について書きました。

前回のカスタムディレクティブ、言い換えるとリゾルバーは、databaseへのクエリの実行を必要としないものでした。

もしdatabaseへのクエリの実行を伴うリゾルバーを定義する時は、N+1問題を考慮する必要があります。

N+1が発生してしまうケース

スキーマ

type Query {
fetchAllPosts: [Post!]! @all
}

type Post {
    id: ID!
    is_favorite: Int!
    content: String!
    player: Player @extraPlayer
}

type Player {
    id: ID!
    name: String!
    team: String!
}

extraPlayerのカスタムディレクティブの定義

post.idと同じidのplayerを取得する

<?php

namespace App\GraphQL\Directives;

use App\Models\Player;
use App\Models\Post;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldResolver;

final class ExtraPlayerDirective extends BaseDirective implements FieldResolver
{
    public static function definition(): string
    {
        return /** @lang GraphQL */ <<<'GRAPHQL'
directive @extraPlayer on FIELD_DEFINITION
GRAPHQL;
    }

    public function resolveField(FieldValue $fieldValue)
    {
        return $fieldValue->setResolver(function(Post $post, array $args, $context, $info){
            $player = Player::query()->find($post->id);
            return $post->player = $player;
        });
    }
}

クエリを叩く

  fetchAllPosts {
    id
    player {
      id
      name
      team
    }
  }

実行結果

"fetchAllPosts": [
      {
        "id": "1",
        "player": {
          "id": "1",
          "name": "レブロンジェームス",
          "team": "レイカーズ"
        }
      },
      {
        "id": "2",
        "player": {
          "id": "2",
          "name": "アンソニーデイビス",
          "team": "レイカーズ"
        }
      },
      {
        "id": "3",
        "player": {
          "id": "3",
          "name": "ディアンジェロラッセル",
          "team": "レイカーズ"
        }
      },
      {
        "id": "4",
        "player": {
          "id": "4",
          "name": "ルイハチムラ",
          "team": "レイカーズ"
        }
      },
      {
        "id": "5",
        "player": {
          "id": "5",
          "name": "オースティンリーブス",
          "team": "レイカーズ"
        }
      }
    ]

叩かれたSQLクエリ・問題点

三連休の真ん中でzenn書いていることがバレてしまいました。。。
問題点は、type Playerが取得できるたびにresolveFieldが発火し、クエリを叩いてしまっているので、
n+1問題が生じてしまっています。

[2023-07-16 08:38:23] local.INFO: (16.8) select * from `posts`  
[2023-07-16 08:38:23] local.INFO: array (
)  
[2023-07-16 08:38:23] local.INFO: (1.98) select * from `players` where `players`.`id` = ? limit 1  
[2023-07-16 08:38:23] local.INFO: array (
  0 => 1,
)  
[2023-07-16 08:38:23] local.INFO: (0.67) select * from `players` where `players`.`id` = ? limit 1  
[2023-07-16 08:38:23] local.INFO: array (
  0 => 2,
)  
[2023-07-16 08:38:23] local.INFO: (0.47) select * from `players` where `players`.`id` = ? limit 1  
[2023-07-16 08:38:23] local.INFO: array (
  0 => 3,
)  
[2023-07-16 08:38:23] local.INFO: (0.48) select * from `players` where `players`.`id` = ? limit 1  
[2023-07-16 08:38:23] local.INFO: array (
  0 => 4,
)  
[2023-07-16 08:38:23] local.INFO: (0.49) select * from `players` where `players`.`id` = ? limit 1  
[2023-07-16 08:38:23] local.INFO: array (
  0 => 5,
)  

バッチローダーを使ったケース

https://lighthouse-php.com/master/performance/n-plus-one.html#custom-batch-loaders
lighthouseのドキュメントを参考にしました。
先ほど定義していた@extraPlayerを修正していきます。

省略

 public function resolveField(FieldValue $fieldValue)
    {
        return $fieldValue->setResolver(function(Post $post, array $args, $context,  ResolveInfo $info){
            $postCreatorBatchLoader = BatchLoaderRegistry::instance(
                $info->path,
                fn () => new PostCreatorBatchLoader()
            );
            return $postCreatorBatchLoader->load($post);
        });
    }

省略

バッチローダーの実装

<?php
namespace App\GraphQL\BatchLoader;

use App\Models\Player;
use App\Models\Post;
use GraphQL\Deferred;

final class PostCreatorBatchLoader
{
    protected array $posts = [];

    protected array $results = [];

    protected bool $hasResolved = false;

    public function load(Post $post): Deferred
    {
        $this->posts[$post->id] = $post;

        return new Deferred(function () use ($post): array {
            if (! $this->hasResolved) {
                $this->resolve();
            }
            return $this->results[$post->id];
        });
    }

    protected function resolve(): void
    {
        $postIds = collect($this->posts)->pluck('id')->toArray();
        $players = Player::query()->whereIn('id', $postIds)->get()->toArray();

        foreach ($players as $player) {
            $this->results[$player['id']] = $player;
        }
        $this->hasResolved = true;
    }
}

叩かれたクエリ

クエリ数が減りn+1問題を解決することができました。post数が増えるほど、その違いを実感できると思います。

[2023-07-16 10:27:00] local.INFO: (22.48) select * from `posts`  
[2023-07-16 10:27:00] local.INFO: array (
)  
[2023-07-16 10:27:00] local.INFO: (0.67) select * from `players` where `id` in (?, ?, ?, ?, ?)  
[2023-07-16 10:27:00] local.INFO: array (
  0 => 1,
  1 => 2,
  2 => 3,
  3 => 4,
  4 => 5,
)  

なぜn+1を解決できたか

問題がある実装では、postの数だけplayerを取得するためのクエリを投げていました。
しかし、
今回の実装では、
postを取得し、playerフィールドの値を通常なら解決するタイミングで、
postをloadし、溜めておき、溜めておいたpostをもとに1回のクエリを叩きplayerを複数取得し、
一気に全てのpostのplayerフィールドを解決しました。

上記文章だけだと少し分かりづらいので、batchloaderに対してログを仕込んでみました。

final class PostCreatorBatchLoader
{
    protected array $posts = [];

    protected array $results = [];

    protected bool $hasResolved = false;

+     private int $loadCount = 0;
+     private int $resolveCount = 0;

    public function load(Post $post): Deferred
    {
+         $this->loadCount += 1;
+         Logger("load{$this->loadCount}回目");
        $this->posts[$post->id] = $post;

        return new Deferred(function () use ($post): array {
            if (! $this->hasResolved) {
+                 $this->resolveCount +=  1;
+                 Logger("resolve{$this->resolveCount}回目");
                $this->resolve();
            }
            return $this->results[$post->id];
        });
    }

    protected function resolve(): void
    {
        $postIds = collect($this->posts)->pluck('id')->toArray();
        $players = Player::query()->whereIn('id', $postIds)->get()->toArray();

        foreach ($players as $player) {
           

loadが5回呼ばれて、resolveが1回呼ばれていることがわかります。

postをloadし、溜めておき、溜めておいたpostをもとに1回のクエリを叩きplayerを複数取得し、
一気に全てのpostのplayerフィールドを解決しました。

[2023-07-16 10:40:12] local.INFO: (14.25) select * from `posts`  
[2023-07-16 10:40:12] local.INFO: array (
)  
[2023-07-16 10:40:12] local.DEBUG: load1回目  
[2023-07-16 10:40:12] local.DEBUG: load2回目  
[2023-07-16 10:40:12] local.DEBUG: load3回目  
[2023-07-16 10:40:12] local.DEBUG: load4回目  
[2023-07-16 10:40:12] local.DEBUG: load5回目  
[2023-07-16 10:40:12] local.DEBUG: resolve1回目  
[2023-07-16 10:40:12] local.INFO: (1.01) select * from `players` where `id` in (?, ?, ?, ?, ?)  
[2023-07-16 10:40:12] local.INFO: array (
  0 => 1,
  1 => 2,
  2 => 3,
  3 => 4,
  4 => 5,
)  

さいごに

上記バッチローダーでポイントになるのは、一回のアクションで複数のアイテムを取得できるかだと思います。
例えばフィールドが外部api等で取得できるデータの場合、
その外部apiが一回のアクションで複数のアイテムを取得できるかをしっかりリサーチする必要があります。

またlighthouseではモデル間のリレーション、例えば@belongsToや@hasManyは
lighthouseが自動的にeagerloadingをやってくれるみたいです。

Lighthouse will batch the relationship queries together in a single database query

https://lighthouse-php.com/master/performance/n-plus-one.html#eager-loading-relationships

Discussion