GraphQL BatchLoader

GitLab은 batch-loader Ruby gem을 사용하여 최적화하고 N+1 SQL 쿼리를 피합니다.

BatchLoader는 GraphQL 쿼리 트리의 속성으로 인해이러한 배치 작업의 기회를 만듭니다. 연결되지 않은 노드들은 같은 데이터가 필요하지만 그들 자신에 대해 알지 못할 수 있습니다.

언제 사용해야 하나요?

GraphQL 쿼리 실행 동안 DB 요청을 최대한 많이 배치 처리해야 합니다. 뮤테이션의 경우 배치 처리가 필요하지 않습니다. 데이터베이스 쿼리를 수행해야하며 두 가지 유사한 (그러나 반드시 동일하지는 않은) 쿼리를 결합할 수 있다면 배치 로더를 사용하는 것을 고려해보십시오.

새로운 엔드포인트를 구현할 때 SQL 쿼리의 수를 최소화해야 합니다. 안정성과 확장 가능성을 위해 쿼리가 N+1 성능 문제에 영향을받지 않도록해야합니다.

구현

일련의 입력 Qα, Qβ, ... Qω에 대한 쿼리를 Q[α, β, ... ω]로 결합할 수있는 경우 배치로드는 유용합니다. 이 예로는 ID로 조회하는 경우가 있으며, 실제 세계의 예제는 더 복잡할 수 있습니다.

결과 세트가 서로 다른 정렬 순서, 그룹화, 집계 또는 다른 비합성 기능을 포함하는 경우 배치로드는 적합하지 않습니다.

코드에서 배치로드를 사용하는 두 가지 방법이 있습니다. 간단한 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은 결과를 입력 키(here user is mapped to its username)로 매핑하는데 사용됩니다.
  • 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

정확히 어떻게 작동합니까?

lazy 객체는 로드해야 할 데이터와 쿼리를 배치하는 방법을 알고 있습니다. lazy 객체를 사용해야하는 경우 (#sync를 호출하여 알릴 때), 현재 배치에 있는 모든 유사한 객체와 함께 로드됩니다.

하나의 배치를 실행하고 나면 이후에 모든 작업이 완료되는데, 우리는 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

batch-loader는 블록의 소스 코드 위치를 사용하여 동일한 큐에 속하는 요청을 결정하지만 각 배치에 대해 블록의 인스턴스 상태를 참조하지 않아야합니다. 가장 좋은 방법은 모든 데이터를 for(data) 호출을 통해 블록에 전달하는 것입니다.

배치로드 사용시 주의사항

  • 일반적인 모든 요청에서 batch.sync를 호출하지 마십시오. 대신 Lazy.with_value를 사용하십시오.

또한, 배치 로드를 사용할 때 의존성 분석을 하지 않아야합니다. 보류 중인 요청 대기열이 있으며 그 중 하나의 결과가 필요한 즉시 모든 보류 중인 요청이 평가됩니다.

테스트

이상적으로는 요청 스펙 및 Schema.execute를 사용하여 모든 테스트를 수행해야하며, 이렇게 함으로써 게으른(lazy) 값의 수명주기를 직접 관리할 필요가 없으며 정확한 결과를 보장받을 수 있습니다.

게으른(lazy) 값을 반환하는 GraphQL 필드의 경우, 테스트에서 이러한 값을 강제로 실행해야 할 수 있습니다. 여기서 강제 실행이란 일반적으로 프레임워크에서 안정적으로 처리되는 평가의 명시적 요구를 의미합니다.

GraphQLHelpersGraphqlHelpers#batch_sync 메서드를 사용하거나 Gitlab::Graphql::Lazy.force를 사용하여 게으른(lazy) 값을 강제로 실행할 수 있습니다. 예를 들어:

it '데이터를 일괄 처리로 반환합니다' 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를 사용하여 호출당 단 하나의 SQL 쿼리만 수행되는지 확인할 수 있습니다.

it '단 하나의 SQL 쿼리만을 실행합니다' do
  query_count = ActiveRecord::QueryRecorder.new { subject }

  expect(query_count).not_to exceed_query_limit(1)
end