데이터베이스 테이블의 중복 레코드 제거
GitLab v19.4요약
이 가이드는 데이터가 있는 기존 데이터베이스 테이블에 데이터베이스 수준의 고유 제약 조건(유니크 인덱스)을 도입하는 전략을 설명합니다. 전체 실행 시간은 주로 데이터베이스 테이블의 레코드 수에 따라 달라집니다. 이 전략에는 마일스톤 3개가 필요합니다.
이 가이드는 데이터가 있는 기존 데이터베이스 테이블에 데이터베이스 수준의 고유 제약 조건(유니크 인덱스)을 도입하는 전략을 설명합니다.
요구 사항:
- 해당 칼럼과 관련된 속성 변경(
INSERT,UPDATE)이 ActiveRecord를 통해서만 이루어집니다(이 기법은 AR 콜백에 의존합니다). - 중복이 드물게 발생하며 대부분 동시 레코드 생성으로 인해 발생합니다. 이는 teleport로 프로덕션 데이터베이스 테이블을 확인하여 검증할 수 있습니다(도움이 필요하면 데이터베이스 maintainer에게 문의합니다).
전체 실행 시간은 주로 데이터베이스 테이블의 레코드 수에 따라 달라집니다. 마이그레이션은 모든 레코드를 스캔해야 합니다. 배포 후 마이그레이션 실행 시간 제한(약 10분) 안에 들어가려면, 행이 1천만 개 미만인 데이터베이스 테이블을 소형 테이블로 볼 수 있습니다.
소형 테이블의 중복 제거 전략#
이 전략에는 마일스톤 3개가 필요합니다. 예시로 title 칼럼을 기준으로 issues 테이블의 중복을 제거하며, 주어진 project_id 칼럼에 대해 title 이 고유해야 한다고 가정합니다.
마일스톤 1:
- 배포 후 마이그레이션으로 테이블에 새 데이터베이스 인덱스(유니크 아님)를 추가합니다(아직 없는 경우).
- 중복 가능성을 줄이기 위해 모델 수준의 고유성 검증을 추가합니다(아직 없는 경우).
- 중복 레코드 생성을 막기 위해 트랜잭션 수준 어드바이저리 잠금을 추가합니다.
두 번째 단계만으로는 중복 레코드를 막지 못합니다. 자세한 내용은 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 콜백 메서드를 제거하여 어드바이저리 잠금을 없앱니다.
이 마일스톤은 필수 중단 지점 이후여야 합니다.
대형 테이블의 중복 제거 전략#
대형 테이블의 중복을 제거할 때는 배치 처리와 중복 제거 로직을 배치 백그라운드 마이그레이션으로 옮길 수 있습니다.
마일스톤 1:
- 배포 후 마이그레이션으로 테이블에 새 데이터베이스 인덱스(유니크 아님)를 추가합니다.
- 중복 가능성을 줄이기 위해 모델 수준의 고유성 검증을 추가합니다(아직 없는 경우).
- 중복 레코드 생성을 막기 위해 트랜잭션 수준 어드바이저리 잠금을 추가합니다.
마일스톤 2:
- 배치 백그라운드 마이그레이션에 중복 제거 로직을 구현하고 배포 후 마이그레이션에서 큐에 등록합니다.
마일스톤 3:
- 배치 백그라운드 마이그레이션을 마무리합니다.
- 기존 인덱스를 유니크 인덱스로 교체합니다.
prevent_concurrent_insertsActiveRecord 콜백 메서드를 제거하여 어드바이저리 잠금을 없앱니다.
이 마일스톤은 필수 중단 지점 이후여야 합니다.