• トップ
  • ブログ一覧
  • 【GraphQL】Dataloaderを使用してN+1問題を回避しよう!
  • 【GraphQL】Dataloaderを使用してN+1問題を回避しよう!

    はじめに

    今回はDataloaderを使用してN+1問題を解消する方法を解説したいと思います。
    Dataloaderはデータ取得を一度に行いN+1問題を解決するGraphQLに組み込まれているライブラリです。
    GraphQLでは、ネストされた構造で取得することが多いため、その構造上N+1問題が起きやすいです。
    Dataloaderでは上記問題を簡単に解決することができます。
    動物の一覧と種別を取得する例をもとに解説していきます。

    モデル

    1class Species < ApplicationRecord
    2  has_many :animals
    3end
    4
    5
    6class Animal < ApplicationRecord
    7  belongs_to :species
    8end

    スキーマ

    1type Animal {
    2  id: Int!
    3  name: String!
    4  species: Species!
    5}
    6
    7type Species {
    8  id: Int!
    9  name: String!
    10}
    11
    12type Query {
    13  animals: [Animal!]!
    14}

    リゾルバ

    シンプルにallですべての動物を返します。

    1Animal.all

    クエリ例

    1{
    2  animals {
    3    id
    4    name
    5  }
    6}

    レスポンス例

    1{
    2  "data": {
    3    "animals": [
    4      {
    5        "id": 1,
    6        "name": "ライオン",
    7        "species": {
    8          "id": 1,
    9          "name": "哺乳類"
    10        }
    11      },
    12      {
    13        "id": 2,
    14        "name": "スズメ",
    15        "species": {
    16          "id": 2,
    17          "name": "鳥類"
    18        }
    19      },
    20      {
    21        "id": 3,
    22        "name": "ワニ",
    23        "species": {
    24          "id": 3,
    25          "name": "爬虫類"
    26        }
    27      }
    28    ]
    29  }
    30}

    実行されたSQL

    当然なにもしていないので、N+1問題が発生します。

    12025-10-21 13:45:10   Animal Load (1.6ms)  SELECT `animals`.* FROM `animals`
    22025-10-21 13:45:10   Species Load (0.4ms)  SELECT `species`.* FROM `species` WHERE `species`.`id` = 1 LIMIT 1
    32025-10-21 13:45:10   Species Load (0.1ms)  SELECT `species`.* FROM `species` WHERE `species`.`id` = 2 LIMIT 1
    42025-10-21 13:45:10   Species Load (0.1ms)  SELECT `species`.* FROM `species` WHERE `species`.`id` = 3 LIMIT 1

    Dataloaderを利用

    Dataloaderを利用してN+1問題を回避します。
    一括取得したいフィールドのリゾルバでdataload_associationメソッドを呼び出すだけでだけです。
    引数にはActiveRecordで設定したbelongs_toやhas_manyのキー名を指定します。

    1module ObjectTypes
    2  class AnimalType < BaseObject
    3
    4    field :id, Integer, null: false
    5    field :name, String, null: false
    6    field :species, SpeciesType, null: false
    7
    8    def species
    9      dataload_association(:species)
    10    end
    11  end
    12end

    これにより、アソシエーション名を渡すだけで自動的に、対象のアソシエーションのデータをまとめて取得してくれます。
    再度クエリを実行すると、N+1問題が解消されています。

    12025-10-21 13:46:48   Animal Load (2.0ms)  SELECT `animals`.* FROM `animals`
    22025-10-21 13:46:48   Species Load (2.0ms)  SELECT `species`.* FROM `species` WHERE `species`.`id` IN (1, 2, 3)

    カスタムDataloaderを利用

    先ほどの dataload_association は、ActiveRecord素直な関連 を取得するには便利です。
    ただし、ActiveRecord 以外のデータや、集計・変換を伴う要件には対応しづらい場面があります。

    • Redis / YAML のデータを扱いたい
    • 取得後にデータを加工したい
    • 外部 API から取得したい
    • そのほか、集計など関連そのものではない結果を返したい

    こうした場合は カスタムDataloaderを使用するのが良いアプローチです。

    ここでは、これまで dataload_association で実装していた箇所を カスタムDataloader に置き換えてみます。
    以下のような一括取得用のクラスを作成します。(公式参照)

    1class Sources::ActiveRecordObject < GraphQL::Dataloader::Source
    2  def initialize(model_class)
    3    @model_class = model_class
    4  end
    5
    6  def fetch(ids)
    7	  # レコードをまとめて取得
    8    index_records = @model_class.where(id: ids).index_by(&:id)
    9		# idsの順にデータを並び替えて取得
    10    ids.map { |id| index_records[id] }
    11  end
    12end

    上記クラスを dataloader.with の引数に指定します。
    第二引数に取得対象のモデルクラスを渡すことでSources::ActiveRecordObjectクラスinitializemodel_class引数にその値が渡されます。

    1module ObjectTypes
    2  class AnimalType < BaseObject
    3
    4    field :id, Integer, null: false
    5    field :name, String, null: false
    6    field :species, SpeciesType, null: false
    7
    8    def species
    9      dataloader.with(Sources::ActiveRecordObject, ::Species).load(object.species_id)
    10    end
    11  end
    12end

    再度クエリを実行すると、この実装でも同様にN+1が解消できています。

    12025-10-21 13:58:25   Animal Load (0.8ms)  SELECT `animals`.* FROM `animals`
    22025-10-21 13:58:25   Species Load (0.3ms)  SELECT `species`.* FROM `species` WHERE `species`.`id` IN (1, 2, 3)

    loadメソッドの振る舞い

    • load(key) は 「このキーのデータをあとでまとめ取得してね」 という 予約 を行います。この時点では SQL はまだ発行されません。
    • 同じ Source に対する複数の load が内部バッファへ積まれ、GraphQL が解決フェーズの適切なタイミングで fetch(ids) が 1 度だけ 呼ばれます。

    fetch の振る舞い

    • featch(ids) 内でデータをまとめて取得します。
    • fetch(ids) は ids と同じ順序 の配列を返す必要があります。

    なぜ同じ順序にするのか

    Dataloaderは内部的にこんな形で動いています。

    1dataloader.with(Sources::ActiveRecordObject, ::Species).load(1)
    2dataloader.with(Sources::ActiveRecordObject, ::Species).load(2)
    3dataloader.with(Sources::ActiveRecordObject, ::Species).load(3)

    ここで fetch(ids) に渡される ids はこの3つ

    1[1, 2, 3]

    fetchメソッドでは、これらのキーに対応する結果を同じ順序で返す必要があります。

    1# idsの順でデータを生成
    2ids.map { |id| index_records[id] }

    こうすることでDataloderが処理した結果を適切なレスポンス内に割り振ってくれます。
    並び順がバラバラだと、意図しないデータ構造内にDataloderの処理結果が割り振られてしまいます。
    例として並び順を変えた場合の挙動を見てみましょう。
    以下のように reverseメソッド を使用して並び順を逆にしてみます。

    1# reverseで並び順を逆にする
    2ids.map { |id| index_records[id] }.reverse

    すると以下のようなレスポンスになります。
    ライオンが爬虫類になってしまい、適切でないデータの割り振りが発生してしまいます。
    この挙動には十分に気をつけましょう。

    1{
    2  "data": {
    3    "animals": [
    4      {
    5        "id": 1,
    6        "name": "ライオン",
    7        "species": {
    8          "id": 3,
    9          "name": "爬虫類"
    10        }
    11      },
    12      {
    13        "id": 2,
    14        "name": "スズメ",
    15        "species": {
    16          "id": 2,
    17          "name": "鳥類"
    18        }
    19      },
    20      {
    21        "id": 3,
    22        "name": "ワニ",
    23        "species": {
    24          "id": 1,
    25          "name": "哺乳類"
    26        }
    27      }
    28    ]
    29  }
    30}

    includesではだめなのか?

    includesでもN+1問題は解決できます。
    問題なのは、eager loadのため即SQL発行されてしまう点です。
    今回でいうと、speciesをincludesに指定することでN+1問題は解決できます。

    1Animal.all.includes(:species)

    ただGraphQLでは必要なデータのみ取得することができるため、以下のようにspeciesを指定しない場面もありえます。

    1{
    2  animals {
    3    id
    4    name
    5  }
    6}

    includesはこの場合でも確定でSQLを実行してしまうため、上記のようなクエリでも以下のように不要なspeciesを取得するSQLが実行されてしまいます。

    12025-10-21 17:42:42   Animal Load (0.6ms)  SELECT `animals`.* FROM `animals`
    22025-10-21 17:42:42   Species Load (0.3ms)  SELECT `species`.* FROM `species` WHERE `species`.`id` IN (1, 2, 3)

    この点、Dataloderは遅延実行のため、フィールドが呼び出されたタイミングで実行する挙動のおかげで、フィールドを指定しない限り余分なSQLが発生することはありません。
    このようにGraphQLは遅延実行と相性が良いと言えるかと思います。

    最後に

    いかがでしたでしょうか。

    Dataloaderは最初は挙動がわかりづらく、とっつきにくい部分もありますが、使いこなせるようになると非常に便利な機能です。
    もしまだGraphQLの理解が十分でなかったり、納期に追われている場合は、まずはincludesなどのeager Loadを利用して一時的にN+1問題を回避するのも良いと思います。
    ただ、時間に余裕があるときには、ぜひDataloaderの導入も検討してみてください。
    きっと利便性を実感できるはずです。

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    はっと(エンジニア)
    はっと(エンジニア)
    Show more...

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background