マイグレーション・スタイルガイド

GitLabのマイグレーションを書くときは、何十万ものあらゆる規模の組織で実行され、データベースには何年ものデータがあることを考慮しなければなりません。

さらに、アップグレードの大小に関わらず、サーバーをオフラインにしなければならないことは、ほとんどの組織にとって大きな負担です。このような理由から、マイグレーションは慎重に書かれ、オンラインで適用でき、以下のスタイルガイドを遵守することが重要です。

マイグレーションでは、GitLabのインストールをオフラインにする必要はありません。マイグレーションは常にダウンタイムを避けるように書かなければなりません。過去には、DOWNTIME 定数を設定することでダウンタイムを許容するマイグレーション定義のプロセスがありました。古いマイグレーションを見るとわかるかもしれません。このプロセスは一度も使われることなく4年間実施されました。そのため、ダウンタイムを回避するためにマイグレーションを別の方法で書く方法を常に見つけ出すことができることを学びました。

マイグレーションを作成する際には、データベースに古いデータや不整合があるかもしれないことも考慮し、そのような事態に備えましょう。データベースの状態については、できるだけ仮定しないようにしてください。

GitLab固有のコードは将来のバージョンで変わる可能性があるので、依存しないでください。必要であれば、マイグレーションにGitLabのコードをコピーペーストして互換性を持たせてください。

適切なマイグレーションタイプを選択してください。

新しいマイグレーションを追加する前の最初のステップは、どのタイプが最も適切かを決めることです。

現在、マイグレーションには、実行する必要のある作業の種類と完了までにかかる時間に応じて、3つの種類があります:

  1. 通常のスキーママイグレーション。db/migrate 、新しいアプリケーションコードがデプロイされる_前_(GitLab.comの場合はCanaryがデプロイされる前_)に_実行される従来のRailsマイグレーションです。つまり、デプロイを不必要に遅らせないように、数分以内の比較的高速なものでなければなりません。

    ただし、アプリケーションを正しくオペレーションするために絶対に必要なマイグレーションは例外です。例えば、一意なタプルを強制するインデックスや、アプリケーションの重要な部分でクエリのパフォーマンスに必要なインデックスがあるかもしれません。しかし、マイグレーションに許容できないほど時間がかかるような場合、機能フラグでその機能を保護し、代わりにデプロイ後のマイグレーションを実行する方が良い選択肢かもしれません。マイグレーションが終了した後に、この機能を有効にすることができます。

    新しいモデルを追加するためのマイグレーションも、通常のスキーママイグレーションの一部です。唯一の違いは、マイグレーションを生成するRailsコマンドと、生成される追加ファイル(モデル用とモデルのspec用)です。

  2. デプロイ後のマイグレーション。これらはdb/post_migrate の Rails マイグレーションで、GitLab.com のデプロイとは独立して実行されます。保留中のポストマイグレーションは、デプロイ後のマイグレーションパイプラインを通じて、リリースマネージャの判断で毎日実行されます。これらのマイグレーションは、アプリケーションのオペレーションにとって重要でないスキーマの変更や、せいぜい数分しかかからないデータマイグレーションに使うことができます。ポストデプロイで実行すべきスキーマ変更の一般的な例には、次のようなものがあります:

    • 未使用カラムの削除などのクリーンアップ。
    • トラフィックの多いテーブルに重要でないインデックスを追加。
    • 作成に時間がかかる重要でないインデックスの追加。

    これらのマイグレーションは、アプリケーションの動作に不可欠なスキーマの変更には使用しないでください。デプロイ後のマイグレーションでこのようなスキーマ変更を行うと、過去にイシューなどの問題が発生しました。常に通常のスキーママイグレーションを行うべきであり、デプロイ後のマイグレーションで実行すべきではない変更には、以下のようなものがあります:

    • 新しいテーブルの作成 例:create_table.
    • 既存のテーブルへの新しいカラムの追加、例:add_column.
  3. バッチバックグラウンドマイグレーション。これは通常のRailsマイグレーションではなく、Sidekiqジョブを介して実行されるアプリケーションコードです。デプロイ後マイグレーションのタイミングガイドラインを超えるデータマイグレーションにのみ使用してください。バッチバックグラウンドマイグレーションでは、スキーマを変更_しないで_ください。

以下の図を参考に決定してください。ただし、これは単なるツールであり、最終的な結果は常に具体的な変更内容に依存することに留意してください:

graph LR A{Schema<br/>changed?} A -->|Yes| C{Critical to<br/>speed or<br/>behavior?} A -->|No| D{Is it fast?} C -->|Yes| H{Is it fast?} C -->|No| F[Post-deploy migration] H -->|Yes| E[Regular migration] H -->|No| I[Post-deploy migration<br/>+ feature flag] D -->|Yes| F[Post-deploy migration] D -->|No| G[Background migration]

マイグレーションにかかる時間

一般的に、GitLab.comでは1回のデプロイにかかるマイグレーションに1時間以上かかることはありません。以下のガイドラインは厳密なルールではなく、マイグレーションにかかる時間を最小限に抑えるために見積もられたものです。

note
すべての所要時間はGitLab.comに対して計測されていることに留意してください。
マイグレーションタイプ推奨期間備考
定期マイグレーション<= 3 minutes有効な例外は、アプリケーションの機能またはパフォーマンスが著しく低下し、遅延させることができない変更です。
デプロイ後のマイグレーション<= 10 minutesスキーマの変更はバックグラウンドマイグレーションで起こってはならないので、有効な例外です。
バックグラウンドマイグレーション> 10 minutes 1 second これらは大規模なテーブルに適しているため、正確なタイミングガイドラインを設定することはできませんが、コールドキャッシュを使用する場合、どのクエリも実行時間 を下回る必要があります。

対象とするデータベースの決定

GitLab は2つの異なる Postgres データベースに接続します:mainci 。この分割はマイグレーションに影響を与える可能性があります。

追加するマイグレーションがこのことを考慮すべきなのか、あるいはどのように考慮すべきなのかを理解するには、複数データベースのマイグレーションを読んでください。

通常のスキーママイグレーションの作成

マイグレーションを作成するには、以下のRailsジェネレータを使用できます:

bundle exec rails g migration migration_name_here

db/migrate でマイグレーションファイルを生成します。

新しいモデルを追加する通常のスキーママイグレーション

新しいモデルを作成するには、次のRailsジェネレータを使います:

bundle exec rails g model model_name_here

これで生成されます:

  • のマイグレーションファイルが生成されます。db/migrate
  • のモデルファイルapp/models
  • スペックファイルspec/models

スキーマの変更

スキーマへの変更はdb/structure.sql にコミットする必要があります。 このファイルはbundle exec rails db:migrate を実行すると Rails によって自動的に生成されるので、通常はこのファイルを手で編集すべきではありません。マイグレーションでテーブルにカラムを追加する場合、そのカラムは一番下に追加されます。Railsが生成したdb/structure.sql を使っている他の人を混乱させることになるので、既存のテーブルのカラムを手動で並べ替えないでください。

note
インデックスを非同期に作成するには2回のマージリクエストが必要です。完了したら、インデックスを追加するマージリクエストのスキーマ変更をadd_concurrent_index でコミットしてください。

GDK の内部データベースがmain のスキーマと異なっている場合、スキーマの変更を Git にきれいにコミットするのは難しいでしょう。そのような場合は、scripts/regenerate-schema スクリプトを使って、追加するマイグレーション用のdb/structure.sql をきれいに再生成することができます。このスクリプトはdb/migratedb/post_migrateで見つかったマイグレーションをすべて適用します。スキーマにコミットしたくないマイグレーションがある場合は、名前を変更するか削除してください。ブランチがデフォルトの Git ブランチをターゲットにしていない場合は、環境変数TARGET を設定します。

# Regenerate schema against `main`
scripts/regenerate-schema

# Regenerate schema against `12-9-stable-ee`
TARGET=12-9-stable-ee scripts/regenerate-schema

scripts/regenerate-schema スクリプトは、さらなる差分を作成する可能性があります。このような場合は、<migration ID> をマイグレーションファイルのDATETIME の部分とする手動手順を使用してください。

# Rebase against master
git rebase master

# Rollback changes
VERSION=<migration ID> bundle exec rails db:rollback:main

# Checkout db/structure.sql from master
git checkout origin/master db/structure.sql

# Migrate changes
VERSION=<migration ID> bundle exec rails db:migrate:main

テーブルが作成されたら、データベース辞書ガイドに記載されている手順に従って、そのテーブルをデータベース辞書に追加する必要があります。

ダウンタイムの回避

マイグレーションにおけるダウンタイムの回避」というドキュメントでは、以下のような様々なデータベースオペレーションが規定されています:

へのプライマリキーの移行とインデックスの追加、テーブルの追加と削除、外部キーの移行を、ダウンタイムなしで実行する方法を説明します。

可逆性

マイグレーションは可逆的でなければなりません。脆弱性やバグがあった場合にダウングレードが可能でなければならないからです。

Note: GitLabの本番環境では、問題が発生した場合、db:rollback を使ってマイグレーションをロールバックする代わりにロールフォワード戦略を使います。セルフマネージドインスタンスでは、アップグレードプロセスが始まる前に作成されたバックアップを復元するようユーザーに助言します。down の方法は、主に開発環境で使われます。例えば、開発者がコミットやブランチを切り替えるときに、ローカルのstructure.sql ファイルとデータベースのコピーが一貫した状態にあることを確認したい場合などです。

マイグレーションに、マイグレーションの可逆性がどのようにテストされたかを記述したコメントを追加してください。

一部のマイグレーションは元に戻せません。たとえば、マイグレーション前のデータベースの状態に関する情報が失われるため、データマイグレーションを元に戻せないものがあります。マイグレーション中に実行された変更が元に戻せなくても、マイグレーション自体は元に戻せるように、up メソッドで実行された変更が元に戻せない理由を説明するコメント付きのdown メソッドを作成する必要があります:

def down
  # no-op

  # comment explaining why changes performed by `up` cannot be reversed.
end

このようなマイグレーションは本質的にリスクが高く、レビューのためにマイグレーションを準備する際には追加のアクションが必要です。

原子性とトランザクション

デフォルトでは、マイグレーションは単一のトランザクションです。トランザクションはマイグレーションの開始時にオープンされ、すべてのステップが処理された後にコミットされます。

マイグレーションを単一のトランザクションで実行することで、ステップのひとつが失敗しても、どのステップも実行されず、データベースは有効な状態に保たれます。そのため、次のいずれかを実行します:

  • すべてのマイグレーションを1つの単一トランザクションマイグレーションにまとめます。
  • 必要であれば、ほとんどのアクションを1つのマイグレーションにまとめ、1つのトランザクションで実行できないステップについては別のマイグレーションを作成します。

例えば、空のテーブルを作成し、インデックスを作成する必要がある場合、通常のシングルトランザクションマイグレーションとデフォルトのRailsスキーマステートメントを使用する必要があります:add_index。このオペレーションはブロッキングオペレーションですが、テーブルがまだ使用されていないため、レコードがないので問題は発生しません。

note
サブトランザクションは一般的に禁止されています。1つのトランザクションでの重いオペレーションで説明されているように、必要であれば複数の別々のトランザクションを使用してください。

単一トランザクションでの重いオペレーション

単一トランザクションによるマイグレーションを使用する場合、トランザクションはマイグレーション期間中データベース接続を保持します。一般的に、トランザクションは迅速に実行されなければなりません。そのため、マイグレーションで実行される各クエリの最大クエリ時間制限に注意してください。

単一トランザクションのマイグレーションに時間がかかる場合、いくつかの選択肢があります。いずれの場合も、マイグレーションにかかる時間に応じて適切なマイグレーション・タイプを選択することを忘れないでください。

  • マイグレーションを複数の単一トランザクションマイグレーションに分割します。

  • disable_ddl_transaction!](#disable-transaction-wrapped-migration)を使用して[で複数のトランザクションを使用します。

  • ステートメントとロックのタイムアウト設定を調整した後、単一トランザクションのマイグレーションを使用してください。重いワークロードがトランザクションの保証を使用する必要がある場合は、マイグレーションがタイムアウト制限に達することなく実行できることを確認する必要があります。同じアドバイスが、シングルトランザクションマイグレーションと個々のトランザクションの両方に適用されます。

    • ステートメントのタイムアウト: GitLab.comの本番データベースでは、ステートメントのタイムアウトは15s に設定されていますが、インデックスの作成には15秒以上かかることがよくあります。add_concurrent_index を含む既存のヘルパーを使用すると、必要に応じてステートメントタイムアウトを自動的にオフにします。まれに、 disable_statement_timeoutを使ってタイムアウトの制限を自分で設定する必要があるかもしれません。
    • ロックタイムアウト: マイグレーションがトランザクションとして実行されなければならないが、ロック取得中にタイムアウトする可能性がある場合、 enable_lock_retries!を使用してください。
note
マイグレーションを実行するために、statement_timeoutlock_wait_timeout のような設定を制御するPgBouncerをバイパスして、プライマリデータベースに直接接続します。

ステートメントのタイムアウト制限を一時的にオフにします。

マイグレーションヘルパーdisable_statement_timeout では、ステートメントタイムアウトを一時的にトランザクションごとまたは接続ごとに0 に設定することができます。

  • CREATE INDEX CONCURRENTLY のように、ステートメントが明示的なトランザクション内部での実行をサポートしていない場合、接続ごとのオプションを使用します。

  • ALTER TABLE ... VALIDATE CONSTRAINT のように、ステートメントが明示的トランザクション・ブロックをサポートしている場合は、per-transaction オプションを使用する必要があります。

ほとんどのマイグレーションヘルパーは、必要なときにすでに内部で使用しているため、disable_statement_timeout を使用する必要はほとんどありません。たとえば、インデックスの作成には通常15秒以上かかります。これはGitLab.comの本番データベースで設定されているデフォルトのステートメントタイムアウトです。ヘルパーadd_concurrent_indexdisable_statement_timeout に渡されたブロックの内部でインデックスを作成し、接続ごとのステートメントタイムアウトを無効にします。

マイグレーションで生の SQL 文を書く場合は、手動でdisable_statement_timeoutを使う必要があるかもしれません。その際はデータベースのレビュアーやメンテナーに相談してください。

トランザクションラップされたマイグレーションを無効にします。

disable_ddl_transaction!ActiveRecordのメソッドで disable_ddl_transaction!、マイグレーションを単一のトランザクションとして実行しないようにすることができます。disable_ddl_transaction!このメソッドは他のデータベースシステムでも呼び出せるかもしれませんが、結果は異なります。GitLab ではもっぱら PostgreSQL を使っています。 disable_ddl_transaction!という意味でdisable_ddl_transaction!読んで disable_ddl_transaction!ください:

「このマイグレーションを単一のPostgreSQLトランザクションで実行しないでください。PostgreSQLのトランザクションは、必要な_ときに_ _必要_なだけ開きます。”

note
明示的なPostgreSQLトランザクション.transaction (もしくはBEGIN; COMMIT;)を使用しなくても、すべてのSQL文はトランザクションとして実行されます。トランザクションに関するPostgreSQLのドキュメントを参照してください。
note
GitLab では、disable_ddl_transaction! を使ったマイグレーションを非トランザクションマイグレーションと呼ぶことがあります。これは、マイグレーションが_単一の_トランザクションとして実行されないことを意味します。

どのような場合にdisable_ddl_transaction! を使うべきですか?ほとんどの場合、既存の RuboCop ルールやマイグレーションヘルパーが、disable_ddl_transaction!. disable_ddl_transaction!NET を使うべきかどうかを検出できます。マイグレーションで使用すべきかどうかわからない場合はdisable_ddl_transaction!スキップ disable_ddl_transaction!し、RuboCop のルールとデータベースのレビューに任せましょう。

PostgreSQLが明示的なトランザクションの外部でオペレーションを実行する必要がある場合、disable_ddl_transaction!

  • このようなオペレーションの最も顕著な例はCREATE INDEX CONCURRENTLY. CREATE INDEX CONCURRENTLYNETコマンドです。CREATE INDEX CONCURRENTLYPostgreSQLでは、CREATE INDEXトランザクション内で CREATE INDEXブロック版()を実行することができます。CREATE INDEXとは異なり CREATE INDEX CREATE INDEX CONCURRENTLY、()はトランザクション外で実行CREATE INDEX CONCURRENTLYしなければ CREATE INDEX CONCURRENTLYなりません。そのため、マイグレーションがたった1つの文CREATE INDEX CONCURRENTLYを実行するだけであっても、disable_ddl_transaction!を無効にすべきです。これは、ヘルパーadd_concurrent_index requiresdisable_ddl_transaction! CREATE INDEX CONCURRENTLY の使用が規則よりも例外に近い理由でもあります。

何らかの理由でマイグレーションで複数のトランザクションを実行する必要がある場合は、disable_ddl_transaction! を使用してください。ほとんどの場合、1つの遅いトランザクションの実行を避けるために複数のトランザクションを使用することになるでしょう。

  • 例えば、(DML) 大量のデータを挿入、更新、削除する場合は、バッチで実行する必要があります。バッチごとにオペレーションをグループ化する必要がある場合は、バッチ処理時に明示的にトランザクションブロックをオープンすることができます。それなりに大きなワークロードに対しては、バッチバックグランドマイグレーションを使用することを検討してください。

マイグレーションヘルパーが必要とする場合は、disable_ddl_transaction! 。さまざまなマイグレーションヘルパーは、いつ、どのようにトランザクションをオープンするかを正確に制御する必要があるため、disable_ddl_transaction!

  • 外部キーは、CREATE INDEX CONCURRENTLY.N CREATE INDEX CONCURRENTLYETとは異なり、トランザクションの内部で追加する_ことが_できます。CREATE INDEX CONCURRENTLYしかし、PostgreSQLは.NETのようなオプションを提供して CREATE INDEX CONCURRENTLYいません。 ヘルパーadd_concurrent_foreign_key は、外部キーの追加と検証の間、ロックを最小限にする方法でソーステーブルとターゲットテーブルをロックするために、代わりに独自のトランザクションをオープンします。
  • 先にアドバイスしたように、自信がない場合はdisable_ddl_transaction! をスキップし、RuboCopチェックに違反しないか確認してください。

マイグレーションが実際にはPostgreSQLデータベースに触れないか、_複数の_PostgreSQLデータベースに触れる場合は、disable_ddl_transaction!

  • 例えば、マイグレーションはRedisサーバを対象とするかもしれません。原則として、PostgreSQLトランザクション内で外部サービスとやりとりすることはできません。
  • トランザクションは単一のデータベース接続に使用されます。cimain データベースなど、複数のデータベースをマイグレーション対象とする場合は、Migrations for multiple databasesに従ってください。

命名規則

データベースオブジェクト(テーブル、インデックス、ビューなど)の名前は小文字にする必要があります。小文字にすることで、引用符で囲まれていない名前でのクエリがエラーにならないようにします。

カラム名はActiveRecordのスキーマ規約と一致させてください。

カスタムのインデックス名と制約名は、制約命名規約のガイドラインに従ってください。

長いインデックス名の切り捨て

PostgreSQLは列名やインデックス名のような識別子の長さを制限しています。列名は通常問題ありませんが、インデックス名は長くなりがちです。長すぎる名前を短くするいくつかの方法があります:

  • index_ の代わりにi_ を先頭につけます。
  • 冗長な接頭辞は省略。例えば、index_vulnerability_findings_remediations_on_vulnerability_remediation_idindex_vulnerability_findings_remediations_on_remediation_id になります。
  • 列の代わりに、index_users_for_unconfirmation_notification のようにインデックスの目的を指定します。

マイグレーションタイムスタンプ年齢

マイグレーションファイル名のタイムスタンプ部分はマイグレーションの実行順序を決定します。大まかな相関を保つことが重要です:

  1. マイグレーションが GitLab コードベースに追加されたとき。
  2. マイグレーション自体のタイムスタンプ。

新しいマイグレーションのタイムスタンプは、前のハードストップより前であってはなりません。マイグレーションは時々つぶされ、タイムスタンプが前のハードストップより前になるマイグレーションが追加された場合、イシュー408304で起きたような問題が発生する可能性があります。

例えば、現在 GitLab 16.0 に対して開発している場合、以前のハードストップは 15.11 です。 15.11は2023年4月23日にリリースされました。したがって、許容できる最小のタイムスタンプは 20230424000000 となります。

ベストプラクティス

上記は厳密なルールと考えるべきですが、マイグレーションのタイムスタンプは、前回のハードストップからの経過時間にかかわらず、マイグレーションがアップストリームにマージされると予想される日から3週間以内に保つようにするのがベストプラクティスです。

マイグレーションのタイムスタンプを更新するには

  1. cimain DB のマイグレーションをダウンします:

    rake db:migrate:down:main VERSION=<timestamp>
    rake db:migrate:down:ci VERSION=<timestamp>
    
  2. マイグレーションファイルを削除します。
  3. マイグレーションスタイルガイドに従ってマイグレーションを再作成します。

マイグレーション・ヘルパーとバージョニング

GitLab 14.3で導入されました

データベースマイグレーションにおける多くの一般的なパターンに対して、様々なヘルパーメソッドが用意されています。これらのヘルパーはGitlab::Database::MigrationHelpers や関連モジュールにあります。

時間とともにヘルパーのふるまいを変更できるようにするために、マイグレーションヘルパーのバージョン管理スキームを実装します。これにより、すでに存在するマイグレーションに対してはヘルパーのふるまいをメンテナーに維持し、新しいマイグレーションに対してはふるまいを変更することができます。

この目的のために、すべてのデータベースマイグレーションはGitlab::Database::Migration を継承しなければなりません。新しいマイグレーションでは、マイグレーションヘルパーの最新バージョンを使うために、最新バージョンを使うべきです (Gitlab::Database::Migration::MIGRATION_CLASSES で調べることができます)。

この例では、マイグレーションクラスのバージョン2.1を使います:

class TestMigration < Gitlab::Database::Migration[2.1]
  def change
  end
end

マイグレーションにGitlab::Database::MigrationHelpers を直接含めないでください。代わりに、マイグレーションヘルパーの最新バージョンを自動的に公開する最新バージョンのGitlab::Database::Migration

マイグレーションヘルパーとバージョニングは GitLab 14.3 で導入されました。以前の安定ブランチを対象としたマージリクエストには、古い形式を使用し、Gitlab::Database::Migration[2.1]の代わりにActiveRecord::Migration[6.1] を継承してください。

データベースロック取得時のリトライメカニズム

データベース・スキーマを変更する際には、DDL (Data Definition Language) ステートメントを呼び出すヘルパー・メソッドを使用します。場合によっては、これらの DDL 文は特定のデータベース・ロックを必要とします。

使用例:

def change
  remove_column :users, :full_name, :string
end

このマイグレーションを実行するには、users テーブルに対する users排他ロックが必要です。users テーブルが他のプロセスから同時にアクセスされ変更される場合、ロックの取得に時間がかかることがあります。ロック要求はキューで待機しており users、いったんキューに入れられるとusers テーブルに対する他のクエリをブロックする可能性が usersあります。

PostgreSQLのロックについての詳細はこちらを参照してください:明示的ロック

安定性の理由から、GitLab.comではstatement_timeout 。マイグレーションが実行されると、どのデータベースクエリも実行にかかる時間は決まっています。最悪のシナリオでは、リクエストはロックキューに置かれ、設定されたステートメントのタイムアウトの間、他のクエリをブロックし、canceling statement due to statement timeout エラーで失敗します。

この問題は、アプリケーションのアップグレード処理に失敗したり、テーブルが短時間アクセスできなくなる可能性があるため、アプリケーションの安定性に問題が発生したりする可能性があります。

データベースマイグレーションの信頼性と安定性を高めるために、GitLabコードベースでは、lock_timeout の設定と試行間の待機時間を変えてオペレーションを再試行する方法を提供しています。必要なロックを取得するための複数の短い試行により、データベースは他のステートメントを処理することができます。

ロックの再試行には2つの方法があります:

  1. トランザクションマイグレーション内部:enable_lock_retries! を使用します。
  2. 非トランザクションマイグレーション内部:with_lock_retries を使用してください。

可能であれば、トラフィックの多いテーブルに触れるマイグレーションではロック・リトライを有効にしてください。

トランザクションマイグレーションでの使用法

通常のマイグレーションはトランザクション内で完全なマイグレーションを実行します。マイグレーションレベルでenable_lock_retries! を呼び出すことで、ロックリトライ手法を有効にすることができます。

これにより、このマイグレーションではロックのタイムアウトが制御されます。また、タイムアウト内にロックが許可されなかった場合、マイグレーション全体を再試行することができます。

これは現在オプトインの設定ですが、私たちはすべてのマイグレーションでロック再試行を使用することを希望しており、将来的にはこれをデフォルトにする予定です。

時折、マイグレーションは異なるオブジェクトに対して複数のロックを取得する必要があるかもしれません。カタログの肥大化を防ぐには、DDLを実行する前に明示的にすべてのロックを要求します。より良い戦略は、マイグレーションを分割して、一度に1つのロックしか取得しないようにすることです。

カラムの削除

enable_lock_retries!

def change
  remove_column :users, :full_name, :string
end

同じテーブルに対する複数の変更

ロック・リトライ手法を有効にすると、すべてのオペレーションは1つのトランザクションにまとまります。ロックを取得したら、後で別のロックを取得しようとするのではなく、トランザクションの中でできる限りのことを行うべきです。ブロック内で長いデータベース文を実行することには注意してください。取得したロックはトランザクション(ブロック)が終了するまで保持され、ロックの種類によっては他のデータベースオペレーションをブロックする可能性があります。

enable_lock_retries!

def up
  add_column :users, :full_name, :string
  add_column :users, :bio, :string
end

def down
  remove_column :users, :full_name
  remove_column :users, :bio
end

外部キーの削除

enable_lock_retries!

def up
  remove_foreign_key :issues, :projects
end

def down
  add_foreign_key :issues, :projects
end

カラムのデフォルト値の変更

カラムのデフォルト値を変更すると、複数のリリースプロセスに従わない場合、アプリケーションのダウンタイムが発生する可能性があることに注意してください。詳細については、列のデフォルト値を変更するマイグレーションにおけるダウンタイムの回避を参照してください。

enable_lock_retries!

def up
  change_column_default :merge_requests, :lock_version, from: nil, to: 0
end

def down
  change_column_default :merge_requests, :lock_version, from: 0, to: nil
end

外部キーを持つ新しいテーブルの作成

create_table メソッドをwith_lock_retries でラップします:

enable_lock_retries!

def up
  create_table :issues do |t|
    t.references :project, index: true, null: false, foreign_key: { on_delete: :cascade }
    t.string :title, limit: 255
  end
end

def down
  drop_table :issues
end

2つの外部キーがある場合の新しいテーブルの作成

1つのトランザクションで作成する外部キーは1つだけにしてください。これは、、外部キー制約の追加には、参照されるテーブルに対するSHARE ROW EXCLUSIVE ロックが必要であり、同じトランザクションで複数のテーブルをロックすることは避ける必要があるためです。

そのためには、3つのマイグレーションが必要です:

  1. 外部キーなし(インデックス付き)のテーブルの作成。
  2. 最初のテーブルに外部キーを追加します。
  3. 2番目のテーブルに外部キーを追加します。

テーブルの作成

def up
  create_table :imports do |t|
    t.bigint :project_id, null: false
    t.bigint :user_id, null: false
    t.string :jid, limit: 255

    t.index :project_id
    t.index :user_id
  end
end

def down
  drop_table :imports
end

projects への外部キーの追加 :

このヘルパーメソッドにはロックの再試行が組み込まれているので、この場合はadd_concurrent_foreign_key メソッドを使うことができます。

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :project_id
  end
end

users への外部キーの追加 :

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :users, column: :user_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :user_id
  end
end

非トランザクションマイグレーションでの使用 (disable_ddl_transaction!)

disable_ddl_transaction! を使ってトランザクションマイグレーションを無効にした場合のみ、with_lock_retries ヘルパーを使って個々の一連のステップをガードすることができます。これは、与えられたブロックを実行するためにトランザクションをオープンします。

カスタム RuboCop ルールは、許可されたメソッドのみがロック再試行ブロック内に配置できるようにします。

disable_ddl_transaction!

def up
  with_lock_retries do
    add_column :users, :name, :text unless column_exists?(:users, :name)
  end

  add_text_limit :users, :name, 255 # Includes constraint validation (full table scan)
end

RuboCopルールは一般に、以下に示す標準的なRailsマイグレーションメソッドを許可します。この例ではRuboCop違反が発生します:

disable_ddl_transaction!

def up
  with_lock_retries do
    add_concurrent_index :users, :name
  end
end

ヘルパーメソッドを使うタイミング

with_lock_retries ヘルパーメソッドを使えるのは、実行がまだオープントランザクション内部でない場合だけです (PostgreSQL サブトランザクションの使用は推奨されません)。標準的なRailsマイグレーションヘルパーメソッドと一緒に使うことができます。同じテーブルに対して実行するのであれば、複数のマイグレーションヘルパーを呼び出しても問題ありません。

with_lock_retries ヘルパーメソッドを使うことをお勧めするのは、データベースのマイグレーションがトラフィックの多いテーブルの1つを含む場合です。

変更例

  • add_foreign_key /remove_foreign_key
  • add_column /remove_column
  • change_column_default
  • create_table /drop_table

change メソッド内部でwith_lock_retries メソッドを使用することはできません。マイグレーションをリバーシブルにするには、手動でup メソッドとdown メソッドを定義する必要があります。

ヘルパーメソッドの仕組み

  1. 50回繰り返します。
  2. 各反復ごとに、事前に設定したlock_timeout を設定します。
  3. 与えられたブロックの実行を試みます。(remove_column).
  4. LockWaitTimeout エラーが発生した場合、事前に設定されたsleep_time の間スリープし、ブロックを再試行します。
  5. エラーが発生しなかった場合、現在のイテレーションでブロックが正常に実行されたことになります。

詳細はGitlab::Database::WithLockRetries クラスを参照してください。with_lock_retries ヘルパーメソッドはGitlab::Database::MigrationHelpers モジュールに実装されています。

最悪の場合、このメソッドは

  • 40分間に最大50回ブロックを実行します。
    • ほとんどの時間は、各反復の後にあらかじめ設定されたスリープ期間に費やされます。
  • 50回目の再試行の後、ブロックはlock_timeout なしで実行され、標準的なマイグレーション呼び出しと同じようになります。
  • ロックを取得できない場合、マイグレーションはstatement timeout エラーで失敗します。

users テーブルにアクセスするトランザクションが非常に長い時間 (40 分以上) 実行されている場合、マイグレーションが失敗する可能性があります。

SQLレベルでのロック・リトライ手法

このセクションでは、lock_timeout の使用を示す簡略化したSQLの例を提供します。与えられたスニペットを複数のpsql セッションで実行することで、一緒に追うことができます。

カラムを追加するためにテーブルを変更する場合、AccessExclusiveLockほとんどのロック・タイプと競合する , がテーブルに必要 AccessExclusiveLockです。AccessExclusiveLockターゲットテーブルが非常にビジーなものである場合、カラムを追加するトランザクションが AccessExclusiveLockタイムリーにAccessExclusiveLock取得できない可能性が AccessExclusiveLockあります。

あるトランザクションがテーブルに行を挿入しようとしているとします:

-- Transaction 1
BEGIN;
INSERT INTO my_notes (id) VALUES (1);

この時点でトランザクション1はRowExclusiveLock my_notes。トランザクション1は、コミットまたはアボートする前に、さらにステートメントを実行することができます。. my_notesテーブルへの行挿入を試みるトランザクションがあるとします。

トランザクションマイグレーションが、ロック再試行ヘルパーを使用せずにテーブルに列を追加しようとしているとします:

-- Transaction 2
BEGIN;
ALTER TABLE my_notes ADD COLUMN title text;

トランザクション 1 がまだ実行中でmy_notes 上のRowExclusiveLock を保持しているため、トランザクション 2 はmy_notes テーブル上のAccessExclusiveLock を取得できず、ブロックされました。

さらに悪質な影響として、トランザクション2がAccessExclusiveLock を取得するためにキューに入っているため、通常はトランザクショ ン1と競合しないはずのトランザクションがブロックされています。通常であれば、別のトランザクションがトランザクション1と同時に同じテーブルmy_notes から読み取りと書き込みを行おうとした場合、読み取りと書き込みに必要なロックはトランザクション1が保持しているRowExclusiveLock と競合しないため、トランザクションは通過します。しかし、AccessExclusiveLock の取得要求がキューに入れられると、トランザクション1と同時に実行できるにもかかわらず、テーブル上の競合するロックに対する後続の要求がブロックされることになります。

with_lock_retries を使用した場合、トランザクション 2 は指定された時間内にロックを取得できなかった後、すぐにタイムアウトし、他のトランザクションが処理を続行できるようになります:

-- Transaction 2 (version with with lock timeout)
BEGIN;
SET LOCAL lock_timeout to '100ms'; -- added by the lock retry helper.
ALTER TABLE my_notes ADD COLUMN title text;

ロック再試行ヘルパーは、成功するまで同じトランザクションを異なる時間間隔で繰り返し試行します。

SET LOCAL はパラメータ (lock_timeout) の変更をトランザクションにスコープすることに注意してください。

インデックスの削除

インデックスを削除する際にテーブルが空でない場合は、通常のremove_index メソッドではなく、remove_concurrent_index メソッドを必ず使用してください。remove_concurrent_index メソッドは同時にインデックスを削除するので、ロックは必要なく、ダウンタイムも発生しません。このメソッドを使用するには、マイグレーション・クラスの内部でdisable_ddl_transaction! メソッドを呼び出してシングル・トランザクション・モードを無効にする必要があります:

class MyMigration < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  INDEX_NAME = 'index_name'

  def up
    remove_concurrent_index :table_name, :column_name, name: INDEX_NAME
  end
end

インデックスがThanosで使用されていないことを確認できます:

sum by (type)(rate(pg_stat_user_indexes_idx_scan{env="gprd", indexrelname="INSERT INDEX NAME HERE"}[30d]))

しかし、削除するインデックスの名前を指定する必要があります。これは、remove_index またはremove_concurrent_index の適切な形式にオプションとして名前を渡すか、remove_concurrent_index_by_name メソッドを使用することで可能です。名前を明示的に指定することは、正しいインデックスを確実に削除するために重要です。

小さなテーブル(空のテーブルやレコード数が1,000 未満のテーブルなど)では、remove_index をシングルトランザクションのマイグレーションで使用し、disable_ddl_transaction! を必要としない他のオペレーションと組み合わせることをお勧めします。

インデックスの無効化

インデックスを削除する前に、そのインデックスを無効にしたい場合があります。詳細はメンテナンス・オペレーション・ガイドを参照してください。

インデックスの追加

インデックスを追加する前に、インデックスが必要かどうかを検討します。データベースのインデックスの追加ガイドには、インデックスが必要かどうかを判断するための詳細と、インデックスを追加するためのベストプラクティスが記載されています。

インデックスの存在のテスト

マイグレーションでインデックスの有無に基づく条件ロジックが必要な場合は、その名前を使用してインデックスの存在をテストする必要があります。これにより、Railsがインデックス定義を比較する際の問題を回避することができます。

詳しくはデータベースインデックスの追加ガイドをレビューしてください。

外部キー制約の追加

既存のカラムまたは新しいカラムに外部キー制約を追加する場合は、カラムにインデックスを追加することも忘れないでください。

これはすべての外部キーに対して必要です。たとえば、効率的なカスケード削除をサポートするためです。テーブルの行が大量に削除されると、参照されているレコードも削除する必要があります。インデックスがない場合、これはテーブルのシーケンシャルスキャンとなり、長い時間がかかります。

外部キー制約を持つ新しいカラムを追加する例を示します。index: true 、インデックスを作成していることに注意してください。

class Migration < Gitlab::Database::Migration[2.1]

  def change
    add_reference :model, :other_model, index: true, foreign_key: { on_delete: :cascade }
  end
end

空でないテーブルの既存の列に外部キー制約を追加する場合、add_reference の代わりに、add_concurrent_foreign_keyadd_concurrent_index を使用しなければなりません。

トラフィックの多いテーブルを参照しない新しいテーブルや空のテーブルがある場合は、add_reference を単一トランザクションマイグレーションで使用することをお勧めします。disable_ddl_transaction! を必要としない他のオペレーションと組み合わせることができます。

既存の列に外部キー制約を追加する方法については、こちらを参照してください。

NOT NULL 制約

GitLab 13.0から導入されました

詳しくはNOT NULL constraints のスタイルガイドをご覧ください。

デフォルト値を持つカラムの追加

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

PostgreSQL 11以前では、デフォルト値を持つカラムを追加することは、テーブルの全面的な書き換えを引き起こすという問題がありました。

NULLにできない列のデフォルトの削除

NULLでないカラムを追加し、既存のデータを入力するためにデフォルト値を利用した場合、少なくともアプリケーションコードが更新されるまでデフォルト値を維持する必要があります。モデルコードが更新される前にマイグレーションが実行され、モデルは古いスキーマキャッシュを持つので、このカラムのことを知らず、設定できないからです。この場合

  1. 標準マイグレーションでカラムをデフォルト値で追加します。
  2. デプロイ後のマイグレーションではデフォルトを削除します。

デプロイ後のマイグレーションはアプリケーションの再起動後に行われ、新しいカラムが検出されたことを確認します。

カラムのデフォルトの変更

change_column_default 、デフォルトのカラムを変更することは、大きなテーブルでは高価で破壊的なオペレーションだと思われるかもしれませんが、実際にはそうではありません。

次のマイグレーションを例にとってみましょう:

class DefaultRequestAccessGroups < Gitlab::Database::Migration[2.1]
  def change
    change_column_default(:namespaces, :request_access_enabled, from: false, to: true)
  end
end

上記のマイグレーションは、最大のテーブルの1つのデフォルトのカラム値を変更します:namespaces 。これは次のように変換できます:

ALTER TABLE namespaces
ALTER COLUMN request_access_enabled
SET DEFAULT false

この特定のケースでは、デフォルト値は存在し、request_access_enabled カラムのメタデータを変更するだけで、namespaces テーブルのすべての既存レコードの書き換えを意味するものではありません。デフォルト値を持つ新しいカラムを作成する場合のみ、すべてのレコードが書き換えられます。

note
PostgreSQL 11.0では、より高速なALTER TABLE ADD COLUMN with non-null defaultが導入され、デフォルト値を持つ新しい列が追加された時にテーブルを書き換える必要がなくなりました。

上述の理由から、disable_ddl_transaction! を必要としないシングルトランザクションのマイグレーションでは、change_column_default を使用するのが安全です。

既存のカラムの更新

既存のカラムを特定の値に更新するには、update_column_in_batches を使用します。これは更新をバッチに分割するので、一度のステートメントで多くの行を更新することはありません。

これは、projects テーブルの列foo を 10 に更新します。some_column'hello'です:

update_column_in_batches(:projects, :foo, 10) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

計算による更新が必要な場合、値はArel.sql でラップすることができるので、ArelはこれをSQLリテラルとして扱います。これはRails 6の必須非推奨でもあります。

以下の例は上の例と同じですが、値はbarbaz 列の積に設定されています:

update_value = Arel.sql('bar * baz')

update_column_in_batches(:projects, :foo, update_value) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

update_column_in_batches の場合は、テーブルの行の一部だけを更新するのであれば、大きなテーブルで実行してもよいかもしれません。しかし、GitLab.com のステージング環境で検証することなく、あるいは誰かにお願いすることなく、それを無視しないでください。

外部キー制約の削除

外部キー制約を削除する場合、外部キーに関連する両方のテーブルのロックを取得する必要があります。書き込みパターンが多いテーブルでは、with_lock_retries を使用することをお勧めします。また、通常アプリケーションはparent,child の順番で書き込みを行うため、ロック取得時にデッドロックが発生する可能性があります。しかし、外部キーを削除すると、child,parent の順番でロックを取得します。これを解決するには、例えば、parent,childで明示的にロックを取得します:

disable_ddl_transaction!

def up
  with_lock_retries do
    execute('lock table ci_pipelines, ci_builds in access exclusive mode')

    remove_foreign_key :ci_builds, to_table: :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
  end
end

def down
  add_concurrent_foreign_key :ci_builds, :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
end

データベース・テーブルの削除

データベーステーブルを削除することは珍しく、Railsが提供するdrop_table の方法が一般的に安全と考えられています。テーブルを削除する前に、以下を検討してください:

テーブルがトラフィックの多いテーブルの外部キーを持っている場合(projects など)、DROP TABLE ステートメントはステートメントタイムアウトエラーで失敗するまで同時トラフィックを滞らせる可能性があります。

テーブルにはレコードがなく(機能は使用されていません)、外部キーもありません:

  • マイグレーションにはdrop_table メソッドを使用してください。
def change
  drop_table :my_table
end

テーブルにはレコードがありますが、外部キーがありません:

  • モデル、コントローラ、サービスなど、テーブルに関連するアプリケーションコードを削除します。
  • デプロイ後のマイグレーションでは、drop_table

コードが使用されないことが確実であれば、これはすべて1つのマイグレーションで行うことができます。リスクを少し減らしたいのであれば、アプリケーションの変更がマージされた後に、マイグレーションを2回目のマージリクエストに入れることを検討してください。このアプローチはロールバックの機会を提供します。

def up
  drop_table :my_table
end

def down
  # create_table ...
end

テーブルには外部キーがあります:

  • モデル、コントローラ、サービスなど、テーブルに関連するアプリケーションコードを削除します。
  • デプロイ後のマイグレーションでは、with_lock_retries ヘルパーメソッドを使用して外部キーを削除します。デプロイ後の別のマイグレーションでは、drop_table を使用します。

コードが使用されないことが確実であれば、これはすべて1つのマイグレーションで行うことができます。リスクを少し減らしたいのであれば、アプリケーションの変更がマージされた後に、マイグレーションを2回目のマージリクエストに入れることを検討してください。このアプローチはロールバックの機会を提供します。

非トランザクションマイグレーションを使用してprojects テーブルの外部キーを削除します:

# first migration file
class RemovingForeignKeyMigrationClass < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    with_lock_retries do
      remove_foreign_key :my_table, :projects
    end
  end

  def down
    add_concurrent_foreign_key :my_table, :projects, column: COLUMN_NAME
  end
end

テーブルの削除:

# second migration file
class DroppingTableMigrationClass < Gitlab::Database::Migration[2.1]
  def up
    drop_table :my_table
  end

  def down
    # create_table with the same schema but without the removed foreign key ...
  end
end

テーブルが削除されたら、データベース辞書ガイドの手順に従って、そのテーブルをデータベース辞書に追加します。

シーケンスの削除

GitLab 15.1で導入されました

シーケンスの削除は一般的ではありませんが、データベースチームが提供するdrop_sequence メソッドを使うことができます。

その仕組みは次のようなものです:

シーケンスを削除します:

  • シーケンスが実際に使用されている場合、デフォルト値を削除します。
  • 実行DROP SEQUENCE

シーケンスを再追加します:

  • 現在の値を指定してシーケンスを作成します。
  • 列のデフォルト値を変更します。

Railsマイグレーションの例:

class DropSequenceTest < Gitlab::Database::Migration[2.1]
  def up
    drop_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq)
  end

  def down
    default_value = Ci::Pipeline.maximum(:id) + 10_000

    add_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq, default_value)
  end
end
note
add_sequence は外部キーを持つカラムには使用しないでください。これらのカラムにシーケンスを追加できるのは、downメソッド(以前のスキーマ状態を復元する)のみです。

テーブルの切り捨て

GitLab 15.11 で導入

テーブルの切り捨ては一般的ではありませんが、データベースチームが提供するtruncate_tables! メソッドを使うことができます。

その仕組みは次のようなものです:

  • 切り捨てられるテーブルのgitlab_schema を検索します。
  • テーブルのgitlab_schema が接続のgitlab_schemaに含まれていれば、TRUNCATE 文を実行します。
  • gitlab_schema for the gitlab_schematablesが接続のsに含まれていなければ、何もしません。

主キーの入れ替え

GitLab 15.5 で導入されました

パーティション・キーがプライマリ・キーに含まれている必要があるため、プライマリ・キーを入れ替える必要があります。

データベースチームが提供するswap_primary_key

その仕組みは次のようなものです:

  • 主キー制約を削除します。
  • 事前に定義したインデックスを使用して主キーを追加します。
class SwapPrimaryKey < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  TABLE_NAME = :table_name
  PRIMARY_KEY = :table_name_pkey
  OLD_INDEX_NAME = :old_index_name
  NEW_INDEX_NAME = :new_index_name

  def up
    swap_primary_key(TABLE_NAME, PRIMARY_KEY, NEW_INDEX_NAME)
  end

  def down
    add_concurrent_index(TABLE_NAME, :id, unique: true, name: OLD_INDEX_NAME)
    add_concurrent_index(TABLE_NAME, [:id, :partition_id], unique: true, name: NEW_INDEX_NAME)

    unswap_primary_key(TABLE_NAME, PRIMARY_KEY, OLD_INDEX_NAME)
  end
end
note
主キーを入れ替えるために、必ず別のマイグレーションで新しいインデックスを事前に導入してください。

整数カラム型

デフォルトでは、整数カラムは4バイト(32ビット)までの数値を保持できます。最大値は2,147,483,647です。ファイルサイズをバイト単位で保持するカラムを作成する場合は、この点に注意してください。ファイルサイズをバイト単位で追跡する場合、最大ファイルサイズは2GB強に制限されます。

整数カラムに8バイト(64ビット)までの数値を保持させるには、明示的に制限を8バイトに設定します。これにより、カラムは9,223,372,036,854,775,807 までの値を保持することができます。

Railsマイグレーションの例:

add_column(:projects, :foo, :integer, default: 10, limit: 8)

文字列とTextデータ型

GitLab 13.0から導入されました

詳しくはテキストデータ型のスタイルガイドをご覧ください。

タイムスタンプ列型

デフォルトでは、Railsはtimestamp タイムゾーン情報を含まないタイムスタンプデータを格納するデータ型を timestamp使用します。timestamp この timestampデータ型は、add_timestamps またはtimestamps メソッドを呼び出すことで使用されます。

また、Railsは:datetime データ型をtimestamp データ型に変換します。

使用例:

# timestamps
create_table :users do |t|
  t.timestamps
end

# add_timestamps
def up
  add_timestamps :users
end

# :datetime
def up
  add_column :users, :last_sign_in, :datetime
end

これらのメソッドを使用する代わりに、タイムゾーンを含むタイムスタンプを保存するには以下のメソッドを使用します:

  • add_timestamps_with_timezone
  • timestamps_with_timezone
  • datetime_with_timezone

これにより、すべてのタイムスタンプにタイムゾーンが指定されることになります。これにより、システムのタイムゾーンが変更されたときに、既存のタイムスタンプが突然異なるタイムゾーンを使用することがなくなります。また、最初にどのタイムゾーンが使用されたかも明確になります。

JSONのデータベースへの保存

Rails 5はネイティブでJSONB (binary JSON)カラムタイプをサポートしています。このカラムを追加するマイグレーション例:

class AddOptionsToBuildMetadata < Gitlab::Database::Migration[2.1]
  def change
    add_column :ci_builds_metadata, :config_options, :jsonb
  end
end

デフォルトではハッシュキーは文字列になります。オプションでカスタムデータ型を追加して、キーに異なるアクセスを提供することもできます。

class BuildMetadata
  attribute :config_options, :ind_jsonb # for indifferent accesss or :sym_jsonb if you need symbols only as keys.
end

JSONB カラムを使用する場合は、JsonSchemaValidatorを使用して、時間の経過とともに挿入されるデータを管理します。

class BuildMetadata
  validates :config_options, json_schema: { filename: 'build_metadata_config_option' }
end

暗号化された属性

GitLab 14.0 で導入されました

attr_encrypted 属性を:text としてデータベースに保存しないでください。代わりに:binary を使ってください。これはPostgreSQLのbytea 型を使用し、ストレージをより効率的にします:

class AddSecretToSomething < Gitlab::Database::Migration[2.1]
  def change
    add_column :something, :encrypted_secret, :binary
    add_column :something, :encrypted_secret_iv, :binary
  end
end

暗号化された属性をバイナリ列に格納する場合、attr_encryptedencode: falseencode_iv: false オプションを指定する必要があります:

class Something < ApplicationRecord
  attr_encrypted :secret,
    mode: :per_attribute_iv,
    key: Settings.attr_encrypted_db_key_base_32,
    algorithm: 'aes-256-gcm',
    encode: false,
    encode_iv: false
end

テスト

Railsマイグレーションのテストスタイルガイドを参照してください。

データマイグレーション

通常のActiveRecord構文よりも、ArelやプレーンSQLを優先してください。プレーンSQLを使用する場合は、quote_string ヘルパーを使用して手動ですべての入力を引用符で囲む必要があります。

Arelを使った例:

users = Arel::Table.new(:users)
users.group(users[:user_id]).having(users[:id].count.gt(5))

#update other tables with these results

プレーン SQL とquote_string ヘルパーを使った例:

select_all("SELECT name, COUNT(id) as cnt FROM tags GROUP BY name HAVING COUNT(id) > 1").each do |tag|
  tag_name = quote_string(tag["name"])
  duplicate_ids = select_all("SELECT id FROM tags WHERE name = '#{tag_name}'").map{|tag| tag["id"]}
  origin_tag_id = duplicate_ids.first
  duplicate_ids.delete origin_tag_id

  execute("UPDATE taggings SET tag_id = #{origin_tag_id} WHERE tag_id IN(#{duplicate_ids.join(",")})")
  execute("DELETE FROM tags WHERE id IN(#{duplicate_ids.join(",")})")
end

より複雑なロジックが必要な場合、マイグレーションにローカルなモデルを定義して使うことができます。たとえば

class MyMigration < Gitlab::Database::Migration[2.1]
  class Project < MigrationRecord
    self.table_name = 'projects'
  end

  def up
    # Reset the column information of all the models that update the database
    # to ensure the Active Record's knowledge of the table structure is current
    Project.reset_column_information

    # ... ...
  end
end

その際には、モデルのテーブル名を明示的に設定し、クラス名や名前空間から派生しないようにしてください。

マイグレーションでモデルを使用する際の制限に注意してください。

既存データの変更

ほとんどの場合、データベース内のデータを変更する場合は、一括でデータをマイグレーションすることをお勧めします。

新しいヘルパーeach_batch_range を導入し、コレクションを繰り返し処理する際のパフォーマンスを向上させました。バッチのデフォルトサイズはBATCH_SIZE 定数で定義されています。

次の例を見てください。

バッチ内のデータのパージ

include ::Gitlab::Database::DynamicModelHelpers

disable_ddl_transaction!

def up
  each_batch_range('ci_pending_builds', scope: ->(table) { table.ref_protected }, of: BATCH_SIZE) do |min, max|
    execute <<~SQL
      DELETE FROM ci_pending_builds
        USING ci_builds
        WHERE ci_builds.id = ci_pending_builds.build_id
          AND ci_builds.status != 'pending'
          AND ci_builds.type = 'Ci::Build'
          AND ci_pending_builds.id BETWEEN #{min} AND #{max}
    SQL
  end
end
  • 最初の引数は変更するテーブルです:'ci_pending_builds'.
  • 2番目の引数は、選択された関連データセットを取得するラムダを呼び出します (デフォルトは.all です):scope: ->(table) { table.ref_protected }.
  • 第3引数はバッチサイズ(デフォルトはBATCH_SIZE 定数に設定):of: BATCH_SIZE.

新しいヘルパーの使い方を示すMRの例です。

予約パスの名前の変更

プロジェクトの新しいルートが導入されると、既存のレコードと衝突する可能性があります。これらのレコードのパスの名前を変更し、関連するデータをディスク上に移動する必要があります。

私たちはすでに何度かこの作業をしなければならなかったので、現在ではこの作業を支援するヘルパーが用意されています。

これを使うには、マイグレーションにGitlab::Database::RenameReservedPathsMigration::V1 。これは3つのメソッドを提供し、拒否したいパスを1つ以上渡すことができます。

  • rename_root_paths:parent_idを持たない、指定された名前のすべての_名前空間の_パスをリネームします。
  • rename_child_paths:parent_idを持つ、指定された名前のすべての_名前_空間のパスを変更します。
  • rename_wildcard_paths:すべての_プロジェクトと_、project_idを持つすべての_名前_空間のパスを変更します。

これらの行のpath 列は、以前の値の後に整数が付いた名前に変更されます。例:users は次のようになります。users0

マイグレーションでのアプリケーションコードの使用 (推奨しません)

マイグレーションでアプリケーションコード (モデルを含む) を使うことは一般的に推奨されません。マイグレーションは長い間放置され、マイグレーションが依存するアプリケーションコードが変更され、将来マイグレーションが壊れる可能性があるからです。過去には、複数のファイルにまたがる何百行ものコードをマイグレーションにコピーするのを避けるために、アプリケーションコードを使用する必要があるバックグラウンドマイグレーションもありました。このようなまれなケースでは、マイグレーションに適切なテストがあることを確認することが重要です。将来、コードをリファクタリングする人が、マイグレーションを壊してしまうかどうかを知ることができるようにするためです。アプリケーションコードを使うことも、バッチバックグラウンドマイグレーションでは推奨されません

通常、MigrationRecord を継承するクラスを定義することで、マイグレーションでアプリケーションコード (特にモデル) を使わないようにできます (下記の例を参照)。

モデル (マイグレーションで定義されたものを含む) を使う場合、最初にreset_column_information を使ってカラムキャッシュをクリアしなければなりません。

単一テーブル継承(STI)を利用するモデルを使用する場合、特別な考慮事項があります。

これは、使用するカラムが以前のマイグレーションで変更されキャッシュされていた場合の問題を回避します。

例users テーブルにカラムmy_column を追加します。

古いスキーマがキャッシュから削除され、ActiveRecordが更新されたスキーマ情報をロードするように、User.reset_column_information コマンドを省略しないことが重要です。

class AddAndSeedMyColumn < Gitlab::Database::Migration[2.1]
  class User < MigrationRecord
    self.table_name = 'users'
  end

  def up
    User.count # Any ActiveRecord calls on the model that caches the column information.

    add_column :users, :my_column, :integer, default: 1

    User.reset_column_information # The old schema is dropped from the cache.
    User.find_each do |user|
      user.my_column = 42 if some_condition # ActiveRecord sees the correct schema here.
      user.save!
    end
  end
end

基礎となるテーブルが変更され、ActiveRecord経由でアクセスされます。

もし両方のマイグレーションが同じdb:migrate プロセスで実行されるのであれば、以前の別のマイグレーションでテーブルが変更された場合にも、これを使用する必要があることに注意してください。

この結果、以下のようになります。my_column が内部に含まれていることに注意してください:

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-18T00:41:26.851769 #459802] DEBUG -- :   AddAndSeedMyColumn::User Update (1.1ms)  UPDATE "users" SET "my_column" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["my_column", 42], ["updated_at", "2020-07-17 23:41:26.849044"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

スキーマキャッシュ (User.reset_column_information) のクリアをスキップすると、このカラムはActiveRecordによって使用されず、意図した変更が行われないため、my_column がクエリから欠落した以下のような結果になります。

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-06T00:37:12.653459 #130101] DEBUG -- :   AddAndSeedMyColumn::User Update (0.5ms)  UPDATE "users" SET "updated_at" = $1 WHERE "users"."id" = $2  [["updated_at", "2020-07-05 23:37:12.652297"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

トラフィックの多いテーブル

現在アクセス数の多いテーブルのリストです。

どのテーブルが高トラフィックなのかを判断するのは難しいかもしれません。セルフマネージド・インスタンスでは、GitLab のさまざまな機能をさまざまな利用パターンで使っている可能性があり、GitLab.com から推測するだけでは不十分です。

GitLab.comの高トラフィックテーブルを特定するために、以下の指標を考慮します。ここでリンクされているメトリクスはGitLab内部のみのものであることに注意してください:

現在の高トラフィックテーブルと比較して、読み取りオペレーションが高いテーブルが良い候補になるかもしれません。

一般的なルールとして、純粋にGitLab.comの分析やレポーティングのためだけにトラフィックの多いテーブルにカラムを追加することはお勧めしません。これは、すべてのセルフマネージド・インスタンスに直接的な価値を提供することなく、パフォーマンスに悪影響を及ぼす可能性があります。