緩い外部キー

問題ステートメント

リレーショナルデータベース(PostgreSQLを含む)において、外部キーは2つのデータベーステーブルを結び付け、それらの間のデータの一貫性を保証する方法を提供します。GitLabでは、外部キーはデータベース設計プロセスの重要な一部です。私たちのデータベーステーブルのほとんどは外部キーを持っています。

現在進行中のデータベースの分解作業により、リンクされたレコードが2つの異なるデータベースサーバーに存在する可能性があります。2つのデータベース間でデータの一貫性を確保することは、標準的なPostgreSQLの外部キーでは不可能です。PostgreSQLは、ネットワーク経由で2つの異なるデータベースサーバにある2つのデータベーステーブル間のリンクを定義する、1つのデータベースサーバ内での外部キーのオペレーションをサポートしていません。

使用例:

  • データベース “Main”:projects テーブル
  • データベース “CI”:ci_pipelines テーブル

プロジェクトは多くのパイプラインを持つことができます。プロジェクトが削除されると、関連するci_pipelineproject_id カラム経由)のレコードも削除されなければなりません。

マルチデータベースのセットアップでは、外部キーでこれを実現することはできません。

非同期アプローチ

この問題に対する私たちの好ましいアプローチは、最終的な一貫性です。緩やかな外部キー機能を使用すると、アプリケーションのパフォーマンスに悪影響を与えることなく、関連付けのクリーンアップを遅延させる設定ができます。

どのように動作するか

前述の例では、projects テーブルのレコードは、ci_pipeline レコードを複数持つことができます。実際の親レコードの削除とは別にクリーンアップ処理を行うには、次のようにします:

  1. projects テーブルにDELETE トリガーを作成します。削除を別のテーブル (deleted_records) に記録します。
  2. ジョブが1~2分ごとにdeleted_records テーブルをチェックします。
  3. テーブルの各レコードについて、project_id 列を使用して、関連するci_pipelines レコードを削除します。
note
このプロシージャを動作させるには、非同期にクリーンアップするテーブルを登録する必要があります。

そのscripts/decomposition/generate-loose-foreign-key

分解作業の一環として、外部キーを緩い外部キーにマイグレーションするための自動化ツールを構築しました。既存のキーを提示し、選択した外部キーを自動的に緩い外部キーに変換できるようにします。これにより、外部キーと緩やかな外部キーの定義間の一貫性が保証され、それらが適切にテストされるようになります。

caution
任意の外部キーを緩い外部キーにスワップするには、自動化スクリプトを使用することを強くお勧めします。

このツールは、外部キーのスワップのすべての側面をカバーしています。これには以下が含まれます:

  • 外部キーを削除するマイグレーションを作成します。
  • 新しいマイグレーションでdb/structure.sql を更新します。
  • config/gitlab_loose_foreign_keys.yml を更新して、新しい緩い外部キーを追加します。
  • 緩やかな外部キーが適切にサポートされるように、モデルの仕様を作成または更新します。

このツールはscripts/decomposition/generate-loose-foreign-key にあります:

$ scripts/decomposition/generate-loose-foreign-key -h

Usage: scripts/decomposition/generate-loose-foreign-key [options] <filters...>
    -c, --cross-schema               Show only cross-schema foreign keys
    -n, --dry-run                    Do not execute any commands (dry run)
    -r, --[no-]rspec                 Create or not a rspecs automatically
    -h, --help                       Prints this help

スキーマをまたがる外部キーのマイグレーションでは、-c 修飾子を使用して、まだマイグレーションしていない外部キーを表示します:

$ scripts/decomposition/generate-loose-foreign-key -c
Re-creating current test database
Dropped database 'gitlabhq_test_ee'
Dropped database 'gitlabhq_geo_test_ee'
Created database 'gitlabhq_test_ee'
Created database 'gitlabhq_geo_test_ee'

Showing cross-schema foreign keys (20):
   ID | HAS_LFK |                                     FROM |                   TO |                         COLUMN |       ON_DELETE
    0 |       N |                                ci_builds |             projects |                     project_id |         cascade
    1 |       N |                         ci_job_artifacts |             projects |                     project_id |         cascade
    2 |       N |                             ci_pipelines |             projects |                     project_id |         cascade
    3 |       Y |                             ci_pipelines |       merge_requests |               merge_request_id |         cascade
    4 |       N |                   external_pull_requests |             projects |                     project_id |         cascade
    5 |       N |                     ci_sources_pipelines |             projects |                     project_id |         cascade
    6 |       N |                                ci_stages |             projects |                     project_id |         cascade
    7 |       N |                    ci_pipeline_schedules |             projects |                     project_id |         cascade
    8 |       N |                       ci_runner_projects |             projects |                     project_id |         cascade
    9 |       Y |             dast_site_profiles_pipelines |         ci_pipelines |                 ci_pipeline_id |         cascade
   10 |       Y |                   vulnerability_feedback |         ci_pipelines |                    pipeline_id |         nullify
   11 |       N |                             ci_variables |             projects |                     project_id |         cascade
   12 |       N |                                  ci_refs |             projects |                     project_id |         cascade
   13 |       N |                       ci_builds_metadata |             projects |                     project_id |         cascade
   14 |       N |                ci_subscriptions_projects |             projects |          downstream_project_id |         cascade
   15 |       N |                ci_subscriptions_projects |             projects |            upstream_project_id |         cascade
   16 |       N |                      ci_sources_projects |             projects |              source_project_id |         cascade
   17 |       N |         ci_job_token_project_scope_links |             projects |              source_project_id |         cascade
   18 |       N |         ci_job_token_project_scope_links |             projects |              target_project_id |         cascade
   19 |       N |                ci_project_monthly_usages |             projects |                     project_id |         cascade

To match foreign key (FK), write one or many filters to match against FROM/TO/COLUMN:
- scripts/decomposition/generate-loose-foreign-key (filters...)
- scripts/decomposition/generate-loose-foreign-key ci_job_artifacts project_id
- scripts/decomposition/generate-loose-foreign-key dast_site_profiles_pipelines

このコマンドは、外部キー生成のために、from、to、columnにマッチするフィルタのリストを受け付けます。例えば、このコマンドを実行すると、分解されたデータベースの全ての外部キーがci_job_token_project_scope_links

scripts/decomposition/generate-loose-foreign-key -c ci_job_token_project_scope_links

分解されたデータベースに対してci_job_token_project_scope_linkssource_project_id のみを交換するには、次のように実行します:

scripts/decomposition/generate-loose-foreign-key -c ci_job_token_project_scope_links source_project_id

すべての外部キー (_id が追加されたもの) を入れ替えますが、新しいブランチは作成せず (変更点のみをコミットします)、RSpec テストも作成しません:

scripts/decomposition/generate-loose-foreign-key -c --no-branch --no-rspec _id

projects を参照する外部キーをすべて入れ替えますが、新しいブランチは作成しません (変更点のみをコミットします):

scripts/decomposition/generate-loose-foreign-key -c --no-branch projects

マイグレーションと設定の例

緩やかな外部キーの設定

ルースな外部キーは YAML ファイルで定義されます。設定には次の情報が必要です:

  • 親テーブル名 (projects)
  • 子テーブル名 (ci_pipelines)
  • データのクリーンアップ方法 (async_delete またはasync_nullify)

YAML ファイルはconfig/gitlab_loose_foreign_keys.yml にあります。このファイルは子テーブルの名前で外部キー定義をグループ化します。子テーブルは複数の緩い外部キー定義を持つことができるので、配列として保存します。

定義の例です:

ci_pipelines:
  - table: projects
    column: project_id
    on_delete: async_delete

ci_pipelines キーがすでに YAML ファイルに存在する場合、新しいエントリを配列に追加できます:

ci_pipelines:
  - table: projects
    column: project_id
    on_delete: async_delete
  - table: another_table
    column: another_id
    on_delete: :async_nullify

レコードの変更履歴

projects テーブルの削除を知るには、デプロイ後のマイグレーションを使用してDELETE トリガを設定します。トリガは一度だけ設定する必要があります。モデルにloose_foreign_key 定義が既に1つ以上ある場合、このステップは省略できます:

class TrackProjectRecordChanges < Gitlab::Database::Migration[2.1]
  include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers

  enable_lock_retries!

  def up
    track_record_deletions(:projects)
  end

  def down
    untrack_record_deletions(:projects)
  end
end

外部キーの削除

既存の外部キーがある場合は、データベースから削除することができます。GitLab 14.5では、以下の外部キーがprojectsci_pipelines テーブル間のリンクを記述しています:

ALTER TABLE ONLY ci_pipelines
ADD CONSTRAINT fk_86635dbd80
FOREIGN KEY (project_id)
REFERENCES projects(id)
ON DELETE CASCADE;

マイグレーションは、DELETE トリガーがインストールされ、緩い外部キー定義がデプロイされた後に実行されなければなりません。そのため、このマイグレーションは、トリガのマイグレーション後のデプロイ後のマイグレーションでなければなりません。もし外部キーが先に削除されると、手動でのクリーンアップが必要なデータの不整合が発生する可能性が高くなります:

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

  def up
    with_lock_retries do
      remove_foreign_key_if_exists(:ci_pipelines, :projects, name: "fk_86635dbd80")
    end
  end

  def down
    add_concurrent_foreign_key(:ci_pipelines, :projects, name: "fk_86635dbd80", column: :project_id, target_column: :id, on_delete: "cascade")
  end
end

この時点で、設定フェーズは終了です。削除されたprojects レコードは、スケジュールされたクリーンアップワーカージョブによって自動的にピックアップされるはずです。

緩い外部キー

ルースな外部キーの定義が不要になったとき (親テーブルが削除された、もしくは FK がリストアされた)、YAML ファイルから定義を削除し、削除された保留レコードをデータベースに残さないようにする必要があります。

  1. 親テーブルから削除追跡トリガーを削除します(親テーブルが残っている場合)。
  2. 設定 (config/gitlab_loose_foreign_keys.yml) から緩い外部キー定義を削除します。
  3. loose_foreign_keys_deleted_records テーブルから削除済みレコードを削除します。

トリガを削除するためのマイグレーション:

class UnTrackProjectRecordChanges < Gitlab::Database::Migration[2.1]
  include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers

  enable_lock_retries!

  def up
    untrack_record_deletions(:projects)
  end

  def down
    track_record_deletions(:projects)
  end
end

トリガの削除により、loose_foreign_keys_deleted_records テーブルにそれ以上レコードが挿入されることはなくなりましたが、テーブル内に保留レコードが残っている可能性があります。これらのレコードは、インライン データ マイグレーションで削除する必要があります。

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

  def up
    loop do
      result = execute <<~SQL
      DELETE FROM "loose_foreign_keys_deleted_records"
      WHERE
      ("loose_foreign_keys_deleted_records"."partition", "loose_foreign_keys_deleted_records"."id") IN (
        SELECT "loose_foreign_keys_deleted_records"."partition", "loose_foreign_keys_deleted_records"."id"
        FROM "loose_foreign_keys_deleted_records"
        WHERE
        "loose_foreign_keys_deleted_records"."fully_qualified_table_name" = 'public.projects' AND
        "loose_foreign_keys_deleted_records"."status" = 1
        LIMIT 100
      )
      SQL

      break if result.cmd_tuples == 0
    end
  end

  def down
    # no-op
  end
end

テスト

it has loose foreign keys” 共有例は、ON DELETE トリガーと緩い外部キー定義の存在をテストするために使用することができます。

モデルのテストファイルに追加してください:

it_behaves_like 'it has loose foreign keys' do
  let(:factory_name) { :project }
end

外部キーを削除した、”cleanup by a loose foreign key” 共有サンプルを使って、追加された緩い外部キーによる子レコードの削除や無効化をテストします:

it_behaves_like 'cleanup by a loose foreign key' do
  let!(:model) { create(:ci_pipeline, user: create(:user)) }
  let!(:parent) { model.user }
end

緩い外部キーの注意点

レコードの作成

この機能は、親レコードが削除された後に関連レコードをクリーンアップする効率的な方法を提供します。外部キーがない場合、新しい関連レコードが作成されたときに親レコードが存在するかどうかを検証するのはアプリケーションの責任です。

悪い例: 指定されたID (project_id はユーザー入力によるもの) でレコードを作成した場合。この例では、ランダムなプロジェクトIDを渡すことを妨げるものはありません:

Ci::Pipeline.create!(project_id: params[:project_id])

良い例:特別なチェックを加えたレコード作成:

project = Project.find(params[:project_id])
Ci::Pipeline.create!(project_id: project.id)

アソシエーション検索

以下のHTTPリクエストを考えてみましょう:

GET /projects/5/pipelines/100

コントローラアクションはproject_id パラメータを無視し、ID を使用してパイプラインを見つけます:

  def show
  # bad, avoid it
  pipeline = Ci::Pipeline.find(params[:id]) # 100
end

このエンドポイントは、親であるProject モデルが削除されても動作します。これは、一般的な環境では発生しないはずのデータリークと考えられます:

def show
  # good
  project = Project.find(params[:project_id])
  pipeline = project.pipelines.find(params[:pipeline_id]) # 100
end
note
GitLabでは通常、権限チェックを行うために親モデルを調べるので、この例はありえません。

dependent: :destroydependent: :nullify

外部キーの代替としてこれらのRailsの機能を使うことも考えましたが、以下のようないくつかの問題があります:

  1. これらはトランザクションのコンテキストで別の接続で実行されるため、私たちはこれを許可していません。
  2. これらはPostgreSQLから全てのレコードをロードし、Rubyでそれらをループし、個々のDELETE クエリを呼び出すため、深刻な性能劣化につながる可能性があります。
  3. これらは、destroy メソッドがモデル上で直接呼び出された場合のみを対象としているため、データを見逃す可能性があります。delete_all 、別の親テーブルからのカスケード削除を含む他のケースもあり、これらを見逃す可能性があります。

dependent: :destroy 、データベース外部(オブジェクトストレージなど)のデータをクリーンアップする必要がある非自明なオブジェクトについては、 dependent: :nullify およびdependent: :destroy across databasesを参照してください。

緩い外部キーのリスクと可能な緩和策

一般的に、緩い外部キーアーキテクチャは最終的に一貫性があり、クリーンアップの待ち時間はGitLabユーザーやオペレーションから見える問題につながるかもしれません。私たちはこのトレードオフは許容範囲と考えますが、問題があまりに頻繁であったり、深刻であったりする場合もあるかもしれません。一般的な緩和策としては、クリーンアップの遅延によって影響が大きくなるレコードのクリーンアップのために “urgent” キューを用意することでしょう。

以下に、発生する可能性のある問題の具体例とその緩和策を示します。ここに挙げたすべてのケースにおいて、私たちは記述された問題を低リスク、低影響とみなし、その場合はいかなる緩和策も実施しないことを選択する可能性があります。

レコードは削除されるべきですが、ビューに表示されます。

この仮想的な例は、以下のような外部キーで発生する可能性があります:

ALTER TABLE ONLY vulnerability_occurrence_pipelines
    ADD CONSTRAINT fk_rails_6421e35d7d FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;

この例では、関連するci_pipelines レコードを削除するたびに、関連するvulnerability_occurrence_pipelines レコードをすべて削除することを想定しています。この場合、GitLab の脆弱性ページに脆弱性の発生が表示されるかもしれません。しかし、パイプラインへのリンクを選択しようとすると、パイプラインが削除されているため、404が表示されます。パイプラインが削除されているためです。そして、ナビゲートし直すと、その発生も消えていることに気づくかもしれません。

緩和策

脆弱性ページで脆弱性の発生をレンダリングするとき、対応するパイプラインをロードし、パイプラインが見つからない場合、その発生を表示しないようにすることができます。

削除された親レコードはビューをレンダリングするために必要であり、500 エラーの原因となります。

この仮想的な例は、以下のような外部キーで発生する可能性があります:

ALTER TABLE ONLY vulnerability_occurrence_pipelines
    ADD CONSTRAINT fk_rails_6421e35d7d FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;

この例では、関連するci_pipelines レコードを削除するたびに、関連するvulnerability_occurrence_pipelines レコードをすべて削除することを想定しています。この場合、GitLabの脆弱性ページに脆弱性の “occursence “が表示されてしまうかもしれません。しかし、その発生をレンダリングする際に、例えばoccurrence.pipeline.created_atをロードしようとすると、ユーザーに 500 を与えてしまいます。

緩和策

脆弱性ページで脆弱性の発生をレンダリングするとき、対応するパイプラインをロードし、パイプラインが見つからない場合、その発生を表示しないようにすることができます。

削除された親レコードが Sidekiq ワーカーでアクセスされ、ジョブが失敗します。

この仮想的な例は、以下のような外部キーで発生する可能性があります:

ALTER TABLE ONLY vulnerability_occurrence_pipelines
    ADD CONSTRAINT fk_rails_6421e35d7d FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;

この例では、関連するci_pipelines レコードを削除するたびに、関連するvulnerability_occurrence_pipelines レコードをすべて削除することを想定しています。この場合、Sidekiqワーカーが脆弱性の処理を担当し、すべての発生をループすることになり、Sidekiqジョブがoccurrence.pipeline.created_atを実行すると失敗することになります。

緩和策

Sidekiqワーカーで脆弱性の発生をループするとき、対応するパイプラインをロードしようとし、パイプラインが見つからない場合、その発生の処理をスキップすることを選択できます。

アーキテクチャ

緩やかな外部キー機能は、LooseForeignKeys Ruby名前空間内に実装されています。このコードはCoreアプリケーションコードから分離されており、理論的にはスタンドアロンライブラリにすることができます。

この機能はLooseForeignKeys::CleanupWorker ワーカークラスでのみ呼び出されます。ワーカーはcronジョブでスケジュールされ、スケジュールはGitLabインスタンスの設定に依存します。

  • 非分解 GitLab (1 データベース): 1 分毎に起動。
  • 分解された GitLab (2 データベース, CI と Main): 1 分ごとに起動され、一度に 1 つのデータベースをクリーンアップします。例えば、メインデータベースのクリーンアップワーカーは2分ごとに実行されます。

ロックの競合や同じデータベース行の処理を避けるため、ワーカーは並列実行しません。この動作はRedisロックによって保証されます。

レコードのクリーンアップ手順

  1. Redis ロックを取得します。
  2. どのデータベースをクリーンアップするかを決定します。
  3. 削除を追跡するすべてのデータベース・テーブル(親テーブル)を収集します。
    • これは、config/gitlab_loose_foreign_keys.yml ファイルを読み込むことで達成されます。
    • テーブルに緩い外部キー定義が存在し、DELETE トリガがインストールされている時、テーブルは “追跡されている “とみなされます。
  4. 無限ループでテーブルを循環させます。
  5. 各テーブルについて、削除された親レコードのバッチをロードしてクリーンアップします。
  6. YAML の設定に応じて、参照される子テーブルに対してDELETE もしくはUPDATE (nullify) クエリをビルドします。
  7. クエリを呼び出します。
  8. すべての子レコードがクリーンアップされるか、上限に達するまで繰り返します。
  9. すべての子レコードがクリーンアップされたら、削除された親レコードを削除します。

データベースの構造

この機能は、親テーブルにインストールされたトリガーに依存しています。親レコードが削除されると、トリガーは自動的に新しいレコードをloose_foreign_keys_deleted_records データベーステーブルに挿入します。

挿入されたレコードには、削除されたレコードに関する以下の情報が保存されます:

  • fully_qualified_table_nameレコードがあるデータベーステーブルの名前。
  • primary_key_value: レコードの ID、その値は外部キー値として子テーブルに存在します。現在のところ、複合主キーはサポートされていません。親テーブルにはid カラムが必要です。
  • status: デフォルトは pending で、クリーンアップ処理のステータスを表します。
  • consume_afterデフォルトは現在の時刻です。
  • cleanup_attemptsワーカーがこのレコードをクリーンアップしようとした回数です。0 以外の数値は、このレコードに多くの子レコードがあり、クリーンアップに数回の実行が必要であることを意味します。

データベースの分解

loose_foreign_keys_deleted_records テーブルは、データベース分解後、両方のデータベースサーバ (cimain) に存在します。ワーカーはlib/gitlab/database/gitlab_schemas.yml YAML ファイルを読むことで、どの親テーブルがどのデータベースに属しているかを判断します。

使用例:

  • 主なデータベーステーブル
    • projects
    • namespaces
    • merge_requests
  • Ciデータベーステーブル
    • ci_builds
    • ci_pipelines

ci データベースに対してワーカーが起動されると、ワーカーはci_buildsci_pipelines テーブルからのみ削除されたレコードをロードします。クリーンアップ処理中、DELETEUPDATE クエリは、主に Main データベースにあるテーブルで実行さ UPDATEれます。UPDATE この例では、1 つの UPDATEクエリがmerge_requests.head_pipeline_id カラムを無効にしています。

データベースのパーティショニング

データベース・テーブルには毎日大量のインサートが行われるため、データの肥大化の懸念にアドレスするため、特別なパーティショニング戦略が実装されました。当初、この機能には時間減衰戦略が検討されましたが、データ量が大きいため、新しい戦略を実装することにしました。

削除されたレコードは、その直接の子レコードがすべてクリーンアップされたときに完全に処理されたとみなされます。これが起こると、緩い外部キーワーカーは削除されたレコードのstatus 列を更新します。このステップの後、そのレコードは不要になります。

スライディングパーティショニングストラテジーは、新しいデータベースパーティションを追加し、特定の条件が満たされたときに古いパーティションを削除することで、古い未使用のデータをクリーンアップする効率的な方法を提供します。loose_foreign_keys_deleted_records データベース・テーブルはリスト・パーティショニングされており、ほとんどの場合、テーブルには1つのパーティションしかアタッチされていません。

                                                             Partitioned table "public.loose_foreign_keys_deleted_records"
           Column           |           Type           | Collation | Nullable |                            Default                             | Storage  | Stats target | Description
----------------------------+--------------------------+-----------+----------+----------------------------------------------------------------+----------+--------------+-------------
 id                         | bigint                   |           | not null | nextval('loose_foreign_keys_deleted_records_id_seq'::regclass) | plain    |              |
 partition                  | bigint                   |           | not null | 84                                                             | plain    |              |
 primary_key_value          | bigint                   |           | not null |                                                                | plain    |              |
 status                     | smallint                 |           | not null | 1                                                              | plain    |              |
 created_at                 | timestamp with time zone |           | not null | now()                                                          | plain    |              |
 fully_qualified_table_name | text                     |           | not null |                                                                | extended |              |
 consume_after              | timestamp with time zone |           |          | now()                                                          | plain    |              |
 cleanup_attempts           | smallint                 |           |          | 0                                                              | plain    |              |
Partition key: LIST (partition)
Indexes:
    "loose_foreign_keys_deleted_records_pkey" PRIMARY KEY, btree (partition, id)
    "index_loose_foreign_keys_deleted_records_for_partitioned_query" btree (partition, fully_qualified_table_name, consume_after, id) WHERE status = 1
Check constraints:
    "check_1a541f3235" CHECK (char_length(fully_qualified_table_name) <= 150)
Partitions: gitlab_partitions_dynamic.loose_foreign_keys_deleted_records_84 FOR VALUES IN ('84')

partition 列は挿入方向を制御し、 partition値は、どのパーティションがトリガによって削除された行を挿入するかを決定します。partition テーブルのデフォルト値は、リスト パーティションの値(84)と一致します。トリガ内部のINSERT クエリでは、partition の値は省略され、トリガは常に列のデフォルト値に依存します。

トリガのINSERT クエリの例:

INSERT INTO loose_foreign_keys_deleted_records
(fully_qualified_table_name, primary_key_value)
SELECT TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, old_table.id FROM old_table;

パーティションの「スライディング」プロセスは、2つの、定期的に実行されるコールバックによって制御されます。これらのコールバックは、LooseForeignKeys::DeletedRecord モデル内部で定義されています。

next_partition_if コールバックは、新しいパーティションを作成するタイミングを制御します。新しいパーティションは、現在のパーティションに24時間以上前のレコードが1つでもあると作成されます。新しいパーティションは、PartitionManager によって以下の手順で追加されます:

  1. 新しいパーティションを作成します。パーティションのVALUECURRENT_PARTITION + 1 です。
  2. partition 列のデフォルト値をCURRENT_PARTITION + 1 に更新します。

これらのステップにより、トリガーを介した新しいINSERT クエリは全て新しいパーティションで終了します。この時点で、データベーステーブルには2つのパーティションがあります。

detach_partition_if コールバックは、古いパーティションをテーブルから切り離すことができるかどうかを判断します。パーティション内に保留中の(未処理の)レコードがない場合、パーティションは切り離し可能です(status = 1 )。切り離されたパーティションはしばらくの間利用可能です。detached_partitions テーブルで切り離されたパーティションのリストを見ることができます:

select * from detached_partitions;

クリーンアップ・クエリ

LooseForeignKeys::CleanupWorker にはArel に依存するデータベースクエリビルダがあります。この機能は、予期せぬ副作用を避けるために、アプリケーション固有のActiveRecord モデルを参照しません。データベースクエリはバッチ処理されるので、複数の親レコードが同時にクリーンアップされます。

DELETE クエリの例:

DELETE
FROM "merge_request_metrics"
WHERE ("merge_request_metrics"."id") IN
  (SELECT "merge_request_metrics"."id"
    FROM "merge_request_metrics"
    WHERE "merge_request_metrics"."pipeline_id" IN (1, 2, 10, 20)
    LIMIT 1000 FOR UPDATE SKIP LOCKED)

親レコードの主キー値は 1、2、10、および 20 です。

UPDATE (nullify) クエリ:

UPDATE "merge_requests"
SET "head_pipeline_id" = NULL
WHERE ("merge_requests"."id") IN
    (SELECT "merge_requests"."id"
     FROM "merge_requests"
     WHERE "merge_requests"."head_pipeline_id" IN (3, 4, 30, 40)
     LIMIT 500 FOR UPDATE SKIP LOCKED)

これらのクエリはバッチ処理されるため、多くの場合、関連するすべての子レコードをクリーンアップするために複数のクエリを実行する必要があります。

バッチ処理はループで実装されており、関連する子レコードがすべてクリーンアップされるか、制限に達すると処理が停止します。

loop do
  modification_count = process_batch_with_skip_locked

  break if modification_count == 0 || over_limit?
end

loop do
  modification_count = process_batch

  break if modification_count == 0 || over_limit?
end

ループベースのバッチ処理は、EachBatch よりも以下の理由で好まれます:

  • バッチ内のレコードは変更されるため、次のバッチには異なるレコードが含まれます。
  • 外部キーカラムには常にインデックスが存在しますが、そのカラムは通常一意ではありません。EachBatch 、反復処理には一意なカラムが必要です。
  • レコードの順序はクリーンアップには関係ありません。

2つのループがあることに注意してください。最初のループはSKIP LOCKED 節を SKIP LOCKED持つレコードを処理します。SKIP LOCKED クエリは、他のアプリケーションプロセスによってロックされた行をスキップします。これにより、クリーンアップ ワーカーがブロックされにくくなります。2番目のループ SKIP LOCKEDは、すべてのレコードが処理されたことを確認するために、SKIP LOCKED データベースクエリを実行しません SKIP LOCKED

処理の制限

常に大量のレコードの更新や削除が行われると、インシデントを引き起こし、GitLabの可用性に影響を与える可能性があります:

  • テーブルの肥大化。
  • 保留中の WAL ファイル数の増加。
  • ビジー・テーブル、ロック取得時の困難。

これらのイシューを軽減するために、ワーカーの実行時にいくつかの制限が適用されます。

  • 各クエリにはLIMIT があり、クエリは制限のない数の行を処理することはできません。
  • レコード削除とレコード更新の最大数は制限されています。
  • データベースクエリの最大実行時間(30秒)が制限されます。

制限ルールはLooseForeignKeys::ModificationTracker クラスに実装されています。制限 (レコード修正回数、制限時間) のいずれかに達すると、処理は即座に停止します。しばらくすると、次のスケジュールされたワーカーがクリーンアップ処理を続けます。

パフォーマンス特性

親テーブル上のデータベーストリガは、レコード削除速度を低下させます。親テーブルから行を削除する各ステートメントは、loose_foreign_keys_deleted_records テーブルにレコードを挿入するトリガを呼び出します。

クリーンアップワーカー内のクエリは、かなり効率的なインデックススキャンで、制限を設ければアプリケーションの他の部分に影響を与えることはまずありません。

データベースクエリはトランザクションでは実行されず、ステートメントのタイムアウトやワーカーのクラッシュなどのエラーが発生すると、次のジョブが処理を続行します。

トラブルシューティング

削除された記録の蓄積

作業員が異常に大量のデータを処理する必要がある場合があります。例えば、大規模なプロジェクトやグループが削除された場合などです。このシナリオでは、数百万行が削除または無効化される可能性があります。ワーカーによる制限のため、このデータの処理には時間がかかります。

ヘビーヒッター” をクリーンアップする場合、この機能は、より大きなバッチを後で再スケジューリングすることで、公平な処理を保証します。これにより、他の削除されたレコードを処理する時間を確保できます。

例えば、数百万件のci_builds レコードを ci_builds持つプロジェクトがci_builds 削除 ci_buildsされたとします。ci_builds この ci_buildsレコードは、緩い外部キー機能によって削除されます。

  1. クリーンアップワーカーはスケジュールされ、削除されたprojects レコードのバッチをピックアップします。この大規模プロジェクトはバッチの一部です。
  2. 孤児となったci_builds 行の削除が開始されました。
  3. 制限時間に達しましたが、クリーンアップは完了していません。
  4. 削除されたレコードのcleanup_attempts 列がインクリメントされます。
  5. ステップ 1 に進みます。次のクリーンアップ ワーカーがクリーンアップを続行します。
  6. cleanup_attempts が 3 に達すると、consume_after 列を更新することで、バッチは 10 分後に再スケジュールされます。
  7. 次のクリーンアップワーカーは別のバッチを処理します。

削除されたレコードのクリーンアップを監視するために、Prometheusのメトリクスを導入しています:

  • loose_foreign_key_processed_deleted_records:処理された削除レコードの数。大規模なクリーンアップが発生すると、この数は減少します。
  • loose_foreign_key_incremented_deleted_records:処理が完了していない削除レコードの数。cleanup_attempts カラムがインクリメントされました。
  • loose_foreign_key_rescheduled_deleted_records:クリーンアップを 3 回試行した後、後日再スケジュールしなければならなかった削除レコードの数。

Thanosクエリの例:

loose_foreign_key_rescheduled_deleted_records{env="gprd", table="ci_runners"}

この状況を見るもう一つの方法は、データベースクエリを実行することです。このクエリでは、未処理のレコードの正確な数がわかります:

SELECT partition, fully_qualified_table_name, count(*)
FROM loose_foreign_keys_deleted_records
WHERE
status = 1
GROUP BY 1, 2;

出力例です:

 partition | fully_qualified_table_name | count
-----------+----------------------------+-------
        87 | public.ci_builds           |   874
        87 | public.ci_job_artifacts    |  6658
        87 | public.ci_pipelines        |   102
        87 | public.ci_runners          |   111
        87 | public.merge_requests      |   255
        87 | public.namespaces          |    25
        87 | public.projects            |     6

クエリにはパーティション番号が含まれており、クリーンアップが大幅に遅れているかどうかを検出するのに役立ちます。リストに複数の異なるパーティション値がある場合、削除されたレコードのクリーンアップが数日経っても終わらないことを意味します(毎日1つの新しいパーティションが追加されます)。

問題を診断する手順

  • どの記録が蓄積されているかを確認します。
  • 残りの記録数の見積もりを取ってみてください。
  • ワーカーのパフォーマンス統計(Kibana または Thanos)を調べます。

解決策

  • 短期的には、バッチサイズを大きくします。
  • 長期的には: Worker の起動頻度を増やします。Worker の並列化

一度だけの修正であれば、rails コンソールからクリーンアップ Worker を複数回実行できます。Worker は並列に実行できますが、ロック競合が発生し、Worker の実行時間が長くなる可能性があります。

LooseForeignKeys::CleanupWorker.new.perform

クリーンアップが終わると、古いパーティションはPartitionManager によって自動的に切り離されます。

PartitionManagerのバグ

note
このイシューは過去にステージで発生し、修正されました。

新しいパーティションを追加すると、partition カラムの partitionデフォルトpartition 値も更新 partitionされます。partition これは新しいパーティションの作成と同じトランザクションで実行されるスキーマの変更です。 partitionカラムが古くなるpartition 可能性はほとんど partitionありません。

しかし、partition の値が存在しないパーティションを指すため、アプリケーション全体にインシデントが発生する可能性があります。症状:DELETE トリガがインストールされているテーブルからのレコードの削除に失敗します。

\d+ loose_foreign_keys_deleted_records;

           Column           |           Type           | Collation | Nullable |                            Default                             | Storage  | Stats target | Description
----------------------------+--------------------------+-----------+----------+----------------------------------------------------------------+----------+--------------+-------------
 id                         | bigint                   |           | not null | nextval('loose_foreign_keys_deleted_records_id_seq'::regclass) | plain    |              |
 partition                  | bigint                   |           | not null | 4                                                              | plain    |              |
 primary_key_value          | bigint                   |           | not null |                                                                | plain    |              |
 status                     | smallint                 |           | not null | 1                                                              | plain    |              |
 created_at                 | timestamp with time zone |           | not null | now()                                                          | plain    |              |
 fully_qualified_table_name | text                     |           | not null |                                                                | extended |              |
 consume_after              | timestamp with time zone |           |          | now()                                                          | plain    |              |
 cleanup_attempts           | smallint                 |           |          | 0                                                              | plain    |              |
Partition key: LIST (partition)
Indexes:
    "loose_foreign_keys_deleted_records_pkey" PRIMARY KEY, btree (partition, id)
    "index_loose_foreign_keys_deleted_records_for_partitioned_query" btree (partition, fully_qualified_table_name, consume_after, id) WHERE status = 1
Check constraints:
    "check_1a541f3235" CHECK (char_length(fully_qualified_table_name) <= 150)
Partitions: gitlab_partitions_dynamic.loose_foreign_keys_deleted_records_3 FOR VALUES IN ('3')

partition 列のデフォルト値を確認し、利用可能なパーティション(4 対 3)と比較してください。値 4 のパーティションは存在しません。この問題を軽減するには、緊急のスキーマ変更が必要です:

ALTER TABLE loose_foreign_keys_deleted_records ALTER COLUMN partition SET DEFAULT 3;