🦍

GraphQLのN+1をDataloaderでどのように解決しているか

2022/12/24に公開約3,400字

概要

僕が勤めている会社(株式会社マイベスト)では、Railsを使いながらGraphQL APIを実装しています。
ライブラリとしてgraphql-rubyを使っています。

N+1の対策として、graphql-batchも使っているのですが、サンプルのような読み込みをなんとなく使っていることが多く、その実装の内容までは理解できていませんでした。

field :product, Types::Product, null: true do
  argument :id, ID, required: true
end

def product(id:)
  RecordLoader.for(Product).load(id)
end

https://github.com/Shopify/graphql-batch

しかし、今回社内で『Production Ready GraphQL』という本を使い勉強会をおこない、Dataloaderパターンの内容を勉強したので、その仕組について今回は書きたいと思います。

https://book.productionreadygraphql.com/

GraphQLにおけるN+1問題

例えば以下のようなクエリが合った際に、データの取得はフィールドを1つ読んだら次へ、というように順番に実行されます。

query {
  name
  age
  friends(first: 3) {
   bestFriend {
     name 
   }
  }
}

こちらで実行したい理想的なクエリはこのようになものです。

SELECT * FROM users WHERE id = 1
// friendsの最初の3人のID
SELECT * FROM friends WHERE user_id = 1 LIMIT 3
// friendsの情報をIDを用いて取得
SELECT * FROM users WHERE id IN (2,3,4)
// friendsのbestFriendの情報を取得
SELECT * FROM users WHERE id IN (5,6,7)

ところが、GraphQLではResolverでの取得は並行に処理されるため、こちらの例では、friends(first: 3)のResolverがそれぞれについてのbestFriendを先読みする必要があることがbestFriendフィールドを読み込むまでわかりません。
実際に上記のクエリを実行するると、上記の4つのクエリではなく6つのクエリが作成されます。

SELECT * FROM users WHERE id = 1
// friendsの最初の3人のID
SELECT * FROM friends WHERE user_id = 1 LIMIT 3
// friendsの情報をIDを用いて取得
SELECT * FROM users WHERE id IN (2,3,4)
// それぞれのfriendに対してbestFriendの情報を取得
SELECT * FROM users WHERE id = 5
SELECT * FROM users WHERE id = 6
SELECT * FROM users WHERE id = 7

データセットが大きくなるにつれて、このようなたくさんのクエリが実行されるとサーバーが破綻してしまいます。

先読みを行う

これの解決方法として、現在は一般的なものではありませんが「先読み」を用いる方法があります。
上記の例の場合では、friendsResolverが、それぞれのfriendsデータをあらかじめロードしておき、 bestFriendsResolverが事前にロードされたデータの一部を使うという方法です。

ところが、これを実現する方法は今はまだないようです。
クライアントごとに好きな表現でデータをクエリできるようにするのがGraphQLの利点の一つですが、自由に表現されたクエリの先で現れる可能性があるデータ要件の全てのシナリオに適応できる先読みの仕組みが必要となります。

Dataloaderパターン

その代わりとして、一般的になっているのが「Dataloader」と呼ばれる方法です。

非同期的アプローチ

Dataloaderパターンでは、先読みを行う代わりに意図的にデータの読み込みを遅延させます。
実際にはResolverそれそれがPromiseによって非同期的なふるまいをします。

深さ優先の探索ではクエリの実行後に、同じ深さの他のフィールド取得の前に子フィールドをResolveしますが、Dataloaderパターンでは子フィールドをすぐに取得するのではなく、先にクエリツリーの同じ深さの次のResolverに進んだ後に実行します。

Loader

Resolverにおいて。直接データを取得するのではなく、Loaderを経由するようにします。
このLoaderでは、それぞれのResolverからオブジェクトを取得するために必要な識別子を収集しておき、データをより効率的にバッチロードします。

典型的なLoaderは、loadperformの2つの主要なメソッドを持つクラスまたはオブジェクトです。

  • loadは、呼び出し元が読み込もうとているデータのローディングキーを引数として受け取り、呼び出し元が要求したデータで最終的に満たされるPromiseを返します。このメソッドはResolver内で使用されます。
  • perform (batchFunction) は、load関数の呼び出しが追加したすべての累積キーを受け取り、最も効率的な方法でデータをロードします。このメソッドは通常、自分たちで定義するか、提供されるバッチ関数を呼び出すかのどちらかです。

こちらのにperformやbatch関数が実際に呼ばれるタイミングは実装や言語によって異なり、例えばGraphQL-Rubyではlazy executorがあり、クエリの単一レベルですべてのフィールドのResolverを実行が始まれば、それ以上進めなくなるまでPromiseを実行していきます。その後でLoaderにてバッチ関数を呼び出し、Promiseの結果がすべて返ってきた段階で実行を継続します。

https://graphql-ruby.org/schema/lazy_execution

まとめ

GraphQLのN+1問題の解決DataLoaderまわりはなかなか複雑で理解しづらいですが、動作をおいながら読むことで少しずつではありますがわかるようになってきました。

『Production Ready GraphQL』も良い本で勉強になるので、興味がある方はぜひ読んでみていただければと思います。

https://book.productionreadygraphql.com/

参考

https://dev.classmethod.jp/articles/graphql-dataloader-sample/

https://qiita.com/yuku_t/items/2c1735cbf45e75c0bfb8

https://zenn.dev/mybest/articles/a8f3096821851c#graphql-batchのloaderによるn%2B1対応

Discussion

ログインするとコメントできます