일괄 테이블 반복

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가 지원하는 인수를 모두 지원하지는 않습니다. 특정한 필요가 없는 한 항상 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

이는 레코드의 수 있는 최댓값 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 스코프를 반환합니다. 다른 열은 로드되지 않습니다.

기본 데이터베이스 쿼리는 재귀 CTE를 사용하여 추가 오버헤드가 추가됩니다. 따라서 표준 each_batch 반복에 사용하는 것보다 작은 일괄 크기를 권장합니다.

열 정의

기본적으로 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 없이 고유하지 않은 열을 사용하는 것은 다음과 같이 무한 루프에 빠지게 하므로 이러한 이슈에 대해 설명한 것을 참조하세요. issue.

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

대량의 데이터를 반복 처리하는 경우 EachBatch를 사용하는 것이 좋습니다.

배치된 백그라운드 마이그레이션(batched background migration)의 특수한 경우는 실제 데이터 수정이 백그라운드 작업에서 실행되는 데이터 마이그레이션입니다. 데이터 범위(슬라이스)를 결정하고 백그라운드 작업을 예약하는 마이그레이션 코드는 each_batch를 사용합니다.

each_batch의 효율적인 사용

EachBatch는 대규모 테이블을 반복하는 데 도움이 됩니다. EachBatch가 반복 관련 성능 문제를 마법같이 해결해 주는 것은 아니며, 일부 시나리오에서는 전혀 도움이 되지 않을 수 있습니다. 데이터베이스 관점에서 정확히 구성된 데이터베이스 인덱스도 EachBatch의 성능을 향상시키는 데 필요합니다.

예시 1: 간단한 반복

users 테이블을 반복하고 User 레코드를 표준 출력에 인쇄하려고 합니다. 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 값 읽기

해당 쿼리는 색인에서 데이터만 읽고(INDEX ONLY SCAN), 테이블에는 접근하지 않습니다. 데이터베이스 인덱스는 정렬되어 있으므로 첫 번째 아이템을 가져오는 것은 매우 저렴한 작업입니다.

다음 단계는 다음 id (끝 id)를 찾는 것인데, 이는 배치 크기 설정을 준수해야 합니다. 이 예에서는 배치 크기로 5를 사용했습니다. EachBatch는 “이동된” id 값을 가져오기 위해 OFFSET 절을 사용합니다.

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

끝 ID 값 읽기

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

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

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

< 기호에 주목해 주세요. 이전에 색인에서 여섯 개의 항목을 읽었으며, 이 쿼리에서는 마지막 값이 “제외”됩니다. 이 쿼리는 디스크의 사용자 행 위치를 얻기 위해 색인을 확인하고, 테이블에서 행을 읽습니다. 반환된 배열은 Ruby에서 처리됩니다.

첫 번째 반복이 끝났습니다. 다음 반복을 위해 이전 반복에서 마지막 id 값이 재사용되어 다음 끝 id 값을 찾습니다.

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

이제 우리는 두 번째 반복을 위한 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의 데이터 분포는 온프레미스 인스턴스와 다를 수 있음을 염두에 두세요.

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 값으로 필터링된 테이블을 반복하려고 할 때, 이전에 제안한 조건부 인덱스를 사용할 수 없습니다. 왜냐하면 WHERE 조건이 새 필터와 일치하지 않기 때문입니다 (sign_in_count > 10).

이 문제를 해결하기 위해 두 가지 옵션이 있습니다:

  • 새로운 쿼리에 대한 추가 조건부 인덱스 생성
  • 인덱스를 보다 일반화된 구성으로 교체

참고: 동일한 테이블 및 열에 대해 여러 개의 인덱스가 있는 경우 데이터 쓰기 성능에 영향을 미칠 수 있습니다.

다음과 같은 인덱스를 사용하는 것은 고려해봐야 할 사항입니다(지양):

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인 첫 번째 항목을 찾아야 합니다.

인덱스 정의에서 열을 교체함으로써 쿼리를 크게 개선할 수 있습니다(권장).

CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count, id)

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

CREATE INDEX index_on_users_never_logged_in ON users (sign_in_count)

each_batchid 열을 기반으로 범위 쿼리를 작성하기 때문에 이 인덱스를 효율적으로 사용할 수 없습니다. 데이터베이스는 테이블의 행을 읽거나 기본 키 인덱스를 읽는 비트맵 검색을 사용해야 합니다.

“느린” 반복

느린 반복은 테이블을 반복하고 생성된 관계에 필터링을 적용하는 데 적절한 인덱스 구성을 사용하는 것을 의미합니다.

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

이 반복은 기본 키 인덱스(id 열에 대한)를 사용하여 구현되며, 이로 인해 문장 시간 초과가 발생하지 않습니다. 필터(sign_in_count: 0)는 이미 제약 조건(범위)이 정의된 relation에 적용됩니다. 행 수가 제한되어 있습니다.

느린 반복은 일반적으로 완료하는 데 더 많은 시간이 소요됩니다. 반복 횟수가 더 많으며 한 번의 반복에서는 배치 크기보다 작은 레코드가 생성될 수 있습니다. 반복별로 레코드가 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은 잘 동작합니다. 연결된 테이블에 대한 조건은 생성된 관계로 이동하여 데이터 분포에 유의하지 않게 됩니다.

예시:

User.each_batch do |relation|
  relation
    .joins("LEFT JOIN personal_access_tokens on personal_access_tokens.user_id = users.id")
    .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를 사용하는 단점은 생성된 관계 객체에서 추가적인 카운트 쿼리가 실행된다는 것입니다.

each_batch_count 메서드는 추가적인 카운트 쿼리를 필요로하지 않고 반복적으로 레코드를 계산할 수 있는 더 효율적인 방법입니다. 이 기능은 에러 발생 후 5분이 지난 후와 같이 시간 초과가 발생하는 경우(예: Sidekiq 워커에서 레코드 계산을 수행하는 경우) 유용합니다.

예시로, EachBatch를 사용하여 레코드를 세는 것은 다음과 같은 추가적인 카운트 쿼리를 실행하는 것을 포함합니다:

count = 0

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

puts count

그러나 each_batch_count 메서드를 사용하면 추가적인 카운트 쿼리를 실행하지 않고 더 효율적으로 레코드를 계산할 수 있습니다:

count, _last_value = Issue.each_batch_count # last value can be ignored here

또한, 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를 개선하기 위한 대부분의 팁과 제안 사항은 BatchCount에도 적용됩니다.

Keyset 페이지네이션으로 반복

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가 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가 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가 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
  ),
  Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
    attribute_name: 'relative_order',
    order_expression: MergeRequestDiffCommit.arel_table[:relative_order].asc,
    nullable: :not_nullable
  )
])
MergeRequestDiffCommit.include(FromUnion) # keyset 페이지네이션은 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 설정을 키셋 페이지네이션 라이브러리에서 자동으로 결정할 수 없는 경우 오류가 발생합니다.

Gitlab::Pagination::Keyset::OrderGitlab::Pagination::Keyset::ColumnOrderDefinition 클래스의 코드 주석은 ORDER BY 절을 구성하는 데 사용할 수있는 옵션에 대한 개요를 제공합니다. 또한 키셋 페이지네이션 문서에서 몇 가지 코드 예제를 찾을 수 있습니다.