일괄 처리된 테이블 반복

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” 쿼리는 WHERE "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 스코프를 반환합니다. 다른 열은 로드되지 않습니다.

기저 데이터베이스 쿼리는 재귀 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

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

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

데이터 이관에서의 EachBatch

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

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

each_batch의 효율적인 사용

EachBatch를 사용하여 대규모 테이블을 반복하는 것은 중요합니다. EachBatch가 모든 반복에 관련된 성능 문제를 마법처럼 해결하지는 않으며, 일부 시나리오에서는 전혀 도움이 되지 않을 수 있습니다. 데이터베이스 관점에서 올바르게 구성된 데이터베이스 인덱스도 EachBatch가 잘 동작하도록 하는 데 필요합니다.

예시 1: 간단한 반복

users 테이블을 반복하고 User 레코드를 표준 출력으로 출력하려고 하는 경우를 고려해봅시다. users 테이블에는 수백만 개의 레코드가 있으므로 사용자를 검색하기 위해 단일 쿼리를 실행하면 시간이 초과될 가능성이 높습니다.

이 테이블은 몇 개의 더 작은 갭이 있는 id 열을 갖고 있는 단순화된 users 테이블입니다 (일부 레코드가 이미 삭제되었습니다). 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로 설정했습니다. 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 값을 가져옵니다. 이 쿼리는 테이블 크기나 반복 횟수에 관계없이 인덱스에서 최대 여섯 개의 아이템을 읽습니다. 이 시점에서 최초 일괄 처리를 위한 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: 필터링된 반복

이전 예제를 기반으로, sign_in_count가 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 열에 추가 조건이 있습니다. 이 열은 인덱스의 일부가 아니기 때문에, 데이터베이스는 첫 번째 일치하는 행을 찾기 위해 실제 테이블을 참조해야 합니다.

추가 필터링된 인덱스 읽기

note
스캔된 행 수는 테이블 내 데이터 분포에 따라 다릅니다.
  • 최상의 경우: 첫 번째 사용자가 로그인한 적이 없습니다. 데이터베이스는 한 행만 읽습니다.
  • 최악의 경우: 모든 사용자가 최소한 한 번은 로그인했습니다. 데이터베이스는 모든 행을 읽습니다.

이 특정 예에서 데이터베이스는 첫 번째 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 값으로 필터링하고 싶을 수 있습니다. 이러한 경우, 이전에 제안한 조건부 인덱스를 사용할 수 없습니다. 왜냐하면 WHERE 조건이 새 필터와 일치하지 않기 때문입니다 (sign_in_count > 10).

이 문제에 대처하기 위해 두 가지 옵션이 있습니다:

  • 새로운 쿼리에 대한 조건부 인덱스를 만듭니다.
  • 인덱스를 보다 일반화된 구성으로 대체합니다.
note
동일한 테이블과 동일한 열에 여러 인덱스를 가지고 있는 것은 데이터를 작성할 때 성능 병목이 될 수 있습니다.

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

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)는 이미 제한된(range) 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 테이블의 데이터 분포에 따라 달라집니다.

note
서브쿼리는 서브쿼리에서 일정 수의 행을 반환할 때에만 작동합니다.

서브쿼리 개선

서브쿼리를 다룰 때, 느린 반복 접근법이 작동할 수 있습니다: 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이 잘 작동합니다. 연결된 테이블의 조건은 생성된 관계에 있어야 하므로 데이터 배포가 연결된 테이블에 미치는 영향을 반복에 반영되지 않습니다.

예:

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 사용의 단점은 생성된 관계 객체의 추가적인 카운트 쿼리가 실행된다는 것입니다.

each_batch_count 메서드는 추가적인 카운트 쿼리가 실행되지 않고 반복적으로 레코드를 계산하는 효율적인 방법을 제공합니다. 이 기능은 특히 5분 후에 오류 예산 위반이 트리거되는 상황과 같이 반복적으로 계산 작업을 수행할 때 유용합니다.

예를 들어, EachBatch를 사용하여 레코드를 세는 것은 다음과 같은 추가적인 카운트 쿼리를 호출하는 것을 포함합니다.

count = 0

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

puts count

반면, each_batch_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

EachBatchBatchCount

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

키셋 페이지네이션으로 반복

EachBatch로 반복하는 데 작동하지 않는 몇 가지 특별한 경우가 있습니다. 일반적으로 기본 키인 하나의 고유한 열이 필요한 EachBatch는 타임스탬프 열 및 복합 기본 키를 가진 테이블에 대해 반복이 불가능하게 만듭니다.

EachBatch가 작동하지 않는 경우 키셋 페이지네이션을 사용하여 테이블이나 행 범위를 반복할 수 있습니다. 스케일링 및 성능 특성은 EachBatch와 매우 유사합니다.

예시:

  • 특정 순서(타임스탬프 열)로 테이블을 반복하며 유니크한 값이 있는지 확인합니다. 값이 고유하지 않은 경우 타이 브레이커를 사용하세요.
  • 복합 기본 키로 테이블을 반복합니다.

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

특정 순서(예: created_at DESC)로 데이터베이스 열을 반복할 수 있습니다. created_at으로 동일한 값의 반환되는 레코드의 일관된 순서를 보장하려면 고유한 값이 있는 타이 브레이커 열(예: 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는 타이 브레이커입니다.

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는 타이 브레이커입니다.

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는 타이 브레이커입니다.

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
note
반복을 안정적이고 예측 가능하게 유지하려면 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 스코프와 잘 작동합니다 (첫 번째 예시). 그러나 특별한 경우에는 기본 keyset 페이지네이션 라이브러리를 위한 ORDER BY 절의 열을 설명해야 합니다 (두 번째 예시). ORDER BY 구성이 키셋 페이지네이션 라이브러리에 의해 자동으로 결정될 수 없는 경우 오류가 발생합니다.

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