GraphQL BatchLoader

GitLabはバッチローダーRuby gemを使ってN+1 SQLクエリを最適化し、回避します。

このようなバッチ処理の機会を生み出すのは、GraphQLクエリツリーの特性です。切断されたノードは同じデータを必要とするかもしれませんが、自分自身について知ることはできません。

どのような場合に使用すべきでしょうか?

GraphQLクエリ実行中は、DBリクエストをできるだけバッチ処理するようにします。変異中のロードはシリアルに実行されるため、バッチ処理する必要はありません。データベースクエリを作成する必要があり、2つの類似した(必ずしも同一ではない)クエリを組み合わせることが可能な場合は、バッチローダーの使用を検討してください。

新しいエンドポイントを実装する際には、SQL クエリの数を最小限にすることを目指すべきです。安定性とスケーラビリティのために、クエリが N+1 パフォーマンスの問題に悩まされないようにしなければなりません。

実施

バッチロードは、入力Qα, Qβ, ... Qω に対する一連のクエリを、Q[α, β, ... ω] に対する単一のクエリにまとめることができる場合に便利です。この例として、IDによる検索があります。この場合、ユーザー名によって2人のユーザーを1人よりも安く見つけることができます。

バッチロードは、結果セットが異なるソート順、グループ化、集約、その他のComposerでない特徴を持つ場合には適していません。

バッチローダーをコードで使用するには、2つの方法があります。単純な ID 検索には::Gitlab::Graphql::Loaders::BatchModelLoader.new(model, id).find を使います。より複雑な場合は、バッチ API を直接使用します。

たとえば、username によってUser をロードするには、以下のようにバッチ処理を追加します:

class UserResolver < BaseResolver
  type UserType, null: true
  argument :username, ::GraphQL::Types::String, required: true

  def resolve(**args)
    BatchLoader::GraphQL.for(username).batch do |usernames, loader|
      User.by_username(usernames).each do |user|
        loader.call(user.username, user)
      end
    end
  end
end
  • username はクエリしたいユーザー名です。つの名前でも複数の名前でもかまいません。
  • loader.call は、結果を入力キーにマップするために使用します (ここでは、ユーザー名をユーザー名にマップしています)。
  • BatchLoader::GraphQL 遅延オブジェクトを返します (データを取得するための中断された約束)

BatchLoading メカニズムの使用方法を示すMR の例を示します。

そのBatchModelLoader

IDルックアップには、BatchModelLoader

def project
  ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, object.project_id).find
end

関連付けをプリロードするには、関連付けの配列を渡します:

def issue(lookahead:)
  preloads = [:author] if lookahead.selects?(:author)

  ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, object.issue_id, preloads).find
end

具体的にはどのように動作するのですか?

それぞれの遅延オブジェクトは、どのデータをロードする必要があるのか、どのようにクエリをバッチ処理するのかを知っています。遅延オブジェクトを使用する必要があるとき (#sync をコールすることでそれを通知します)、それらは現在のバッチ内の他のすべての類似オブジェクトと一緒にロードされます。

このブロックの内部で、アイテムのバッチクエリを実行します (User)。その後、BatchLoader::GraphQL.for メソッドで使用されたアイテム (usernames) とロードされたオブジェクト (user) を渡して、ローダーを呼び出すだけです:

BatchLoader::GraphQL.for(username).batch do |usernames, loader|
  User.by_username(usernames).each do |user|
    loader.call(user.username, user)
  end
end

バッチローダーはブロックのソースコードの場所を使用して、同じキューに属する リクエストを決定しますが、各バッチに対して評価されるブロックのインスタンスは 一つだけです。どれを評価するかは制御できません。

このため、以下のことが重要です:

  • ブロックはオブジェクトのインスタンス・ステートを参照(クローズ・オーバー)してはいけません。ベストプラクティスは、for(data) の呼び出しで、ブロックが必要とするすべてのデータをブロックに渡すことです。
  • ブロックは、バッチされたデータの種類に固有でなければなりません。汎用のローダー(BatchModelLoader など)を実装することも可能ですが、その場合、目的格のkey 引数を使用する必要があります。
  • バッチは、同じブロックを参照しない限り、共有されません - 同じ動作、パラメータ、キーを持つ2つの同じブロックは、共有されません。このため、決して自分でバッチIDルックアップを実装せず、BatchModelLoader 。2つのフィールドが同じバッチ・ローディングを定義している場合、そのフィールドを新しいLoader 、共有できるようにすることを検討してください。

lazyとはどういう意味ですか?

バッチの同期(評価の強制)は、早すぎないようにすることが重要です。次の例は、同期を早く呼び出すとバッチの機会が失われることを示しています。

この例では、x の同期を呼び出すのが早すぎます:

x = find_lazy(1)
y = find_lazy(2)

# calling .sync will flush the current batch and will inhibit maximum laziness
x.sync

z = find_lazy(3)

y.sync
z.sync

# => will run 2 queries

しかし、この例ではすべてのリクエストがキューに入るまで待ち、 余分なクエリを省いています:

x = find_lazy(1)
y = find_lazy(2)
z = find_lazy(3)

x.sync
y.sync
z.sync

# => will run 1 query
note
バッチローディングの使用には依存関係の解析はありません。リクエストの待ち行列があり、どれか一つの結果が必要になるとすぐに、 すべての待ち行列のリクエストが評価されます。

リゾルバのコードでbatch.sync をコールしたりLazy.force を使ったりしてはいけません。遅延値に依存する場合は、代わりにLazy.with_value を使用してください:

def publisher
  ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Publisher, object.publisher_id).find
end

# Here we need the publisher to generate the catalog URL
def catalog_url
  ::Gitlab::Graphql::Lazy.with_value(publisher) do |p|
    UrlHelpers.book_catalog_url(publisher, object.isbn)
  end
end

テスト

理想的には、すべてのテストをリクエスト仕様とSchema.execute を使って行うことです。そうすれば、遅延値のライフサイクルを自分で管理する必要がなくなり、正確な結果が保証されます。

遅延値を返すGraphQLフィールドは、これらの値をテストで強制する必要がある場合があります。強制とは、評価の明示的な要求のことで、通常はフレームワークによって調整されます。

遅延値を強制するには、GraphQLHelpers で利用可能なGraphqlHelpers#batch_sync メソッドを使用するか、Gitlab::Graphql::Lazy.forceを使用します。例えば

it 'returns data as a batch' do
  results = batch_sync(max_queries: 1) do
    [{ id: 1 }, { id: 2 }].map { |args| resolve(args) }
  end

  expect(results).to eq(expected_results)
end

def resolve(args = {}, context = { current_user: current_user })
  resolve(described_class, obj: obj, args: args, ctx: context)
end

QueryRecorderを使用して、呼び出しごとに1 つの SQL クエリのみを実行するようにすることもできます。

it 'executes only 1 SQL query' do
  query_count = ActiveRecord::QueryRecorder.new { subject }

  expect(query_count).not_to exceed_query_limit(1)
end