일괄 처리 테이블 반복

Rails는 일괄 처리에서 사용할 수있는 in_batches 메서드를 제공합니다. 예를 들어:

User.in_batches(of: 10) do |relation|
  relation.update_all(updated_at: Time.now)
end

불행하게도, 이 방법은 쿼리 및 메모리 사용 측면에서 효율적이지 않게 구현되었습니다.

이를 해결하기 위해 모델에 EachBatch 모듈을 포함시킨 다음 each_batch 클래스 메서드를 사용할 수 있습니다. 예를 들어:

class User < ActiveRecord::Base
  include EachBatch
end

User.each_batch(of: 10) do |relation|
  relation.update_all(updated_at: Time.now)
end

이렇게 하면 다음과 같은 쿼리가 생성됩니다:

User Load (0.7ms)  SELECT  "users"."id" FROM "users" WHERE ("users"."id" >= 41654)  ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000
  (0.7ms)  SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687)

이 메서드의 API는 in_batches에서 지원하는 모든 인수를 지원하지는 않지만, in_batches에 특별한 필요성이 없다면 항상 each_batch를 사용해야 합니다.

고유하지 않은 열을 반복하는 경우

관련성의 컨텍스트에서 고유하지 않은 열을 사용하여 each_batch 메서드를 사용해서는 안됩니다. 이는 무한 루프에 빠질 수 있습니다. 또한 일관되지 않은 일괄 크기는 고유하지 않은 열을 반복할 때 성능 문제를 일으킵니다. 특성에 대한 최대 일괄 크기를 적용해도 결과적인 일괄이 해당 크기를 초과하지 않을 것이 보장되지 않습니다. 다음 스니펫은 이 상황을 보여줍니다. 예를 들어, id1에서10,000 사이인 사용자의 Ci::Build 항목을 선택하려고 시도하면, 데이터베이스는 1,215,178개의 일치하는 행을 반환합니다.

[ gstg ] production> Ci::Build.where(user_id: (1..10_000)).size
=> 1215178

이는 빌드 관계가 다음 쿼리로 변환되기 때문에 발생합니다:

[ gstg ] production> puts Ci::Build.where(user_id: (1..10_000)).to_sql
SELECT "ci_builds".* FROM "ci_builds" WHERE "ci_builds"."type" = 'Ci::Build' AND "ci_builds"."user_id" BETWEEN 1 AND 10000
=> nil

비고 여부와 상관없이 고유하지 않은 열을 범위로 필터링하는 AND 쿼리는 "ci_builds"."user_id" BETWEEN ? AND ?로 번역됩니다. 이전 예제에서 범위 크기가 10,000으로 제한되었지만 이 임계값은 반환된 데이터셋의 크기에는 영향을 주지 못합니다. 이는 속성의 가능한 값 n을 가져올 때 n보다 작은 레코드 수를 확신할 수 없기 때문입니다.

distinct_each_batch로 느슨한 인덱스 스캔하기

고유하지 않은 열을 반복하는 경우 distinct_each_batch 도우미 메서드를 사용합니다. 이 도우미는 데이터베이스 인덱스 내에서 중복된 값을 건너 뛰기 위해 느슨한 인덱스 스캔 기술(스킵 인덱스 스캔)을 사용합니다.

예: Issue 모델에서 고유한 author_id를 반복하는 예제

Issue.distinct_each_batch(column: :author_id, of: 1000) do |relation|
  users = User.where(id: relation.select(:author_id)).to_a
end

이 기술은 데이터 분포와 상관없이 일괄 크기 간 안정된 성능을 제공합니다. relation 객체는 주어진 column만 사용할 수 있는 ActiveRecord 스코프를 반환합니다. 다른 열은 로드되지 않습니다.

기본적으로 EachBatch는 모델의 기본 키를 반복에 사용합니다. 이는 대부분의 경우에 작동하지만 일부 경우에는 반복에 다른 열을 사용하고 싶을 수 있습니다.

Project.distinct.each_batch(column: :creator_id, of: 10) do |relation|
  puts User.where(id: relation.select(:creator_id)).map(&:id)
end

위의 쿼리는 프로젝트 생성자를 반복하여 중복을 제외하고 출력합니다.

비고: 열이 고유하지 않은 경우 (고유한 인덱스 정의 없음) 관련성에distinct 메서드를 호출하는 것이 필요합니다. 고유하지 않은 열을 distinct 없이 사용하면 다음에 설명된 무한 루프에 빠질 수 있습니다.

데이터 마이그레이션에서의 EachBatch

대량의 데이터를 처리할 때 선호되는 방법은 EachBatch를 사용하여 데이터를 반복하는 것입니다.

데이터 마이그레이션의 특수한 경우는 일괄 배경 마이그레이션으로, 실제 데이터 수정이 백그라운드 작업에서 실행됩니다. 데이터 범위(슬라이스)를 결정하고 백그라운드 작업을 예약하는 마이그레이션 코드가 each_batch를 사용합니다.

each_batch의 효율적인 사용

EachBatch를 사용하면 대규모 테이블을 반복할 수 있습니다. EachBatch가 모든 반복 관련 성능 문제를 마법처럼 해결해 주지는 않으며, 어떤 경우에는 전혀 도움이 되지 않을 수도 있음을 강조하는 것이 중요합니다. 데이터베이스 관점에서는 EachBatch가 잘 수행되도록 올바르게 구성된 데이터베이스 인덱스도 필요합니다.

예제 1: 간단한 반복

users 테이블을 반복하고 User 레코드를 표준 출력에 출력하려고 한다고 가정해 봅시다. users 테이블에는 수백만 개의 레코드가 있기 때문에 사용자를 검색하기 위해 한 번의 쿼리를 실행하는 것은 시간 초과될 가능성이 높습니다.

이 테이블은 users 테이블의 단순화된 버전으로 여러 행이 포함되어 있습니다. 예제를 조금 더 현실적으로 만들기 위해 id 열에 몇 개의 작은 간격이 있습니다(몇 개의 레코드가 이미 삭제되었습니다). id 필드에 하나의 인덱스가 있습니다.

ID sign_in_count created_at
1 1 2020-01-01
2 4 2020-01-01
9 1 2020-01-03
300 5 2020-01-03
301 9 2020-01-03
302 8 2020-01-03
303 2 2020-01-03
350 1 2020-01-03
351 3 2020-01-04
352 0 2020-01-05
353 9 2020-01-11
354 3 2020-01-12

메모리에 모든 사용자를 로딩하기(피하기):

users = User.all

users.each { |user| puts user.inspect }

each_batch 사용:

# 참고: 이 예제에서 배치 크기로 5를 선택했지만, 기본값은 1,000입니다
User.each_batch(of: 5) do |relation|
  relation.each { |user| puts user.inspect }
end

each_batch 작동 방식

첫 번째 단계로, 다음 데이터베이스 쿼리를 실행하여 테이블에서 가장 낮은 id(시작 id)을 찾습니다.

SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC LIMIT 1

첫 번째 ID 값 읽기

쿼리는 인덱스에서만 데이터를 읽음을 주목하세요(인덱스 전용 스캔), 테이블은 접근하지 않습니다. 데이터베이스 인덱스는 정렬되어 있으므로 첫 번째 항목을 제거하는 것은 매우 저렴한 작업입니다.

다음 단계는 배치 크기 설정을 준수하는 다음 id (끝 id)를 찾는 것입니다. 이 예제에서는 배치 크기를 5로 사용했습니다. EachBatchOFFSET 절을 사용하여 “이동된” id 값을 얻습니다.

SELECT "users"."id" FROM "users" WHERE "users"."id" >= 1 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5

끝 ID 값 읽기

다시, 쿼리는 인덱스만을 참조합니다. OFFSET 5는 여섯 번째 id 값을 가져옵니다. 이 쿼리는 테이블 크기나 반복 횟수에 관계없이 최대 여섯 개의 항목을 인덱스에서 읽습니다.

이 시점에서 첫 번째 배치의 id 범위를 알게 되었습니다. 이제 relation 블록을 위한 쿼리를 작성할 시간입니다.

SELECT "users".* FROM "users" WHERE "users"."id" >= 1 AND "users"."id" < 302

`users` 테이블에서 행 읽기

< 기호를 주목하세요. 이전에 인덱스에서 여섯 개의 항목이 읽혔고, 이 쿼리에서는 “제외된” 마지막 값입니다. 쿼리는 인덱스를 참조하여 디스크의 다섯 개의 user 행의 위치를 가져오고 테이블에서 행을 읽습니다. 반환된 배열은 Ruby에서 처리됩니다.

첫 번째 반복이 완료되었습니다. 다음 반복에서는 이전 반복에서 마지막 id 값을 재사용하여 다음 끝 id 값을 찾습니다.

SELECT "users"."id" FROM "users" WHERE "users"."id" >= 302 ORDER BY "users"."id" ASC LIMIT 1 OFFSET 5

두 번째 끝 ID 값 읽기

이제 우리는 두 번째 반복을 위한 users 쿼리를 손쉽게 작성할 수 있습니다.

SELECT "users".* FROM "users" WHERE "users"."id" >= 302 AND "users"."id" < 353

두 번째 반복을 위한 테이블에서 행 읽기

예시 2: 필터링된 반복

이전 예시를 기반으로, 우리는 로그인 횟수가 0인 사용자를 출력하고 싶습니다. ‘sign_in_count’ 열에 로그인 횟수를 추적하기 때문에 다음과 같은 코드를 작성합니다.

users = User.where(sign_in_count: 0)

users.each_batch(of: 5) do |relation|
  relation.each { |user| puts user.inspect }
end

each_batch는 시작 id 값에 대한 다음 SQL 쿼리를 생성합니다.

SELECT "users"."id" FROM "users" WHERE "users"."sign_in_count" = 0 ORDER BY "users"."id" ASC LIMIT 1

id 열만 선택하고 id로 정렬함으로써 데이터베이스는 id (기본 키 인덱스)에 대한 인덱스를 사용하지만, sign_in_count 열에 추가 조건이 있는 것으로 인해 실제 테이블을 조회해야 합니다.

추가 필터링된 인덱스 읽기

참고: 조회된 행의 수는 테이블 내 데이터 분포에 따라 다릅니다.

  • 최상의 시나리오: 첫 번째 사용자가 한 번도 로그인하지 않았습니다. 데이터베이스는 하나의 행만 읽습니다.
  • 최악의 시나리오: 모든 사용자가 한 번 이상 로그인했습니다. 데이터베이스는 모든 행을 읽습니다.

이 특정 예시에서, 데이터베이스는 첫 번째 id 값을 결정하기 위해 10개의 행을 읽어야 했습니다 (일관된 배치 크기 설정과 관계없이). “실제 세계”에서는 필터링이 문제를 일으키는지 여부를 예측하기 어렵지만, GitLab의 경우 프로덕션 복제본에서 데이터를 확인하는 것이 좋습니다. 그러나 GitLab.com의 데이터 분포는 Self-Managed 인스턴스와 다를 수 있다는 점을 염두에 두세요.

each_batch로 필터링 개선

전용 조건 인덱스
CREATE INDEX index_on_users_never_logged_in ON users (id) WHERE sign_in_count = 0

이것은 우리의 테이블과 새로 생성된 인덱스가 이렇게 보입니다:

전용 인덱스 읽기

이 인덱스 정의는 idsign_in_count 열에 대한 조건을 포함하므로 each_batch 쿼리를 매우 효과적으로 만듭니다 (단순한 반복 예시처럼).

사용자가 한 번도 로그인하지 않은 경우는 드물기 때문에 작은 인덱스 크기가 예상됩니다. 인덱스 정의에서 id만 포함하는 것은 인덱스 크기를 작게 유지하는 데 도움이 됩니다.

열별 인덱스

나중에 우리는 테이블을 순회하면서 다른 sign_in_count 값을 필터링하고 싶어질 수 있습니다. 그런 경우 이전에 제안된 조건부 인덱스를 사용할 수 없기 때문에 새로운 필터링에 맞는 조건부 인덱스를 생성하거나 더 일반화된 구성으로 인덱스를 대체해야 합니다.

참고: 동일한 테이블 및 동일한 열에 여러 개의 인덱스를 갖는 것은 데이터 작성 시 성능 병목이 될 수 있습니다.

다음과 같은 인덱스를 고려해 봅시다 (피하세요):

CREATE INDEX index_on_users_never_logged_in ON users (id, sign_in_count)

이 인덱스 정의는 인덱스 선택성 측면에서 매우 비효율적인데, 이는 인덱스 정의가 id 열로 시작하기 때문입니다.

SELECT "users"."id" FROM "users" WHERE "users"."sign_in_count" = 0 ORDER BY "users"."id" ASC LIMIT 1

위의 쿼리 실행 결과는 INDEX ONLY SCAN입니다. 그러나 쿼리는 여전히 인덱스의 알 수 없는 수의 항목을 반복하여 sign_in_count0인 첫 번째 항목을 찾아야 합니다.

비효율적인 인덱스 읽기

다음과 같은 인덱스 정의는 each_batch와 잘 작동하지 않습니다 (피하세요).

CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count)

each_batchid 열을 기반으로 범위 쿼리를 구축하기 때문에 이 인덱스를 효율적으로 사용할 수 없습니다. DB는 테이블의 행을 읽거나 기본 키 인덱스도 읽는 바이트맵 검색을 사용합니다.

“느린” 반복

“느린” 반복은 우리가 좋은 인덱스 구성을 사용하여 테이블을 반복하고 생성된 관계에 필터링을 적용하는 것을 의미합니다.

User.each_batch(of: 5) do |relation|
  relation.where(sign_in_count: 0).each { |user| puts user inspect }
end

이 반복은 이미 제한된(범위) id에 필터(sign_in_count: 0)가 적용되어 있기 때문에 주요 키 인덱스(id 열)를 사용합니다. 행 수가 제한됩니다.

느린 반복은 일반적으로 완료하는 데 더 많은 시간이 걸립니다. 반복 횟수가 많아지고 하나의 반복이 배치 크기보다 적은 레코드를 생성할 수도 있습니다. 때로는 반복이 0개의 레코드를 생성할 수도 있습니다. 이는 최적의 솔루션이 아니지만, 일부 경우(특히 대규모 테이블을 다룰 때)에는 유일한 선택사항일 수 있습니다.

서브쿼리 사용

each_batch 쿼리에서 서브쿼리를 사용하는 것은 대부분의 경우 잘 작동하지 않습니다. 다음 예제를 고려해보세요.

projects = Project.where(creator_id: Issue.where(confidential: true).select(:author_id))

projects.each_batch do |relation|
  # 무언가 수행
end

이 반복은 projects 테이블의 id 열을 사용합니다. 배치 처리는 서브쿼리에 영향을 주지 않습니다. 즉, 각 반복마다 서브쿼리가 데이터베이스에서 실행됩니다. 이로 인해 쿼리에 일정한 “부하”가 추가되어 대부분의 경우 명령 시간 초과로 이어집니다. 우리는 알려지지 않은 수의 기밀 사항이 있으며, 실행 시간 및 액세스하는 데이터베이스 행은 issues 테이블의 데이터 분포에 따라 달라집니다.

참고: 서브쿼리는 서브쿼리가 작은 수의 행을 반환할 때에만 작동합니다.

서브쿼리 개선

서브쿼리를 다룰 때, 느린 반복 접근 방식이 동작할 수 있습니다: creator_id에 대한 필터가 생성된 relation 오브젝트의 일부가 될 수 있습니다.

projects = Project.all

projects.each_batch do |relation|
  relation.where(creator_id: Issue.where(confidential: true).select(:author_id))
end

issues 테이블 자체의 쿼리가 성능적으로 충분하지 않은 경우, 중첩된 루프를 구성할 수 있습니다. 가능하면 이를 피하려고 노력하세요.

projects = Project.all

projects.each_batch do |relation|
  issues = Issue.where(confidential: true)

  issues.each_batch do |issues_relation|
    relation.where(creator_id: issues_relation.select(:author_id))
  end
end

issues 테이블이 projects보다 더 많은 행을 가진다고 가정할 때, issues 테이블이 먼저 배치 처리되는 것이 합리적일 것입니다.

JOINEXISTS 사용

JOIN을 사용해야 하는 경우:

  • 테이블 간에 1:1 또는 1:N 관계가 있고 결합된 레코드가 (거의) 항상 존재한다는 것을 알고 있는 경우. 이는 “확장 같은” 테이블에 잘 작동합니다:
    • projects - project_settings
    • users - user_details
    • users - user_statuses
  • 이 경우 LEFT JOIN이 잘 작동합니다. 결합된 테이블의 조건은 생성된 relation에 있어야 하므로 반복이 결합된 테이블의 데이터 분포에 영향을 받지 않습니다.

예시:

users = User.joins("LEFT JOIN personal_access_tokens on personal_access_tokens.user_id = users.id")

users.each_batch do |relation|
  relation.where("personal_access_tokens.name = 'name'")
end

EXISTS 쿼리는 each_batch 쿼리의 내부 relation에만 추가해야 합니다:

User.each_batch do |relation|
  relation.where("EXISTS (SELECT 1 FROM ...")
end

관계 객체에서 복잡한 쿼리

relation 객체에 여러 추가 조건이 있는 경우 실행 계획이 “불안정”해질 수 있습니다.

예시:

Issue.each_batch do |relation|
  relation
    .joins(:metrics)
    .joins(:merge_requests_closing_issues)
    .where("id IN (SELECT ...)")
    .where(confidential: true)
end

여기서, 기대하는 것은 relation 쿼리가 BATCH_SIZE의 사용자 레코드를 읽고 제공된 쿼리에 따라 결과를 필터링하는 것입니다. 플래너는 confidential 열에 인덱싱된 인덱스를 사용하여 비트맵 인덱스 조회를 수행하는 것이 더 나은 방법이라고 결정할 수 있습니다. 이로 인해 예상치 못한 많은 수의 행이 읽히고 쿼리가 시간 초과될 수 있습니다.

문제: 우리는 관계가 최대로 BATCH_SIZE의 레코드를 반환한다는 것을 확실히 알고 있지만, 플래너는 이를 모르고 있습니다.

공통 테이블 표현식(CTE) 트릭을 사용하여 범위 쿼리가 먼저 실행되도록 강제합니다:

Issue.each_batch(of: 1000) do |relation|
  cte = Gitlab::SQL::CTE.new(:batched_relation, relation.limit(1000))

  scope = cte
    .apply_to(Issue.all)
    .joins(:metrics)
    .joins(:merge_requests_closing_issues)
    .where("id IN (SELECT ...)")
    .where(confidential: true)

  puts scope.to_a
end

레코드 카운트

대량의 데이터를 가진 테이블에서 쿼리를 통해 레코드를 계산하는 것은 시간 초과로 이어질 수 있습니다. EachBatch 모듈은 순차적으로 레코드를 계산할 수 있는 대안적인 방법을 제공합니다. each_batch 사용의 단점은 생성된 relation 객체에서 추가적인 count 쿼리가 실행된다는 것입니다.

each_batch_count 메서드는 불필요한 count 쿼리를 제거하여 더 효율적으로 레코드를 계산하는 방법을 제공합니다. 이 메서드를 호출함으로써, 반복 프로세스를 일시 중지하고 필요에 따라 재개할 수 있습니다. 이 기능은 특히 Sidekiq 워커 내에서 5분 후에 오류 예산 위반이 트리거되는 상황과 같이 도움이 됩니다.

예를 들어, EachBatch를 사용하여 레코드를 계산하려면 다음과 같이 추가적인 count 쿼리를 호출해야 합니다:

count = 0

Issue.each_batch do |relation|
  count += relation.count
end

puts count

반면, each_batch_count 메서드는 불필요한 count 쿼리를 호출하지 않고 효율적으로 계산 과정을 수행합니다(계산이 반복 쿼리의 일부가 됨):

count, _last_value = Issue.each_batch_count # 여기서 마지막 값은 무시될 수 있습니다

또한, each_batch_count 메서드는 카운팅 프로세스를 언제든지 일시 중지하고 재개할 수 있습니다. 이 능력은 다음 코드 스니펫에서 확인할 수 있습니다:

stop_at = Time.current + 3.minutes

count, last_value = Issue.each_batch_count do
  stop_at.past? # 카운팅을 중지하기 위한 조건
end

# 나중에 카운팅 계속하기
stop_at = Time.current + 3.minutes

count, last_value = Issue.each_batch_count(last_count: count, last_value: last_value) do
  stop_at.past?
end

EachBatch vs BatchCount

새로운 Service Ping에 대한 새 카운터를 추가할 때, 레코드를 세는 우선적인 방법은 Gitlab::Database::BatchCount 클래스를 사용하는 것입니다. BatchCount에 구현된 반복 논리는 EachBatch와 유사한 성능 특성을 가지고 있습니다. BatchCount의 성능을 개선하기 위한 대부분의 팁과 제안은 EachBatch에도 적용됩니다.

키팅 페이지네이션을 사용하여 반복

EachBatch로는 작동하지 않는 특수한 경우가 몇 가지 있습니다. EachBatch는 하나의 구분되는 열(일반적으로 기본 키)을 필요로 하기 때문에 타임스탬프 열 및 복합 기본 키가 있는 테이블에 대한 반복을 불가능하게 합니다.

EachBatch가 작동하지 않는 경우, 키팅 페이지네이션을 사용하여 테이블이나 일련의 행을 반복할 수 있습니다. 확장 및 성능 특성은 EachBatch와 매우 유사합니다.

예시:

  • 특정 순서(타임스탬프 열)로 테이블 반복 및 정렬 기준 열에 고유한 값이 포함되어 있지 않은 경우 tie-breaker와 함께
  • 복합 기본 키가 있는 테이블 반복

프로젝트별 이슈 반복(생성 날짜별)

특정 순서(예: created_at DESC)로 데이터베이스 열을 반복하기 위해 키팅 페이지네이션을 사용할 수 있습니다. created_at에 대해 동일한 값의 반환된 레코드의 일관된 순서를 보장하기 위해 고유한 값을 가진 tie-breaker 열을 사용할 수 있습니다(예: id).

다음과 같은 인덱스가 issues 테이블에 있다고 가정해보세요:

idx_issues_on_project_id_and_created_at_and_id" btree (project_id, created_at, id)

추가 처리를 위한 레코드 가져오기

다음 스니펫은 지정된 순서(created_at, id)로 프로젝트 내의 이슈 레코드를 반복합니다.

scope = Issue.where(project_id: 278964).order(:created_at, :id) # id is the tie-breaker

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

iterator.each_batch(of: 100) do |records|
  puts records.map(&:id)
end

쿼리에 추가 필터를 추가할 수 있습니다. 이 예시는 지난 30일 동안에 생성된 이슈 ID만 나열합니다:

scope = Issue.where(project_id: 278964).where('created_at > ?', 30.days.ago).order(:created_at, :id) # id is the tie-breaker

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

iterator.each_batch(of: 100) do |records|
  puts records.map(&:id)
end

레코드 일괄 업데이트

복잡한 ActiveRecord 쿼리의 경우 .update_all 메서드는 잘 작동하지 않습니다. 이는 잘못된 UPDATE 문을 생성하기 때문입니다. 따라서 레코드를 일괄로 업데이트하기 위해 원시 SQL을 사용할 수 있습니다:

scope = Issue.where(project_id: 278964).order(:created_at, :id) # id is the tie-breaker

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

iterator.each_batch(of: 100) do |records|
  ApplicationRecord.connection.execute("UPDATE issues SET updated_at=NOW() WHERE issues.id in (#{records.dup.reselect(:id).to_sql})")
end

참고: 반복을 안정적이고 예측 가능하게 유지하려면 ORDER BY 절의 열을 업데이트하지 않도록 주의하세요.

merge_request_diff_commits 테이블 반복

merge_request_diff_commits 테이블은 (merge_request_diff_id, relative_order)로 이뤄진 복합 기본 키를 사용하기 때문에 EachBatch를 효율적으로 사용할 수 없습니다.

merge_request_diff_commits 테이블을 페이지별로 나누려면 다음 스니펫을 사용할 수 있습니다:

# 사용자 정의 순서 객체 구성:
order = Gitlab::Pagination::Keyset::Order.build([
  Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
    attribute_name: 'merge_request_diff_id',
    order_expression: MergeRequestDiffCommit.arel_table[:merge_request_diff_id].asc,
    nullable: :not_nullable,
    distinct: false,
  ),
  Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
    attribute_name: 'relative_order',
    order_expression: MergeRequestDiffCommit.arel_table[:relative_order].asc,
    nullable: :not_nullable,
    distinct: false,
  )
])
MergeRequestDiffCommit.include(FromUnion) # 키팅 페이지네이션은 UNION 쿼리 생성

scope = MergeRequestDiffCommit.order(order)

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

iterator.each_batch(of: 100) do |records|
  puts records.map { |record| [record.merge_request_diff_id, record.relative_order] }.inspect
end

순서 객체 구성

키팅 페이지네이션은 간단한 ActiveRecord order 스코프와 잘 작동합니다(첫 번째 예시). 그러나 특수한 경우에는 ORDER BY 절을 키팅 페이지네이션 라이브러리에 자동으로 결정할 수 없는 경우, 기반 키팅 페이지네이션 라이브러리의 ORDER BY 절에 있는 열을 설명해야 합니다(두 번째 예시). 키팅 페이지네이션 라이브러리의 ORDER BY 구성을 자동으로 결정할 수 없는 경우에는 오류가 발생합니다.

Gitlab::Pagination::Keyset::OrderGitlab::Pagination::Keyset::ColumnOrderDefinition 클래스의 코드 주석을 통해 ORDER BY 절을 구성하는 가능한 옵션에 대한 개요를 찾을 수 있습니다. 또한 키팅 페이지네이션 설명서에 몇 가지 코드 예제를 찾을 수 있습니다.