NOT NULL 제약 조건

값으로 NULL을 허용해서는 안 되는 모든 속성은 데이터베이스의 NOT NULL 열로 정의되어야 합니다.

애플리케이션 로직에 따라 NOT NULL 열은 해당 모델에서 presence: true 유효성이 정의되거나 데이터베이스 정의의 일부로 기본값을 가져야 합니다. 예를 들어, 논리적으로 항상 NULL이 아닌 값을 가져야 하지만 애플리케이션이 각각 강제할 필요가 없는 부울 속성의 경우에는 이후의 예시처럼 기본값을 가질 수 있습니다.

NOT NULL 열을 갖는 새로운 테이블 생성

새 테이블을 추가할 때, 모든 NOT NULL 열은 create_table 내에서 직접 정의되어야 합니다.

예를 들어, 두 개의 NOT NULL 열을 갖는 테이블을 생성하는 마이그레이션을 고려해봅시다. db/migrate/20200401000001_create_db_guides.rb:

class CreateDbGuides < Gitlab::Database::Migration[2.1]
  def change
    create_table :db_guides do |t|
      t.bigint :stars, default: 0, null: false
      t.bigint :guide, null: false
    end
  end
end

기존 테이블에 NOT NULL 열 추가

GitLab에서 최소 버전이 PostgreSQL 11인 상황에서 NULL 및/또는 기본값을 가진 열을 추가하는 것이 훨씬 더 쉬워졌으며 모든 경우에 표준 add_column 헬퍼를 사용해야 합니다.

예를 들어, 테이블 db_guides에 새로운 NOT NULLactive을 추가하는 마이그레이션을 고려해봅시다. db/migrate/20200501000001_add_active_to_db_guides.rb:

class AddExtendedTitleToSprints < Gitlab::Database::Migration[2.1]
  def change
    add_column :db_guides, :active, :boolean, default: true, null: false
  end
end

기존 열에 NOT NULL 제약 조건 추가

기존 데이터베이스 열에 NOT NULL을 추가하는 것은 대부분 적어도 두 개의 다른 릴리스로 나눠야 하는 여러 단계가 필요합니다. 테이블이 충분히 작아서 백그라운드 마이그레이션을 사용할 필요가 없는 경우, 모든 것을 동일한 머지 요청에 포함시킬 수 있습니다. 트랜잭션 기간을 줄이기 위해 별도의 마이그레이션을 사용하는 것이 좋습니다.

필요한 단계는 다음과 같습니다:

  1. N.M 릴리스 (현재 릴리스)

    1. $ATTRIBUTE 값을 응용 프로그램 수준에서 설정하는지 확인합니다.
      1. 속성에 기본값이 있는 경우, 새 레코드에 기본값이 설정되도록 모델에 기본값을 추가합니다.
      2. nil로 속성을 설정해야 하는 코드가 있는 경우, 기존 및 새 레코드에 대해 해당 코드를 모두 업데이트합니다. 일부 프로세스에서 before_savebefore_validation과 같은 ActiveRecord 콜백을 건너뛰는 경우도 있으므로 주의가 필요합니다.
    2. 기존 레코드를 수정하기 위한 디플로이 뒤 시간에 마이그레이션을 추가합니다.

    참고: 테이블의 크기에 따라 다음 릴리스에서 대규모 테이블에 대한 백그라운드 마이그레이션이 필요할 수 있습니다. 자세한 정보는 대규모 테이블에서 NOT NULL 제약 조건 섹션을 참조하세요.

  2. N.M+1 릴리스 (다음 릴리스)

    1. GitLab.com의 모든 기존 레코드가 속성으로 설정된지 확인합니다. 그렇지 않은 경우, N.M 릴리스의 단계 1로 돌아갑니다.
    2. 단계 1이 정상적으로 보이고 N.M 릴리스의 백필이 배치된 백그라운드 마이그레이션을 통해 완료되었을 경우, 배포된 백그라운드 마이그레이션 완료를 추가합니다.
    3. 모든 기존 및 새 레코드가 유효하도록 속성에 대한 유효성 검사를 추가합니다.
    4. NOT NULL 제약 조건을 추가하기 위한 디플로이 뒤 시간에 마이그레이션을 추가합니다.

예시

13.0과 같은 특정 릴리스 마일스톤을 고려해봅시다.

생산 데이터베이스를 확인한 후, epicsNULL 설명이 있는 것을 알았으므로 제약을 한 번에 추가하고 검증할 수 없습니다.

참고: NULL 설명이 있는 에픽이 없더라도 다른 GitLab 인스턴스에는 그런 레코드가 있을 수 있으므로 어차피 동일한 프로세스를 따릅니다.

새로운 유효하지 않은 레코드 방지 (현재 릴리스)

nil로 속성을 설정하는 모든 코드 경로를 업데이트하여 새로운 및 기존 레코드에 대해 속성을 nil이 아닌 값으로 설정합니다.

Rails attributes API를 사용해 기본값을 설정하기 위해 epic.rb에 속성이 추가되었습니다.

class Epic < ApplicationRecord
  attribute :description, default: 'No description'
end

기존 레코드 수정을 위한 데이터 마이그레이션 (현재 릴리스)

여기서 사용하는 방법은 데이터 양과 정리 전략에 따라 다릅니다. GitLab.com에서 고쳐야 하는 레코드의 수는 우리가 어떤 옵션을 사용할지 결정하는데 도움이 되는 좋은 지표입니다.

  • 데이터 양이 1000개 미만인 경우, 데이터 마이그레이션을 포스트 마이그레이션 내에서 실행할 수 있습니다.
  • 데이터 양이 1000개 이상인 경우, 백그라운드 마이그레이션을 생성하는 것이 좋습니다.

어떤 옵션을 사용할지 확신이 없을 때는 데이터베이스 팀에 문의하세요.

우리 예제로 돌아와서, 에픽 테이블이 크지 않으며 자주 접근되지 않으므로 13.0 마일스톤을 위해 포스트-디플로이 마이그레이션을 추가합니다 (db/post_migrate/20200501000002_cleanup_epics_with_null_description.rb):

class CleanupEpicsWithNullDescription < Gitlab::Database::Migration[2.1]
  # BATCH_SIZE=1000 및 GitLab.com에서 epics.count=29500을 가정.
  # - 30번의 반복 실행
  # - 각각 평균 ~150ms가 소요됨
  # 예상 총 실행 시간: ~5초
  BATCH_SIZE = 1000

  disable_ddl_transaction!

  class Epic < ActiveRecord::Base
    include EachBatch

    self.table_name = 'epics'
  end

  def up
    Epic.each_batch(of: BATCH_SIZE) do |relation|
      relation.
        where('description IS NULL').
        update_all(description: 'No description')
    end
  end

  def down
    # 아무것도 하지 않음: 먼저 `NOT NULL` 제약을 삭제하지 않고 `NULL`로 돌아갈 수 없습니다.
  end
end

모든 레코드가 수정되었는지 확인 (다음 릴리스)

프로덕션 데이터베이스의 씬 클론을 생성하고 postgres.ai를 사용하여 GitLab.com의 모든 레코드가 지정된 속성이 설정되었는지 확인합니다. 그렇지 않으면 새로운 잘못된 레코드를 방지 단계로 돌아가 속성이 명시적으로 nil로 설정된 코드 경로를 찾아 수정합니다. 그런 다음 마이그레이션을 재스케줄하여 기존 레코드를 수정하고 다음 릴리스에서 다음 단계를 수행하기를 기다립니다.

백그라운드 마이그레이션 최종화 (다음 릴리스)

마이그레이션이 백그라운드 마이그레이션을 사용하여 완료된 경우, 마이그레이션을 최종화합니다.

모델에 유효성 검사 추가 (다음 릴리스)

이제 모든 기존 및 새로운 레코드가 유효해야 하므로 모델에 속성에 대한 유효성 검사를 추가합니다.

class Epic < ApplicationRecord
  validates :description, presence: true
end

NOT NULL 제약 추가 (다음 릴리스)

NOT NULL 제약을 추가하면 전체 테이블을 스캔하여 각 레코드가 올바른지 확인합니다.

여전히 예시에서, 13.1 마일스톤(다음)에서 add_not_null_constraint 마이그레이션 도우미를 사용하여 마지막 배포 후 마이그레이션을 실행합니다:

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

  def up
    # 이것은 `NOT NULL` 제약을 추가하고 유효성을 검사합니다
    add_not_null_constraint :epics, :description
  end

  def down
    # `add_not_null_constraint`이 뒤로 되돌릴 수 없으므로 필요합니다
    remove_not_null_constraint :epics, :description
  end
end

대규모 테이블의 NOT NULL 제약

고트래픽 테이블에서 널 가능한 열을 정리해야 하는 경우(예: ci_buildsartifacts), 백그라운드 마이그레이션이 잠시 진행되며 데이터 마이그레이션 추가 후에 추가적인 배치된 백그라운드 마이그레이션 정리가 필요합니다.

이 경우 릴리스 수는 기존 레코드의 마이그레이션에 필요한 시간에 따라 다릅니다. 정리는 백그라운드 마이그레이션이 완료된 후에 예정되며, 제약이 추가된 후 여러 릴리스가 필요할 수 있습니다.

  1. 릴리스 N.M:
    • 기존 레코드를 수정하기 위해 배경 마이그레이션을 추가합니다:

      # db/post_migrate/
      class QueueBackfillMergeRequestDiffsProjectId < Gitlab::Database::Migration[2.2]
        milestone '16.7'
        restrict_gitlab_migration gitlab_schema: :gitlab_main
      
        MIGRATION = 'BackfillMergeRequestDiffsProjectId'
        DELAY_INTERVAL = 2.minutes
      
        def up
          queue_batched_background_migration(
            MIGRATION,
            :merge_request_diffs,
            :id,
            job_interval: DELAY_INTERVAL
          )
        end
      
        def down
          delete_batched_background_migration(MIGRATION, :merge_request_diffs, :id, [])
        end
      end
      
  2. 릴리스 N.M+X, 여기서 X는 마이그레이션이 실행된 릴리스 수입니다:
    • 모든 기존 레코드가 수정되었는지 확인합니다.

    • 백그라운드 마이그레이션 정리:

      # db/post_migrate/
      class FinalizeMergeRequestDiffsProjectIdBackfill < Gitlab::Database::Migration[2.2]
        disable_ddl_transaction!
        milestone '16.10'
        restrict_gitlab_migration gitlab_schema: :gitlab_main
      
        MIGRATION = 'BackfillMergeRequestDiffsProjectId'
      
        def up
          ensure_batched_background_migration_is_finished(
            job_class_name: MIGRATION,
            table_name: :merge_request_diffs,
            column_name: :id,
            job_arguments: [],
            finalize: true
          )
        end
      
        def down
          # 아무것도 안 함
        end
      end
      
    • NOT NULL 제약 추가:

      # db/post_migrate/
      class AddMergeRequestDiffsProjectIdNotNullConstraint < Gitlab::Database::Migration[2.2]
        disable_ddl_transaction!
        milestone '16.7'
      
        def up
          add_not_null_constraint :merge_request_diffs, :project_id
        end
      
        def down
          remove_not_null_constraint :merge_request_diffs, :project_id
        end
      end
      
    • 선택 사항. 매우 큰 테이블의 경우, 잘못된 NOT NULL 제약을 추가하고 비동기 검증을 예약합니다:

      # db/post_migrate/
      class AddMergeRequestDiffsProjectIdNotNullConstraint < Gitlab::Database::Migration[2.2]
        disable_ddl_transaction!
        milestone '16.7'
      
        def up
          add_not_null_constraint :merge_request_diffs, :project_id, validate: false
        end
      
        def down
          remove_not_null_constraint :merge_request_diffs, :project_id
        end
      end
      
      # db/post_migrate/
      class PrepareMergeRequestDiffsProjectIdNotNullValidation < Gitlab::Database::Migration[2.2]
        milestone '16.10'
      
        CONSTRAINT_NAME = 'check_11c5f029ad'
      
        def up
          prepare_async_check_constraint_validation :merge_request_diffs, name: CONSTRAINT_NAME
        end
      
        def down
          unprepare_async_check_constraint_validation :merge_request_diffs, name: CONSTRAINT_NAME
        end
      end
      
    • 선택 사항. 파티셔닝된 테이블의 경우:

      # db/post_migrate/
      
      PARTITIONED_TABLE_NAME = :p_ci_builds
      CONSTRAINT_NAME = 'check_9aa9432137'
      
      # 파티셔닝된 테이블의 체크 제약을 검증합니다 https://gitlab.com/gitlab-org/gitlab/-/issues/XXXXX
      def up
        prepare_partitioned_async_check_constraint_validation PARTITIONED_TABLE_NAME, name: CONSTRAINT_NAME
      end
      
      def down
        unprepare_partitioned_async_check_constraint_validation PARTITIONED_TABLE_NAME, name: CONSTRAINT_NAME
      end
      

      참고: prepare_partitioned_async_check_constraint_validation은 모든 파티션에 대해 기존 NOT VALID 체크 제약을 비동기적으로 검증합니다. 파티셔닝된 테이블의 체크 제약을 생성하거나 검증하지 않습니다.

  3. 선택 사항. 제약이 비동기적으로 검증되었다면 검증이 완료된 후 NOT NULL 제약을 유효성 검사합니다:

    # db/post_migrate/
    class ValidateMergeRequestDiffsProjectIdNullConstraint < Gitlab::Database::Migration[2.2]
      milestone '16.10'
    
      def up
        validate_not_null_constraint :merge_request_diffs, :project_id
      end
    
      def down
        # 아무것도 안 함
      end
    end
    

이러한 경우에는 업데이트 주기 초기에 데이터베이스 팀과 상의하십시오. NOT NULL 제약이 필요하지 않을 수도 있으며 실제로 대규모 또는 자주 액세스되는 테이블에 영향을주지 않는 다른 옵션이 있을 수 있습니다.

여러 열에 대한 NOT NULL 제약 조건

가끔은 특정 수의 NOT NULL 값을 포함하는 열 집합을 보장하고 싶을 때가 있습니다. 일반적인 예로는 프로젝트 또는 그룹에 속할 수 있는 테이블이 있는데, 따라서 project_id 또는 group_id가 있어야 합니다. 이를 강제하기 위해서는 위에서 설명한 당신의 사용 사례에 맞는 단계를 따르되, add_multi_column_not_null_constraint 도우미를 사용합니다.

이 예에서 labels는 반드시 프로젝트 또는 그룹에 속해야 하지만 둘 다 속할 수는 없습니다. 이를 강제하기 위해 다음과 같은 체크 제약 조건을 추가할 수 있습니다:

class AddLabelsNullConstraint < Gitlab::Database::Migration[2.2]
  disable_ddl_transaction!
  milestone '16.10'

  def up
    add_multi_column_not_null_constraint(:labels, :group_id, :project_id)
  end

  def down
    remove_multi_column_not_null_constraint(:labels, :group_id, :project_id)
  end
end

이렇게 하면 labels에 다음과 같은 제약 조건이 추가됩니다:

CREATE TABLE labels (
    ...
    CONSTRAINT check_45e873b2a8 CHECK ((num_nonnulls(group_id, project_id) = 1))
);

num_nonnulls는 제공된 인수 중 값이 널이 아닌 것의 수를 반환합니다. 이 제약 조건에서 값이 1인지 확인하는 것은 행에서 group_idproject_id 중 하나만 널이 아닌 값을 포함해야 하지만 둘 다 포함해서는 안 된다는 것을 의미합니다.

사용자 정의 제약 조건과 연산자

만약 우리가 필요한 널이 아닌 값의 수를 사용자 정의하고 싶다면, 다른 limit 및/또는 operator를 사용할 수 있습니다:

class AddLabelsNullConstraint < Gitlab::Database::Migration[2.2]
  disable_ddl_transaction!
  milestone '16.10'

  def up
    add_multi_column_not_null_constraint(:labels, :group_id, :project_id, limit: 0, operator: '>')
  end

  def down
    remove_multi_column_not_null_constraint(:labels, :group_id, :project_id)
  end
end

이것은 그 후에 제약 조건에 반영되어 project_idgroup_id 둘 다 존재할 수 있게 됩니다:

CREATE TABLE labels (
    ...
    CONSTRAINT check_45e873b2a8 CHECK ((num_nonnulls(group_id, project_id) > 0))
);