【GraphQL Ruby】N+1問題を防ぐ GraphQL::Dataloaderのまとめ - PLEX Product Team Blog

【GraphQL Ruby】N+1問題を防ぐ GraphQL::Dataloaderのまとめ

はじめに

こんにちは、SaaS事業部(サクミル)のエンジニアの栃川です。

今回は、N+1問題を防ぐGraphQL Rubyの「GraphQL::Dataloader」について調べたことをまとめていきたいと思います。

github.com

対象とする読者

  • GraphQL Rubyについて基本的な知識があるという方
  • GraphQL::Dataloaderについて概要を知りたい方

GraphQL::Dataloaderとは

GraphQL::Dataloaderは、データベースやマイクロサービスなどの外部サービスへの効率的なバッチ アクセスを提供してくれます。

その仕組みはとてもシンプルで2つのアプローチを行います。

  1. GraphQLフィールドは、何が要求されているかをクエリパラメーターやオブジェクトIDなどをリクエストキーとして登録します。
  2. できるだけ多くのリクエストキーを収集した後に、まとめて外部サービス(例えば、DB)へのフェッチを開始する

このアプローチによって、GraphQLでは取得したいデータのノードを辿って必要なデータを一度に取得できるため、不必要にSQLが大量発行されてしまうN+1問題を防ぐことができます。

GraphQL::Dataloaderの実装例

実際の実装例を紹介します。

前提

ある会社Aの従業員は必ず1つの部署だけに属しているとします。

この場合、ある会社Aの従業員と部署名を取得する例を考えます。

query Employees (
  $companyId: ID!
) {
  company: node(id: $companyId) {
    ... onCompany {
      id
      employees {
        nodes {
          id
          department {
            id
            name
          }
        }
      }
    }
  }
}

fieldの定義

まずはfieldを定義していきます。

departmentフィールドは、dataloader.with(::Sources::Department).load(object.id)と定義します。

こうすることで、object.id(employeeのid)をリクエストキーとして、「リクエストをしたい」ということだけをGraphQL::Dataloaderに伝えます。

あとで記載しますが、これによって逐一SQLが発行されることを防ぎ、N+1問題の発生を回避できます。

module Types
  module Objects
    class Employee < Types::Bases::Object
      field :department, Types::Objects::Department, null: false

      def department
        dataloader.with(::Sources::Department).load(object.id)
      end
    end
  end  
end

sourcesの実装

次にSourcesを定義していきます。

fetchメソッドには、GraphQL::Dataloader が外部サービスからデータをフェッチするためのロジックを定義します。

引数のemployee_idsは、loadメソッドに渡されたobject.idの集まりが配列として渡されます。 つまり、要求されているリクエストの全リクエストキーがfetchメソッドの引数に渡されます。

今回の例の場合、取得するべき全ての従業員のidがemployee_idsとしてfetchメソッドに渡されます。

fetchメソッドでは、このemployee_idsを使い、@model_class.where(employee_id: employee_ids)と定義します。

# frozen_string_literal: true

module Sources
  class Department < GraphQL::Dataloader::Source
    def initialize()
      @model_class = :Department
    end

    def fetch(employee_ids)
       @model_class.where(employee_id: employee_ids)
    end
  end
end

上記のように定義することで、以下のようなSQLが発行されて、一気に部署を取得してくることができます!!

SELECT 
    "departments".* 
FROM 
    "departments" 
WHERE 
    "departments"."employee_id" IN (1, 2, 3, 4)

どんな仕組みになっているのか?

GraphQL::Dataloaderが実際にどんな仕組みで動いているのかを見ていきます。

Fiberについて

前提として、GraphQL::Dataloaderの実装は内部的にはノンプリエンプティブな軽量スレッドであるRubyのFiberを利用して、実装されています。

docs.ruby-lang.org

このFiberについては、普段からRubyを使われていたとしてもあまり知らない方もいると思うので簡単に説明します。

Fiberは、任意の場所で実行を中断して再開できる機能を提供します。

例として、非同期処理の実装例を紹介します。

# 非同期処理のシミュレーション
fiber1 = Fiber.new do
  5.times do |i|
    puts "Fiber 1 - ステップ #{i}"
    Fiber.yield
  end
end

fiber2 = Fiber.new do
  3.times do |i|
    puts "Fiber 2 -ステップ #{i}"
    Fiber.yield
  end
end

# メインループでFiberを順番に実行
loop do
  if fiber1.alive?
    fiber1.resume
  end
  if fiber2.alive?
    fiber2.resume
  end
  break unless fiber1.alive? || fiber2.alive?
end

puts "完了!"

このコードでは、fiber1fiber2を順番に実行しています。 Fiber.yieldによって処理が中断するたびにメインループに戻ります。

そして、resumeメソッドを使い、再度再開することができます。

つまり、Fiberを通じてコンテキストを切り替えることで、処理を交互に行っています。

なお、上記のコード出力結果は以下の通りです。

Fiber 1 -ステップ 0
Fiber 2 - ステップ 0
Fiber 1 - ステップ 1
Fiber 2 - ステップ 1
Fiber 1 - ステップ 2
Fiber 2 - ステップ 2
Fiber 1 - ステップ 3
Fiber 1 - ステップ 4
完了!

このように、Fiberを使うことで処理を途中で中断したり再開したりすることができます。

GraphQL::Dataloaderの仕組み

では、GraphQL::DataloaderではFiberは何の用途で使われているのでしょうか?

答えは、上述した「2. できるだけ多くのリクエストキーを収集した後に、まとめて外部サービス(例えば、DB)へのフェッチを開始する」という挙動を実現するために利用されています。

GraphQLでは、クエリ全体の処理を親Fiberが担い、各フィールド処理を子Fiberが担当します。 (親Fiberと子ファイバーはGraphQL::DataLoader#runメソッドで生成されます。)

GraphQLフィールドの処理が行われる時、loadメソッドが呼び出されると、内部的には下記のsyncメソッドが呼び出されます。

syncメソッドは、子Fiberの処理を中断し、親Fiberへコンテキストを移します。

 MAX_ITERATIONS = 1000

 # Wait for a batch, if there's anything to batch.
# Then run the batch and update the cache.
# @return [void]
def sync(pending_result_keys)
  # 親Fiberにコンテキストを移す
  @dataloader.yield
  iterations = 0

  # 処理が保留されたフィールドがある場合
  while pending_result_keys.any? { |key| !@results.key?(key) }
    iterations += 1

    if iterations > MAX_ITERATIONS
      raise "#{self.class}#sync tried #{MAX_ITERATIONS} times to load pending keys (#{pending_result_keys}), but they still weren't loaded. There is likely a circular dependency."
    end

    # 親Fiberにコンテキストを移す
    @dataloader.yield
  end

  nil  # メソッドの終了
end

Github 該当箇所

こうすることで、GraphQLフィールドの処理が行われる際に、loadメソッドが呼ばれると、 そのフィールドの処理は一時中断し、次のフィールドへ処理が移ります。

この処理を兄弟フィールド全てに対して行います。

その後、親Fiberは未処理状態のフィールドを解決するため、GraphQL::DataLoader::Source#fetchメソッドに定義された処理を行います。

fetchメソッドは、実装例にあるように、データを取得するためのロジックを定義する必要があります。

      # Subclasses must implement this method to return a value for each of `keys`
      # @param keys [Array<Object>] keys passed to {#load}, {#load_all}, {#request}, or {#request_all}
      # @return [Array<Object>] A loaded value for each of `keys`. The array must match one-for-one to the list of `keys`.
      def fetch(keys)
        # somehow retrieve these from the backend
        raise "Implement `#{self.class}#fetch(#{keys.inspect}) to return a record for each of the keys"
      end

Githubの該当箇所

このように、Fiberの特性を用いることで、複数のフィールドの処理を一時停止して、最後にまとめて取得するといったことをGraphQL::Dataloaderでは行っているということがわかりました。

まとめ

今回は、簡単にGraphQL::Dataloaderについて内部実装を確認しながら自分なりにまとめてみました。

GraphQL::Dataloaderがあることで、開発時に特別意識しなくとも、 N+1問題を回避しつつ効率的にデータ取得することができると理解できました。

まだまだGraphQL::Dataloaderへの理解は浅いので、これからもさらに深掘っていきたいと思います。

さいごに

最後に、弊社では全ての事業部でエンジニア採用を積極的に行なっています。

少しでも興味を持っていただけた方は業務委託や副業からでも、ぜひご応募ください!

dev.plex.co.jp