NOT NULL 제약 조건
GitLab v19.4요약
NULL을 값으로 가져서는 안 되는 모든 속성은 데이터베이스에서 NOT NULL 칼럼으로 정의해야 합니다. 애플리케이션 로직에 따라 NOT NULL 칼럼은 모델에 정의된 존재 유효성 검사를 갖거나 데이터베이스 정의의 일부로 기본값을 가져야 합니다.
NULL을 값으로 가져서는 안 되는 모든 속성은 데이터베이스에서 NOT NULL
칼럼으로 정의해야 합니다.
애플리케이션 로직에 따라 NOT NULL 칼럼은 모델에 정의된 존재 유효성 검사를 갖거나
데이터베이스 정의의 일부로 기본값을 가져야 합니다.
후자의 예로는 항상 NULL이 아닌 값을 가져야 하지만 애플리케이션이 매번 강제할
필요가 없는 잘 정의된 기본값을 가진 불리언 속성을 들 수 있습니다
(예를 들어 active=true입니다).
belongs_to 연관 관계에 속한 외래 키 칼럼에는 별도의 presence: true 유효성 검사 대신
연관 관계에 optional: false를 사용하는 것을 권장합니다. 이 방식이 의미상 더
정확하고 Rails의 내장 연관 관계 유효성 검사를 활용합니다. GitLab은 config/application.rb에
config.active_record.belongs_to_required_by_default = false를
두고 있으므로, belongs_to 연관 관계는 기본적으로 선택 사항이며 필수임을 명시해야 합니다.
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 NULL 칼럼 active를 추가하는 마이그레이션
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을 추가하려면 보통 최소 두 개의 서로 다른 릴리스로
나뉜 여러 단계가 필요합니다. 테이블이 충분히 작아 백그라운드 마이그레이션을
사용할 필요가 없다면 이 모든 것을 같은 머지 리퀘스트에
포함할 수 있습니다. 트랜잭션 시간을 줄이기 위해 마이그레이션을 분리하는 것을
권장합니다.
필요한 단계는 다음과 같습니다.
-
릴리스
N.M(현재 릴리스)-
애플리케이션 수준에서 $ATTRIBUTE 값이 설정되고 있는지 확인합니다.
- 속성에 기본값이 있으면 새 레코드에 기본값이 설정되도록 모델에 기본값을 추가합니다.
- 새 레코드와 기존 레코드에 대해 속성이
nil로 설정될 수 있는 코드의 모든 위치를 수정합니다.before_save나before_validation같은 ActiveRecord 콜백만으로는 충분하지 않을 수 있습니다. 일부 프로세스가 이 콜백을 건너뛰기 때문입니다.update_column,update_columns, 그리고insert_all과update_all같은 일괄 작업이 주의해야 할 메서드의 예입니다.
-
기존 레코드를 수정하는 배포 후 마이그레이션을 추가합니다.
[!note] 테이블 크기에 따라 다음 릴리스에서 정리용 백그라운드 마이그레이션이 필요할 수 있습니다. 자세한 내용은 대규모 테이블의
NOT NULL제약 조건 섹션을 참고합니다.
-
-
릴리스
N.M+1(다음 릴리스)- GitLab.com의 모든 기존 레코드에 속성이 설정되어 있는지 확인합니다. 그렇지 않으면 릴리스
N.M의 1단계로 돌아갑니다. - 1단계에 문제가 없어 보이고 릴리스
N.M의 백필을 배치 백그라운드 마이그레이션으로 수행했다면 백그라운드 마이그레이션을 완료하는 배포 후 마이그레이션을 추가합니다. - 이제 모든 기존 레코드와 새 레코드가 유효해야 하므로,
nil속성을 가진 레코드를 막기 위해 모델에 해당 속성에 대한 유효성 검사를 추가합니다. NOT NULL제약 조건을 추가하는 배포 후 마이그레이션을 추가합니다.
- GitLab.com의 모든 기존 레코드에 속성이 설정되어 있는지 확인합니다. 그렇지 않으면 릴리스
예시#
13.0과 같은 특정 릴리스 마일스톤을 가정합니다.
프로덕션 데이터베이스를 확인한 결과 description이 NULL인 epics가 있음을 알고 있으므로,
제약 조건을 한 단계로 추가하고 검증할 수 없습니다.
description이 NULL인 에픽이 없었더라도 다른 GitLab 인스턴스에는 그런 레코드가
있을 수 있으므로, 어느 쪽이든 같은 절차를 따릅니다.
새로운 유효하지 않은 레코드 방지(현재 릴리스)#
속성이 nil로 설정되는 모든 코드 경로를 수정해, 새 레코드와 기존 레코드에 대해
속성이 nil이 아닌 값으로 설정되도록 합니다.
새 레코드에 기본값이 설정되도록
Rails 속성 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]
# With BATCH_SIZE=1000 and epics.count=29500 on GitLab.com
# - 30 iterations will be run
# - each requires on average ~150ms
# Expected total run time: ~5 seconds
BATCH_SIZE = 1000
disable_ddl_transaction!
class Epic < MigrationRecord
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로 설정되는 위치를 찾습니다. 해당 코드 경로를 수정한 다음 기존 레코드를 수정하는 마이그레이션을 다시 예약하고,
다음 릴리스에서 아래 단계를 진행합니다.
백그라운드 마이그레이션 완료(다음 릴리스)#
백그라운드 마이그레이션으로 마이그레이션을 수행했다면 마이그레이션을 완료합니다.
모델에 유효성 검사 추가(다음 릴리스)#
이제 모든 기존 레코드와 새 레코드가 유효해야 하므로, nil 속성을 가진 레코드를 막기 위해 모델에 해당 속성에 대한 유효성 검사를 추가합니다.
belongs_to 연관 관계에 속한 외래 키 칼럼에는 optional: false를 사용하는 것을 권장합니다.
class Epic < ApplicationRecord
belongs_to :group, optional: false
end
이 방식이 다음보다 권장됩니다.
class Epic < ApplicationRecord
belongs_to :group
validates :group, presence: true
end
일반 속성의 경우:
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
# This will add the `NOT NULL` constraint and validate it
add_not_null_constraint :epics, :description
end
def down
# Down is required as `add_not_null_constraint` is not reversible
remove_not_null_constraint :epics, :description
end
end
대규모 테이블의 NOT NULL 제약 조건#
트래픽이 많은 테이블(예를 들어 ci_builds의 artifacts입니다)에서
널 허용 칼럼을 정리해야 한다면 백그라운드 마이그레이션이 한동안 계속되며,
데이터 마이그레이션을 추가한 다음 릴리스에서 배치 백그라운드 마이그레이션 정리가
추가로 필요합니다.
이 경우 릴리스 수는 기존 레코드를 마이그레이션하는 데 필요한 시간에 따라 달라집니다. 정리는 백그라운드 마이그레이션이 완료된 후에 예약되며, 제약 조건을 추가한 시점보다 여러 릴리스 뒤가 될 수 있습니다.
-
릴리스
N.M:-
기존 레코드를 수정하는 백그라운드 마이그레이션을 추가합니다.
# db/post_migrate/ class QueueBackfillMergeRequestDiffsProjectId < Gitlab::Database::Migration[2.2] milestone '16.7' restrict_gitlab_migration gitlab_schema: :gitlab_main_org MIGRATION = 'BackfillMergeRequestDiffsProjectId' DELAY_INTERVAL = 2.minutes def up queue_batched_background_migration( MIGRATION, :merge_request_diffs, :id ) end def down delete_batched_background_migration(MIGRATION, :merge_request_diffs, :id, []) end end
-
-
릴리스
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_org 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.10' 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.10' 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' # Partitioned check constraint to be validated in 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[!note]
prepare_partitioned_async_check_constraint_validation은 모든 파티션에 대해 기존NOT VALID체크 제약 조건을 비동기로 검증만 합니다. 파티션된 테이블에 대해 체크 제약 조건을 생성하거나 검증하지는 않습니다.
-
선택 사항. 제약 조건을 비동기로 검증했다면 검증이 완료된 뒤에
NOT NULL제약 조건을 검증합니다.-
Database Lab으로 검증이 성공했는지 확인합니다.
\d+ table_name명령을 실행해 체크 제약 조건 정의에서NOT VALID가 제거되었는지 확인합니다. -
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는 전달된 인수 중 널이 아닌 것의 개수를 반환합니다. 제약 조건에서 이 값이
1인지 확인한다는 것은 한 행에서 group_id와 project_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_id와 group_id가 모두 있을 수 있습니다.
CREATE TABLE labels (
...
CONSTRAINT check_45e873b2a8 CHECK ((num_nonnulls(group_id, project_id) > 0))
);
기존 테이블의 칼럼에서 NOT NULL 제약 조건 제거#
기존 데이터베이스 칼럼에서 NOT NULL 제약 조건을 제거하려면 여러 단계의 마이그레이션 과정이 필요합니다.
NOT NULL제약 조건을 제거하는 스키마 마이그레이션입니다.- 롤백이 발생할 경우 데이터 정합성을 보장하는 별도의 데이터 마이그레이션입니다. 이 마이그레이션은 다음을 수행할 수 있습니다.
- 유효하지 않은 레코드를 제거합니다.
- 유효하지 않은 레코드를 기본값으로 업데이트합니다.
데이터 수정(DML)과 스키마 변경(DDL)을 한 마이그레이션에 합치는 것은 허용되지 않으므로 마이그레이션을 여러 개로 나누어야 합니다.
칼럼에 체크 제약 조건이 있는 경우 NOT NULL 제약 조건 제거#
먼저 해당 칼럼에 제약 조건이 있는지 확인합니다. 다음과 같은 여러 방법으로 확인할 수 있습니다.
- Rails 콘솔에서
Gitlab::Database::PostgresConstraint뷰를 쿼리합니다 psql로 테이블 자체를 확인합니다:\d+ table_namestructure.sql을 확인합니다.
CREATE TABLE labels (
...
CONSTRAINT check_061f6f1c91 CHECK ((project_view IS NOT NULL))
);
예시#
마일스톤 번호는 예시일 뿐입니다. 올바른 버전을 사용합니다.
# frozen_string_literal: true
class DropNotNullConstraintFromLabelsProjectView< Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '16.7'
def up
remove_not_null_constraint :labels, :project_view
end
def down
add_not_null_constraint :labels, :project_view
end
end
# frozen_string_literal: true
class CleanupRecordsWithNullProjectViewValuesFromLabels < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '16.7'
BATCH_SIZE = 1000
class Label < MigrationRecord
include EachBatch
self.table_name = 'labels'
end
def up
# no-op - this migration is required to allow a rollback of `DropNotNullConstraintFromLabelsProjectView`
end
def down
Label.each_batch(of: BATCH_SIZE) do |relation|
relation.
where('project_view IS NULL').
delete_all
end
end
end
칼럼에 체크 제약 조건이 없는 경우 NOT NULL 제약 조건 제거#
NOT NULL이 칼럼에만 정의되어 있고 체크 제약 조건이 없다면 change_column_null을 사용할 수 있습니다.
structure.sql의 예시:
CREATE TABLE labels (
...
projects_limit integer NOT NULL
);
예시#
마일스톤 번호는 예시일 뿐입니다. 올바른 버전을 사용합니다.
# frozen_string_literal: true
class DropNotNullConstraintFromLabelsProjectsLimit < Gitlab::Database::Migration[2.2]
milestone '16.7'
def up
change_column_null :labels, :projects_limit, true
end
def down
change_column_null :labels, :projects_limit, false
end
end
# frozen_string_literal: true
class CleanupRecordsWithNullProjectsLimitValuesFromLabels < Gitlab::Database::Migration[2.2]
disable_ddl_transaction!
milestone '16.7'
BATCH_SIZE = 1000
class Label < MigrationRecord
include EachBatch
self.table_name = 'labels'
end
def up
# no-op - this migration is required to allow a rollback of `DropNotNullConstraintFromLabelsProjectsLimit`
end
def down
Label.each_batch(of: BATCH_SIZE) do |relation|
relation.
where('projects_limit IS NULL').
delete_all
end
end
end
파티션 테이블에서 NOT NULL 제약 조건 제거#
중요한 참고 사항: 상위 테이블에 NOT NULL 제약 조건이 있으면 모든 파티션이 상위 테이블에서 제약 조건을 상속하므로 개별 파티션에서는 제약 조건을 제거할 수 없습니다. 이 때문에 상위 테이블에서 제약 조건을 제거해야 하며, 그러면 모든 하위 파티션으로 연쇄 적용됩니다.