- 問題ステートメント
- 非同期アプローチ
- その
scripts/decomposition/generate-loose-foreign-key
- マイグレーションと設定の例
- テスト
- 緩い外部キーの注意点
dependent: :destroy
とdependent: :nullify
- 緩い外部キーのリスクと可能な緩和策
- アーキテクチャ
- トラブルシューティング
緩い外部キー
問題ステートメント
リレーショナルデータベース(PostgreSQLを含む)において、外部キーは2つのデータベーステーブルを結び付け、それらの間のデータの一貫性を保証する方法を提供します。GitLabでは、外部キーはデータベース設計プロセスの重要な一部です。私たちのデータベーステーブルのほとんどは外部キーを持っています。
現在進行中のデータベースの分解作業により、リンクされたレコードが2つの異なるデータベースサーバーに存在する可能性があります。2つのデータベース間でデータの一貫性を確保することは、標準的なPostgreSQLの外部キーでは不可能です。PostgreSQLは、ネットワーク経由で2つの異なるデータベースサーバにある2つのデータベーステーブル間のリンクを定義する、1つのデータベースサーバ内での外部キーのオペレーションをサポートしていません。
使用例:
- データベース “Main”:
projects
テーブル - データベース “CI”:
ci_pipelines
テーブル
プロジェクトは多くのパイプラインを持つことができます。プロジェクトが削除されると、関連するci_pipeline
(project_id
カラム経由)のレコードも削除されなければなりません。
マルチデータベースのセットアップでは、外部キーでこれを実現することはできません。
非同期アプローチ
この問題に対する私たちの好ましいアプローチは、最終的な一貫性です。緩やかな外部キー機能を使用すると、アプリケーションのパフォーマンスに悪影響を与えることなく、関連付けのクリーンアップを遅延させる設定ができます。
どのように動作するか
前述の例では、projects
テーブルのレコードは、ci_pipeline
レコードを複数持つことができます。実際の親レコードの削除とは別にクリーンアップ処理を行うには、次のようにします:
-
projects
テーブルにDELETE
トリガーを作成します。削除を別のテーブル (deleted_records
) に記録します。 - ジョブが1~2分ごとに
deleted_records
テーブルをチェックします。 - テーブルの各レコードについて、
project_id
列を使用して、関連するci_pipelines
レコードを削除します。
そのscripts/decomposition/generate-loose-foreign-key
分解作業の一環として、外部キーを緩い外部キーにマイグレーションするための自動化ツールを構築しました。既存のキーを提示し、選択した外部キーを自動的に緩い外部キーに変換できるようにします。これにより、外部キーと緩やかな外部キーの定義間の一貫性が保証され、それらが適切にテストされるようになります。
このツールは、外部キーのスワップのすべての側面をカバーしています。これには以下が含まれます:
- 外部キーを削除するマイグレーションを作成します。
- 新しいマイグレーションで
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_links
のsource_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では、以下の外部キーがprojects
とci_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 ファイルから定義を削除し、削除された保留レコードをデータベースに残さないようにする必要があります。
- 親テーブルから削除追跡トリガーを削除します(親テーブルが残っている場合)。
- 設定 (
config/gitlab_loose_foreign_keys.yml
) から緩い外部キー定義を削除します。 -
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
dependent: :destroy
とdependent: :nullify
外部キーの代替としてこれらのRailsの機能を使うことも考えましたが、以下のようないくつかの問題があります:
- これらはトランザクションのコンテキストで別の接続で実行されるため、私たちはこれを許可していません。
- これらはPostgreSQLから全てのレコードをロードし、Rubyでそれらをループし、個々の
DELETE
クエリを呼び出すため、深刻な性能劣化につながる可能性があります。 - これらは、
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ロックによって保証されます。
レコードのクリーンアップ手順
- Redis ロックを取得します。
- どのデータベースをクリーンアップするかを決定します。
- 削除を追跡するすべてのデータベース・テーブル(親テーブル)を収集します。
- これは、
config/gitlab_loose_foreign_keys.yml
ファイルを読み込むことで達成されます。 - テーブルに緩い外部キー定義が存在し、
DELETE
トリガがインストールされている時、テーブルは “追跡されている “とみなされます。
- これは、
- 無限ループでテーブルを循環させます。
- 各テーブルについて、削除された親レコードのバッチをロードしてクリーンアップします。
- YAML の設定に応じて、参照される子テーブルに対して
DELETE
もしくはUPDATE
(nullify) クエリをビルドします。 - クエリを呼び出します。
- すべての子レコードがクリーンアップされるか、上限に達するまで繰り返します。
- すべての子レコードがクリーンアップされたら、削除された親レコードを削除します。
データベースの構造
この機能は、親テーブルにインストールされたトリガーに依存しています。親レコードが削除されると、トリガーは自動的に新しいレコードを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
テーブルは、データベース分解後、両方のデータベースサーバ (ci
とmain
) に存在します。ワーカーはlib/gitlab/database/gitlab_schemas.yml
YAML ファイルを読むことで、どの親テーブルがどのデータベースに属しているかを判断します。
使用例:
- 主なデータベーステーブル
projects
namespaces
merge_requests
- Ciデータベーステーブル
ci_builds
ci_pipelines
ci
データベースに対してワーカーが起動されると、ワーカーはci_builds
とci_pipelines
テーブルからのみ削除されたレコードをロードします。クリーンアップ処理中、DELETE
とUPDATE
クエリは、主に 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
によって以下の手順で追加されます:
- 新しいパーティションを作成します。パーティションの
VALUE
はCURRENT_PARTITION + 1
です。 -
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
レコードは、緩い外部キー機能によって削除されます。
- クリーンアップワーカーはスケジュールされ、削除された
projects
レコードのバッチをピックアップします。この大規模プロジェクトはバッチの一部です。 - 孤児となった
ci_builds
行の削除が開始されました。 - 制限時間に達しましたが、クリーンアップは完了していません。
- 削除されたレコードの
cleanup_attempts
列がインクリメントされます。 - ステップ 1 に進みます。次のクリーンアップ ワーカーがクリーンアップを続行します。
-
cleanup_attempts
が 3 に達すると、consume_after
列を更新することで、バッチは 10 分後に再スケジュールされます。 - 次のクリーンアップワーカーは別のバッチを処理します。
削除されたレコードのクリーンアップを監視するために、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のバグ
新しいパーティションを追加すると、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;