Help us learn about your current experience with the documentation. Take the survey.

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 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 回调可能不够,因为某些进程会跳过这些回调。update_columnupdate_columns 以及 insert_allupdate_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 描述的 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),你的后台迁移会持续一段时间,并且在添加数据迁移后的版本中需要额外的 批量后台清理

在这种情况下,版本数量取决于迁移现有记录所需的时间。清理是在后台迁移完成后安排的,这可能在添加约束后的几个版本之后。

  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
          )
        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
    • 可选。 对于分区表,使用:

      # 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

      prepare_partitioned_async_check_constraint_validation 只会异步验证所有分区上现有的 NOT VALID 检查约束。它不会为分区表创建或验证检查约束。

  3. 可选。 如果约束是异步验证的,一旦验证完成,验证 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_idgroup_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))
);

删除现有表中列的 NOT NULL 约束

从现有数据库列中删除 NOT NULL 约束需要多步迁移过程:

  1. 一个架构迁移来删除 NOT NULL 约束。
  2. 一个单独的数据迁移来确保在潜在回滚后的数据完整性。此迁移可能会:
    • 删除无效记录。
    • 使用默认值更新无效记录。

需要多个迁移,因为在单个迁移中组合数据修改(DML)和架构更改(DDL)是不允许的。

列上带有检查约束的 NOT NULL 约束

首先,验证列上是否有约束。你可以通过几种方式检查:

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 约束,因为所有分区都从父表继承约束。因此,我们需要从父表删除约束,这会级联到所有子分区。