3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【GraphQL Ruby】AssociationのN+1対策(Dataloader)

Last updated at Posted at 2025-03-26

はじめに

過不足なく必要なデータのみを取得することが可能な GraphQL 。必要に応じて「親レコードのみ取得できればよい」「子レコードも一緒にまとめて取得したい」といった使い分け可能な点が魅力ですが、それゆえにうっかりN + 1問題を引き起こさないように対策が必要となってきます。
今回は GraphQL::Dataloaderを用いてアソシエーション部分のN + 1問題の対策を行なってみました。

用意したアソシエーション

projects テーブルから見てcategoriesを親レコード、tasksを子レコードとして持ち、teamsとは中間テーブルteam_projectsを挟んで「多対多」の構成とします。

class Project < ApplicationRecord
  belongs_to :category
  has_many :tasks
  has_many :team_projects
  has_many :teams, through: :team_projects
end

class Category < ApplicationRecord
  has_many :projects
end

class Task < ApplicationRecord
  belongs_to :project
end

class TeamProject < ApplicationRecord
  belongs_to :team
  belongs_to :project
end

class Team < ApplicationRecord
  has_many :team_projects
  has_many :projects, through: :team_projects
end

ER図で表すと以下のようになります。

Dataloaderで親子モデル

belongs_to

projects を起点に親レコードのcategoryを一緒に取得するパターンを考えます。

query Projects {
  projects {
    id
    name
    category {
      id
      name
    }
  }
}
project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :category, Types::CategoryType, null: false # belongs_to
  end
end

まずはGraphQL::Dataloaderを使わずprojects(全5レコード)を取得します。
下記のように1レコードごとに親のcategoriesを取得するクエリが発行されてしまっています。

  Project Load (0.4ms)  SELECT `projects`.* FROM `projects`
  Category Load (0.3ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 1 LIMIT 1
  Category Load (0.3ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 2 LIMIT 1
  Category Load (0.2ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 3 LIMIT 1
  Category Load (0.1ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 4 LIMIT 1
  Category Load (0.2ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 5 LIMIT 1

それでは、親のcategories を取得する際に GraphQL::Dataloader を使うようにコードを修正します。ドキュメントに掲載されている例をほぼそのまま使用しています。

active_record_single_object.rb
class Sources::ActiveRecordSingleObject < GraphQL::Dataloader::Source
  def initialize(model_class)
    @model_class = model_class
  end

  def fetch(ids)
    records = @model_class.where(id: ids)
    # return a list with `nil` for any ID that wasn't found
    ids.map { |id| records.find { |r| r.id == id.to_i } }
  end
end
project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :category, Types::CategoryType, null: false # belongs_to

+    def category
+      dataloader.with(Sources::ActiveRecordSingleObject, ::Category).load(object.category_id)
+    end
  end
end

上記対応後の発行SQLがこちらとなります。親 categories を取得するSQLがIN検索で1つにまとめられて、N+1を回避することができました。

  Project Load (0.2ms)  SELECT `projects`.* FROM `projects`
  Category Load (0.3ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` IN (1, 2, 3, 4, 5)

has_many

projects を起点に子レコード tasks を取得します。

query Projects {
  projects {
    id
    name
    tasks {
      id
      name
    }
  }
}

GraphQL::Dataloader を使わないと、やはりN+1問題が発生しています。

  Project Load (0.3ms)  SELECT `projects`.* FROM `projects`
  Task Load (0.3ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 1
  Task Load (0.3ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 2
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 3
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 4
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 5

こちらも子レコード取得時に GraphQL::Dataloader を利用するように修正します。コードはこちらの記事を参考にさせていただきました。

active_record_multiple_object.rb
class Sources::ActiveRecordMultipleObject < GraphQL::Dataloader::Source
  def initialize(model_class, foreign_key_column_name)
    @model_class = model_class
    @foreign_key_column_name = foreign_key_column_name
  end

  def fetch(ids)
    records = @model_class.where({ @foreign_key_column_name => ids })
                          .group_by { |record| record[@foreign_key_column_name] }
    ids.map { |id| records[id] || [] }
  end
end
project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :tasks, [Types::TaskType], null: true # has_many

+    def tasks
+      dataloader.with(Sources::ActiveRecordMultipleObject, ::Task, :project_id).load(object.id)
+    end
  end
end

この場合も、GraphQL::Dataloader を使用することによって下記のようにN+1問題を回避できるようになりました。

  Project Load (0.3ms)  SELECT `projects`.* FROM `projects`
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` IN (1, 2, 3, 4, 5)

Dataloaderで「多対多(N:N)」モデル

さて、「多対多」関係にある teams はどのように取得するべきでしょうか。

query Projects {
  projects {
    id
    name
    teams {
      id
      name
    }
  }
}

そのまま取得しにかかるとやはりN+1問題が発生します。

  Project Load (0.3ms)  SELECT `projects`.* FROM `projects`
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 1
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 2
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 3
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 4
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 5

team_projects 経由で取得する(has_manybelongs_to にあたる部分で GraphQL::Dataloaderを使う)ことでN+1問題は回避できますが、外部キー以外の情報を持っていないteam_projectsをわざわざ取得したくはありません。

query Projects {
  projects {
    id
    name
    teamProjects {
      id
      team {
        id
        name
      }
    }
  }
}

そこで、has_many 用に実装したコードを発展させて、中間テーブル取得時に関連先を同時にキャッシュし、最終的には関連先のレコードを返すクラスを新たに実装します。

active_record_multiple_through_object.rb
class Sources::ActiveRecordMultipleThroughObject < GraphQL::Dataloader::Source
  def initialize(model_class, foreign_key_column_name, through_class)
    @model_class = model_class
    @foreign_key_column_name = foreign_key_column_name
    @through_class = through_class
  end

  def fetch(ids)
    association_name = @model_class.name.underscore
    # 中間テーブル取得時に関連先もキャッシュ
    records = @through_class.where({ @foreign_key_column_name => ids }).preload(association_name)
                            .group_by { |record| record[@foreign_key_column_name] }
    # キャッシュした関連先レコードを返す
    ids.map { |id|
      (records[id] || []).map {|record|
        record.public_send association_name
      }
    }
  end
end
project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :teams, [Types::TeamType], null: true # has_many througth

+    def teams
+      dataloader.with(Sources::ActiveRecordMultipleThroughObject, ::Team, :project_id, ::TeamProject).load(object.id)
+    end
  end
end

上記対応後、teams を取得するSQLが1つにまとめらて、N+1問題を回避することができました。

  Project Load (0.2ms)  SELECT `projects`.* FROM `projects`
  TeamProject Load (0.3ms)  SELECT `team_projects`.* FROM `team_projects` WHERE `team_projects`.`project_id` IN (1, 2, 3, 4, 5)
   app/graphql/sources/active_record_multiple_object.rb:9:in `group_by'
  Team Load (0.3ms)  SELECT `teams`.* FROM `teams` WHERE `teams`.`id` IN (1, 2, 3, 5)
  ↳ app/graphql/sources/active_record_single_object.rb:9:in `block in fetch'

まとめ

過不足なく必要なデータのみを取得できるのがGraphQLの良いところですが、N+1問題が発生してしまうと結局のところパフォーマンスの低下につながってしまいます。GraphQLの強みを存分に活かすたには、GraphQL::Dataloader等を活用したN+1問題の対策が必要不可欠と言えるでしょう。

参考

GraphQL - Sources
GraphQL RubyのDataloaderを使ってみる

3
5
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
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?