배치 처리 최상의 방법론

이 문서는 GitLab에서 사용하는 사용 가능한 배치 전략에 대한 개요를 제공합니다. 각 전략의 장단점을 나열하여 엔지니어들이 자신의 사용 사례에 이상적인 접근 방식을 선택할 수 있도록 합니다.

배치 처리의 필요성

대량의 레코드를 처리할 때 한 번의 데이터베이스 쿼리에서 레코드를 읽거나 업데이트하거나 삭제하는 것은 어려울 수 있습니다. 작업이 쉽게 시간 초과될 수 있습니다. 이러한 문제를 피하기 위해 레코드를 일괄적으로 처리해야 합니다. 배치 처리는 보통 웹 요청 중에 보다는 런타임 제약이 덜한 백그라운드 작업에서 일어납니다.

백그라운드 작업 및 웹 요청에서 배치 처리 사용

드물게 (기존 기능 중) 배치 처리는 웹 요청에서도 이루어집니다. 그러나 새로운 기능에서는 기본 웹 요청 시간 제한(기본값 60초) 때문에 이는 권장되지 않습니다. 일반적으로 대량의 레코드를 처리해야 하는 기능을 구현할 때는 백그라운드 작업 (Sidekiq 워커)을 사용하는 것이 최선의 옵션으로 고려해야 합니다.

성능 고려사항

배치 처리 성능은 기본적으로 페이지네이션 성능과 관련이 있으며, 이에 따라 기본 라이브러리와 데이터베이스 쿼리가 핵심적으로 동일합니다. 배치 처리를 구현할 때는 페이지네이션 성능 가이드라인 및 우리의 배치 처리 유틸리티에 대한 문서를 잘 숙지하는 것이 중요합니다.

백그라운드 작업에서의 배치 처리

백그라운드 작업에서 배치 처리를 구현할 때 고려해야 할 주요 측면은 총 런타임과 데이터 수정 볼륨입니다.

백그라운드 작업은 오랫동안 실행되어서는 안 됩니다. Sidekiq 프로세스는 충돌할 수 있거나 강제로 중지될 수 있습니다(예: 다시 시작 또는 배포 시). 또한 에러 예산 규칙에 따르면 실행 시간이 5분을 초과하면 해당 기능이 등록된 그룹에 에러 예산 위반 사항이 추가됩니다. 배치 처리를 구현할 때는 동질적 작업 관련 가이드라인에 익숙해야 합니다.

대량의 레코드를 업데이트하거나 삭제하면 데이터베이스 복제 지연이 증가하고 주 데이터베이스에 부하가 추가될 수 있습니다. 처리하는 레코드의 총 수를 제한하는 것이 좋습니다.

위에서 언급한 잠재적 문제를 해결하기 위해 다음과 같은 조치를 고려해야 합니다.

  • 작업의 총 런타임 제한
  • 레코드 수정 제한
  • 배치 간의 쉬는 시간(수밀리초)

한편, 규칙을 적용할 때 중요한 점은 긴 실행 시간을 가진 백그라운드 작업이 배치 처리가 중지된 시점에서 신규 작업을 예약하여 작업을 계속할 수 있는 “나중에 계속” 메커니즘을 구현해야 합니다. 이는 5분의 실행 시간에 맞지 않을 수 있기 때문에 중요한 사항입니다.

Gitlab::Metrics::RuntimeLimiter 클래스를 사용하여 런타임 제한을 예시로 들면 다음과 같습니다.

def perform(project_id)
  runtime_limiter = Gitlab::Metrics::RuntimeLimiter.new(3.minutes)

  project = Project.find(1)
  project.issues.each_batch(of: :iid) do |scope|
    scope.update_all(updated_at: Time.current)
    break if runtime_limiter.over_time?
  end
end

이 코드 스니펫에서 3분의 런타임이 경과하면 배치 처리가 중지됩니다. 이제 문제는 처리를 계속할 수 있는 방법이 없다는 것입니다. 이를 위해 처리를 계속하기 위한 충분한 정보를 제공하여 새로운 백그라운드 작업을 예약해야 합니다. 이제 우리는 프로젝트 내의 이슈를 iid 열을 기준으로 배치 처리하는 예제입니다. 다음 작업에는 프로젝트 ID 및 마지막으로 처리된 iid 값을 제공해야 합니다. 이 정보는 보통 커서라고 부릅니다.

def perform(project_id, iid = nil)
  runtime_limiter = Gitlab::Metrics::RuntimeLimiter.new(3.minutes)

  project = Project.find(project_id)
  # 매개변수로 전달된 iid가 존재하면 이를 사용하여 이전의 iid를 복원합니다
  project.issues.where('iid > ?', iid || 0).each_batch(of: :iid) do |scope|
    max_iid = scope.maximum(:iid)
    scope.update_all(updated_at: Time.current)

    if runtime_limiter.over_time?
      MyJob.perform_in(2.minutes, project_id, iid)

      break
    end
  end
end

“나중에 계속” 메커니즘을 구현하는 것은 구현을 상당히 복잡하게 만들 수 있습니다. 따라서 이 작업에 착수하기 전에 프로덕션 데이터를 분석하고 데이터 성장을 추정해보는 것이 중요합니다. 다음은 몇 가지 예시입니다.

  • 사용자의 모든 보류 중 할 일을 완료됨으로 표시하는 경우 “나중에 계속” 메커니즘은 필요하지 않습니다.
    • Reasoning: 가장 바쁜 사용자의 경우에도 보류 중인 할 일의 수는 아마도 최대 수천 개의 데이터베이스 행을 초과하지 않을 것입니다. 이러한 행을 업데이트하는 것은 99.9%의 경우 1분 미만에 완료될 것입니다.
  • 특정 프로젝트 내에서 CI 작업 레코드를 CSV 파일에 저장한다면 “나중에 계속” 메커니즘이 필요할 수 있습니다.
    • Reasoning: 매우 활발한 프로젝트의 경우 CI 작업 수는 수백만 개의 행까지 급격히 증가할 수 있습니다.

백그라운드 작업에서 매우 큰 규모의 업데이트가 발생하는 경우(엄격한 요구 사항은 아님) 코드에 약간의 대기 시간을 추가하고 업데이트할 레코드의 총 수를 제한하는 것이 좋습니다. 이를 통해 주 데이터베이스에 가해지는 압력을 줄이고 잠재적 데이터베이스 마이그레이션에 더 무겁게 잠금을 확보할 시간 창을 제공합니다.

def perform(project_id, iid = nil)
  max_updates = 100_000 # 최대 N개의 업데이트 허용
  updates = 0
  status = :completed
  runtime_limiter = Gitlab::Metrics::RuntimeLimiter.new(3.minutes)

  project = Project.find(project_id)
  project.issues.where('iid > ?', iid || 0).each_batch(of: :iid) do |scope|
    max_iid = scope.maximum(:iid)
    updates += scope.update_all(updated_at: Time.current)

    if runtime_limiter.over_time? || updates >= max_updates
      MyJob.perform_in(2.minutes, project_id, iid)
      status = :limit_reached

      break
    end

    # 우리가 긴 실행 시간의 배치 처리를 예상하는 경우 대기 시간 추가 및 업데이트할 총 레코드 수 제한
    sleep 0.01
  end
end

추적성

추적성을 위해 배치 처리의 수행 상황을 Kibana에서 확인할 수 있도록 메트릭을 노출하는 것이 좋은 실천입니다.

log_extra_metadata_on_done(:result, {
  status: :limit_reached, # 또는 :completed
  updated_rows: updates
})

다음 작업 스케줄링

위의 예시에서 다음 작업을 스케줄링하는 것은 충돌로부터 안전하지 않습니다 (작업이 손실될 수 있음), 매우 중요한 작업의 경우 이 접근 방식은 적합하지 않습니다. 안전하고 일반적인 패턴은 커서를 기반으로 작업을 실행하는 예약된 워커를 사용하는 것입니다. 커서는 일관성 요구사항에 따라 DB 또는 Redis에 유지될 수 있습니다. 이는 커서가 더 이상 작업 인수를 통과하지 않음을 의미합니다.

예약된 워커의 빈도는 작업의 긴급성에 따라 조정될 수 있습니다. 우리는 긴급한 항목을 처리하기 위해 매분마다 예약된 워커가 큐에 추가된 예제를 가지고 있습니다.

Redis 기반 커서

예: 프로젝트의 모든 이슈 처리.

def perform
  project_id, iid = load_cursor # Redis에서 커서 로드

  return unless project_id # 큐에 아무것도 추가되지 않음

  project = Project.find(project_id)
  project.issues.where('iid > ?', iid || 0).each_batch(of: :iid) do |scope|
    # 이슈 처리
    # 여기에서 중지하고 시간 제한이 초과되었을 때 중단된 플래그를 설정합니다.
    # 처리된 마지막 값을 iid로 설정합니다.
  end

  # 나중에 작업 계속
  push_cursor(project_id, iid) if interrupted?
end

private

def load_cursor
  # 1 요소 가져오기, 충돌 방지 안됨.
  raw_cursor = Gitlab::Redis::SharedState.with do |redis|
    redis.lpop('my_cursor')
  end

  return unless raw_cursor

  cursor = Gitlab::Json.parse(raw_cursor)
  [cursor['project_id'], cursor['iid']]
end

def push_cursor(project_id, iid)
  # 작업이 완료되지 않았으므로 다음 작업에서 커서를 가져올 수 있도록 목록의 시작에 커서를 넣습니다.
  Gitlab::Redis::SharedState.with do |redis|
    redis.lpush('my_cursor', Gitlab::Json.dump({ project_id: project_id, iid: iid }))
  end
end

애플리케이션 코드에서 데이터베이스 트랜잭션 커밋 후에 큐에 아이템을 넣을 수 있습니다(자세한 내용은 트랜잭션 가이드라인 참조):

def execute
  ApplicationRecord.transaction do
    user.save!
    Event.create!(user: user, issue: issue)
  end

  # 여기에서 애플리케이션이 충돌할 수 있습니다.

  MyRedieQueue.add(user: user, issue: issue)
end

이러한 방식은 충돌 방지가 되지 않으며, 트랜잭션이 커밋된 후 애플리케이션이 충돌하는 경우 해당 항목이 큐에 추가되지 않을 수 있습니다.

장점:

  • 추적을 위한 별도의 데이터베이스 테이블이 필요하지 않아 구현이 쉽습니다.
  • 내부 호출된 작업에 대해 저 처리량에 좋습니다. (예: 전체 테이블 주기적 일관성 검사, 백그라운드 집계)

단점:

  • 작업을 예약하는 것(커서를 큐에 넣는 것)이 충돌 방지가 되지 않습니다.
  • 커서를 읽을 때 잠재적 직렬화 문제(다중 버전 호환성).
  • 데이터베이스 트랜잭션에 대한 추가 주의가 필요합니다.

PostgreSQL 기반 커서

대안적인 접근 방식은 PostgreSQL 데이터베이스에 큐를 저장하는 것입니다. 이 경우, 애플리케이션(웹 또는 워커) 충돌의 경우 일관성을 보장하는 트랜잭션 아웃박스 패턴을 구현할 수 있습니다.

장점:

  • 작업을 예약하는 것을 다른 레코드 변경과 완전히 일관성 있게 할 수 있습니다 (예: 이슈 생성 트랜잭션 내부에서 작업 예약).
  • 큐에 대거의 아이템을 가용할 수 있습니다.

단점:

  • 볼륨에 따라 구현은 매우 복잡해질 수 있습니다.

예: 이메일 보내는 신뢰할 수 있는 방법 설정

# 서비스 내
def execute
  ApplicationRecord.transaction do
    user.save!
    Event.create!(user: user, issue: issue)
    IssueEmailWorkerQueue.insert!(user: user, issue: issue)
  end
end

IssueEmailWorkerQueue 레코드는 작업을 실행하기 위한 모든 필수 정보를 저장합니다. 예약된 백그라운드 작업에서 테이블을 특정 순서대로 처리할 수 있습니다.

def perform
  runtime_limiter = Gitlab::Metrics::RuntimeLimiter.new(3.minutes)
  items = EmailWorkerQueue.order(:id).take(25)

  items.each do |item|
    # 아이템 처리
  end
end

참고: 레코드의 병렬 처리를 피하기 위해 분산 Redis 락으로 실행을 포장할 수도 있습니다.

예제 Redis 락 사용:

class MyJob
  include ApplicationWorker
  include Gitlab::ExclusiveLeaseHelpers

  MAX_TTL = 2.5.minutes.to_i # 런타임 제한과 유사해야 합니다.

  def perform
    in_lock('my_lock_key', ttl: MAX_TTL, retries: 0) do
      # 여기에서 작업을 수행합니다.
    end
  end
end

Sidekiq 작업에 대한 고려

Sidekiq 작업은 상당한 데이터베이스 리소스를 사용할 수 있습니다. 작업이 데이터베이스에서 데이터를 배치 처리하지만 데이터를 수정하지 않는 경우, 데이터베이스 복제를 선호하는 속성을 설정하는 것을 고려하세요. Sidekiq 워커 속성 문서를 참조하세요.

배치 전략

참고: 예시를 쉽게 이해하기 위해 런타임 제한 코드를 생략합니다.

참고: 일부 예에서 선택적으로 cursor 변수에 값을 할당합니다. 이는 “나중에 계속” 메커니즘을 구현할 때 사용할 수 있는 선택 사항입니다.

루프 기반 배치

이 전략은 데이터베이스에서 레코드를 업데이트하거나 삭제한 후, 정확히 같은 쿼리가 다른 레코드를 반환한다는 사실을 활용합니다. 이 전략은 특정 레코드를 삭제 또는 업데이트하고 싶을 때만 사용할 수 있습니다.

예시:

loop do
  # project_id에 대한 인덱스가 필요합니다.
  delete_count = project.issues.limit(1000).delete_all
  break if delete_count == 0 # 삭제할 레코드가 없는 경우 루프를 종료합니다.
end

장점:

  • 구현하기 쉽고, 커서를 유지할 필요가 없습니다.
  • 단일 열 데이터베이스 인덱스는 종종 사용 가능한 배치를 구현하는 데 충분합니다(외래 키).
  • 순서가 중요하지 않다면, 복잡한 필터 조건을 사용할 수 있습니다. (인덱스로 covered만 하면 됨)

단점:

  • DELETE 또는 UPDATE 쿼리의 기반을 꼼꼼히 테스트하고 수동으로 검증해야 합니다. 레코드를 업데이트하거나 삭제할 때 CTEs에 문제가 있습니다.
  • break 로직에 버그가 있는 경우 무한 루프에 빠질 수 있습니다.

루프 기반 방식으로 특정 순서로 레코드를 처리할 수 있습니다:

loop do
  # (project_id, created_at)에 대한 복합 인덱스가 필요합니다.
  delete_count = project.issues.limit(1000).order(created_at: :desc).delete_all
  break if delete_count == 0
end

이전 예시에서 언급한 인덱스를 사용하여 timestamp 조건을 사용할 수도 있습니다:

loop do
  # (project_id, created_at)에 대한 복합 인덱스가 필요합니다.
  delete_count = project
    .issues
    .where('created_at < ?', 1.month.ago)
    .limit(1000)
    .order(created_at: :desc)
    .delete_all

  break if delete_count == 0
end

단일 열 일괄 처리

EachBatch 모듈을 사용하여 단일 고유 열(기본 키 또는 고유 인덱스가 있는 열)을 일괄 처리할 수 있습니다. 이것은 GitLab에서 가장 흔히 사용되는 배치 전략 중 하나입니다.

# (project_id, id)에 대한 복합 인덱스가 필요합니다.
# EachBatch는 일괄 처리에 기본적으로 기본 키를 사용합니다.
cursor = nil
project.issues.where('id > ?', cursor || 0).each_batch do |batch|
  issues = batch.to_a
  cursor = issues.last.id # 다음 작업을 위해

  # issues 레코드를 사용하는 작업을 수행합니다
end

장점:

  • GitLab 애플리케이션 내에서 가장 인기 있는 일괄 처리 방법입니다.
  • 구현이 쉽고 다양한 사용 사례를 포함합니다.

단점:

  • 쿼리의 맥락에서 ORDER BY 열(ID)이 고유해야 합니다.
  • timestamp 열 조건 또는 다른 복잡한 조건 (IN, NOT EXISTS)이 있는 경우 효율적으로 작동하지 않습니다.

고유한 값에 대한 일괄 처리

EachBatch는 일반적으로 고유한 데이터베이스 열(일반적으로 ID 열)이 필요하지만, 고유하지 않은 열에 대해 일괄 처리해야 하는 드문 경우가 있습니다. 예: 하나 이상의 이슈가 있는 모든 프로젝트 timestamp 값을 증가시킵니다.

한 가지 접근법은 “상위” 테이블을 일괄 처리하는 것인데, 이 경우 Project 모델을 사용합니다.

cursor = nil
# 기본 키 인덱스를 사용합니다
Project.where('id > ?', cursor || 0).each_batch do |batch|
  cursor = batch.maximum(:id) # 다음 작업을 위해

  project_ids = batch
    .where('EXISTS (SELECT 1 FROM issues WHERE projects.id=issues.project_id)')
    .pluck(:id)

  Project.where(id: project_ids).update_all(update_all: Time.current)
end

장점:

  • 열이 외래 키인 경우, 상위 테이블의 기본 키는 이미 인덱싱되었을 것입니다.

단점:

  • 블록 내의 추가 조건이 일치하는 행이 적은 경우 낭비될 수 있습니다.

일괄 처리 쿼리는 projects 테이블 전체를 스캔할 수 있는데, 대안으로 distinct_each_batch 도우미 메서드를 사용할 수 있습니다.

# (project_id)에 대한 인덱스가 필요합니다
Issue.distinct_each_batch(column: :project_id) do |scope|
  project_ids = scope.pluck(:project_id)
  cursor = project_ids.last # 다음 작업을 위해

  Project.where(id: project_ids).update_all(update_all: Time.current)
end

장점:

  • 열이 외래 키 열인 경우 이미 인덱스가 있을 것입니다.
  • 일괄 처리 논리가 스캔해야 하는 데이터 양을 크게 줄일 수 있습니다.

단점:

  • 사용 범위가 제한적이며 널리 사용되지 않습니다.

키셋 기반 일괄 처리

키셋 기반 일괄 처리를 사용하면 다중 열 정렬도 가능한 특정 순서로 레코드를 반복할 수 있습니다. 가장 일반적인 사용 사례는 timestamp 열을 통해 데이터를 처리해야 하는 경우입니다.

예: 1년 이전에 생성된 이슈 레코드 삭제하기.

def perform
  cursor = load_cursor || {}
  # (created_at, id) 열에 대한 복합 인덱스가 필요합니다
  scope = Issue.where('created_at > ?', 1.year.ago).order(:created_at, :id)

  iterator = Gitlab::Pagination::Keyset::Iterator.new(scope: scope, cursor: cursor)

  iterator.each_batch(of: 100) do |records|
    loaded_records = records.to_a

    loaded_records.each { |record| record.destroy } # 콜백이 호출되도록 destroy 호출
  end

  cursor = iterator.send(:cursor) # 다음 작업을 위해 커서 저장
end

키셋 기반 일괄 처리를 사용하면 기존 인덱스의 열 구성과 일치하는 ORDER BY 절을 조정할 수 있습니다. 다음과 같은 인덱스를 고려해보세요.

CREATE INDEX issues_search_index ON issues (project_id, state, created_at, id)

위 스니펫에서 사용된 ORDER BY 열 목록이 인덱스 정의의 열 목록과 정확히 일치하지 않기 때문에 이 인덱스를 사용할 수 없습니다. 그러나 ORDER BY 절을 수정하면 쿼리 플래너가 인덱스를 선택할 수 있습니다.

# 참고: 이는 다른 정렬 순서이지만 기존 인덱스를 사용할 수 있습니다
scope = Issue.where('created_at > ?', 1.year.ago).order(:project_id, :state, :created_at, :id)

장점:

  • 다중 열 정렬 순서 및 더 복잡한 필터링이 가능합니다.
  • 새로운 인덱스를 도입하지 않고 기존 인덱스를 재사용할 수 있습니다.

단점:

  • 커서 크기가 커질 수 있습니다 (ORDER BY 열마다 커서에 저장됨).

오프셋 일괄 처리

이 일괄 처리 기술은 새로운 레코드를로드할 때 오프셋 페이지네이션을 사용합니다. 주어진 쿼리를 EachBatch나 키셋 페이지네이션을 통해 페이지 나눌 수 없을 때만 오프셋 페이지네이션을 사용해야 합니다. 이 기술을 선택하는 이유 중 하나는 SQL 쿼리에 적합한 인덱스가 없어 다른 일괄 처리 기술을 사용할 수 없을 때입니다. 예: 백그라운드 작업에서 레코드를 제한 없이로드하고 시간이 초과되기 시작했습니다. 레코드의 순서가 중요합니다.

def perform(project_id)
  # (project_id, created_at) 열에 대한 복합 인덱스가 있습니다
  issues = Issue
    .where(project_id: project_id)
    .order(:created_at)
    .to_a

  # issues와 무언가를 수행합니다
end

프로젝트 내의 이슈 수가 증가함에 따라 쿼리가 느려져 최종적으로 시간이 초과됩니다. 희망적으로는 created_at, id 열에 대해 정렬해야 하지만 해당 인덱스를 사용할 수 없습니다. 시간이 민감한 시나리오(인시던트와 같은)에서 새로운 인덱스를 즉시 도입하기는 불가능할 수 있으므로 마지막 수단으로 오프셋 페이지네이션을 시도할 수 있습니다.

def perform(project_id)
  page = 1

  loop do
    issues = Issue.where(project_id: project_id).order(:created_at).page(page).to_a
    page +=1
    break if issues.empty?

    # issues와 무언가를 수행합니다
  end
end

위 스니펫은 적절한 솔루션이 도입될 때까지의 단기적인 해결책이 될 수 있습니다. 오프셋 페이지네이션은 페이지 번호가 증가함에 따라 선형적으로 성능이 저하되므로 처음의 쿼리와 마찬가지로 타임아웃될 수 있습니다. 디비 버퍼 캐시는 이전에 로드된 레코드를 메모리에 유지하기 때문에 연이은 (단기적) 동일한 행의 조회가 성능에 매우 높은 영향을 미치지 않을 수 있습니다.

장점:

  • 구현이 쉽습니다.

단점:

  • 페이지 번호가 증가함에 따라 성능이 선형적으로 저하됩니다.
  • 새로운 기능에 사용해서는 안 되는 임시 조치입니다.
  • 페이지 번호를 커서로 저장할 수 있지만 이전 지점에서 처리를 복원하는 것이 신뢰성이 떨어질 수 있습니다.

그룹 계층 구조 배치 처리

우리는 최상위 네임스페이스 및 해당 서브그룹에서 데이터를 조회해야 하는 여러 기능을 갖고 있습니다. 몇 천 개의 서브그룹 또는 프로젝트가 포함된 이상적이지 않은 그룹 계층도 있습니다. 추가 서브쿼리나 조인이 추가될 경우 이러한 계층을 조회하는 것은 쉽게 데이터베이스 문제 시간 초과로 이어질 수 있습니다.

예: 그룹 내 이슈를 반복

group = Group.find(9970)

Issue.where(project_id: group.all_project_ids).each_batch do |scope|
  # 이슈에 대한 작업 수행
end

위의 예시는 모든 서브그룹, 모든 프로젝트 및 모든 이슈를 그룹 계층에서 로드하게 되어 데이터베이스 문제 시간 초과를 유발할 가능성이 매우 높습니다. 단기적인 해결책으로 데이터베이스 인덱스를 사용하여 이 쿼리를 약간 개선할 수 있습니다.

in 연산자 최적화 사용

특정 순서로 그룹 내 레코드를 처리해야 할 때 표준 each_batch 기반 배치 전략보다 더 나은 성능을 제공할 수 있는in 연산자 최적화를 사용할 수 있습니다.

그룹 계층에서 레코드를 반복하는 예는 여기에서 확인할 수 있습니다.

장점:

  • 특정 순서로 그룹 계층 내에서 효율적으로 배치 처리할 수 있는 유일한 방법입니다.

단점:

  • 보다 복잡한 설정이 필요합니다.
  • 매우 큰 계층(많은 수의 프로젝트나 서브그룹)을 대상으로 배치 처리하는 경우 배치 크기가 작아야 합니다.

항상 최상위 그룹부터 배치 처리

이 기법은 항상 최상위 그룹(부모 그룹이 없는 그룹)에서 배치 처리해야 할 때 사용할 수 있습니다. 이 경우 namespaces 테이블의 다음 인덱스를 활용할 수 있습니다.

"index_on_namespaces_namespaces_by_top_level_namespace" btree ((traversal_ids[1]), type, id) -- traversal_ids[1]는 최상위 그룹 ID입니다

배치 처리 쿼리의 예시:

Namespace.where('traversal_ids[1] = ?', 9970).where(type: 'Project').each_batch do |project_namespaces|
  project_ids = Project.where(project_namespace_id: project_namespaces.select(:id)).pluck(:id)
  cursor = project_namespaces.last.id # 다음 작업을 위한 커서

  project_ids.each do |project_id|
    Issue.where(project_id: project_id).each_batch(column: :iid) do |issues|
      # 이슈에 대한 작업 수행
    end
  end
end

장점:

  • 전체 그룹 계층을 로드하는 것을 피할 수 있습니다.
  • 중첩된 EachBatch를 사용하여 균일하게 분산된 배치 처리를 수행합니다.

단점:

  • 이중 배치 처리로 인해 더 많은 데이터베이스 쿼리가 필요합니다.

그룹 계층의 어떤 노드에서나 배치 처리

NamespaceEachBatch 클래스를 사용하면 그룹 계층(트리)의 특정 분기를 배치 처리할 수 있습니다.

# current_id: 반복 작업을 시작하는 네임스페이스 레코드의 ID
# depth: 이전에 작업이 중지된 트리의 깊이. 초기에는 현재_id와 동일해야 합니다
cursor = { current_id: 9970, depth: [9970] } # 어떤 네임스페이스 ID든 가능합니다
iterator = Gitlab::Database::NamespaceEachBatch.new(namespace_class: Namespace, cursor: cursor)

# (parent_id, id) 열에 대한 복합 인덱스가 필요합니다
iterator.each_batch(of: 100) do |ids, new_cursor|
  namespace_ids = Namespaces::ProjectNamespace.where(id: ids)
  cursor = new_cursor # 다음 작업을 위해 새로운 current_id 및 depth 값을 포함합니다

  project_ids = Project.where(project_namespace_id: namespace_ids)
  project_ids.each do |project_id|
    Issue.where(project_id: project_id).each_batch(column: :iid) do |issues|
      # 이슈에 대한 작업 수행
    end
  end
end

장점:

  • 어떤 노드에서나 그룹 계층을 처리할 수 있습니다.

단점:

  • 드물게 사용되며 매우 드문 사용 사례에서 유용합니다.

복잡한 쿼리의 배치 처리

쿼리에 여러 필터와 조인이 포함된 복잡한 쿼리를 고려합니다. 대부분의 경우 이러한 쿼리는 쉽게 배치 처리할 수 없습니다. 몇 가지 예시:

  • JOIN을 사용하여 행을 필터링합니다.
  • 서브쿼리 사용.
  • 여러 IN 필터 또는 복잡한 AND 또는 OR 조건 사용.