데이터베이스 테이블의 중복 레코드 제거
GitLab v19.1이 가이드는 데이터가 있는 기존 데이터베이스 테이블에 데이터베이스 수준의 고유 제약 조건(유니크 인덱스)을 도입하기 위한 전략을 설명합니다. 관련 칼럼에 대한 속성 수정(INSERT, UPDATE)은 ActiveRecord를 통해서만 이루어집니다(이 기법은 AR 콜백에 의존합니다).
이 가이드는 데이터가 있는 기존 데이터베이스 테이블에 데이터베이스 수준의 고유 제약 조건(유니크 인덱스)을 도입하기 위한 전략을 설명합니다.
요구 사항:
-
관련 칼럼에 대한 속성 수정(
INSERT,UPDATE)은 ActiveRecord를 통해서만 이루어집니다(이 기법은 AR 콜백에 의존합니다). -
중복은 드물게 발생하며 주로 동시 레코드 생성으로 인해 발생합니다. teleport를 통해 프로덕션 데이터베이스 테이블을 확인하여 검증할 수 있습니다(도움이 필요하면 데이터베이스 메인테이너에게 문의하세요).
총 실행 시간은 주로 데이터베이스 테이블의 레코드 수에 따라 다릅니다. 마이그레이션은 모든 레코드를 스캔해야 하므로, 배포 후 마이그레이션 실행 시간 제한(약 10분)을 충족하려면 1,000만 행 미만의 데이터베이스 테이블을 소형 테이블로 간주할 수 있습니다.
소형 테이블의 중복 제거 전략#
이 전략은 3단계 마일스톤이 필요합니다. 예시로, project_id 칼럼 기준으로 title이 고유해야 하는 조건에서 issues 테이블을 title 칼럼 기반으로 중복 제거합니다.
마일스톤 1:
-
배포 후 마이그레이션(post-migration)을 통해 테이블에 새로운 데이터베이스 인덱스(유니크가 아닌)를 추가합니다(아직 없는 경우).
-
중복 발생 가능성을 줄이기 위해 모델 수준 고유성 검증을 추가합니다(아직 없는 경우).
-
중복 레코드 생성을 방지하기 위해 트랜잭션 수준의 어드바이저리 락(advisory lock)을 추가합니다.
두 번째 단계만으로는 중복 레코드를 방지할 수 없습니다. 자세한 내용은 Rails 가이드를 참조하세요.
인덱스 생성을 위한 배포 후 마이그레이션:
def up
add_concurrent_index :issues, [:project_id, :title], name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :issues, INDEX_NAME
end
Issue 모델 검증 및 어드바이저리 락:
class Issue < ApplicationRecord
validates :title, uniqueness: { scope: :project_id }
before_validation :prevent_concurrent_inserts
private
# This method will block while another database transaction attempts to insert the same data.
# After the lock is released by the other transaction, the uniqueness validation may fail
# with record not unique validation error.
# Without this block the uniqueness validation wouldn't be able to detect duplicated
# records as transactions can't see each other's changes.
def prevent_concurrent_inserts
return if project_id.nil? || title.nil?
lock_key = ['issues', project_id, title].join('-')
lock_expression = "hashtext(#{connection.quote(lock_key)})"
connection.execute("SELECT pg_advisory_xact_lock(#{lock_expression})")
end
end
마일스톤 2:
-
배포 후 마이그레이션에서 중복 제거 로직을 구현합니다.
-
기존 인덱스를 유니크 인덱스로 교체합니다.
중복을 해결하는 방법(예: 속성 병합, 가장 최근 레코드 유지)은 데이터베이스 테이블 위에 구축된 기능에 따라 다릅니다. 이 예시에서는 가장 최근 레코드를 유지합니다.
def up
model = define_batchable_model('issues')
# Single pass over the table
model.each_batch do |batch|
# find duplicated (project_id, title) pairs
duplicates = model
.where("(project_id, title) IN (#{batch.select(:project_id, :title).to_sql})")
.group(:project_id, :title)
.having('COUNT(*) > 1')
.pluck(:project_id, :title)
next if duplicates.empty?
value_list = Arel::Nodes::ValuesList.new(duplicates).to_sql
# Locate all records by (project_id, title) pairs and keep the most recent record.
# The lookup should be fast enough if duplications are rare.
cleanup_query = <<~SQL
WITH duplicated_records AS MATERIALIZED (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY project_id, title ORDER BY project_id, title, id DESC) AS row_number
FROM issues
WHERE (project_id, title) IN (#{value_list})
ORDER BY project_id, title
)
DELETE FROM issues
WHERE id IN (
SELECT id FROM duplicated_records WHERE row_number > 1
)
SQL
model.connection.execute(cleanup_query)
end
end
def down
# no-op
end
이 작업은 롤백이 불가능한 파괴적인 작업입니다. 중복 제거 로직을 철저히 테스트해야 합니다.
기존 인덱스를 유니크 인덱스로 교체:
def up
add_concurrent_index :issues, [:project_id, :title], name: UNIQUE_INDEX_NAME, unique: true
remove_concurrent_index_by_name :issues, INDEX_NAME
end
def down
add_concurrent_index :issues, [:project_id, :title], name: INDEX_NAME
remove_concurrent_index_by_name :issues, UNIQUE_INDEX_NAME
end
마일스톤 3:
prevent_concurrent_insertsActiveRecord 콜백 메서드를 제거하여 어드바이저리 락을 제거합니다.
이 마일스톤은 필수 정지 이후에 수행해야 합니다.
대형 테이블의 중복 제거 전략#
대형 테이블을 중복 제거할 때는 배치 처리와 중복 제거 로직을 일괄 백그라운드 마이그레이션(batched background migration)으로 이동할 수 있습니다.
마일스톤 1:
-
배포 후 마이그레이션을 통해 테이블에 새로운 데이터베이스 인덱스(유니크가 아닌)를 추가합니다.
-
중복 발생 가능성을 줄이기 위해 모델 수준 고유성 검증을 추가합니다(아직 없는 경우).
-
중복 레코드 생성을 방지하기 위해 트랜잭션 수준의 어드바이저리 락을 추가합니다.
마일스톤 2:
- 일괄 백그라운드 마이그레이션에서 중복 제거 로직을 구현하고 배포 후 마이그레이션에서 이를 큐에 추가합니다.
마일스톤 3:
-
일괄 백그라운드 마이그레이션을 완료합니다.
-
기존 인덱스를 유니크 인덱스로 교체합니다.
-
prevent_concurrent_insertsActiveRecord 콜백 메서드를 제거하여 어드바이저리 락을 제거합니다.
이 마일스톤은 필수 정지 이후에 수행해야 합니다.