배치 처리 모범 사례
GitLab v19.1이 문서는 GitLab에서 사용하는 배치 처리 전략에 대한 개요를 제공합니다. 대량의 레코드를 다룰 때, 하나의 데이터베이스 쿼리로 레코드를 읽거나 업데이트하거나 삭제하는 것은 어려울 수 있습니다. 드문 경우(오래된 기능)에는 웹 요청에서도 배치 처리가 이루어집니다.
이 문서는 GitLab에서 사용하는 배치 처리 전략에 대한 개요를 제공합니다. 엔지니어가 자신의 사용 사례에 맞는 최적의 접근 방식을 선택할 수 있도록 각 전략의 장단점을 정리하였습니다.
배치 처리가 필요한 이유#
대량의 레코드를 다룰 때, 하나의 데이터베이스 쿼리로 레코드를 읽거나 업데이트하거나 삭제하는 것은 어려울 수 있습니다. 이러한 작업은 쉽게 타임아웃될 수 있습니다. 이 문제를 피하기 위해 레코드를 배치 단위로 처리해야 합니다. 배치 처리는 주로 백그라운드 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을 예약하는 방식은 충돌 안전(crash safe)하지 않습니다(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
레코드의 병렬 처리를 피하려면 실행을 분산 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는 커서 관리, 충돌 안전 진행 추적, 런타임 제한, 배치 크기 최적화 및 데이터베이스 상태 검사를 자동으로 처리합니다.
BBO는 실험적이며 변경될 수 있습니다. 채택하기 전에 Slack의 #g_database_architecture에 문의하십시오.
릴리스와 연관된 일회성 데이터 마이그레이션의 경우 배치 백그라운드 마이그레이션을 사용하십시오.
Sidekiq job에 대한 고려사항#
Sidekiq job은 상당한 데이터베이스 리소스를 소비할 수 있습니다. job이 데이터를 배치 처리하기만 하고 데이터베이스에서 아무것도 수정하지 않는 경우, 데이터베이스 복제본을 선호하는 속성을 설정하는 것을 고려하십시오. Sidekiq 워커 속성 문서를 참조하십시오.
배치 처리 전략#
예제를 이해하기 쉽게 하기 위해 런타임 제한 코드는 생략합니다.
일부 예시에는 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
장점:
-
구현이 쉽고 커서 유지 관리가 필요하지 않습니다.
-
배치 처리를 구현하는 데 단일 칼럼 데이터베이스 인덱스로 충분하며 이는 보통 이미 사용 가능합니다(외래 키).
-
순서가 중요하지 않은 경우, 복잡한 필터 조건도 인덱스로 커버되는 한 사용할 수 있습니다.
단점:
-
오래된 인덱스 항목의 반복 스캔 및 가시성 검사로 인한 부정적 부작용 때문에 이후 루프에서 쿼리 성능이 저하됩니다. 따라서 이 전략은 상대적으로 적은 양의 데이터에 영향을 미치는 단기 작업에만 적합합니다. 안전한 한계는 일반적으로 최대 10,000개 행이지만 테이블 크기 및 인덱스 구조와 같은 요인에 따라 달라질 수 있습니다.
-
기반
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 칼럼에 의존하기 때문에 키셋 페이지네이션과 같은 다른 배치 처리 기법을 사용하는 것이 불가능합니다(타이 브레이커(tie-breaker) 섹션 참조). 이상적으로는 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
장점:
- 임의의 노드에서 그룹 계층을 처리할 수 있습니다.
단점:
- 거의 사용되지 않으며 매우 드문 사용 사례에서만 유용합니다.
복잡한 쿼리에 대한 배치 처리#
여러 필터와 조인을 포함하는 쿼리를 복잡한 쿼리로 간주합니다. 대부분의 경우 이러한 쿼리는 쉽게 배치 처리할 수 없습니다. 몇 가지 예시: