NOT NULL 제약 조건

NULL 값을 가질 수 없는 모든 속성은 데이터베이스에서 NOT NULL 열로 정의되어야 합니다.

애플리케이션 로직에 따라 NOT NULL 열은 모델에서 presence: true 검증을 정의하거나 데이터베이스 정의의 일부로 기본 값을 가져야 합니다.

예를 들어, 후자는 항상 비NULL 값을 가져야 하지만 애플리케이션에서 매번 강제할 필요가 없는 잘 정의된 기본 값을 가진 불리언 속성에 해당할 수 있습니다 (예: active=true).

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로 설정되는 모든 곳을 코드에서 업데이트합니다. ActiveRecord 콜백인 before_savebefore_validation을 사용하는 것이 충분하지 않을 수 있습니다. 일부 프로세스는 이러한 콜백을 건너뛰기 때문입니다. update_column, update_columns, insert_all, update_all과 같은 대량 작업을 주의해야 합니다.
    2. 기존 레코드를 수정하기 위해 배포 후 마이그레이션을 추가합니다.

    주의: 테이블 크기에 따라 청소를 위한 백그라운드 마이그레이션이 다음 릴리스에서 필요할 수 있습니다. 더 많은 정보는 NOT NULL 제약 조건이 있는 큰 테이블 섹션을 참조하세요.

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

    1. GitLab.com의 모든 기존 레코드에 속성이 설정되어 있는지 확인합니다. 그렇지 않으면 릴리스 N.M의 1단계로 돌아갑니다.
    2. 1단계가 괜찮고 릴리스 N.M에서 배치된 배경 마이그레이션을 통해 백필이 완료되었다면, 배경 마이그레이션을 완료하기 위해 배포 후 마이그레이션을 추가합니다.
    3. 이제 모든 기존 및 새 레코드는 유효해야 하므로 모델에서 nil 속성이 있는 레코드를 방지하기 위해 속성에 대한 검증을 추가합니다.
    4. NOT NULL 제약 조건을 추가하기 위해 배포 후 마이그레이션을 추가합니다.

예제

주어진 릴리즈 마일스톤, 예를 들어 13.0을 고려합니다.

생산 데이터베이스를 확인한 결과, NULL 설명이 있는 epics가 있어

한 번의 단계에서 제약 조건을 추가하고 검증할 수 없습니다.

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

새로운 잘못된 레코드 방지 (현재 릴리즈)

속성이 nil로 설정되는 모든 코드 경로를 업데이트하여, 새로운 레코드와 기존 레코드의 속성을 비-nil 값으로 설정합니다.

기본값을 지원하는 속성은 Rails attributes API를 사용하여 epic.rb에 추가되어 새로운 레코드에 대한 기본값이 설정됩니다:

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

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

여기서 접근 방식은 데이터 양과 정리 전략에 따라 다릅니다.

GitLab.com에서 수정해야 할 레코드 수는 배치 배포 마이그레이션(post-deployment migration) 또는 백그라운드 데이터 마이그레이션(background data migration) 중 어느 것을 사용해야 할지를 결정하는 좋은 지표입니다:

  • 데이터 양이 1000 레코드 미만인 경우, 데이터 마이그레이션을 배치 마이그레이션 내에서 실행할 수 있습니다.
  • 데이터 양이 1000 레코드 이상인 경우, 백그라운드 마이그레이션을 만드는 것이 좋습니다.

어떤 옵션을 사용할지 불확실한 경우, 데이터베이스 팀에 조언을 요청하십시오.

우리가 예를 돌아보면, epics 테이블은 그리 크지 않으며 자주 접근되지 않기 때문에,

13.0 마일스톤(현재)에 대한 배치 배포 마이그레이션을 추가합니다: db/post_migrate/20200501000002_cleanup_epics_with_null_description.rb:

class CleanupEpicsWithNullDescription < Gitlab::Database::Migration[2.1]
  # BATCH_SIZE=1000 및 epics.count=29500인 경우 GitLab.com에서
  # - 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
    # no-op : 먼저 `NOT NULL` 제약 조건을 삭제하지 않고는 `NULL`로 돌아갈 수 없습니다.
  end
end

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

postgres.ai를 사용하여 얇은 클론 만들기 생산 데이터베이스의 얇은 클론을 작성하고 GitLab.com의 모든 레코드에 속성이 설정되어 있는지 확인합니다.

설정되지 않았다면, 새로운 잘못된 레코드 방지 단계로 돌아가서 속성이 명시적으로 nil로 설정되는 코드 위치를 찾아 수정하십시오. 그런 다음 기존 레코드를 수정하기 위한 마이그레이션을 다시 예약하고 다음 릴리즈를 기다려서 다음 단계를 수행하십시오.

백그라운드 마이그레이션 완료하기 (다음 릴리스)

백그라운드 마이그레이션을 사용하여 마이그레이션을 완료했다면 마이그레이션을 완료하세요.

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

속성에 대한 유효성 검사를 모델에 추가하여 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 제약 조건

트래픽이 많은 테이블을 위한 nullable 열을 정리해야 할 경우(예: 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
          # nop
        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
        # no-op
      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는 제공된 인수 중 null이 아닌 개수를 반환합니다. 제약 조건에서 이 값이 1과 같도록 검사하는 것은 한 행에서 group_idproject_id 중 오직 하나만 null이 아닌 값을 가져야 함을 의미합니다.

사용자 정의 제한 및 연산자

필요한 null이 아닌 수를 사용자 정의하고 싶다면, 다른 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))
);