NOT NULL 制約

GitLab 13.0から導入されました

値としてNULL を持つべきでないすべての属性は、データベースでNOT NULL カラムとして定義されるべきです。

アプリケーションのロジックに応じて、NOT NULL 列は、presence: true バリデーションがモデルで定義されているか、データベース定義の一部としてデフォルト値を持つべきです。例として、NULL 以外の値を常に持つべきですが、アプリケーションが毎回強制する必要のない、明確に定義されたデフォルト値を持つブーリアン属性(例えば、active=true )では、後者が当てはまります。

NOT NULL カラムを持つ新しいテーブルを作成します。

新しいテーブルを追加する場合、NOT NULL のカラムはすべて、create_table の内部で直接定義する必要があります。

例えば、NOT NULL の2つのカラムを持つテーブルを作成するマイグレーションを考えてみましょう。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 カラムを既存のテーブルに追加します。

add_column PostgreSQL 11 が GitLab 13.0 以降の最小バージョンになったことで、NULL やデフォルト値を持つカラムを追加するのがとても簡単になりました。

たとえば、テーブルdb_guides,db/migrate/20200501000001_add_active_to_db_guides.rbに新しいカラムNOT NULL active を追加するマイグレーションを考えてみましょう:

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 を既存のデータベース列に追加するには、通常、少なくとも2つの異なるリリースに分割された複数のステップが必要です。バックグラウンドマイグレーションを使用する必要がないほどテーブルが小さい場合は、これらすべてを同じマージリクエストに含めることができます。トランザクション時間を短縮するために、別々のマイグレーションを使用することをお勧めします。

必要な手順は以下の通りです:

  1. リリースN.M (現在のリリース)

    1. アプリケーション・レベルで$ATTRIBUTE値が設定されていることを確認してください。
      1. 属性にデフォルト値がある場合、新しいレコードにデフォルト値が設定されるように、モデルにデフォルト値を追加します。
      2. 新しいレコードでも既存のレコードでも、属性がnil に設定されている場所があれば、コード内のすべての場所を更新します。
    2. 既存のレコードを修正するために、デプロイ後のマイグレーションを追加します。
    note
    テーブルのサイズによっては、次のリリースでクリーンアップのためのバックグラウンドマイグレーションが必要になる可能性があります。詳細については、「大規模テーブルの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 があることがわかりました。したがって、制約の追加と検証を1つのステップで行うことはできません。

NULL NULL の記述があるエピックがなかったとしても、GitLabの別のインスタンスにはそのようなレコードがあるかもしれません。

新しい無効なレコードを防ぐ (現在のリリース)

属性がnil に設定されているすべてのコードパスを更新して、新規レコードと既存レコードで属性をゼロ以外の値に設定するようにしました。

新しいレコードにデフォルト値が設定されるように、Rails attributes APIを使用したデフォルトを持つ属性がepic.rb に追加されました:

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

既存レコードを修正するためのデータマイグレーション (現在のリリース)

ここでのアプローチは、データ量とクリーンアップ戦略に依存します。GitLab.comで修正しなければならないレコードの数は、デプロイ後のマイグレーションを使うかバックグラウンドのデータマイグレーションを使うかを決めるのに役立つ良い指標です:

  • データ量が1000 レコード以下であれば、データマイグレーションはポストマイグレーション内で実行できます。
  • データ量が1000 レコードより多い場合は、バックグラウンドマイグレーションを作成することをお勧めします。

どのオプションを使用するか不明な場合は、データベースチームにお問い合わせください。

例に戻ると、エピック・テーブルはそれほど大きくなく、頻繁にアクセスされるものでもありません。そこで、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 < 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上のすべてのレコードに属性が設定されているかどうかをチェックします。もしそうでなければ、Prevent new invalid recordsのステップに戻り、コードのどこで属性が明示的に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
    # 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 大規模テーブルの制約

トラフィックの多いテーブルのNULL可能な列(例えば、ci_buildsartifacts )をクリーンアップする必要がある場合、バックグラウンドマイグレーションがしばらく続き、データマイグレーションを追加した後のリリースで、追加のバッチバックグラウンドマイグレーションによるクリーンアップが必要になります。

このようなまれなケースでは、エンドツーエンドで3つのリリースが必要になります:

  1. リリースN.M -NOT NULL 制約を追加し、既存のレコードを修正するためにバックグラウンドマイグレーションを行います。
  2. リリースN.M+1 - バックグラウンド・マイグレーションをクリーンアップ。
  3. リリースN.M+2 -NOT NULL 制約を検証しました。

このような場合は、更新サイクルの早い段階でデータベース・チームに相談してください。NOT NULL 制約が必要ない場合や、本当に大きなテーブルや頻繁にアクセスされるテーブルに影響を与えない他のオプションが存在する場合があります。