GraphQL BatchLoader

GitLab은 N+1 SQL 쿼리를 최적화하고 방지하기 위해 batch-loader Ruby gem을 사용합니다.

이는 GraphQL 쿼리 트리의 속성으로, 서로 연결되지 않은 노드가 동일한 데이터를 필요로 할 수 있지만 자신에 대해 알 수 없는 경우 배치를 위한 기회를 만듭니다.

언제 사용해야 하나요?

우리는 GraphQL 쿼리 실행 동안 DB 요청을 최대한 배치하려고 노력해야 합니다. 변경 동안에는 직렬로 실행되기 때문에 로딩을 배치할 필요가 없습니다. 데이터베이스 쿼리를 만들어야 하고, 두 개의 유사(하지만 반드시 동일하지는 않은) 쿼리를 결합할 수 있는 경우, batch-loader 사용을 고려하세요.

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

구현

일련의 쿼리 Qα, Qβ, ... QωQ[α, β, ... ω]에 대한 단일 쿼리로 결합할 수 있을 때 배치 로딩이 유용합니다. 예를 들면 ID로 인한 조회가 있으며, 사용자 이름으로 두 사용자를 한 번에 찾는 것이 더 저렴하지만, 현실적인 예는 더 복잡할 수 있습니다.

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

코드에 batch-loader를 사용하는 두 가지 방법이 있습니다. 간단한 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)를 구현하는 것은 가능하지만, injective key 인자가 필요합니다.
  • 배치는 동일한 블록을 참조하지 않는 한 공유되지 않습니다 - 동일한 동작, 매개변수 및 키를 가진 두 개의 동일한 블록은 공유되지 않습니다. 이러한 이유로, 최대한 공유를 위해서는 배치 ID 조회를 직접 구현하지 말고 BatchModelLoader를 사용하세요. 동일한 배치 로딩을 정의하는 두 개의 필드를 발견하면 이를 새로운 Loader로 추출하고 공유하도록 활성화하는 것을 고려하세요.

게으르다는 무엇을 의미합니까?

배치 동기화를 너무 일찍(평가를 강제) 하는 것을 피하는 것이 중요합니다. 다음 예제는 너무 일찍 sync를 호출하면 배치의 기회를 없앨 수 있음을 보여줍니다.

이 예제는 x에서 sync를 너무 일찍 호출합니다:

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

# .sync를 호출하면 현재 배치가 플러시되고 최대한의 게으름을 억제합니다
x.sync

z = find_lazy(3)

y.sync
z.sync

# => 2개의 쿼리를 실행합니다

하지만 이 예제는 모든 요청이 큐에 대기할 때까지 기다리며 추가 쿼리를 제거합니다:

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

x.sync
y.sync
z.sync

# => 1개의 쿼리를 실행합니다

참고: 배치 로딩 사용에 의한 의존성 분석이 없습니다. 요청의 보류 큐가 있으며, 어떤 결과가 필요해지면 모든 보류 요청이 평가됩니다.

결코 batch.sync를 호출하거나 해결자 코드에서 Lazy.force를 사용해서는 안 됩니다.

lazy 값에 의존하는 경우 대신 Lazy.with_value를 사용하세요:

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

# 여기서 우리는 카탈로그 URL을 생성하기 위해 퍼블리셔가 필요합니다
def catalog_url
  ::Gitlab::Graphql::Lazy.with_value(publisher) do |p|
    UrlHelpers.book_catalog_url(publisher, object.isbn)
  end
end

우리는 일반적으로 GitlabSchema.find_by_gid 또는 .object_from_id로 레코드를 찾은 후 변형에서 #sync를 사용합니다. 이 메서드들은 결과를 배치 로더 래퍼로 반환합니다. 변형은 직렬로 실행되므로 배치 로딩이 필요하지 않고 객체를 즉시 평가할 수 있습니다.

테스트

이상적으로는 모든 테스트를 요청 사양을 사용하고 Schema.execute를 사용하여 수행해야 합니다. 그렇게 하면 lazy 값의 생명 주기를 직접 관리할 필요가 없으며 정확한 결과를 보장받을 수 있습니다.

게으른 값을 반환하는 GraphQL 필드는 테스트에서 이러한 값을 강제로 평가해야 할 수도 있습니다. 강제는 일반적으로 프레임워크에 의해 조정되는 평가에 대한 명시적인 요구를 의미합니다.

GraphqlHelpers#batch_sync 메서드를 사용하여 lazy 값을 강제할 수 있으며, 이는 GraphQLHelpers에서 사용할 수 있습니다. 또는 Gitlab::Graphql::Lazy.force를 사용할 수 있습니다. 예를 들어:

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 '오직 1개의 SQL 쿼리만 실행합니다' do
  query_count = ActiveRecord::QueryRecorder.new { subject }

  expect(query_count).not_to exceed_query_limit(1)
end