InfoGrab DocsInfoGrab Docs

외래 키와 연관 관계

요약

외래 키는 관련 데이터베이스 테이블 간의 일관성을 보장합니다. 애플리케이션 수준에서 데이터 일관성을 보장하는 방법은 불행한 상황에서 실패할 수 있으므로, 테이블에 일관성 없는 데이터가 남을 수 있습니다. 다른 테이블의 레코드를 참조하는 테이블을 생성할 때는 데이터 무결성을 유지하기 위해 FK를 추가해야 합니다.

외래 키는 관련 데이터베이스 테이블 간의 일관성을 보장합니다. Rails 버전 4부터 Rails는 데이터베이스 테이블에 외래 키 제약을 추가하기 위한 마이그레이션 헬퍼를 제공합니다. Rails 4 이전에는 일정 수준의 일관성을 보장하는 유일한 방법이 연관 관계 정의의 dependent 옵션이었습니다.

애플리케이션 수준에서 데이터 일관성을 보장하는 방법은 불행한 상황에서 실패할 수 있으므로, 테이블에 일관성 없는 데이터가 남을 수 있습니다. 이는 주로 데이터베이스 수준에서 일관성을 보장하는 프레임워크 지원이 없었던 오래된 테이블에 영향을 미칩니다. 이러한 데이터 불일관성은 예상치 못한 애플리케이션 동작이나 버그를 유발할 수 있습니다.

다른 테이블의 레코드를 참조하는 테이블을 생성할 때는 데이터 무결성을 유지하기 위해 FK를 추가해야 합니다. 모델에 연관 관계를 추가할 때도 반드시 외래 키를 추가해야 합니다. 또한 외래 키를 추가할 때는 항상 인덱스를 먼저 추가해야 합니다.

예를 들어, 다음과 같은 모델이 있다고 가정합니다.

class User < ActiveRecord::Base
  has_many :posts
end

여기서 칼럼 posts.user_id에 외래 키를 추가합니다. 이렇게 하면 데이터베이스 수준에서 데이터 일관성이 강제됩니다. 외래 키는 또한 데이터베이스가 연관 데이터를 제거할 수 있음을 의미합니다(예: 사용자를 제거할 때), Rails가 이를 처리하는 대신 데이터베이스가 직접 처리합니다.

다운타임 및 마이그레이션 실패 방지#

외래 키 추가에는 두 부분이 있습니다.

  • FK 칼럼과 제약 추가.

  • 데이터 무결성을 유지하기 위해 추가된 제약 검증.

(1)은 가장 엄격한 잠금(ACCESS EXCLUSIVE)을 사용하는 ALTER TABLE 구문을 사용하며, 제약을 검증하려면 전체 테이블을 순회해야 하므로 대용량/고트래픽 테이블에서는 시간이 많이 걸립니다.

따라서 거의 모든 경우에 더 엄격한 잠금을 유지하고 테이블에 대한 다른 작업을 더 오래 차단하는 것을 피하기 위해 별도의 트랜잭션으로 실행해야 합니다.

새 테이블에서#

  • 새 테이블이 다른 하나의 테이블만 참조하는 경우에는 간단합니다. 참조되는 테이블에 관계없이 create_table (t.references, ..., foreign_key: true)를 사용할 수 있습니다.

  • 새 테이블이 두 개의 서로 다른 테이블을 참조하는 경우. 외래 키가 두 개일 때 새 테이블 생성을 참조하세요.

새 칼럼에서#

새로운(레코드가 많지 않은) 테이블이라면 아래 방법 중 하나를 사용할 수 있습니다. 외래 키를 두 개 추가해야 한다면, 동일한 마이그레이션에서 두 개 이상의 테이블을 잠그는 것을 방지하기 위해 서로 다른 마이그레이션으로 분리하세요.

  • add_reference(… foreign_key: true)

  • 동일한 트랜잭션에서 add_column(…)과 add_foreign_key(…) 실행.

그 외 모든 경우, 칼럼 추가, FK 제약 추가, 제약 검증은 별도의 트랜잭션에서 수행해야 합니다.

기존 칼럼에서#

기존 데이터베이스 칼럼에 외래 키를 추가하려면 데이터베이스 구조 변경과 잠재적인 데이터 변경이 필요합니다.

테이블이 사용 중인 경우, 항상 일관성 없는 데이터가 있다고 가정해야 합니다.

기존 칼럼에 FK 제약을 추가하는 것은 여러 마일스톤에 걸친 프로세스입니다.

  • N.M: 칼럼에 NOT VALID FK 제약을 추가합니다. 이는 일관성 없는 레코드가 생성되거나 업데이트되지 않도록 보장합니다.

  • N.M: 기존 레코드를 수정하거나 정리하기 위한 데이터 마이그레이션을 추가합니다.

  1. 마이그레이션 쿼리가 타이밍 가이드라인 내에 있다면 일반 마이그레이션 또는 배포 후 마이그레이션이 될 수 있습니다.
  2. 그렇지 않은 경우, 배치 백그라운드 마이그레이션을 사용해야 합니다.
  • FK 제약 검증
  1. 데이터 마이그레이션이 일반 또는 배포 후 마이그레이션이었다면, 동일한 마일스톤에서 제약을 검증할 수 있습니다.
  2. 백그라운드 마이그레이션이었다면, BBM이 완료된 후에만 FK를 검증할 수 있습니다. 이는 데이터 마이그레이션이 백그라운드에서 실행되는 동안 FK 검증이 발생하지 않도록 하기 위해 필요합니다.

기존 칼럼이나 새 칼럼에 외래 키 제약을 추가하려면 해당 칼럼에 인덱스가 필요합니다.

인덱스가 비동기적으로 추가된 경우, 인덱스가 structure.sql에 추가될 때까지 기다려야 합니다.

이는 모든 외래 키에 필수입니다. 예를 들어, 효율적인 연속 삭제를 지원하기 위해 필요합니다. 테이블의 많은 행이 삭제될 때, 참조된 레코드도 삭제되어야 합니다. 데이터베이스는 참조된 테이블에서 해당 레코드를 찾아야 합니다. 인덱스 없이는 테이블에 대한 순차 스캔이 발생하여 오랜 시간이 걸릴 수 있습니다.

예시#

다음 테이블 구조를 고려합니다.

users 테이블:

  • id (integer, 기본 키)

  • name (string)

emails 테이블:

  • id (integer, 기본 키)

  • user_id (integer)

  • email (string)

ActiveRecord에서 관계를 표현합니다.

class User < ActiveRecord::Base
  has_many :emails
end

class Email < ActiveRecord::Base
  belongs_to :user
end

문제: 사용자가 제거될 때, 제거된 사용자와 관련된 이메일 레코드가 emails 테이블에 남습니다.

user = User.find(1)
user.destroy

emails = Email.where(user_id: 1) # 삭제된 사용자의 이메일을 반환함

FK 제약 추가 (NOT VALID)#

테이블에 NOT VALID 외래 키 제약을 추가하면, 레코드를 추가하거나 업데이트할 때 일관성이 강제됩니다.

위 예시에서, emails 테이블의 레코드를 여전히 업데이트할 수 있습니다. 하지만 존재하지 않는 값으로 user_id를 업데이트하려고 하면 제약이 오류를 발생시킵니다.

NOT VALID 외래 키 추가를 위한 마이그레이션 파일:

class AddNotValidForeignKeyToEmailsUser < Gitlab::Database::Migration[2.1]
  milestone '17.10'

  disable_ddl_transaction!

  def up
    add_concurrent_foreign_key(
      :emails,
      :users,
      column: :user_id,
      on_delete: :cascade,
      validate: false
    )
  end

  def down
    remove_foreign_key_if_exists :emails, column: :user_id
  end
end

INFO: 기본적으로 add_concurrent_foreign_key 메서드는 외래 키를 검증하므로, 명시적으로 validate: false를 전달하세요.

외래 키를 검증하지 않고 추가하는 것은 빠른 작업입니다. 새 데이터에 제약을 강제하기 전에 테이블에 대한 짧은 잠금만 필요합니다.

또한 add_concurrent_foreign_key는 제약이 존재하지 않는 경우에만 추가합니다.

동일한 마이그레이션 파일에서 소스 및 타깃 테이블이 동일하지 않은 한, add_foreign_key 또는 add_concurrent_foreign_key 제약을 두 번 이상 사용하지 마세요.

기존 레코드 수정을 위한 데이터 마이그레이션#

여기서의 접근 방식은 데이터 볼륨과 정리 전략에 따라 다릅니다. 데이터베이스 쿼리로 "유효하지 않은" 레코드를 찾을 수 있고 레코드 수가 많지 않다면, 데이터 마이그레이션을 일반 또는 배포 후 Rails 마이그레이션에서 실행할 수 있습니다.

데이터 볼륨이 더 많은 경우(>1000건), 백그라운드 마이그레이션을 생성하는 것이 좋습니다. 확실하지 않다면, 쿼리 가이드라인을 참조하거나 데이터베이스 프레임워크 팀에 문의하세요.

데이터베이스 마이그레이션에서 emails 테이블의 레코드를 정리하는 예시:

class RemoveRecordsWithoutUserFromEmailsTable < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  class Email < ActiveRecord::Base
    include EachBatch
  end

  def up
    Email.each_batch do |batch|
      batch.joins('LEFT JOIN users ON emails.user_id = users.id')
           .where('users.id IS NULL')
           .delete_all
    end
  end

  def down
    # Can be a no-op when data inconsistency is not affecting the pre and post deployment version of the application.
    # In this case we might have records in the `emails` table where the associated record in the `users` table is not there anymore.
  end
end

이 데이터 마이그레이션을 추가하는 MR에는 ~data-deletion 라벨이 적용되어야 합니다. 자세한 내용은 preparation-when-adding-data-migrations를 참조하세요.

외래 키 검증#

외래 키 검증은 전체 테이블을 스캔하여 각 관계가 올바른지 확인합니다. 다행히, 이 작업은 소스 테이블(users)을 잠그지 않고 실행됩니다.

앞서 언급했듯이 배치 백그라운드 마이그레이션을 사용할 때, 외래 키 검증은 BBM이 완료된 후에만 수행되어야 합니다.

외래 키 검증을 위한 마이그레이션 파일:

# frozen_string_literal: true

class ValidateForeignKeyOnEmailUsers < Gitlab::Database::Migration[2.1]
  def up
    validate_foreign_key :emails, :user_id
  end

  def down
    # Can be safely a no-op if we don't roll back the inconsistent data.
  end
end

비동기적으로 외래 키 검증#

매우 큰 테이블의 경우, 외래 키 검증이 여러 시간 동안 실행될 때 관리하기 어려울 수 있습니다. autovacuum과 같은 필수 데이터베이스 작업이 실행될 수 없으며, GitLab.com에서는 마이그레이션이 완료될 때까지 배포 프로세스가 차단됩니다.

GitLab.com에 대한 영향을 제한하기 위해, 주말 시간대에 비동기적으로 검증하는 프로세스가 있습니다. 일반적으로 트래픽이 낮고 배포가 적기 때문에, FK 검증을 더 낮은 위험 수준에서 진행할 수 있습니다.

영향이 낮은 시간에 외래 키 검증 예약#
FK 검증 예약#
  • 비동기 검증을 위해 외래 키를 준비하는 배포 후 마이그레이션이 포함된 머지 리퀘스트를 생성합니다.

  • 외래 키를 동기적으로 검증하는 마이그레이션을 추가하기 위한 후속 이슈를 생성합니다.

  • 비동기 외래 키를 준비하는 머지 리퀘스트에 후속 이슈를 언급하는 코멘트를 추가합니다.

비동기 헬퍼를 사용하여 외래 키를 검증하는 예시는 아래 블록에서 볼 수 있습니다. 이 마이그레이션은 외래 키 이름을 postgres_async_foreign_key_validations 테이블에 입력합니다. 주말에 실행되는 프로세스가 이 테이블에서 외래 키를 가져와 검증을 시도합니다.

# in db/post_migrate/

FK_NAME = :fk_be5624bf37

# TODO: FK to be validated synchronously in issue or merge request
def up
  # `some_column` can be an array of columns, and is not mandatory if `name` is supplied.
  # `name` takes precedence over other arguments.
  prepare_async_foreign_key_validation :ci_builds, :some_column, name: FK_NAME

  # Or in case of partitioned tables, use:
  prepare_partitioned_async_foreign_key_validation :p_ci_builds, :some_column, name: FK_NAME
end

def down
  unprepare_async_foreign_key_validation :ci_builds, :some_column, name: FK_NAME

  # Or in case of partitioned tables, use:
  unprepare_partitioned_async_foreign_key_validation :p_ci_builds, :some_column, name: FK_NAME
end
MR 배포 및 FK가 프로덕션에서 유효한지 확인#
  • ChatOps에서 /chatops gitlab run auto_deploy status <merge_sha>를 사용하여 배포 후 마이그레이션이 GitLab.com에서 실행되었는지 확인합니다. 출력에 db/gprd가 반환되면, 배포 후 마이그레이션이 프로덕션 데이터베이스에서 실행된 것입니다. 자세한 내용은 GitLab.com에서 배포 후 마이그레이션 실행 여부 확인 방법을 참조하세요.

  • FK가 주말 동안 검증될 수 있도록 다음 주까지 기다립니다.

  • Database Lab을 사용하여 검증이 성공했는지 확인합니다. 출력에 외래 키가 NOT VALID임을 나타내지 않는지 확인합니다.

FK를 동기적으로 검증하는 마이그레이션 추가#

프로덕션 데이터베이스에서 외래 키가 유효해진 후, 외래 키를 동기적으로 검증하는 두 번째 머지 리퀘스트를 생성합니다. 스키마 변경 사항은 이 두 번째 머지 리퀘스트에서 structure.sql에 업데이트되고 커밋되어야 합니다. 동기 마이그레이션은 GitLab.com에서 no-op으로 실행되지만, 다른 설치 환경을 위해 마이그레이션을 추가해야 합니다. 아래 블록은 이전 비동기 예시에 대한 두 번째 마이그레이션 생성 방법을 보여줍니다.

두 번째 마이그레이션을 validate_foreign_key와 함께 병합하기 전에 외래 키가 프로덕션에서 유효한지 확인하세요. 두 번째 마이그레이션이 검증이 실행되기 전에 배포되면, 두 번째 마이그레이션이 실행될 때 외래 키가 동기적으로 검증됩니다.

# in db/post_migrate/

  FK_NAME = :fk_be5624bf37

  def up
    validate_foreign_key :ci_builds, :some_column, name: FK_NAME
  end

  def down
    # Can be safely a no-op if we don't roll back the inconsistent data.
  end
end

로컬에서 데이터베이스 FK 변경 사항 테스트#

머지 리퀘스트를 생성하기 전에 로컬 환경에서 데이터베이스 외래 키 변경 사항을 테스트해야 합니다.

비동기적으로 검증된 외래 키 확인#

로컬 환경에서 비동기 헬퍼를 사용하여 외래 키 검증 변경 사항을 테스트합니다.

  • Rails 콘솔에서 Feature.enable(:database_async_foreign_key_validation)을 실행하여 기능 플래그를 활성화합니다.

  • bundle exec rails db:migrate를 실행하여 비동기 검증 테이블에 항목을 생성합니다.

  • bundle exec rails gitlab:db:validate_async_constraints:all을 실행하여 FK가 모든 데이터베이스에서 비동기적으로 검증되도록 합니다.

  • 외래 키를 확인하려면, GDK 명령어 gdk psql을 사용하여 PostgreSQL 콘솔을 열고 \d+ table_name 명령을 실행하여 외래 키가 유효한지 확인합니다. 검증이 성공하면 외래 키 정의에서 NOT VALID가 제거됩니다.

외래 키 제거#

이 작업은 다운타임이 필요하지 않습니다.

파티션 테이블에서 외래 키 제거#

파티션 테이블을 사용할 때는 일반 remove_foreign_key 메서드 대신 remove_partitioned_foreign_key 헬퍼 메서드를 사용하세요. 파티션 테이블에 아직 검증된 외래 키가 없는 경우 remove_foreign_key가 파티션의 외래 키를 제거하지 않기 때문입니다. 이는 파티션 테이블에서 외래 키 생성 시 validate: false 옵션이 설정된 경우에 발생합니다.

remove_partitioned_foreign_key 메서드는 파티션 테이블과 모든 파티션에서 외래 키를 제거합니다.

# Remove by column name
remove_partitioned_foreign_key :partitioned_table, :referenced_table, column: :referenced_table_id

# Remove by foreign key name
remove_partitioned_foreign_key :partitioned_table, name: 'fk_rails_123456'

이 메서드는:

  • 파티션 테이블에서 외래 키를 제거하여 각 파티션의 상속된 제약도 제거합니다.

  • 그런 다음 각 파티션에서 외래 키를 개별적으로 제거합니다 (상속되지 않은 제약이 있는 경우).

  • 내부적으로 remove_foreign_key_if_exists를 사용하므로 외래 키가 없어도 오류가 발생하지 않습니다.

  • 일반 remove_foreign_key 메서드와 동일한 옵션을 지원합니다.

마이그레이션 예시:

class RemovePartitionedForeignKey < Gitlab::Database::Migration[2.3]
  include Gitlab::Database::PartitioningMigrationHelpers

  disable_ddl_transaction!

  def up
    # Add partitioned foreign key
    add_concurrent_partitioned_foreign_key :partitioned_table, :projects, column: :project_id
  end

  def down
    # Remove partitioned foreign key
    remove_partitioned_foreign_key :partitioned_table, :projects, column: :project_id
  end
end

외래 키에 bigint 사용#

새 외래 키를 추가할 때는 bigint로 정의해야 합니다. 참조된 테이블의 기본 키 타입이 integer이더라도, 새 외래 키를 bigint로 참조해야 합니다. 모든 기본 키를 bigint로 마이그레이션하고 있으므로, bigint 외래 키를 사용하면 상위 테이블을 bigint 기본 키로 마이그레이션할 때 시간을 절약하고 단계를 줄일 수 있습니다.

reverse_lock_order#

add_concurrent_foreign_key, add_concurrent_partitioned_foreign_key, remove_foreign_key_if_exists, remove_partitioned_foreign_key 모두 기본적으로 reverse_lock_order: true로 설정됩니다. 이는 제약 작업 전에 타깃-소스 순서로 잠금을 획득하여, 동시 애플리케이션 트랜잭션과의 데드락을 방지합니다.

이에 대한 배경은 원본 이슈에서 더 자세히 읽을 수 있습니다.

데드락이 발생하는 경우#

마이그레이션과 애플리케이션 코드가 반대 순서로 동일한 테이블에 잠금을 획득할 때 데드락이 발생할 수 있습니다.

다음과 같은 외래 키를 추가하려는 시나리오를 고려합니다.

ALTER TABLE ONLY todos
    ADD CONSTRAINT fk_91d1f47b13 FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE;

다음과 같은 가상의 애플리케이션 코드를 고려합니다.

Todo.transaction do
   note = Note.create(...)
   # Observe what happens if foreign key is added here!
   todo = Todo.create!(note_id: note.id)
end

두 INSERT 구문 사이에 외래 키를 생성하려고 하면 데드락이 발생할 수 있습니다.

  • Note.create: notes에 행 잠금을 획득합니다.

  • ALTER TABLE ...todos에 테이블 잠금을 획득합니다.

  • ALTER TABLE ... FOREIGN KEYnotes에 테이블 잠금을 획득하려고 하지만 행 잠금을 보유한 다른 트랜잭션에 의해 차단됩니다.

  • Todo.createtodos에 행 잠금을 획득하려고 하지만 todos에 테이블 잠금을 보유한 다른 트랜잭션에 의해 차단됩니다.

두 트랜잭션 모두 서로가 완료되기를 기다리며 모두 타임아웃됩니다. 마이그레이션 트랜잭션 재시도로 보통 이를 처리하지만, 애플리케이션 코드도 타임아웃되어 사용자에게 오류를 일으킬 수 있습니다. 이 애플리케이션 코드가 자주 실행되면, 마이그레이션이 지속적으로 타임아웃될 수 있으며 사용자가 정기적으로 오류를 받을 수 있습니다.

외래 키 제거#

외래 키 제거 시 데드락 케이스도 두 테이블 모두에 잠금을 획득하기 때문에 유사합니다. 위 예시를 사용하면 더 일반적인 시나리오는 DELETE FROM notes WHERE id = ...입니다. 이 쿼리는 notes에 잠금을 획득하고 그 다음 todos에 잠금을 획득하며, 위에서 설명한 정확히 동일한 데드락이 발생할 수 있습니다. remove_foreign_key_if_existsremove_partitioned_foreign_key도 이를 방지하기 위해 기본적으로 reverse_lock_order: true로 설정됩니다.

옵트아웃#

외래 키가 상위 테이블에서 하위 테이블을 가리키는 드문 경우(예: merge_request_diffs.id를 참조하는 merge_requests.latest_merge_request_diff_id), 기본 잠금 순서가 최적이 아닐 수 있습니다. reverse_lock_order: false를 명시적으로 설정하여 옵트아웃할 수 있습니다.

마이그레이션에서 외래 키 업데이트#

때로는 칼럼을 보존하면서 제약 조건을 변경해야 합니다. 예를 들어, ON DELETE CASCADE에서 ON DELETE SET NULL로 또는 그 반대로 변경하는 경우입니다.

PostgreSQL은 중복된 외래 키 추가를 막지 않습니다. 가장 최근에 추가된 제약을 우선시합니다. 이를 통해 칼럼에서 외래 키 보호를 잃지 않고 외래 키를 교체할 수 있습니다.

외래 키를 교체하려면:

새 외래 키를 추가합니다.

class ReplaceFkOnPackagesPackagesProjectId < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  NEW_CONSTRAINT_NAME = 'fk_new'

  def up
    add_concurrent_foreign_key(:packages_packages, :projects, column: :project_id, on_delete: :nullify, name: NEW_CONSTRAINT_NAME)
  end

  def down
    with_lock_retries do
      remove_foreign_key_if_exists(:packages_packages, column: :project_id, on_delete: :nullify, name: NEW_CONSTRAINT_NAME)
    end
  end
end

이전 외래 키를 제거합니다.

class RemoveFkOld < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  OLD_CONSTRAINT_NAME = 'fk_old'

  def up
    with_lock_retries do
      remove_foreign_key_if_exists(:packages_packages, column: :project_id, on_delete: :cascade, name: OLD_CONSTRAINT_NAME)
    end
  end

  def down
    add_concurrent_foreign_key(:packages_packages, :projects, column: :project_id, on_delete: :cascade, name: OLD_CONSTRAINT_NAME)
  end
end

연속 삭제#

모든 외래 키는 ON DELETE 절을 정의해야 하며, 99%의 경우 CASCADE로 설정해야 합니다.

인덱스#

PostgreSQL에서 외래 키를 추가할 때 칼럼이 자동으로 인덱싱되지 않으므로, 동시 인덱스도 추가해야 합니다. 모든 외래 키에 인덱스가 필요하며 외래 키보다 먼저 추가되어야 합니다. 이는 동일한 마이그레이션의 이전 단계이거나 외래 키를 추가하는 마이그레이션보다 이전 마이그레이션에서 추가될 수 있습니다. 같은 이유로, 외래 키를 지원하는 인덱스를 제거하기 전에 외래 키를 먼저 제거해야 합니다.

외래 키에 인덱스가 없으면 참조된 테이블에서 레코드가 삭제될 때마다 Postgres가 전체 테이블 스캔을 수행해야 합니다. 과거에 이는 projectsnamespaces 삭제 시 타임아웃이 발생하는 인시던트로 이어졌습니다.

외래 키가 복합 인덱스의 첫 번째 위치에 있는 한, 이 외래 키를 포함하는 복합 인덱스를 사용하는 것도 가능합니다. 예를 들어 project_id 외래 키가 있다면 BTREE (project_id, user_id)와 같은 복합 인덱스는 괜찮지만, BTREE (user_id, project_id)와 같은 인덱스는 안 됩니다. 후자는 project_id만으로 효율적인 조회를 허용하지 않으므로 연속 삭제가 타임아웃되는 것을 방지하지 못합니다. BTREE (project_id) WHERE user_id IS NULL과 같은 부분 인덱스는 연속 삭제에 사용될 수 없으며 외래 키의 인덱스로 적합하지 않습니다.

외래 키 이름 지정#

기본적으로 Ruby on Rails는 외래 키에 _id 접미사를 사용합니다. 따라서 이 접미사는 두 테이블 간의 연관 관계에서만 사용해야 합니다. 서드파티 플랫폼의 ID를 참조하려면 _xid 접미사를 권장합니다.

스펙 spec/db/schema_spec.rb_id 접미사가 있는 모든 칼럼에 외래 키 제약이 있는지 테스트합니다. 해당 스펙이 실패하면, 칼럼이 다음 두 기준 중 하나에 해당하는 경우 ignored_fk_columns_map에 칼럼을 추가하세요.

  • 칼럼이 다른 테이블을 참조하지만, 두 테이블이 외래 키를 허용하지 않는 GitLab 스키마에 속하는 경우.

  • 성능 상의 이유로 외래 키가 느슨한 외래 키(Loose Foreign Key)로 교체된 경우.

  • 칼럼이 다형성 관계를 나타내는 경우. 다형성 연관 관계는 사용하지 않아야 합니다.

  • 칼럼이 다른 테이블을 참조하기 위한 것이 아닌 경우. 예를 들어, 파티션 테이블에서 partition_id를 갖는 것이 일반적입니다.

종속 제거#

연관 관계를 정의할 때 dependent: :destroy 또는 dependent: :delete와 같은 옵션을 정의하지 마세요. 이러한 옵션을 정의하면 Rails가 데이터 제거를 처리하게 되는데, 이는 데이터베이스가 가장 효율적인 방법으로 이를 처리하도록 두는 것보다 좋지 않습니다.

다시 말해, 다음은 좋지 않은 코드이며 절대적으로 피해야 합니다.

class User < ActiveRecord::Base
  has_many :posts, dependent: :destroy
end

이 방법이 정말로 필요한 경우에는 먼저 데이터베이스 전문가의 승인을 받아야 합니다.

또한 데이터베이스 전문가의 승인이 있는 경우에만 절대적으로 필요한 경우를 제외하고는 모델에 before_destroy 또는 after_destroy 콜백을 정의하지 마세요. 예를 들어, 테이블의 각 행에 파일 시스템의 해당 파일이 있는 경우 after_destroy 훅을 추가하고 싶을 수 있습니다. 하지만 이는 모델에 비데이터베이스 로직을 도입하여, 데이터를 제거하기 위해 외래 키에 더 이상 의존할 수 없게 됩니다. 파일 시스템 데이터가 남게 되기 때문입니다. 이 경우 비데이터베이스 데이터 제거를 담당하는 서비스 클래스를 사용해야 합니다.

관계가 여러 데이터베이스에 걸쳐 있는 경우 dependent: :destroy 또는 위 훅을 사용하면 더 많은 문제가 발생합니다. 대안에 대해서는 여러 데이터베이스에서 dependent: :nullifydependent: :destroy 방지에서 더 자세히 읽을 수 있습니다.

has_one 연관 관계와 대체 기본 키#

때로는 일대일 관계를 만들기 위해 has_one 연관 관계를 사용합니다.

class User < ActiveRecord::Base
  has_one :user_config
end

class UserConfig < ActiveRecord::Base
  belongs_to :user
end

이 경우, 연관된 테이블의 불필요한 id 칼럼(이 예시에서는 user_config.id)을 제거할 기회가 있을 수 있습니다. 대신, 원본 테이블 ID를 연관된 테이블의 기본 키로 사용할 수 있습니다.

create_table :user_configs, id: false do |t|
  t.references :users, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade }
  ...
end

default: nil을 설정하면 기본 키 시퀀스가 생성되지 않으며, 기본 키에 자동으로 인덱스가 생성되므로 중복 생성을 방지하기 위해 index: false를 설정합니다. 모델에도 새 기본 키를 추가해야 합니다.

class UserConfig < ActiveRecord::Base
  self.primary_key = :user_id

  belongs_to :user
end

외래 키를 기본 키로 사용하면 공간을 절약하지만, Service Ping배치 카운팅이 덜 효율적이 될 수 있습니다. 테이블이 Service Ping과 관련이 있다면 일반 id 칼럼을 사용하는 것을 고려하세요.

외래 키와 연관 관계

GitLab v19.1
원문 보기
요약

외래 키는 관련 데이터베이스 테이블 간의 일관성을 보장합니다. 애플리케이션 수준에서 데이터 일관성을 보장하는 방법은 불행한 상황에서 실패할 수 있으므로, 테이블에 일관성 없는 데이터가 남을 수 있습니다. 다른 테이블의 레코드를 참조하는 테이블을 생성할 때는 데이터 무결성을 유지하기 위해 FK를 추가해야 합니다.

외래 키는 관련 데이터베이스 테이블 간의 일관성을 보장합니다. Rails 버전 4부터 Rails는 데이터베이스 테이블에 외래 키 제약을 추가하기 위한 마이그레이션 헬퍼를 제공합니다. Rails 4 이전에는 일정 수준의 일관성을 보장하는 유일한 방법이 연관 관계 정의의 dependent 옵션이었습니다.

애플리케이션 수준에서 데이터 일관성을 보장하는 방법은 불행한 상황에서 실패할 수 있으므로, 테이블에 일관성 없는 데이터가 남을 수 있습니다. 이는 주로 데이터베이스 수준에서 일관성을 보장하는 프레임워크 지원이 없었던 오래된 테이블에 영향을 미칩니다. 이러한 데이터 불일관성은 예상치 못한 애플리케이션 동작이나 버그를 유발할 수 있습니다.

다른 테이블의 레코드를 참조하는 테이블을 생성할 때는 데이터 무결성을 유지하기 위해 FK를 추가해야 합니다. 모델에 연관 관계를 추가할 때도 반드시 외래 키를 추가해야 합니다. 또한 외래 키를 추가할 때는 항상 인덱스를 먼저 추가해야 합니다.

예를 들어, 다음과 같은 모델이 있다고 가정합니다.

class User < ActiveRecord::Base
  has_many :posts
end

여기서 칼럼 posts.user_id에 외래 키를 추가합니다. 이렇게 하면 데이터베이스 수준에서 데이터 일관성이 강제됩니다. 외래 키는 또한 데이터베이스가 연관 데이터를 제거할 수 있음을 의미합니다(예: 사용자를 제거할 때), Rails가 이를 처리하는 대신 데이터베이스가 직접 처리합니다.

다운타임 및 마이그레이션 실패 방지#

외래 키 추가에는 두 부분이 있습니다.

  • FK 칼럼과 제약 추가.

  • 데이터 무결성을 유지하기 위해 추가된 제약 검증.

(1)은 가장 엄격한 잠금(ACCESS EXCLUSIVE)을 사용하는 ALTER TABLE 구문을 사용하며, 제약을 검증하려면 전체 테이블을 순회해야 하므로 대용량/고트래픽 테이블에서는 시간이 많이 걸립니다.

따라서 거의 모든 경우에 더 엄격한 잠금을 유지하고 테이블에 대한 다른 작업을 더 오래 차단하는 것을 피하기 위해 별도의 트랜잭션으로 실행해야 합니다.

새 테이블에서#

  • 새 테이블이 다른 하나의 테이블만 참조하는 경우에는 간단합니다. 참조되는 테이블에 관계없이 create_table (t.references, ..., foreign_key: true)를 사용할 수 있습니다.

  • 새 테이블이 두 개의 서로 다른 테이블을 참조하는 경우. 외래 키가 두 개일 때 새 테이블 생성을 참조하세요.

새 칼럼에서#

새로운(레코드가 많지 않은) 테이블이라면 아래 방법 중 하나를 사용할 수 있습니다. 외래 키를 두 개 추가해야 한다면, 동일한 마이그레이션에서 두 개 이상의 테이블을 잠그는 것을 방지하기 위해 서로 다른 마이그레이션으로 분리하세요.

  • add_reference(… foreign_key: true)

  • 동일한 트랜잭션에서 add_column(…)과 add_foreign_key(…) 실행.

그 외 모든 경우, 칼럼 추가, FK 제약 추가, 제약 검증은 별도의 트랜잭션에서 수행해야 합니다.

기존 칼럼에서#

기존 데이터베이스 칼럼에 외래 키를 추가하려면 데이터베이스 구조 변경과 잠재적인 데이터 변경이 필요합니다.

테이블이 사용 중인 경우, 항상 일관성 없는 데이터가 있다고 가정해야 합니다.

기존 칼럼에 FK 제약을 추가하는 것은 여러 마일스톤에 걸친 프로세스입니다.

  • N.M: 칼럼에 NOT VALID FK 제약을 추가합니다. 이는 일관성 없는 레코드가 생성되거나 업데이트되지 않도록 보장합니다.

  • N.M: 기존 레코드를 수정하거나 정리하기 위한 데이터 마이그레이션을 추가합니다.

  1. 마이그레이션 쿼리가 타이밍 가이드라인 내에 있다면 일반 마이그레이션 또는 배포 후 마이그레이션이 될 수 있습니다.
  2. 그렇지 않은 경우, 배치 백그라운드 마이그레이션을 사용해야 합니다.
  • FK 제약 검증
  1. 데이터 마이그레이션이 일반 또는 배포 후 마이그레이션이었다면, 동일한 마일스톤에서 제약을 검증할 수 있습니다.
  2. 백그라운드 마이그레이션이었다면, BBM이 완료된 후에만 FK를 검증할 수 있습니다. 이는 데이터 마이그레이션이 백그라운드에서 실행되는 동안 FK 검증이 발생하지 않도록 하기 위해 필요합니다.

기존 칼럼이나 새 칼럼에 외래 키 제약을 추가하려면 해당 칼럼에 인덱스가 필요합니다.

인덱스가 비동기적으로 추가된 경우, 인덱스가 structure.sql에 추가될 때까지 기다려야 합니다.

이는 모든 외래 키에 필수입니다. 예를 들어, 효율적인 연속 삭제를 지원하기 위해 필요합니다. 테이블의 많은 행이 삭제될 때, 참조된 레코드도 삭제되어야 합니다. 데이터베이스는 참조된 테이블에서 해당 레코드를 찾아야 합니다. 인덱스 없이는 테이블에 대한 순차 스캔이 발생하여 오랜 시간이 걸릴 수 있습니다.

예시#

다음 테이블 구조를 고려합니다.

users 테이블:

  • id (integer, 기본 키)

  • name (string)

emails 테이블:

  • id (integer, 기본 키)

  • user_id (integer)

  • email (string)

ActiveRecord에서 관계를 표현합니다.

class User < ActiveRecord::Base
  has_many :emails
end

class Email < ActiveRecord::Base
  belongs_to :user
end

문제: 사용자가 제거될 때, 제거된 사용자와 관련된 이메일 레코드가 emails 테이블에 남습니다.

user = User.find(1)
user.destroy

emails = Email.where(user_id: 1) # 삭제된 사용자의 이메일을 반환함

FK 제약 추가 (NOT VALID)#

테이블에 NOT VALID 외래 키 제약을 추가하면, 레코드를 추가하거나 업데이트할 때 일관성이 강제됩니다.

위 예시에서, emails 테이블의 레코드를 여전히 업데이트할 수 있습니다. 하지만 존재하지 않는 값으로 user_id를 업데이트하려고 하면 제약이 오류를 발생시킵니다.

NOT VALID 외래 키 추가를 위한 마이그레이션 파일:

class AddNotValidForeignKeyToEmailsUser < Gitlab::Database::Migration[2.1]
  milestone '17.10'

  disable_ddl_transaction!

  def up
    add_concurrent_foreign_key(
      :emails,
      :users,
      column: :user_id,
      on_delete: :cascade,
      validate: false
    )
  end

  def down
    remove_foreign_key_if_exists :emails, column: :user_id
  end
end

INFO: 기본적으로 add_concurrent_foreign_key 메서드는 외래 키를 검증하므로, 명시적으로 validate: false를 전달하세요.

외래 키를 검증하지 않고 추가하는 것은 빠른 작업입니다. 새 데이터에 제약을 강제하기 전에 테이블에 대한 짧은 잠금만 필요합니다.

또한 add_concurrent_foreign_key는 제약이 존재하지 않는 경우에만 추가합니다.

동일한 마이그레이션 파일에서 소스 및 타깃 테이블이 동일하지 않은 한, add_foreign_key 또는 add_concurrent_foreign_key 제약을 두 번 이상 사용하지 마세요.

기존 레코드 수정을 위한 데이터 마이그레이션#

여기서의 접근 방식은 데이터 볼륨과 정리 전략에 따라 다릅니다. 데이터베이스 쿼리로 "유효하지 않은" 레코드를 찾을 수 있고 레코드 수가 많지 않다면, 데이터 마이그레이션을 일반 또는 배포 후 Rails 마이그레이션에서 실행할 수 있습니다.

데이터 볼륨이 더 많은 경우(>1000건), 백그라운드 마이그레이션을 생성하는 것이 좋습니다. 확실하지 않다면, 쿼리 가이드라인을 참조하거나 데이터베이스 프레임워크 팀에 문의하세요.

데이터베이스 마이그레이션에서 emails 테이블의 레코드를 정리하는 예시:

class RemoveRecordsWithoutUserFromEmailsTable < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  class Email < ActiveRecord::Base
    include EachBatch
  end

  def up
    Email.each_batch do |batch|
      batch.joins('LEFT JOIN users ON emails.user_id = users.id')
           .where('users.id IS NULL')
           .delete_all
    end
  end

  def down
    # Can be a no-op when data inconsistency is not affecting the pre and post deployment version of the application.
    # In this case we might have records in the `emails` table where the associated record in the `users` table is not there anymore.
  end
end

이 데이터 마이그레이션을 추가하는 MR에는 ~data-deletion 라벨이 적용되어야 합니다. 자세한 내용은 preparation-when-adding-data-migrations를 참조하세요.

외래 키 검증#

외래 키 검증은 전체 테이블을 스캔하여 각 관계가 올바른지 확인합니다. 다행히, 이 작업은 소스 테이블(users)을 잠그지 않고 실행됩니다.

앞서 언급했듯이 배치 백그라운드 마이그레이션을 사용할 때, 외래 키 검증은 BBM이 완료된 후에만 수행되어야 합니다.

외래 키 검증을 위한 마이그레이션 파일:

# frozen_string_literal: true

class ValidateForeignKeyOnEmailUsers < Gitlab::Database::Migration[2.1]
  def up
    validate_foreign_key :emails, :user_id
  end

  def down
    # Can be safely a no-op if we don't roll back the inconsistent data.
  end
end

비동기적으로 외래 키 검증#

매우 큰 테이블의 경우, 외래 키 검증이 여러 시간 동안 실행될 때 관리하기 어려울 수 있습니다. autovacuum과 같은 필수 데이터베이스 작업이 실행될 수 없으며, GitLab.com에서는 마이그레이션이 완료될 때까지 배포 프로세스가 차단됩니다.

GitLab.com에 대한 영향을 제한하기 위해, 주말 시간대에 비동기적으로 검증하는 프로세스가 있습니다. 일반적으로 트래픽이 낮고 배포가 적기 때문에, FK 검증을 더 낮은 위험 수준에서 진행할 수 있습니다.

영향이 낮은 시간에 외래 키 검증 예약#
FK 검증 예약#
  • 비동기 검증을 위해 외래 키를 준비하는 배포 후 마이그레이션이 포함된 머지 리퀘스트를 생성합니다.

  • 외래 키를 동기적으로 검증하는 마이그레이션을 추가하기 위한 후속 이슈를 생성합니다.

  • 비동기 외래 키를 준비하는 머지 리퀘스트에 후속 이슈를 언급하는 코멘트를 추가합니다.

비동기 헬퍼를 사용하여 외래 키를 검증하는 예시는 아래 블록에서 볼 수 있습니다. 이 마이그레이션은 외래 키 이름을 postgres_async_foreign_key_validations 테이블에 입력합니다. 주말에 실행되는 프로세스가 이 테이블에서 외래 키를 가져와 검증을 시도합니다.

# in db/post_migrate/

FK_NAME = :fk_be5624bf37

# TODO: FK to be validated synchronously in issue or merge request
def up
  # `some_column` can be an array of columns, and is not mandatory if `name` is supplied.
  # `name` takes precedence over other arguments.
  prepare_async_foreign_key_validation :ci_builds, :some_column, name: FK_NAME

  # Or in case of partitioned tables, use:
  prepare_partitioned_async_foreign_key_validation :p_ci_builds, :some_column, name: FK_NAME
end

def down
  unprepare_async_foreign_key_validation :ci_builds, :some_column, name: FK_NAME

  # Or in case of partitioned tables, use:
  unprepare_partitioned_async_foreign_key_validation :p_ci_builds, :some_column, name: FK_NAME
end
MR 배포 및 FK가 프로덕션에서 유효한지 확인#
  • ChatOps에서 /chatops gitlab run auto_deploy status <merge_sha>를 사용하여 배포 후 마이그레이션이 GitLab.com에서 실행되었는지 확인합니다. 출력에 db/gprd가 반환되면, 배포 후 마이그레이션이 프로덕션 데이터베이스에서 실행된 것입니다. 자세한 내용은 GitLab.com에서 배포 후 마이그레이션 실행 여부 확인 방법을 참조하세요.

  • FK가 주말 동안 검증될 수 있도록 다음 주까지 기다립니다.

  • Database Lab을 사용하여 검증이 성공했는지 확인합니다. 출력에 외래 키가 NOT VALID임을 나타내지 않는지 확인합니다.

FK를 동기적으로 검증하는 마이그레이션 추가#

프로덕션 데이터베이스에서 외래 키가 유효해진 후, 외래 키를 동기적으로 검증하는 두 번째 머지 리퀘스트를 생성합니다. 스키마 변경 사항은 이 두 번째 머지 리퀘스트에서 structure.sql에 업데이트되고 커밋되어야 합니다. 동기 마이그레이션은 GitLab.com에서 no-op으로 실행되지만, 다른 설치 환경을 위해 마이그레이션을 추가해야 합니다. 아래 블록은 이전 비동기 예시에 대한 두 번째 마이그레이션 생성 방법을 보여줍니다.

두 번째 마이그레이션을 validate_foreign_key와 함께 병합하기 전에 외래 키가 프로덕션에서 유효한지 확인하세요. 두 번째 마이그레이션이 검증이 실행되기 전에 배포되면, 두 번째 마이그레이션이 실행될 때 외래 키가 동기적으로 검증됩니다.

# in db/post_migrate/

  FK_NAME = :fk_be5624bf37

  def up
    validate_foreign_key :ci_builds, :some_column, name: FK_NAME
  end

  def down
    # Can be safely a no-op if we don't roll back the inconsistent data.
  end
end

로컬에서 데이터베이스 FK 변경 사항 테스트#

머지 리퀘스트를 생성하기 전에 로컬 환경에서 데이터베이스 외래 키 변경 사항을 테스트해야 합니다.

비동기적으로 검증된 외래 키 확인#

로컬 환경에서 비동기 헬퍼를 사용하여 외래 키 검증 변경 사항을 테스트합니다.

  • Rails 콘솔에서 Feature.enable(:database_async_foreign_key_validation)을 실행하여 기능 플래그를 활성화합니다.

  • bundle exec rails db:migrate를 실행하여 비동기 검증 테이블에 항목을 생성합니다.

  • bundle exec rails gitlab:db:validate_async_constraints:all을 실행하여 FK가 모든 데이터베이스에서 비동기적으로 검증되도록 합니다.

  • 외래 키를 확인하려면, GDK 명령어 gdk psql을 사용하여 PostgreSQL 콘솔을 열고 \d+ table_name 명령을 실행하여 외래 키가 유효한지 확인합니다. 검증이 성공하면 외래 키 정의에서 NOT VALID가 제거됩니다.

외래 키 제거#

이 작업은 다운타임이 필요하지 않습니다.

파티션 테이블에서 외래 키 제거#

파티션 테이블을 사용할 때는 일반 remove_foreign_key 메서드 대신 remove_partitioned_foreign_key 헬퍼 메서드를 사용하세요. 파티션 테이블에 아직 검증된 외래 키가 없는 경우 remove_foreign_key가 파티션의 외래 키를 제거하지 않기 때문입니다. 이는 파티션 테이블에서 외래 키 생성 시 validate: false 옵션이 설정된 경우에 발생합니다.

remove_partitioned_foreign_key 메서드는 파티션 테이블과 모든 파티션에서 외래 키를 제거합니다.

# Remove by column name
remove_partitioned_foreign_key :partitioned_table, :referenced_table, column: :referenced_table_id

# Remove by foreign key name
remove_partitioned_foreign_key :partitioned_table, name: 'fk_rails_123456'

이 메서드는:

  • 파티션 테이블에서 외래 키를 제거하여 각 파티션의 상속된 제약도 제거합니다.

  • 그런 다음 각 파티션에서 외래 키를 개별적으로 제거합니다 (상속되지 않은 제약이 있는 경우).

  • 내부적으로 remove_foreign_key_if_exists를 사용하므로 외래 키가 없어도 오류가 발생하지 않습니다.

  • 일반 remove_foreign_key 메서드와 동일한 옵션을 지원합니다.

마이그레이션 예시:

class RemovePartitionedForeignKey < Gitlab::Database::Migration[2.3]
  include Gitlab::Database::PartitioningMigrationHelpers

  disable_ddl_transaction!

  def up
    # Add partitioned foreign key
    add_concurrent_partitioned_foreign_key :partitioned_table, :projects, column: :project_id
  end

  def down
    # Remove partitioned foreign key
    remove_partitioned_foreign_key :partitioned_table, :projects, column: :project_id
  end
end

외래 키에 bigint 사용#

새 외래 키를 추가할 때는 bigint로 정의해야 합니다. 참조된 테이블의 기본 키 타입이 integer이더라도, 새 외래 키를 bigint로 참조해야 합니다. 모든 기본 키를 bigint로 마이그레이션하고 있으므로, bigint 외래 키를 사용하면 상위 테이블을 bigint 기본 키로 마이그레이션할 때 시간을 절약하고 단계를 줄일 수 있습니다.

reverse_lock_order#

add_concurrent_foreign_key, add_concurrent_partitioned_foreign_key, remove_foreign_key_if_exists, remove_partitioned_foreign_key 모두 기본적으로 reverse_lock_order: true로 설정됩니다. 이는 제약 작업 전에 타깃-소스 순서로 잠금을 획득하여, 동시 애플리케이션 트랜잭션과의 데드락을 방지합니다.

이에 대한 배경은 원본 이슈에서 더 자세히 읽을 수 있습니다.

데드락이 발생하는 경우#

마이그레이션과 애플리케이션 코드가 반대 순서로 동일한 테이블에 잠금을 획득할 때 데드락이 발생할 수 있습니다.

다음과 같은 외래 키를 추가하려는 시나리오를 고려합니다.

ALTER TABLE ONLY todos
    ADD CONSTRAINT fk_91d1f47b13 FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE;

다음과 같은 가상의 애플리케이션 코드를 고려합니다.

Todo.transaction do
   note = Note.create(...)
   # Observe what happens if foreign key is added here!
   todo = Todo.create!(note_id: note.id)
end

두 INSERT 구문 사이에 외래 키를 생성하려고 하면 데드락이 발생할 수 있습니다.

  • Note.create: notes에 행 잠금을 획득합니다.

  • ALTER TABLE ...todos에 테이블 잠금을 획득합니다.

  • ALTER TABLE ... FOREIGN KEYnotes에 테이블 잠금을 획득하려고 하지만 행 잠금을 보유한 다른 트랜잭션에 의해 차단됩니다.

  • Todo.createtodos에 행 잠금을 획득하려고 하지만 todos에 테이블 잠금을 보유한 다른 트랜잭션에 의해 차단됩니다.

두 트랜잭션 모두 서로가 완료되기를 기다리며 모두 타임아웃됩니다. 마이그레이션 트랜잭션 재시도로 보통 이를 처리하지만, 애플리케이션 코드도 타임아웃되어 사용자에게 오류를 일으킬 수 있습니다. 이 애플리케이션 코드가 자주 실행되면, 마이그레이션이 지속적으로 타임아웃될 수 있으며 사용자가 정기적으로 오류를 받을 수 있습니다.

외래 키 제거#

외래 키 제거 시 데드락 케이스도 두 테이블 모두에 잠금을 획득하기 때문에 유사합니다. 위 예시를 사용하면 더 일반적인 시나리오는 DELETE FROM notes WHERE id = ...입니다. 이 쿼리는 notes에 잠금을 획득하고 그 다음 todos에 잠금을 획득하며, 위에서 설명한 정확히 동일한 데드락이 발생할 수 있습니다. remove_foreign_key_if_existsremove_partitioned_foreign_key도 이를 방지하기 위해 기본적으로 reverse_lock_order: true로 설정됩니다.

옵트아웃#

외래 키가 상위 테이블에서 하위 테이블을 가리키는 드문 경우(예: merge_request_diffs.id를 참조하는 merge_requests.latest_merge_request_diff_id), 기본 잠금 순서가 최적이 아닐 수 있습니다. reverse_lock_order: false를 명시적으로 설정하여 옵트아웃할 수 있습니다.

마이그레이션에서 외래 키 업데이트#

때로는 칼럼을 보존하면서 제약 조건을 변경해야 합니다. 예를 들어, ON DELETE CASCADE에서 ON DELETE SET NULL로 또는 그 반대로 변경하는 경우입니다.

PostgreSQL은 중복된 외래 키 추가를 막지 않습니다. 가장 최근에 추가된 제약을 우선시합니다. 이를 통해 칼럼에서 외래 키 보호를 잃지 않고 외래 키를 교체할 수 있습니다.

외래 키를 교체하려면:

새 외래 키를 추가합니다.

class ReplaceFkOnPackagesPackagesProjectId < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  NEW_CONSTRAINT_NAME = 'fk_new'

  def up
    add_concurrent_foreign_key(:packages_packages, :projects, column: :project_id, on_delete: :nullify, name: NEW_CONSTRAINT_NAME)
  end

  def down
    with_lock_retries do
      remove_foreign_key_if_exists(:packages_packages, column: :project_id, on_delete: :nullify, name: NEW_CONSTRAINT_NAME)
    end
  end
end

이전 외래 키를 제거합니다.

class RemoveFkOld < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  OLD_CONSTRAINT_NAME = 'fk_old'

  def up
    with_lock_retries do
      remove_foreign_key_if_exists(:packages_packages, column: :project_id, on_delete: :cascade, name: OLD_CONSTRAINT_NAME)
    end
  end

  def down
    add_concurrent_foreign_key(:packages_packages, :projects, column: :project_id, on_delete: :cascade, name: OLD_CONSTRAINT_NAME)
  end
end

연속 삭제#

모든 외래 키는 ON DELETE 절을 정의해야 하며, 99%의 경우 CASCADE로 설정해야 합니다.

인덱스#

PostgreSQL에서 외래 키를 추가할 때 칼럼이 자동으로 인덱싱되지 않으므로, 동시 인덱스도 추가해야 합니다. 모든 외래 키에 인덱스가 필요하며 외래 키보다 먼저 추가되어야 합니다. 이는 동일한 마이그레이션의 이전 단계이거나 외래 키를 추가하는 마이그레이션보다 이전 마이그레이션에서 추가될 수 있습니다. 같은 이유로, 외래 키를 지원하는 인덱스를 제거하기 전에 외래 키를 먼저 제거해야 합니다.

외래 키에 인덱스가 없으면 참조된 테이블에서 레코드가 삭제될 때마다 Postgres가 전체 테이블 스캔을 수행해야 합니다. 과거에 이는 projectsnamespaces 삭제 시 타임아웃이 발생하는 인시던트로 이어졌습니다.

외래 키가 복합 인덱스의 첫 번째 위치에 있는 한, 이 외래 키를 포함하는 복합 인덱스를 사용하는 것도 가능합니다. 예를 들어 project_id 외래 키가 있다면 BTREE (project_id, user_id)와 같은 복합 인덱스는 괜찮지만, BTREE (user_id, project_id)와 같은 인덱스는 안 됩니다. 후자는 project_id만으로 효율적인 조회를 허용하지 않으므로 연속 삭제가 타임아웃되는 것을 방지하지 못합니다. BTREE (project_id) WHERE user_id IS NULL과 같은 부분 인덱스는 연속 삭제에 사용될 수 없으며 외래 키의 인덱스로 적합하지 않습니다.

외래 키 이름 지정#

기본적으로 Ruby on Rails는 외래 키에 _id 접미사를 사용합니다. 따라서 이 접미사는 두 테이블 간의 연관 관계에서만 사용해야 합니다. 서드파티 플랫폼의 ID를 참조하려면 _xid 접미사를 권장합니다.

스펙 spec/db/schema_spec.rb_id 접미사가 있는 모든 칼럼에 외래 키 제약이 있는지 테스트합니다. 해당 스펙이 실패하면, 칼럼이 다음 두 기준 중 하나에 해당하는 경우 ignored_fk_columns_map에 칼럼을 추가하세요.

  • 칼럼이 다른 테이블을 참조하지만, 두 테이블이 외래 키를 허용하지 않는 GitLab 스키마에 속하는 경우.

  • 성능 상의 이유로 외래 키가 느슨한 외래 키(Loose Foreign Key)로 교체된 경우.

  • 칼럼이 다형성 관계를 나타내는 경우. 다형성 연관 관계는 사용하지 않아야 합니다.

  • 칼럼이 다른 테이블을 참조하기 위한 것이 아닌 경우. 예를 들어, 파티션 테이블에서 partition_id를 갖는 것이 일반적입니다.

종속 제거#

연관 관계를 정의할 때 dependent: :destroy 또는 dependent: :delete와 같은 옵션을 정의하지 마세요. 이러한 옵션을 정의하면 Rails가 데이터 제거를 처리하게 되는데, 이는 데이터베이스가 가장 효율적인 방법으로 이를 처리하도록 두는 것보다 좋지 않습니다.

다시 말해, 다음은 좋지 않은 코드이며 절대적으로 피해야 합니다.

class User < ActiveRecord::Base
  has_many :posts, dependent: :destroy
end

이 방법이 정말로 필요한 경우에는 먼저 데이터베이스 전문가의 승인을 받아야 합니다.

또한 데이터베이스 전문가의 승인이 있는 경우에만 절대적으로 필요한 경우를 제외하고는 모델에 before_destroy 또는 after_destroy 콜백을 정의하지 마세요. 예를 들어, 테이블의 각 행에 파일 시스템의 해당 파일이 있는 경우 after_destroy 훅을 추가하고 싶을 수 있습니다. 하지만 이는 모델에 비데이터베이스 로직을 도입하여, 데이터를 제거하기 위해 외래 키에 더 이상 의존할 수 없게 됩니다. 파일 시스템 데이터가 남게 되기 때문입니다. 이 경우 비데이터베이스 데이터 제거를 담당하는 서비스 클래스를 사용해야 합니다.

관계가 여러 데이터베이스에 걸쳐 있는 경우 dependent: :destroy 또는 위 훅을 사용하면 더 많은 문제가 발생합니다. 대안에 대해서는 여러 데이터베이스에서 dependent: :nullifydependent: :destroy 방지에서 더 자세히 읽을 수 있습니다.

has_one 연관 관계와 대체 기본 키#

때로는 일대일 관계를 만들기 위해 has_one 연관 관계를 사용합니다.

class User < ActiveRecord::Base
  has_one :user_config
end

class UserConfig < ActiveRecord::Base
  belongs_to :user
end

이 경우, 연관된 테이블의 불필요한 id 칼럼(이 예시에서는 user_config.id)을 제거할 기회가 있을 수 있습니다. 대신, 원본 테이블 ID를 연관된 테이블의 기본 키로 사용할 수 있습니다.

create_table :user_configs, id: false do |t|
  t.references :users, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade }
  ...
end

default: nil을 설정하면 기본 키 시퀀스가 생성되지 않으며, 기본 키에 자동으로 인덱스가 생성되므로 중복 생성을 방지하기 위해 index: false를 설정합니다. 모델에도 새 기본 키를 추가해야 합니다.

class UserConfig < ActiveRecord::Base
  self.primary_key = :user_id

  belongs_to :user
end

외래 키를 기본 키로 사용하면 공간을 절약하지만, Service Ping배치 카운팅이 덜 효율적이 될 수 있습니다. 테이블이 Service Ping과 관련이 있다면 일반 id 칼럼을 사용하는 것을 고려하세요.