NOT NULL 约束
所有不应具有 NULL 值的属性,都应在数据库中定义为 NOT NULL 列。
根据应用程序逻辑,NOT NULL 列应该在模型中定义 存在验证,或者在数据库定义中包含默认值。
例如,对于布尔属性,它们应该始终具有非 NULL 值,但有一个明确定义的默认值,应用程序不需要每次都强制执行(例如,active=true)。
对于属于 belongs_to 关联的外键列,优先在关联上使用 optional: false,而不是单独的 presence: true 验证。这种方法在语义上更正确,并利用了 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等批量操作是一些需要注意的方法。
- 添加一个部署后迁移来修复现有记录。
根据表的大小,在下一个版本中可能需要后台迁移进行清理。有关更多信息,请参阅
NOT NULL约束在大表上的应用 部分。 - 确保在应用程序级别设置 $ATTRIBUTE 值。
-
版本
N.M+1(下一个版本)- 确保 GitLab.com 上的所有现有记录都已设置属性。如果没有,请回到版本
N.M的步骤 1。 - 如果步骤 1 看起来没问题,并且从版本
N.M的回填是通过批量后台迁移完成的,则添加一个部署后迁移来 完成后台迁移。 - 在模型中为属性添加验证,以防止具有
nil属性的记录,因为现在所有现有和新记录都应该是有效的。 - 添加一个部署后迁移来添加
NOT NULL约束。
- 确保 GitLab.com 上的所有现有记录都已设置属性。如果没有,请回到版本
示例
考虑给定的版本里程碑,例如 13.0。
在检查我们的生产数据库后,我们知道存在具有 NULL 描述的 epics,所以我们不能一步到位地添加和验证约束。
即使我们没有具有 NULL 描述的 epic,另一个 GitLab 实例也可能存在这样的记录,因此无论哪种情况我们都遵循相同的流程。
防止新的无效记录(当前版本)
更新所有将属性设置为 nil 的代码路径(如果有的话),为新记录和现有记录设置非 nil 值。
在 epic.rb 中添加了一个使用 Rails 属性 API 的默认属性,以便为新记录设置默认值:
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 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 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 -
可选。 对于分区表,使用:
# 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 endprepare_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 console 中查询
Gitlab::Database::PostgresConstraint视图 - 使用
psql检查表本身:\d+ table_name - 检查
structure.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 约束,因为所有分区都从父表继承约束。因此,我们需要从父表删除约束,这会级联到所有子分区。