InfoGrab DocsInfoGrab Docs

데이터베이스 테이블의 중복 레코드 제거

요약

이 가이드는 데이터가 있는 기존 데이터베이스 테이블에 데이터베이스 수준의 고유 제약 조건(유니크 인덱스)을 도입하기 위한 전략을 설명합니다. 관련 칼럼에 대한 속성 수정(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_inserts ActiveRecord 콜백 메서드를 제거하여 어드바이저리 락을 제거합니다.

이 마일스톤은 필수 정지 이후에 수행해야 합니다.

대형 테이블의 중복 제거 전략#

대형 테이블을 중복 제거할 때는 배치 처리와 중복 제거 로직을 일괄 백그라운드 마이그레이션(batched background migration)으로 이동할 수 있습니다.

마일스톤 1:

  • 배포 후 마이그레이션을 통해 테이블에 새로운 데이터베이스 인덱스(유니크가 아닌)를 추가합니다.

  • 중복 발생 가능성을 줄이기 위해 모델 수준 고유성 검증을 추가합니다(아직 없는 경우).

  • 중복 레코드 생성을 방지하기 위해 트랜잭션 수준의 어드바이저리 락을 추가합니다.

마일스톤 2:

  • 일괄 백그라운드 마이그레이션에서 중복 제거 로직을 구현하고 배포 후 마이그레이션에서 이를 큐에 추가합니다.

마일스톤 3:

  • 일괄 백그라운드 마이그레이션을 완료합니다.

  • 기존 인덱스를 유니크 인덱스로 교체합니다.

  • prevent_concurrent_inserts ActiveRecord 콜백 메서드를 제거하여 어드바이저리 락을 제거합니다.

이 마일스톤은 필수 정지 이후에 수행해야 합니다.

데이터베이스 테이블의 중복 레코드 제거

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_inserts ActiveRecord 콜백 메서드를 제거하여 어드바이저리 락을 제거합니다.

이 마일스톤은 필수 정지 이후에 수행해야 합니다.

대형 테이블의 중복 제거 전략#

대형 테이블을 중복 제거할 때는 배치 처리와 중복 제거 로직을 일괄 백그라운드 마이그레이션(batched background migration)으로 이동할 수 있습니다.

마일스톤 1:

  • 배포 후 마이그레이션을 통해 테이블에 새로운 데이터베이스 인덱스(유니크가 아닌)를 추가합니다.

  • 중복 발생 가능성을 줄이기 위해 모델 수준 고유성 검증을 추가합니다(아직 없는 경우).

  • 중복 레코드 생성을 방지하기 위해 트랜잭션 수준의 어드바이저리 락을 추가합니다.

마일스톤 2:

  • 일괄 백그라운드 마이그레이션에서 중복 제거 로직을 구현하고 배포 후 마이그레이션에서 이를 큐에 추가합니다.

마일스톤 3:

  • 일괄 백그라운드 마이그레이션을 완료합니다.

  • 기존 인덱스를 유니크 인덱스로 교체합니다.

  • prevent_concurrent_inserts ActiveRecord 콜백 메서드를 제거하여 어드바이저리 락을 제거합니다.

이 마일스톤은 필수 정지 이후에 수행해야 합니다.