【GraphQL】Dataloaderを使用してN+1問題を回避しよう!
IT技術
はじめに
今回は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 1Dataloaderを利用
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クラスのinitializeのmodel_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の導入も検討してみてください。
きっと利便性を実感できるはずです。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ

服部晋平と申します! 前職では映像業や配送業に携わっていました。 趣味は、バイクツーリングに行ったり、美味しいラーメン屋巡りです。 未経験という身で入社させていただいたので、人一倍努力して頑張っていきたいと思います!







