InfoGrab DocsInfoGrab Docs

배치 처리 모범 사례

요약

이 문서는 GitLab에서 사용하는 배치 처리 전략을 개괄합니다. 대량의 레코드를 다룰 때 하나의 데이터베이스 쿼리로 레코드를 읽거나 업데이트하거나 삭제하기는 어려울 수 있습니다. 드물게(오래된 기능에서) 웹 요청에서도 배치 처리가 이루어집니다.

이 문서는 GitLab에서 사용하는 배치 처리 전략을 개괄합니다. 엔지니어가 자신의 사용 사례에 맞는 최적의 접근 방식을 고를 수 있도록 각 전략의 장단점을 정리했습니다.

배치 처리가 필요한 이유#

대량의 레코드를 다룰 때 하나의 데이터베이스 쿼리로 레코드를 읽거나 업데이트하거나 삭제하기는 어려울 수 있습니다. 이러한 작업은 쉽게 타임아웃될 수 있습니다. 이 문제를 피하려면 레코드를 배치 단위로 처리해야 합니다. 배치 처리는 보통 백그라운드 job에서 이루어지며, 백그라운드 job은 웹 요청보다 런타임 제약이 완화되어 있습니다.

배치 처리는 웹 요청이 아니라 백그라운드 job에서 사용#

드물게(오래된 기능에서) 웹 요청에서도 배치 처리가 이루어집니다. 그러나 새 기능에서는 웹 요청 타임아웃이 짧기 때문에(기본값 60초) 권장하지 않습니다. 지침으로서, 대량의 레코드를 처리해야 하는 기능을 구현할 때는 백그라운드 job(Sidekiq 워커)을 첫 번째 방안으로 고려해야 합니다.

성능 고려 사항#

배치 처리 성능은 기반 라이브러리와 데이터베이스 쿼리가 본질적으로 같기 때문에 페이지네이션 성능과 밀접하게 관련되어 있습니다. 배치 처리를 구현할 때는 페이지네이션 성능 가이드라인과 배치 처리 유틸리티 관련 문서를 숙지하는 것이 중요합니다.

백그라운드 job에서의 배치 처리#

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

백그라운드 job은 오랫동안 실행되어서는 안 됩니다. Sidekiq 프로세스는 충돌할 수도 있고 강제로 중지될 수도 있습니다(예: 재시작 또는 배포 시). 또한 오류 예산 규칙에 따라 런타임이 5분을 넘기면 해당 기능이 등록된 그룹에 오류 예산 위반이 추가됩니다. 백그라운드 job에서 배치 처리를 구현할 때는 멱등성 job 관련 가이드라인을 숙지해야 합니다.

대량의 레코드를 업데이트하거나 삭제하면 데이터베이스 복제 지연이 늘어나고 주 데이터베이스에 부하가 더해질 수 있습니다. 백그라운드 job 안에서 처리(또는 배치 처리)하는 레코드의 총 개수를 제한하는 것이 바람직합니다.

위에서 언급한 잠재적 문제를 해결하려면 다음 조치를 고려해야 합니다:

  • job의 총 런타임을 제한합니다.
  • 레코드 수정을 제한합니다.
  • 배치 사이에 휴식 시간(몇 밀리초)을 둡니다.

제한을 적용할 때 중요한 점은, 장시간 실행되는 백그라운드 job이라면 제한에 도달한 뒤 배치 처리가 멈춘 지점부터 작업을 이어가도록 새 job을 예약하는 "나중에 계속하기(continue later)" 메커니즘을 구현해야 한다는 것입니다. 이는 job이 너무 길어서 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분에 도달하면 멈춥니다. 여기서 문제는 처리를 이어갈 방법이 없다는 점입니다. 처리를 이어가려면 이어서 처리할 수 있을 만큼 충분한 정보를 담은 새 백그라운드 job을 예약해야 합니다. 스니펫에서는 프로젝트 안의 이슈를 iid 칼럼으로 배치 처리합니다. 다음 job에는 프로젝트 ID와 마지막으로 처리한 iid 값을 넘겨야 합니다. 이 정보를 흔히 커서(cursor)라고 부릅니다.

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

  project = Project.find(project_id)
  # Restore the previous iid if present
  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, max_iid)

      break
    end
  end
end

"나중에 계속하기" 메커니즘을 구현하면 구현 복잡도가 크게 올라갈 수 있습니다. 따라서 이 작업에 착수하기 전에 프로덕션 데이터베이스의 기존 데이터를 분석하고 데이터 증가를 추정해 봅니다. 몇 가지 예시입니다:

  • 특정 사용자의 모든 pending 할 일을 done으로 표시하는 작업에는 "나중에 계속하기" 메커니즘이 필요하지 않습니다.
    • 이유: 아무리 바쁜 사용자라도 대기 중인 할 일의 수가 데이터베이스 행 수천 개를 넘지 않을 가능성이 높습니다. 이 행들을 업데이트하는 작업은 99.9%가 1분 이내에 끝납니다.
  • 특정 프로젝트의 CI 빌드 레코드를 CSV 파일에 저장하는 작업에는 "나중에 계속하기" 메커니즘이 필요할 수 있습니다.
    • 이유: 매우 활발한 프로젝트에서는 CI job 수가 수백만 행까지 매우 빠르게 늘어날 수 있습니다.

백그라운드 job에서 매우 많은 양의 업데이트가 일어날 때는 코드에 슬립을 조금 넣고 업데이트하는 레코드의 총 개수를 제한하는 것이 바람직합니다(엄격한 요구 사항은 아닙니다). 이렇게 하면 주 데이터베이스에 걸리는 부담이 줄고, 더 무거운 잠금을 획득해야 하는 데이터베이스 마이그레이션에 짧은 시간 여유를 줄 수 있습니다.

def perform(project_id, iid = nil)
  max_updates = 100_000 # Allow maximum N updates
  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, max_iid)
      status = :limit_reached

      break
    end

    # Adding sleep when we expect long running batching that modifies large volume of data
    sleep 0.01
  end
end

추적 가능성#

추적 가능성을 위해 Kibana에서 배치 처리 성능을 확인할 수 있도록 메트릭을 노출하는 것이 좋습니다:

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

다음 job 예약#

위 예시에서 다음 job을 예약하는 방식은 충돌에 안전하지 않으므로(job이 유실될 수 있습니다) 매우 중요한 작업에는 적합하지 않습니다. 안전하고 널리 쓰이는 패턴은 커서를 기준으로 작업을 실행하는 예약 워커를 사용하는 것입니다. 커서는 일관성 요구 사항에 따라 데이터베이스(DB)나 Redis에 저장할 수 있습니다. 즉, 커서를 job 인수로 넘기지 않게 됩니다.

예약 워커의 실행 빈도는 작업의 긴급도에 따라 조정할 수 있습니다. 긴급한 항목을 처리하기 위해 예약 워커를 매 분 큐에 넣는 예시도 있습니다.

Redis 기반 커서#

예시: 프로젝트의 모든 이슈를 처리합니다.

def perform
  project_id, iid = load_cursor # Load cursor from Redis

  return unless project_id # Nothing was enqueued

  project = Project.find(project_id)
  project.issues.where('iid > ?', iid || 0).each_batch(of: :iid) do |scope|
    # Do something with issues.
    # Break here, set interrupted flag if time limit is up.
    # Set iid to the last processed value.
  end

  # Continue the work later
  push_cursor(project_id, iid) if interrupted?
end

private

def load_cursor
  # Take 1 element, not crash safe.
  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)
  # Work is not finished, put the cursor at the beginning of the list so the next job can pick it up.
  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

  # Application could crash here

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

이 방식은 충돌에 안전하지 않습니다. 트랜잭션이 커밋된 직후에 애플리케이션이 충돌하면 항목이 큐에 들어가지 않습니다.

장점:

  • 구현하기가 더 쉽고, job을 추적하기 위한 별도의 데이터베이스 테이블이 필요하지 않습니다.
  • 처리량이 적고 내부에서 호출되는 job에 적합합니다. (예: 전체 테이블 주기적 일관성 검사, 백그라운드 집계)

단점:

  • 작업을 예약하는 일(커서를 큐에 넣는 일)이 충돌에 안전하지 않습니다.
  • 커서를 읽을 때 직렬화 문제가 생길 수 있습니다(다중 버전 호환성).
  • 데이터베이스 트랜잭션에 각별히 주의해야 합니다.

PostgreSQL 기반 커서#

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

장점:

  • 작업 예약을 다른 레코드 변경과 완전히 일관되게 만들 수 있습니다(예: 이슈 생성 트랜잭션 안에서 작업을 예약).
  • 큐에 항목이 많아도 견딜 수 있습니다.

단점:

  • 볼륨에 따라 구현이 상당히 복잡해질 수 있습니다:
    • 파티셔닝된 데이터베이스 테이블: 처리량이 많은 워커에서는 이 방식을 고려해야 합니다.
    • 슬라이딩 윈도 파티셔닝 전략을 고려합니다.
    • 복잡한 파티션 간 쿼리가 생깁니다.

예시: 이메일을 안정적으로 보내는 방법을 구성합니다

# In a service
def execute
  ApplicationRecord.transaction do
    user.save!
    Event.create!(user: user, issue: issue)
    IssueEmailWorkerQueue.insert!(user: user, issue: issue)
  end
end

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

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

  items.each do |item|
    # Do something with the item
  end
end
Note

레코드가 병렬로 처리되는 것을 막으려면 실행을 분산 Redis 잠금으로 감싸야 할 수 있습니다.

Redis 잠금 사용 예시입니다:

class MyJob
  include ApplicationWorker
  include Gitlab::ExclusiveLeaseHelpers

  MAX_TTL = 2.5.minutes.to_i # It should be similar to the runtime limit.

  def perform
    in_lock('my_lock_key', ttl: MAX_TTL, retries: 0) do
      # Do the work here.
    end
  end
end

데이터 보존 및 반복적인 정리#

오래된 행 제거, 만료된 레코드 삭제, 대용량 테이블에 대한 지속적인 데이터 정리처럼 반복적으로 수행하는 데이터 작업에는 커스텀 배치 처리 로직을 직접 만드는 대신 백그라운드 작업 프레임워크(BBO)를 사용합니다. BBO는 커서 관리, 충돌에 안전한 진행 추적, 런타임 제한, 배치 크기 최적화, 데이터베이스 상태 검사를 자동으로 처리합니다.

Note

BBO는 실험적이며 변경될 수 있습니다. 도입하기 전에 Slack의 #g_database_architecture에 문의합니다.

릴리스와 연관된 일회성 데이터 마이그레이션에는 배치 백그라운드 마이그레이션을 사용합니다.

Sidekiq job 관련 고려 사항#

Sidekiq job은 데이터베이스 리소스를 상당히 많이 소비할 수 있습니다. job이 데이터를 배치 처리하기만 하고 데이터베이스의 내용을 수정하지 않는다면 데이터베이스 복제본을 우선하는 속성을 설정하는 방안을 고려합니다. Sidekiq 워커 속성 문서를 참고합니다.

배치 처리 전략#

예시를 따라가기 쉽도록 런타임을 제한하는 코드는 생략했습니다.

Note

일부 예시에는 cursor 변수에 대한 선택적 변수 할당이 들어 있습니다. 이는 "나중에 계속하기" 메커니즘을 구현할 때 사용할 수 있는 선택적 단계입니다.

루프 기반 배치 처리#

이 전략은 데이터베이스에서 레코드를 업데이트하거나 삭제한 뒤에는 완전히 같은 쿼리가 다른 레코드를 반환한다는 사실을 활용합니다. 이 전략은 특정 레코드를 삭제하거나 업데이트하려는 경우에만 사용할 수 있습니다.

예시:

loop do
  # Requires an index on project_id
  delete_count = project.issues.limit(1000).delete_all
  break if delete_count == 0 # Exit the loop when there are not records to be deleted
end

장점:

  • 구현하기 쉽고, 커서를 유지할 필요가 없습니다.
  • 배치 처리를 구현하는 데 단일 칼럼 데이터베이스 인덱스만 있으면 되고, 그런 인덱스는 흔히 이미 있습니다(외래 키).
  • 순서가 중요하지 않다면 인덱스로 커버되는 한 복잡한 필터 조건도 사용할 수 있습니다.

단점:

  • 오래된 인덱스 항목을 반복해서 스캔하고 가시성 검사를 거치면서 생기는 부정적인 부작용 때문에 이후 루프에서 쿼리 성능이 떨어집니다. 따라서 이 전략은 비교적 적은 양의 데이터에 영향을 주는 짧은 작업에만 적합합니다. 안전한 한도는 일반적으로 최대 1만 행 정도이지만, 테이블 크기나 인덱스 구조 같은 요인에 따라 달라질 수 있습니다.
  • 기반이 되는 DELETE 또는 UPDATE 쿼리를 철저히 테스트하고 수동으로 검증해야 합니다. 레코드를 업데이트하거나 삭제할 때 CTE와 관련된 문제가 있습니다.
  • break 로직에 버그가 있으면 무한 루프에 빠질 수 있습니다.

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

loop do
  # Requires a composite index on (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
  # Requires a composite index on (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에서 가장 흔히 쓰이는 배치 처리 전략 중 하나입니다.

# Requires a composite index on (project_id, id).
# EachBatch uses the primary key by default for the batching.
cursor = nil
project.issues.where('id > ?', cursor || 0).each_batch do |batch|
  issues = batch.to_a
  cursor = issues.last.id # For the next job

  # do something with the issues records
end

장점:

  • GitLab 애플리케이션에서 가장 널리 쓰이는 배치 처리 방식입니다.
  • 구현하기 쉽고 폭넓은 사용 사례를 다룹니다.

단점:

  • ORDER BY 칼럼(ID)이 해당 쿼리 안에서 고유해야 합니다.
  • timestamp 칼럼 조건이나 그 밖의 복잡한 조건(IN, NOT EXISTS)이 있으면 효율적으로 동작하지 않습니다.

고유 값에 대한 배치 처리#

EachBatch는 고유한 데이터베이스 칼럼(보통 ID 칼럼)을 요구하지만, 드물게 고유하지 않은 칼럼으로 배치 처리해야 하는 기능도 있습니다. 예를 들어 이슈가 하나 이상 있는 모든 프로젝트의 timestamp 값을 갱신하는 경우입니다.

한 가지 방법은 "부모" 테이블을 기준으로 배치 처리하는 것입니다. 이 경우에는 Project 모델을 사용합니다.

cursor = nil
# Uses the primary key index
Project.where('id > ?', cursor || 0).each_batch do |batch|
  cursor = batch.maximum(:id) # For the next job

  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 헬퍼 메서드를 사용할 수 있습니다:

# requires an index on (project_id)
Issue.distinct_each_batch(column: :project_id) do |scope|
  project_ids = scope.pluck(:project_id)
  cursor = project_ids.last # For the next job

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

장점:

  • 해당 칼럼이 외래 키 칼럼이면 인덱스가 이미 준비되어 있습니다.
  • 배치 처리 로직이 스캔해야 하는 데이터 양을 크게 줄일 수 있습니다.

단점:

  • 사용 범위가 제한적이고 널리 쓰이지는 않습니다.

키셋 기반 배치 처리#

키셋 기반 배치 처리를 사용하면 특정 순서로 레코드를 순회할 수 있고, 다중 칼럼 정렬도 가능합니다. 가장 흔한 사용 사례는 timestamp 칼럼으로 정렬된 데이터를 처리해야 하는 경우입니다.

예시: 1년이 넘은 이슈 레코드를 삭제합니다.

def perform
  cursor = load_cursor || {}
  # Requires a composite index on (created_at, id) columns
  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 } # Calling destroy so callbacks are invoked
  end

  cursor = iterator.send(:cursor) # Store the cursor after this step, for the next job
end

키셋 기반 배치 처리에서는 기존 인덱스의 칼럼 구성에 맞추어 ORDER BY 절을 조정할 수 있습니다. 다음 인덱스를 살펴봅니다:

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

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

# Note: this is a different sort order but at least we can use an existing index
scope = Issue.where('created_at > ?', 1.year.ago).order(:project_id, :state, :created_at, :id)

장점:

  • 다중 칼럼 정렬 순서와 더 복잡한 필터링이 가능합니다.
  • 새 인덱스를 추가하지 않고 기존 인덱스를 재사용할 수 있습니다.

단점:

  • 커서 크기가 더 커질 수 있습니다(ORDER BY 칼럼마다 커서에 저장됩니다).

오프셋 배치 처리#

이 배치 처리 기법은 새 레코드를 로드할 때 오프셋 페이지네이션을 사용합니다. 오프셋 페이지네이션은 해당 쿼리를 EachBatch나 키셋 페이지네이션으로 페이지네이션할 수 없을 때 최후의 수단으로만 사용해야 합니다. 이 기법을 선택하는 한 가지 이유는 SQL 쿼리가 다른 배치 처리 기법을 쓸 수 있는 적합한 인덱스가 없는 경우입니다. 예를 들어 백그라운드 job에서 제한 없이 너무 많은 레코드를 로드해 타임아웃이 발생하기 시작했고, 레코드의 순서가 중요한 상황입니다.

def perform(project_id)
  # We have a composite index on (project_id, created_at) columns
  issues = Issue
    .where(project_id: project_id)
    .order(:created_at)
    .to_a

  # do something with the issues
end

프로젝트 안의 이슈 수가 늘어나면 쿼리가 느려지고 결국 타임아웃됩니다. ORDER BY 절이 고유하지 않은 timestamp 칼럼에 의존하므로 키셋 페이지네이션 같은 다른 배치 처리 기법을 사용할 수 없습니다(타이 브레이커 섹션을 참고합니다). 이상적으로는 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?

    # do something with the issues
  end
end

위 스니펫은 제대로 된 해법이 마련되기 전까지의 단기 조치가 될 수 있습니다. 오프셋 페이지네이션은 페이지 번호가 커질수록 느려지므로, 오프셋으로 페이지네이션한 쿼리도 원래 쿼리와 마찬가지로 타임아웃될 가능성이 있습니다. 이전에 로드한 레코드를 메모리에 유지하는 데이터베이스 버퍼 캐시가 이 가능성을 어느 정도 줄여 줍니다. 따라서 같은 행을 연이어(단기간에) 조회하는 것은 성능에 큰 영향을 주지 않습니다.

장점:

  • 구현하기 쉽습니다.

단점:

  • 페이지 번호가 커질수록 성능이 선형으로 저하됩니다.
  • 임시 방편일 뿐이므로 새 기능에는 사용하지 않아야 합니다.
  • 페이지 번호를 커서로 저장할 수는 있지만, 이전 지점부터 처리를 재개하는 것은 신뢰하기 어려울 수 있습니다.

그룹 계층에 대한 배치 처리#

최상위 네임스페이스와 그 하위 그룹의 데이터를 쿼리해야 하는 기능이 여러 개 있습니다. 하위 그룹이나 프로젝트를 수천 개씩 포함하는 이례적인 그룹 계층도 있습니다. 서브쿼리나 조인이 추가된 상태로 이러한 계층을 쿼리하면 데이터베이스 statement 타임아웃으로 쉽게 이어질 수 있습니다.

예시: 그룹의 이슈를 순회합니다

group = Group.find(9970)

Issue.where(project_id: group.all_project_ids).each_batch do |scope|
  # Do something with issues
end

위 예시는 그룹 계층의 모든 하위 그룹과 모든 프로젝트, 모든 이슈를 로드하므로 데이터베이스 statement 타임아웃으로 이어질 가능성이 매우 높습니다. 위 쿼리는 단기 해법으로 데이터베이스 인덱스를 사용해 조금 개선할 수 있습니다.

IN 연산자 최적화 사용#

그룹에서 레코드를 특정 순서로 처리해야 할 때는 IN 연산자 최적화를 사용할 수 있으며, 이 방식은 표준 each_batch 기반 배치 처리 전략보다 더 나은 성능을 낼 수 있습니다.

그룹 계층의 레코드를 배치 처리하는 예시를 확인할 수 있습니다.

장점:

  • 그룹 계층 안의 레코드를 특정 순서로 효율적으로 배치 처리할 수 있는 유일한 방법입니다.

단점:

  • 설정이 더 복잡합니다.
  • 매우 큰 계층(프로젝트나 하위 그룹이 많은 계층)을 배치 처리할 때는 배치 크기를 더 작게 잡아야 합니다.

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

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

"index_on_namespaces_namespaces_by_top_level_namespace" btree ((traversal_ids[1]), type, id) -- traversal_ids[1] is the top-level group 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 # For the next job

  project_ids.each do |project_id|
    Issue.where(project_id: project_id).each_batch(column: :iid) do |issues|
      # do something with the issues
    end
  end
end

장점:

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

단점:

  • 이중 배치 처리 때문에 데이터베이스 쿼리가 늘어납니다.

그룹 계층의 임의 노드부터 배치 처리#

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

# current_id: id of the namespace record where we iterate from
# depth: depth of the tree where the iteration was stopped previously. Initially, it should be the same as the current_id
cursor = { current_id: 9970, depth: [9970] } # This can be any namespace id

# Instantiate the object to iterate over project namespaces only.
iterator = Gitlab::Database::NamespaceEachBatch.new(namespace_class: Namespaces::ProjectNamespace, cursor: cursor)

# Requires a composite index on (parent_id, id) columns
iterator.each_batch(of: 100) do |ids, new_cursor|
  cursor = new_cursor # For the next job, contains the new current_id and depth values

  project_ids = Project.where(project_namespace_id: ids)
  project_ids.each do |project_id|
    Issue.where(project_id: project_id).each_batch(column: :iid) do |issues|
      # do something with the issues
    end
  end
end

장점:

  • 그룹 계층을 임의의 노드부터 처리할 수 있습니다.

단점:

  • 거의 쓰이지 않으며, 아주 드문 사용 사례에만 유용합니다.

복잡한 쿼리에 대한 배치 처리#

여러 필터와 조인이 들어 있는 쿼리를 복잡한 쿼리로 봅니다. 대부분의 경우 이러한 쿼리는 쉽게 배치 처리할 수 없습니다. 몇 가지 예시입니다:

배치 처리 모범 사례

GitLab v19.4
원문 보기

요약

이 문서는 GitLab에서 사용하는 배치 처리 전략을 개괄합니다. 대량의 레코드를 다룰 때 하나의 데이터베이스 쿼리로 레코드를 읽거나 업데이트하거나 삭제하기는 어려울 수 있습니다. 드물게(오래된 기능에서) 웹 요청에서도 배치 처리가 이루어집니다.

이 문서는 GitLab에서 사용하는 배치 처리 전략을 개괄합니다. 엔지니어가 자신의 사용 사례에 맞는 최적의 접근 방식을 고를 수 있도록 각 전략의 장단점을 정리했습니다.

배치 처리가 필요한 이유#

대량의 레코드를 다룰 때 하나의 데이터베이스 쿼리로 레코드를 읽거나 업데이트하거나 삭제하기는 어려울 수 있습니다. 이러한 작업은 쉽게 타임아웃될 수 있습니다. 이 문제를 피하려면 레코드를 배치 단위로 처리해야 합니다. 배치 처리는 보통 백그라운드 job에서 이루어지며, 백그라운드 job은 웹 요청보다 런타임 제약이 완화되어 있습니다.

배치 처리는 웹 요청이 아니라 백그라운드 job에서 사용#

드물게(오래된 기능에서) 웹 요청에서도 배치 처리가 이루어집니다. 그러나 새 기능에서는 웹 요청 타임아웃이 짧기 때문에(기본값 60초) 권장하지 않습니다. 지침으로서, 대량의 레코드를 처리해야 하는 기능을 구현할 때는 백그라운드 job(Sidekiq 워커)을 첫 번째 방안으로 고려해야 합니다.

성능 고려 사항#

배치 처리 성능은 기반 라이브러리와 데이터베이스 쿼리가 본질적으로 같기 때문에 페이지네이션 성능과 밀접하게 관련되어 있습니다. 배치 처리를 구현할 때는 페이지네이션 성능 가이드라인과 배치 처리 유틸리티 관련 문서를 숙지하는 것이 중요합니다.

백그라운드 job에서의 배치 처리#

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

백그라운드 job은 오랫동안 실행되어서는 안 됩니다. Sidekiq 프로세스는 충돌할 수도 있고 강제로 중지될 수도 있습니다(예: 재시작 또는 배포 시). 또한 오류 예산 규칙에 따라 런타임이 5분을 넘기면 해당 기능이 등록된 그룹에 오류 예산 위반이 추가됩니다. 백그라운드 job에서 배치 처리를 구현할 때는 멱등성 job 관련 가이드라인을 숙지해야 합니다.

대량의 레코드를 업데이트하거나 삭제하면 데이터베이스 복제 지연이 늘어나고 주 데이터베이스에 부하가 더해질 수 있습니다. 백그라운드 job 안에서 처리(또는 배치 처리)하는 레코드의 총 개수를 제한하는 것이 바람직합니다.

위에서 언급한 잠재적 문제를 해결하려면 다음 조치를 고려해야 합니다:

  • job의 총 런타임을 제한합니다.
  • 레코드 수정을 제한합니다.
  • 배치 사이에 휴식 시간(몇 밀리초)을 둡니다.

제한을 적용할 때 중요한 점은, 장시간 실행되는 백그라운드 job이라면 제한에 도달한 뒤 배치 처리가 멈춘 지점부터 작업을 이어가도록 새 job을 예약하는 "나중에 계속하기(continue later)" 메커니즘을 구현해야 한다는 것입니다. 이는 job이 너무 길어서 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분에 도달하면 멈춥니다. 여기서 문제는 처리를 이어갈 방법이 없다는 점입니다. 처리를 이어가려면 이어서 처리할 수 있을 만큼 충분한 정보를 담은 새 백그라운드 job을 예약해야 합니다. 스니펫에서는 프로젝트 안의 이슈를 iid 칼럼으로 배치 처리합니다. 다음 job에는 프로젝트 ID와 마지막으로 처리한 iid 값을 넘겨야 합니다. 이 정보를 흔히 커서(cursor)라고 부릅니다.

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

  project = Project.find(project_id)
  # Restore the previous iid if present
  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, max_iid)

      break
    end
  end
end

"나중에 계속하기" 메커니즘을 구현하면 구현 복잡도가 크게 올라갈 수 있습니다. 따라서 이 작업에 착수하기 전에 프로덕션 데이터베이스의 기존 데이터를 분석하고 데이터 증가를 추정해 봅니다. 몇 가지 예시입니다:

  • 특정 사용자의 모든 pending 할 일을 done으로 표시하는 작업에는 "나중에 계속하기" 메커니즘이 필요하지 않습니다.
    • 이유: 아무리 바쁜 사용자라도 대기 중인 할 일의 수가 데이터베이스 행 수천 개를 넘지 않을 가능성이 높습니다. 이 행들을 업데이트하는 작업은 99.9%가 1분 이내에 끝납니다.
  • 특정 프로젝트의 CI 빌드 레코드를 CSV 파일에 저장하는 작업에는 "나중에 계속하기" 메커니즘이 필요할 수 있습니다.
    • 이유: 매우 활발한 프로젝트에서는 CI job 수가 수백만 행까지 매우 빠르게 늘어날 수 있습니다.

백그라운드 job에서 매우 많은 양의 업데이트가 일어날 때는 코드에 슬립을 조금 넣고 업데이트하는 레코드의 총 개수를 제한하는 것이 바람직합니다(엄격한 요구 사항은 아닙니다). 이렇게 하면 주 데이터베이스에 걸리는 부담이 줄고, 더 무거운 잠금을 획득해야 하는 데이터베이스 마이그레이션에 짧은 시간 여유를 줄 수 있습니다.

def perform(project_id, iid = nil)
  max_updates = 100_000 # Allow maximum N updates
  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, max_iid)
      status = :limit_reached

      break
    end

    # Adding sleep when we expect long running batching that modifies large volume of data
    sleep 0.01
  end
end

추적 가능성#

추적 가능성을 위해 Kibana에서 배치 처리 성능을 확인할 수 있도록 메트릭을 노출하는 것이 좋습니다:

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

다음 job 예약#

위 예시에서 다음 job을 예약하는 방식은 충돌에 안전하지 않으므로(job이 유실될 수 있습니다) 매우 중요한 작업에는 적합하지 않습니다. 안전하고 널리 쓰이는 패턴은 커서를 기준으로 작업을 실행하는 예약 워커를 사용하는 것입니다. 커서는 일관성 요구 사항에 따라 데이터베이스(DB)나 Redis에 저장할 수 있습니다. 즉, 커서를 job 인수로 넘기지 않게 됩니다.

예약 워커의 실행 빈도는 작업의 긴급도에 따라 조정할 수 있습니다. 긴급한 항목을 처리하기 위해 예약 워커를 매 분 큐에 넣는 예시도 있습니다.

Redis 기반 커서#

예시: 프로젝트의 모든 이슈를 처리합니다.

def perform
  project_id, iid = load_cursor # Load cursor from Redis

  return unless project_id # Nothing was enqueued

  project = Project.find(project_id)
  project.issues.where('iid > ?', iid || 0).each_batch(of: :iid) do |scope|
    # Do something with issues.
    # Break here, set interrupted flag if time limit is up.
    # Set iid to the last processed value.
  end

  # Continue the work later
  push_cursor(project_id, iid) if interrupted?
end

private

def load_cursor
  # Take 1 element, not crash safe.
  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)
  # Work is not finished, put the cursor at the beginning of the list so the next job can pick it up.
  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

  # Application could crash here

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

이 방식은 충돌에 안전하지 않습니다. 트랜잭션이 커밋된 직후에 애플리케이션이 충돌하면 항목이 큐에 들어가지 않습니다.

장점:

  • 구현하기가 더 쉽고, job을 추적하기 위한 별도의 데이터베이스 테이블이 필요하지 않습니다.
  • 처리량이 적고 내부에서 호출되는 job에 적합합니다. (예: 전체 테이블 주기적 일관성 검사, 백그라운드 집계)

단점:

  • 작업을 예약하는 일(커서를 큐에 넣는 일)이 충돌에 안전하지 않습니다.
  • 커서를 읽을 때 직렬화 문제가 생길 수 있습니다(다중 버전 호환성).
  • 데이터베이스 트랜잭션에 각별히 주의해야 합니다.

PostgreSQL 기반 커서#

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

장점:

  • 작업 예약을 다른 레코드 변경과 완전히 일관되게 만들 수 있습니다(예: 이슈 생성 트랜잭션 안에서 작업을 예약).
  • 큐에 항목이 많아도 견딜 수 있습니다.

단점:

  • 볼륨에 따라 구현이 상당히 복잡해질 수 있습니다:
    • 파티셔닝된 데이터베이스 테이블: 처리량이 많은 워커에서는 이 방식을 고려해야 합니다.
    • 슬라이딩 윈도 파티셔닝 전략을 고려합니다.
    • 복잡한 파티션 간 쿼리가 생깁니다.

예시: 이메일을 안정적으로 보내는 방법을 구성합니다

# In a service
def execute
  ApplicationRecord.transaction do
    user.save!
    Event.create!(user: user, issue: issue)
    IssueEmailWorkerQueue.insert!(user: user, issue: issue)
  end
end

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

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

  items.each do |item|
    # Do something with the item
  end
end
Note

레코드가 병렬로 처리되는 것을 막으려면 실행을 분산 Redis 잠금으로 감싸야 할 수 있습니다.

Redis 잠금 사용 예시입니다:

class MyJob
  include ApplicationWorker
  include Gitlab::ExclusiveLeaseHelpers

  MAX_TTL = 2.5.minutes.to_i # It should be similar to the runtime limit.

  def perform
    in_lock('my_lock_key', ttl: MAX_TTL, retries: 0) do
      # Do the work here.
    end
  end
end

데이터 보존 및 반복적인 정리#

오래된 행 제거, 만료된 레코드 삭제, 대용량 테이블에 대한 지속적인 데이터 정리처럼 반복적으로 수행하는 데이터 작업에는 커스텀 배치 처리 로직을 직접 만드는 대신 백그라운드 작업 프레임워크(BBO)를 사용합니다. BBO는 커서 관리, 충돌에 안전한 진행 추적, 런타임 제한, 배치 크기 최적화, 데이터베이스 상태 검사를 자동으로 처리합니다.

Note

BBO는 실험적이며 변경될 수 있습니다. 도입하기 전에 Slack의 #g_database_architecture에 문의합니다.

릴리스와 연관된 일회성 데이터 마이그레이션에는 배치 백그라운드 마이그레이션을 사용합니다.

Sidekiq job 관련 고려 사항#

Sidekiq job은 데이터베이스 리소스를 상당히 많이 소비할 수 있습니다. job이 데이터를 배치 처리하기만 하고 데이터베이스의 내용을 수정하지 않는다면 데이터베이스 복제본을 우선하는 속성을 설정하는 방안을 고려합니다. Sidekiq 워커 속성 문서를 참고합니다.

배치 처리 전략#

예시를 따라가기 쉽도록 런타임을 제한하는 코드는 생략했습니다.

Note

일부 예시에는 cursor 변수에 대한 선택적 변수 할당이 들어 있습니다. 이는 "나중에 계속하기" 메커니즘을 구현할 때 사용할 수 있는 선택적 단계입니다.

루프 기반 배치 처리#

이 전략은 데이터베이스에서 레코드를 업데이트하거나 삭제한 뒤에는 완전히 같은 쿼리가 다른 레코드를 반환한다는 사실을 활용합니다. 이 전략은 특정 레코드를 삭제하거나 업데이트하려는 경우에만 사용할 수 있습니다.

예시:

loop do
  # Requires an index on project_id
  delete_count = project.issues.limit(1000).delete_all
  break if delete_count == 0 # Exit the loop when there are not records to be deleted
end

장점:

  • 구현하기 쉽고, 커서를 유지할 필요가 없습니다.
  • 배치 처리를 구현하는 데 단일 칼럼 데이터베이스 인덱스만 있으면 되고, 그런 인덱스는 흔히 이미 있습니다(외래 키).
  • 순서가 중요하지 않다면 인덱스로 커버되는 한 복잡한 필터 조건도 사용할 수 있습니다.

단점:

  • 오래된 인덱스 항목을 반복해서 스캔하고 가시성 검사를 거치면서 생기는 부정적인 부작용 때문에 이후 루프에서 쿼리 성능이 떨어집니다. 따라서 이 전략은 비교적 적은 양의 데이터에 영향을 주는 짧은 작업에만 적합합니다. 안전한 한도는 일반적으로 최대 1만 행 정도이지만, 테이블 크기나 인덱스 구조 같은 요인에 따라 달라질 수 있습니다.
  • 기반이 되는 DELETE 또는 UPDATE 쿼리를 철저히 테스트하고 수동으로 검증해야 합니다. 레코드를 업데이트하거나 삭제할 때 CTE와 관련된 문제가 있습니다.
  • break 로직에 버그가 있으면 무한 루프에 빠질 수 있습니다.

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

loop do
  # Requires a composite index on (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
  # Requires a composite index on (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에서 가장 흔히 쓰이는 배치 처리 전략 중 하나입니다.

# Requires a composite index on (project_id, id).
# EachBatch uses the primary key by default for the batching.
cursor = nil
project.issues.where('id > ?', cursor || 0).each_batch do |batch|
  issues = batch.to_a
  cursor = issues.last.id # For the next job

  # do something with the issues records
end

장점:

  • GitLab 애플리케이션에서 가장 널리 쓰이는 배치 처리 방식입니다.
  • 구현하기 쉽고 폭넓은 사용 사례를 다룹니다.

단점:

  • ORDER BY 칼럼(ID)이 해당 쿼리 안에서 고유해야 합니다.
  • timestamp 칼럼 조건이나 그 밖의 복잡한 조건(IN, NOT EXISTS)이 있으면 효율적으로 동작하지 않습니다.

고유 값에 대한 배치 처리#

EachBatch는 고유한 데이터베이스 칼럼(보통 ID 칼럼)을 요구하지만, 드물게 고유하지 않은 칼럼으로 배치 처리해야 하는 기능도 있습니다. 예를 들어 이슈가 하나 이상 있는 모든 프로젝트의 timestamp 값을 갱신하는 경우입니다.

한 가지 방법은 "부모" 테이블을 기준으로 배치 처리하는 것입니다. 이 경우에는 Project 모델을 사용합니다.

cursor = nil
# Uses the primary key index
Project.where('id > ?', cursor || 0).each_batch do |batch|
  cursor = batch.maximum(:id) # For the next job

  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 헬퍼 메서드를 사용할 수 있습니다:

# requires an index on (project_id)
Issue.distinct_each_batch(column: :project_id) do |scope|
  project_ids = scope.pluck(:project_id)
  cursor = project_ids.last # For the next job

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

장점:

  • 해당 칼럼이 외래 키 칼럼이면 인덱스가 이미 준비되어 있습니다.
  • 배치 처리 로직이 스캔해야 하는 데이터 양을 크게 줄일 수 있습니다.

단점:

  • 사용 범위가 제한적이고 널리 쓰이지는 않습니다.

키셋 기반 배치 처리#

키셋 기반 배치 처리를 사용하면 특정 순서로 레코드를 순회할 수 있고, 다중 칼럼 정렬도 가능합니다. 가장 흔한 사용 사례는 timestamp 칼럼으로 정렬된 데이터를 처리해야 하는 경우입니다.

예시: 1년이 넘은 이슈 레코드를 삭제합니다.

def perform
  cursor = load_cursor || {}
  # Requires a composite index on (created_at, id) columns
  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 } # Calling destroy so callbacks are invoked
  end

  cursor = iterator.send(:cursor) # Store the cursor after this step, for the next job
end

키셋 기반 배치 처리에서는 기존 인덱스의 칼럼 구성에 맞추어 ORDER BY 절을 조정할 수 있습니다. 다음 인덱스를 살펴봅니다:

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

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

# Note: this is a different sort order but at least we can use an existing index
scope = Issue.where('created_at > ?', 1.year.ago).order(:project_id, :state, :created_at, :id)

장점:

  • 다중 칼럼 정렬 순서와 더 복잡한 필터링이 가능합니다.
  • 새 인덱스를 추가하지 않고 기존 인덱스를 재사용할 수 있습니다.

단점:

  • 커서 크기가 더 커질 수 있습니다(ORDER BY 칼럼마다 커서에 저장됩니다).

오프셋 배치 처리#

이 배치 처리 기법은 새 레코드를 로드할 때 오프셋 페이지네이션을 사용합니다. 오프셋 페이지네이션은 해당 쿼리를 EachBatch나 키셋 페이지네이션으로 페이지네이션할 수 없을 때 최후의 수단으로만 사용해야 합니다. 이 기법을 선택하는 한 가지 이유는 SQL 쿼리가 다른 배치 처리 기법을 쓸 수 있는 적합한 인덱스가 없는 경우입니다. 예를 들어 백그라운드 job에서 제한 없이 너무 많은 레코드를 로드해 타임아웃이 발생하기 시작했고, 레코드의 순서가 중요한 상황입니다.

def perform(project_id)
  # We have a composite index on (project_id, created_at) columns
  issues = Issue
    .where(project_id: project_id)
    .order(:created_at)
    .to_a

  # do something with the issues
end

프로젝트 안의 이슈 수가 늘어나면 쿼리가 느려지고 결국 타임아웃됩니다. ORDER BY 절이 고유하지 않은 timestamp 칼럼에 의존하므로 키셋 페이지네이션 같은 다른 배치 처리 기법을 사용할 수 없습니다(타이 브레이커 섹션을 참고합니다). 이상적으로는 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?

    # do something with the issues
  end
end

위 스니펫은 제대로 된 해법이 마련되기 전까지의 단기 조치가 될 수 있습니다. 오프셋 페이지네이션은 페이지 번호가 커질수록 느려지므로, 오프셋으로 페이지네이션한 쿼리도 원래 쿼리와 마찬가지로 타임아웃될 가능성이 있습니다. 이전에 로드한 레코드를 메모리에 유지하는 데이터베이스 버퍼 캐시가 이 가능성을 어느 정도 줄여 줍니다. 따라서 같은 행을 연이어(단기간에) 조회하는 것은 성능에 큰 영향을 주지 않습니다.

장점:

  • 구현하기 쉽습니다.

단점:

  • 페이지 번호가 커질수록 성능이 선형으로 저하됩니다.
  • 임시 방편일 뿐이므로 새 기능에는 사용하지 않아야 합니다.
  • 페이지 번호를 커서로 저장할 수는 있지만, 이전 지점부터 처리를 재개하는 것은 신뢰하기 어려울 수 있습니다.

그룹 계층에 대한 배치 처리#

최상위 네임스페이스와 그 하위 그룹의 데이터를 쿼리해야 하는 기능이 여러 개 있습니다. 하위 그룹이나 프로젝트를 수천 개씩 포함하는 이례적인 그룹 계층도 있습니다. 서브쿼리나 조인이 추가된 상태로 이러한 계층을 쿼리하면 데이터베이스 statement 타임아웃으로 쉽게 이어질 수 있습니다.

예시: 그룹의 이슈를 순회합니다

group = Group.find(9970)

Issue.where(project_id: group.all_project_ids).each_batch do |scope|
  # Do something with issues
end

위 예시는 그룹 계층의 모든 하위 그룹과 모든 프로젝트, 모든 이슈를 로드하므로 데이터베이스 statement 타임아웃으로 이어질 가능성이 매우 높습니다. 위 쿼리는 단기 해법으로 데이터베이스 인덱스를 사용해 조금 개선할 수 있습니다.

IN 연산자 최적화 사용#

그룹에서 레코드를 특정 순서로 처리해야 할 때는 IN 연산자 최적화를 사용할 수 있으며, 이 방식은 표준 each_batch 기반 배치 처리 전략보다 더 나은 성능을 낼 수 있습니다.

그룹 계층의 레코드를 배치 처리하는 예시를 확인할 수 있습니다.

장점:

  • 그룹 계층 안의 레코드를 특정 순서로 효율적으로 배치 처리할 수 있는 유일한 방법입니다.

단점:

  • 설정이 더 복잡합니다.
  • 매우 큰 계층(프로젝트나 하위 그룹이 많은 계층)을 배치 처리할 때는 배치 크기를 더 작게 잡아야 합니다.

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

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

"index_on_namespaces_namespaces_by_top_level_namespace" btree ((traversal_ids[1]), type, id) -- traversal_ids[1] is the top-level group 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 # For the next job

  project_ids.each do |project_id|
    Issue.where(project_id: project_id).each_batch(column: :iid) do |issues|
      # do something with the issues
    end
  end
end

장점:

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

단점:

  • 이중 배치 처리 때문에 데이터베이스 쿼리가 늘어납니다.

그룹 계층의 임의 노드부터 배치 처리#

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

# current_id: id of the namespace record where we iterate from
# depth: depth of the tree where the iteration was stopped previously. Initially, it should be the same as the current_id
cursor = { current_id: 9970, depth: [9970] } # This can be any namespace id

# Instantiate the object to iterate over project namespaces only.
iterator = Gitlab::Database::NamespaceEachBatch.new(namespace_class: Namespaces::ProjectNamespace, cursor: cursor)

# Requires a composite index on (parent_id, id) columns
iterator.each_batch(of: 100) do |ids, new_cursor|
  cursor = new_cursor # For the next job, contains the new current_id and depth values

  project_ids = Project.where(project_namespace_id: ids)
  project_ids.each do |project_id|
    Issue.where(project_id: project_id).each_batch(column: :iid) do |issues|
      # do something with the issues
    end
  end
end

장점:

  • 그룹 계층을 임의의 노드부터 처리할 수 있습니다.

단점:

  • 거의 쓰이지 않으며, 아주 드문 사용 사례에만 유용합니다.

복잡한 쿼리에 대한 배치 처리#

여러 필터와 조인이 들어 있는 쿼리를 복잡한 쿼리로 봅니다. 대부분의 경우 이러한 쿼리는 쉽게 배치 처리할 수 없습니다. 몇 가지 예시입니다: