외래 키와 연관

모델에 연관을 추가할 때 외래 키도 추가해야 합니다. 예를 들어, 다음과 같은 모델이 있다고 가정해 보겠습니다:

class User < ActiveRecord::Base
  has_many :posts
end

여기에서 posts.user_id 열에 외래 키를 추가하세요. 이렇게 하면 데이터 일관성이 데이터베이스 레벨에서 강제됩니다. 외래 키는 데이터베이스가 관련된 데이터를 매우 빠르게 제거할 수 있음을 의미합니다(예: 사용자를 제거할 때), Rails가 이를 수행할 필요가 없습니다.

마이그레이션에서 외래 키 추가하기

Gitlab::Database::MigrationHelpers에 정의된 대로 add_concurrent_foreign_key를 사용하여 동시에 외래 키를 추가할 수 있습니다. 자세한 내용은 마이그레이션 스타일 가이드를 참조하세요.

기억하세요, 고아 행이 없는 기존 테이블에만 안전하게 외래 키를 추가할 수 있습니다. add_concurrent_foreign_key 메서드는 이를 처리하지 않으므로 수동으로 해야 합니다. 기존 열에 외래 키 제약 조건 추가하기를 참조하세요.

외래 키에 bigint 사용하기

새로운 외래 키를 추가할 때는 bigint로 정의해야 합니다. 참조하는 테이블이 integer 기본 키 유형을 가지고 있더라도 새로운 외래 키는 bigint로 참조해야 합니다. 모든 기본 키를 bigint로 마이그레이션하고 있으므로, bigint 외래 키를 사용하면 시간이 절약되고 부모 테이블을 bigint 기본 키로 마이그레이션할 때 단계가 줄어듭니다.

reverse_lock_order 고려하기

고빈도 테이블에 대해 reverse_lock_order를 사용하는 것을 고려하세요.

add_concurrent_foreign_keyremove_foreign_key_if_exists는 기본값이 false인 불린 옵션 reverse_lock_order를 사용합니다.

원래 이슈에서 이와 관련된 내용을 더 읽을 수 있습니다.

이는 동일한 테이블에 대해 높은 빈도로 잠금을 획득하는 알려진 쿼리가 있는 경우 유용할 수 있습니다.

예를 들어, 다음과 같이 외래 키를 추가하고 싶다고 가정해 보십시오:

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

그리고 다음과 같은 가상의 애플리케이션 코드를 고려하십시오:

Todo.transaction do
   note = Note.create(...)
   # 여기서 외래 키가 추가되면 무슨 일이 일어나는지 관찰하세요!
   todo = Todo.create!(note_id: note.id)
end

2개의 삽입 문 사이에 외래 키를 생성하려고 하면 두 거래 모두 Postgres에서 교착 상태에 빠질 수 있습니다. 그 과정은 다음과 같습니다:

  1. Note.create: notes에서 행 잠금을 획득합니다.
  2. ALTER TABLE ...todos에서 테이블 잠금을 획득합니다.
  3. ALTER TABLE ... FOREIGN KEYnotes에 대한 테이블 잠금을 시도하지만 이는 행 잠금을 가지고 있는 다른 거래에서 차단됩니다.
  4. Todo.createtodos에서 행 잠금을 획득하려고 시도하지만이는 todos에서 테이블 잠금을 가지고 있는 다른 거래에서 차단됩니다.

이것은 두 거래 모두 서로가 완료되기를 기다리며 교착 상태에 빠질 수 있음을 보여줍니다. 우리는 일반적으로 마이그레이션에서 거래 재시도를 하므로 일반적으로 괜찮지만 애플리케이션 코드가 시간 초과될 수 있으며 사용자가 오류를 보고할 가능성도 있습니다. 이 애플리케이션 코드가 매우 빈번하게 실행된다면 마이그레이션이 계속해서 시간 초과될 수 있으며 사용자들도 정기적으로 오류를 겪게 될 수 있습니다.

외래 키를 제거하는 경우의 교착 상태도 유사합니다. 이 경우도 두 테이블에서 잠금을 획득하지만 위의 예를 사용하면 DELETE FROM notes WHERE id = ...와 같은 더 일반적인 시나리오가 될 것입니다. 이 쿼리는 notes에서 잠금을 획득하고 todos에서 잠금을 획득하는데, 위에서 설명한 것과 동일한 교착 상태가 발생할 수 있습니다. 이러한 이유로 외래 키를 제거할 때는 거의 항상 reverse_lock_order를 사용하는 것이 가장 좋습니다.

외래 키 업데이트하는 방법

때때로 외래 키 제약 조건을 변경해야 하며, 열은 유지하면서 제약 조건을 업데이트해야 합니다. 예를 들어, ON DELETE CASCADE에서 ON DELETE SET NULL로 또는 그 반대로 이동하는 것입니다.

PostgreSQL은 겹치는 외래 키를 추가하는 것을 막지 않습니다. 가장 최근에 추가된 제약 조건을 존중합니다. 이를 통해 외래 키 보호를 잃지 않고도 외래 키를 교체할 수 있습니다.

외래 키를 교체하려면:

  1. 새로운 외래 키 추가:

    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
    
  2. 이전 외래 키 제거:

    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에서 외래 키를 추가할 때 열은 자동으로 인덱싱되지 않으므로, 동시 인덱스도 추가해야 합니다. 이를 수행하지 않으면 연쇄 삭제가 매우 느려집니다.

외래 키 이름 지정

기본적으로 Ruby on Rails는 외래 키에 _id 접미사를 사용합니다. 따라서 우리는 두 개의 테이블 간의 관계를 위해서만 이 접미사를 사용해야 합니다. 타사 플랫폼의 ID를 참조하려는 경우 _xid 접미사를 사용하는 것이 좋습니다.

스펙 spec/db/schema_spec.rb_id 접미사가 있는 모든 열에 외래 키 제약 조건이 있는지 테스트합니다. 따라서 해당 스펙이 실패하면 열을 IGNORED_FK_COLUMNS에 추가하지 말고, 대신 FK 제약 조건을 추가하거나 이름을 다르게 지정하는 것을 고려해야 합니다.

종속 제거

관계를 정의할 때 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

이러한 경우, 관련된 테이블인 user_config.id의 불필요한 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

외래 키를 기본 키로 사용하면 공간을 절약할 수 있지만, 배치 카운팅에서 서비스 핑을 덜 효율적으로 만들 수 있습니다.

서비스 핑에 대해 테이블이 중요하다면 일반 id 열을 사용하는 것을 고려하세요.