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을 추가하는 것은 일반적으로 적어도 두 가지 다른 릴리스로 분할된 여러 단계가 필요합니다. 테이블 크기가 백그라운드 마이그레이션을 필요로 하지 않을 정도로 충분히 작다면, 이를 동일한 머지 요청에 포함시킬 수 있습니다. Transaction 기간을 줄이기 위해 별도의 마이그레이션을 사용하는 것이 좋습니다.

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

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

    1. $ATTRIBUTE 값이 응용프로그램 수준에서 설정되고 있는지 확인합니다.
      1. 속성이 기본값을 가지는 경우, 모델에 기본값을 추가하여 새 레코드에 대해 기본값이 설정되도록 합니다.
      2. 새 및 기존 레코드에 대해 속성을 nil로 설정하는 코드의 모든 위치를 업데이트하세요. 일부 프로세스가 이러한 콜백을 건너뛰는 경우 before_savebefore_validation과 같은 ActiveRecord 콜백이 충분하지 않을 수 있습니다. update_column, update_columnsinsert_all, update_all과 같은 대량 작업은 확인할 메서드의 예입니다.
    2. 기존 레코드를 수정하는 후속 배포 마이그레이션을 추가합니다.
    note
    테이블의 크기에 따라 다음 릴리스에서 크리너를 위한 백그라운드 마이그레이션이 필요할 수 있습니다. 자세한 정보는 NOT NULL constraints on large tables 섹션을 확인하세요.
  2. 릴리스 N.M+1 (다음 릴리스)

    1. GitLab.com에서 모든 기존 레코드가 해당 속성을 설정했는지 확인합니다. 그렇지 않으면 릴리스 “N.M”으로 돌아가세요.
    2. 단계 1이 문제가 없고 릴리스 N.M에서 배치된 백그라운드 마이그레이션을 통해 백필이 완료된 것으로 보인다면 후속 배포 마이그레이션을 추가하여 배경 마이그레이션을 최종화합니다.
    3. 속성에 대한 모델의 유효성 검사를 추가하여 모든 기존 및 새 레코드가 유효해야 하므로 레코드를 방지합니다.
    4. NOT NULL 제약 조건을 추가하는 후속 배포 마이그레이션을 추가합니다.

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

프로덕션 데이터베이스를 확인한 후, NULL 설명이 있는 epic이 있기 때문에 제약 조건을 한 단계에 추가하고 유효성을 검사할 수 없습니다.

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

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

새 레코드의 기본값이 설정되도록 nil로 속성을 설정하는 코드 경로를 모두 업데이트하세요.

새 레코드에 대해 기본값을 설정하는Rails attributes API를 사용하는 속성이 epic.rb에 추가되었으므로:

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

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

여기서 사용하는 접근 방식은 데이터 양과 정리 전략에 따라 다릅니다. GitLab.com의 수정이 필요한 기록 수는 포스트 마이그레이션이나 백그라운드 데이터 마이그레이션을 사용하는 데 도움이 되는 좋은 지표입니다.

  • 데이터 양이 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 및 GitLab.com의 epics.count=29500의 경우
  # - 30 iterations will be run
  # - each requires on average ~150ms
  # 예상 총 실행 시간: ~5 seconds
  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 : can't go back to `NULL` without first dropping the `NOT NULL` constraint
  end
end

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

postgres.ai를 사용하여 얇은 클론을 만들어 프로덕션 데이터베이스를 확인하고 GitLab.com의 모든 레코드가 해당 속성을 설정했는지 확인하세요. 그렇지 않으면 유효하지 않은 레코드 방지 단계로 돌아가 코드 경로에서 속성이 명시적으로 nil로 설정되는 곳을 찾아 코드 경로를 수정한 후 기존 레코드를 수정하는 마이그레이션을 재예약하고 다음 릴리스를 위해 다음 단계를 기다리세요.

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

백그라운드 마이그레이션을 완료하세요. (batched_background_migrations.md#depending-on-migrated-data).

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

모든 기존 및 새 레코드가 유효해야 하므로 속성에 대한 모델 유효성을 추가하세요.

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

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

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

여전히 예시에서는 13.1 마일스톤(next)에서 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
          # no-op
        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
      
  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
    

이러한 경우에는 업데이트 주기 초반에 데이터베이스 팀과 상의하십시오. 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))
);