배치 최선 사례
이 문서는 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를 복원합니다.
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
“나중에 계속하기” 메커니즘을 구현하는 것은 구현에 상당한 복잡성을 추가할 수 있습니다. 따라서 이 작업을 확정하기 전에, 프로덕션 데이터베이스의 기존 데이터를 분석하고 데이터 성장 추세를 추정해야 합니다. 몇 가지 예시:
- 주어진 사용자에 대한 모든
pending
할 일을done
으로 표시하는 것은 “나중에 계속하기” 메커니즘이 필요 없습니다.- 이유: 비슷한 사용자라 하더라도 대기 중인 할 일 수는 대부분 몇 천 개의 데이터베이스 행을 넘지 않을 확률이 높습니다. 이 행들을 업데이트하는 데 99.9%는 1분 이내에 완료될 것입니다.
- 주어진 프로젝트 내 CI 빌드 기록을 CSV 파일에 저장하는 것은 “나중에 계속하기” 메커니즘이 필요할 수 있습니다.
- 이유: 매우 활성 프로젝트의 경우, 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
장점:
- 구현하기 쉬우며, 커서를 유지할 필요가 없습니다.
- 배치 구현에 단일 열 데이터베이스 인덱스가 충분하며, 이는 종종 사용 가능합니다 (외래 키).
- 순서가 중요하지 않다면, 인덱스가 적용된 복잡한 필터 조건도 사용할 수 있습니다.
단점:
- 기본
DELETE
또는UPDATE
쿼리에 대한 철저한 테스트 및 수동 검증이 필수입니다. 레코드를 업데이트하거나 삭제할 때 CTE와 관련된 몇 가지 문제가 있습니다. -
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 # 다음 작업을 위한 cursor
# 레코드와 함께 작업 수행
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
# 이슈로 무언가를 수행합니다.
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?
# 이슈로 무언가를 수행합니다.
end
end
위 코드 조각은 적절한 해결책이 마련될 때까지 단기적인 해결책이 될 수 있습니다. 오프셋 페이징은 페이지 번호가 증가함에 따라 느려진다는 점에 유의해야 하며, 이는 오프셋 페이징 쿼리가 원래 쿼리와 같은 방식으로 타임아웃될 가능성이 있음을 의미합니다. 이전에 로드된 레코드를 메모리에 유지하는 데이터베이스 버퍼 캐시에 의해 아무런 영향을 받지 않을 가능성이 어느 정도 줄어듭니다; 따라서 동일한 행을 연속적으로(단기적으로) 조회하는 것은 성능에 매우 큰 영향을 미치지 않습니다.
장점:
- 구현이 쉽습니다.
단점:
- 페이지 번호가 증가함에 따라 성능이 선형적으로 저하됩니다.
- 이것은 새로운 기능에 사용해서는 안 되는 일시적 조치입니다.
- 커서로 페이지 번호를 저장할 수 있지만 이전 지점에서 처리를 복원하는 것은 신뢰할 수 없습니다.
그룹 계층에 대한 배치
우리는 최상위 네임스페이스와 해당 하위 그룹에서 데이터를 쿼리해야 하는 여러 기능이 있습니다. 수천 개의 하위 그룹이나 프로젝트를 포함하는 특이한 그룹 계층이 있습니다. 이러한 계층을 쿼리하면 추가 하위 쿼리나 조인을 추가할 때 데이터베이스 문장 타임아웃이 발생할 수 있습니다.
예: 그룹의 이슈를 반복 처리
group = Group.find(9970)
Issue.where(project_id: group.all_project_ids).each_batch do |scope|
# 이슈로 무언가를 수행합니다.
end
위 예제는 모든 하위 그룹, 모든 프로젝트 및 그룹 계층의 모든 이슈를 로드하는데, 이는 데이터베이스 문장 타임아웃으로 이어질 가능성이 높습니다. 위 쿼리는 단기 해결책으로 데이터베이스 인덱스를 사용하여 약간 개선할 수 있습니다.
in-연산자 최적화 사용
특정 순서로 그룹 내의 레코드를 처리해야 할 때는 in-연산자 최적화를 사용할 수 있으며, 이는 표준 each_batch
기반 배치 전략보다 더 나은 성능을 제공할 수 있습니다.
그룹 계층 내에서 레코드를 배치하는 예제를 여기서 확인할 수 있습니다.
장점:
- 특정 순서로 그룹 계층 내에서 레코드를 효율적으로 배치할 수 있는 유일한 방법입니다.
단점:
- 더 복잡한 설정이 필요합니다.
- 매우 큰 계층(프로젝트 또는 하위 그룹의 수가 많은 경우)에 대한 배치는 배치 크기를 줄여야 합니다.
항상 최상위 그룹에서 배치하기
이 기법은 항상 최상위 그룹(부모 그룹이 없는 그룹)에서 배치해야 할 경우에 사용됩니다. 이 경우 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: 이전에 반복이 중단된 트리의 깊이. 초기에는 current_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
장점:
- 어떤 노드에서든 그룹 계층을 처리할 수 있습니다.
단점:
- 드물게 사용되며, 매우 드문 경우에만 유용합니다.
복잡한 쿼리에 대한 배치
여러 필터 및 조인이 포함된 복잡한 쿼리를 고려합니다. 대부분의 경우 이러한 쿼리는 쉽게 배치할 수 없습니다. 몇 가지 예: