느슨한 외래 키(Loose Foreign Keys)
GitLab v19.1관계형 데이터베이스(PostgreSQL 포함)에서 외래 키는 두 데이터베이스 테이블을 연결하고 테이블 간 데이터 일관성을 보장하는 방법을 제공합니다. 진행 중인 데이터베이스 분해 작업으로 인해, 연결된 레코드가 두 개의 서로 다른 데이터베이스 서버에 존재할 수 있습니다.
문제 정의#
관계형 데이터베이스(PostgreSQL 포함)에서 외래 키는 두 데이터베이스 테이블을 연결하고 테이블 간 데이터 일관성을 보장하는 방법을 제공합니다. GitLab에서 외래 키는 데이터베이스 설계 프로세스의 핵심 요소입니다. 대부분의 데이터베이스 테이블에는 외래 키가 있습니다.
진행 중인 데이터베이스 분해 작업으로 인해, 연결된 레코드가 두 개의 서로 다른 데이터베이스 서버에 존재할 수 있습니다. 두 데이터베이스 간의 데이터 일관성 보장은 표준 PostgreSQL 외래 키로는 불가능합니다. PostgreSQL은 여러 데이터베이스 서버에 걸친 외래 키를 지원하지 않습니다.
예시:
-
데이터베이스 "Main":
projects테이블 -
데이터베이스 "CI":
ci_pipelines테이블
프로젝트는 여러 파이프라인을 가질 수 있습니다. 프로젝트가 삭제될 때, (project_id 칼럼을 통해) 연관된 ci_pipeline 레코드도 함께 삭제되어야 합니다.
다중 데이터베이스 설정에서는 외래 키로 이를 달성할 수 없습니다.
비동기 방식#
이 문제에 대한 선호하는 접근 방식은 최종적 일관성(eventual consistency)입니다. 느슨한 외래 키 기능을 사용하면 애플리케이션 성능에 부정적인 영향을 미치지 않으면서 지연된 연관 관계 정리를 설정할 수 있습니다.
최종적 일관성의 구현 방식#
앞의 예시에서, projects 테이블의 레코드는 여러 ci_pipeline 레코드를 가질 수 있습니다. 정리 프로세스를 실제 부모 레코드 삭제와 분리하기 위해 다음과 같이 할 수 있습니다:
-
projects테이블에DELETE트리거를 생성합니다. 별도 테이블(deleted_records)에 삭제 내역을 기록합니다. -
job이 1~2분마다
deleted_records테이블을 확인합니다. -
테이블의 각 레코드에 대해,
project_id칼럼을 사용하여 연관된ci_pipelines레코드를 삭제합니다.
이 절차가 작동하려면, 비동기적으로 정리할 테이블을 등록해야 합니다.
scripts/decomposition/generate-loose-foreign-key#
저희는 분해 작업의 일환으로 외래 키를 느슨한 외래 키로 마이그레이션하는 것을 지원하기 위한 자동화 도구를 구축했습니다. 이 도구는 기존 키를 표시하고 선택된 외래 키를 자동으로 느슨한 외래 키로 변환할 수 있습니다. 이를 통해 외래 키와 느슨한 외래 키 정의 간의 일관성을 보장하고, 올바르게 테스트되었는지 확인합니다.
외래 키를 느슨한 외래 키로 교체할 때 자동화 스크립트를 사용하는 것을 강력히 권장합니다.
이 도구는 외래 키 교체의 모든 측면이 처리되도록 합니다. 여기에는 다음이 포함됩니다:
-
외래 키 제거를 위한 마이그레이션 생성.
-
새 마이그레이션으로
db/structure.sql업데이트. -
새 느슨한 외래 키를 추가하기 위해
config/gitlab_loose_foreign_keys.yml업데이트. -
느슨한 외래 키가 올바르게 지원되는지 확인하기 위해 모델의 spec 생성 또는 업데이트.
이 도구는 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
테이블 또는 칼럼의 정확한 이름과 일치시키려면, 정규식 위치 앵커 ^와 $를 사용할 수 있습니다. 예를 들어, 다음 명령어는 incident_management_timeline_events 테이블이 아닌 events 테이블의 외래 키만 일치시킵니다.
scripts/decomposition/generate-loose-foreign-key -n ^events$
모든 외래 키(모두 _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
특정 테이블을 커스텀 워커에 할당#
기본적으로 모든 느슨한 외래 키 정리는 LooseForeignKeys::CleanupWorker가 처리합니다. 그러나 특정 테이블의 정리를 처리할 커스텀 워커 클래스를 지정할 수 있습니다. 이를 통해 더 나은 부하 분산과 다양한 테이블 유형의 특수 처리가 가능합니다.
테이블을 커스텀 워커에 할당하려면, 설정에 worker_class 속성을 추가합니다:
ci_pipelines:
- table: projects
column: project_id
on_delete: async_delete
worker_class: 'CustomLooseForeignKeysWorker'
worker_class 속성이 지정되지 않으면, 해당 테이블은 기본적으로 ::LooseForeignKeys::CleanupWorker를 사용합니다.
중요 고려 사항:
-
worker_class는 문자열로 된 유효한 Ruby 클래스 이름이어야 합니다. -
커스텀 워커는
LooseForeignKeys::CleanupWorker와 동일한 패턴을 따라야 합니다. -
각 워커는
worker_class속성을 통해 특별히 할당된 테이블만 처리합니다. -
worker_class가 지정되지 않은 테이블은 기본CleanupWorker가 처리합니다. -
새 커스텀 워커를 추가할 때,
lib/gitlab/database/loose_foreign_keys.rb의ALLOWED_WORKER_CLASSES상수에도 추가해야 합니다. -
새 커스텀 워커를 추가할 때,
config/schedule.yml(FOSS) 또는ee/config/schedule.yml(EE)에 cron job 설정도 추가해야 합니다.
혼합 워커 할당 예시:
ci_pipelines:
- table: projects
column: project_id
on_delete: async_delete
worker_class: 'CustomCiCleanupWorker' # Processed by CustomCiCleanupWorker
- table: users
column: user_id
on_delete: async_nullify
# No worker_class = processed by default CleanupWorker
ci_builds:
- table: projects
column: project_id
on_delete: async_delete # No worker_class = processed by default CleanupWorker
레코드 변경 사항 추적#
일반 비파티셔닝 테이블에서#
projects 테이블의 삭제에 대해 알기 위해 배포 후 마이그레이션(post-deployment migration)을 사용하여 DELETE 트리거를 설정합니다. 트리거는 한 번만 설정하면 됩니다. 모델에 이미 loose_foreign_key 정의가 하나 이상 있는 경우, 이 단계를 건너뛸 수 있습니다:
class TrackProjectRecordChanges < Gitlab::Database::Migration[2.3]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
def up
track_record_deletions(:projects)
end
def down
untrack_record_deletions(:projects)
end
end
파티셔닝 테이블에서#
파티셔닝 테이블의 삭제를 추적하려면, 대신 track_record_deletions_override_table_name 헬퍼를 사용해야 합니다. DELETE 문이 파티셔닝 테이블이나 해당 파티션에 대해 실행될 때, 파티션(자식) 테이블 이름 대신 항상 부모(파티셔닝) 테이블을 등록해야 하기 때문입니다.
예시:
class TrackWorkloadDeletions < Gitlab::Database::Migration[2.3]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
def up
track_record_deletions_override_table_name(:p_ci_workloads)
end
def down
untrack_record_deletions(:p_ci_workloads)
end
end
id 칼럼이 없는 테이블에서#
기본 추적 헬퍼(track_record_deletions 및 track_record_deletions_override_table_name)는 old_table.id를 참조하는 트리거 함수를 설치합니다. 부모 테이블이 다른 칼럼을 고유 식별자로 사용하는 경우(예: group_id), 트리거는 런타임에 column old_table.id does not exist 오류로 실패합니다.
track_record_deletions_with_custom_column을 사용하여 loose_foreign_keys_deleted_records 테이블의 primary_key_value로 저장될 칼럼 값을 지정합니다:
class TrackCoolWidgetRecordChanges < Gitlab::Database::Migration[2.3]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
def up
track_record_deletions_with_custom_column(
:cool_widgets,
column: :group_id,
function_name: 'lfk_deleted_records_for_group_id', # optional, override if default exceeds 63 chars
trigger_name: 'cool_widgets_loose_fk' # optional, override if default exceeds 63 chars
)
end
def down
untrack_record_deletions(
:cool_widgets,
trigger_name: 'cool_widgets_loose_fk' # must match up migration
)
end
end
column 파라미터는 고유 인덱스가 있는 칼럼을 참조하거나 단독 기본 키여야 합니다. 칼럼이 고유하지 않으면 ArgumentError가 발생합니다. 고유하지 않은 칼럼으로 삭제를 추적하면 의도치 않은 데이터 손실이 발생할 수 있기 때문입니다.
column은 자식 테이블의 외래 키 칼럼에 나타나는 값을 참조해야 합니다. 예를 들어, YAML 설정이 다음과 같은 경우:
cool_widget_states:
- table: cool_widgets
column: cool_widget_group_id
on_delete: async_delete
자식 테이블의 cool_widget_group_id가 부모 테이블의 group_id 칼럼 값을 저장하기 때문에, column: :group_id가 올바릅니다.
트리거 함수는 칼럼 이름(lfk_deleted_records_for_#{column})을 따라 명명되므로, 동일한 칼럼을 사용하는 테이블 간에 공유될 수 있습니다. 파티션의 삭제를 추적할 때, 추적 레코드가 부모 테이블 이름을 저장하도록 parent_table:을 전달합니다:
track_record_deletions_with_custom_column(
partition_identifier, column: :group_id,
parent_table: :cool_widgets
)
파생된 함수 또는 트리거 이름이 PostgreSQL 63자 식별자 제한을 초과하면 ArgumentError가 발생합니다. 더 짧은 이름을 제공하려면 function_name: 및 trigger_name: 파라미터를 사용합니다.
외래 키 제거#
기존 외래 키가 있는 경우, 데이터베이스에서 제거할 수 있습니다. 이 외래 키는 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.3]
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 레코드는 예약된 정리 워커 job에 의해 자동으로 처리됩니다.
느슨한 외래 키 제거#
느슨한 외래 키 정의가 더 이상 필요하지 않은 경우(부모 테이블이 제거되거나 FK가 복원됨), YAML 파일에서 정의를 제거하고 데이터베이스에 보류 중인 삭제된 레코드가 남지 않도록 해야 합니다.
- 설정(
config/gitlab_loose_foreign_keys.yml)에서 느슨한 외래 키 정의를 제거합니다.
삭제 추적 트리거는 부모 테이블이 더 이상 느슨한 외래 키를 사용하지 않는 경우에만 제거해야 합니다. 모델에 아직 하나 이상의 loose_foreign_key 정의가 남아 있으면, 다음 단계를 건너뛸 수 있습니다:
-
부모 테이블에서 트리거를 제거합니다(부모 테이블이 여전히 존재하는 경우).
-
loose_foreign_keys_deleted_records테이블에서 남은 삭제된 레코드를 제거합니다.
트리거 제거를 위한 마이그레이션:
class UnTrackProjectRecordChanges < Gitlab::Database::Migration[2.3]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
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.3]
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)
end
외래 키 제거 후, "cleanup by a loose foreign key" 공유 예시를 사용하여 추가된 느슨한 외래 키를 통해 자식 레코드가 삭제되거나 null로 설정되는 것을 테스트합니다:
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
이 예시는 GitLab에서는 드문 경우입니다. 일반적으로 권한 검사를 수행하기 위해 부모 모델을 조회하기 때문입니다.
dependent: :destroy 및 dependent: :nullify에 대한 참고 사항#
저희는 외래 키의 대안으로 이러한 Rails 기능 사용을 고려했지만, 다음을 포함한 여러 문제가 있습니다:
-
이것들은 허용하지 않는 트랜잭션 컨텍스트에서 다른 연결로 실행됩니다.
-
이것들은 PostgreSQL에서 모든 레코드를 로드하고, Ruby에서 반복하며, 개별
DELETE쿼리를 호출하므로 심각한 성능 저하로 이어질 수 있습니다. -
이것들은
destroy메서드가 모델에서 직접 호출될 때만 적용됩니다.delete_all및 다른 부모 테이블의 캐스케이드 삭제를 포함한 다른 경우에는 놓칠 수 있습니다.
데이터베이스 외부의 데이터를 정리해야 하는 중요한 객체(예: 오브젝트 스토리지)에서 dependent: :destroy를 사용하고 싶다면, 데이터베이스 간 dependent: :nullify 및 dependent: :destroy 사용 금지에서 대안을 참조하세요.
타깃 칼럼을 값으로 업데이트#
느슨한 외래 키는 부모 테이블의 항목이 삭제될 때 타깃 칼럼을 특정 값으로 업데이트하는 데 사용될 수 있습니다.
성능 문제를 방지하기 위해 (column, target_column)에 인덱스(아직 존재하지 않는 경우)를 추가하는 것이 중요합니다. 이 두 칼럼으로 시작하는 모든 인덱스가 작동합니다.
설정에는 추가 정보가 필요합니다:
-
업데이트될 칼럼 (
target_column) -
타깃 칼럼에 설정될 값 (
target_value)
정의 예시:
packages:
- table: projects
column: project_id
on_delete: update_column_to
target_column: status
target_value: 4
느슨한 외래 키의 위험과 가능한 완화 방법#
일반적으로, 느슨한 외래 키 아키텍처는 최종적 일관성을 가지며, 정리 지연으로 인해 GitLab 사용자나 운영자에게 문제가 나타날 수 있습니다. 이러한 트레이드오프는 허용 가능하다고 판단하지만, 문제가 너무 빈번하거나 심각한 경우에는 완화 전략을 구현해야 할 수 있습니다. 일반적인 완화 전략은 지연된 정리로 더 큰 영향을 미치는 레코드 정리를 위한 "긴급" 큐를 두는 것일 수 있습니다.
아래는 발생할 수 있는 더 구체적인 문제 예시와 완화 방법입니다. 나열된 모든 경우에서 설명된 문제가 낮은 위험 및 낮은 영향으로 간주될 수 있으며, 그 경우 완화 방법을 구현하지 않기로 선택할 수 있습니다.
레코드가 삭제되어야 하지만 뷰에 표시되는 경우#
이 가상의 예시는 다음과 같은 외래 키에서 발생할 수 있습니다:
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의 취약점 페이지에 취약점 "발생"이 표시될 수 있습니다. 그러나 발생을 렌더링할 때, 예를 들어 occurrence.pipeline.created_at을 로드하려고 시도하면 사용자에게 500 오류가 발생합니다.
완화 방법
취약점 페이지에서 취약점 발생을 렌더링할 때, 해당 파이프라인을 로드하려 시도하고 파이프라인을 찾을 수 없는 경우 해당 발생 표시를 건너뛰도록 선택할 수 있습니다.
삭제된 부모 레코드가 Sidekiq 워커에서 액세스되어 job 실패를 유발하는 경우#
이 가상의 예시는 다음과 같은 외래 키에서 발생할 수 있습니다:
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 워커가 occurrence.pipeline.created_at을 실행하면 Sidekiq job이 실패할 수 있습니다.
완화 방법
Sidekiq 워커에서 취약점 발생을 반복할 때, 해당 파이프라인을 로드하려 시도하고 파이프라인을 찾을 수 없는 경우 해당 발생 처리를 건너뛰도록 선택할 수 있습니다.
아키텍처#
느슨한 외래 키 기능은 LooseForeignKeys Ruby 네임스페이스 내에 구현됩니다. 코드는 핵심 애플리케이션 코드와 분리되어 있으며 이론적으로 독립적인 라이브러리가 될 수 있습니다.
이 기능은 주로 LooseForeignKeys::CleanupWorker 워커 클래스에 의해 호출됩니다. worker_class 설정 옵션을 통해 특정 테이블에 커스텀 워커를 할당할 수 있습니다. 워커는 GitLab 인스턴스의 설정에 따라 스케줄이 달라지는 cron job을 통해 예약됩니다.
-
비분해 GitLab (데이터베이스 1개): 매분 호출됩니다.
-
분해 GitLab (데이터베이스 2개, CI 및 Main): 매분 호출되며, 한 번에 하나의 데이터베이스를 정리합니다. 예를 들어, 메인 데이터베이스의 정리 워커는 2분마다 실행됩니다.
락 경합을 방지하고 동일한 데이터베이스 행이 처리되는 것을 방지하기 위해, 워커는 병렬로 실행되지 않습니다. 이 동작은 Redis 락으로 보장됩니다.
레코드 정리 절차:
-
Redis 락을 획득합니다.
-
정리할 데이터베이스를 결정합니다.
-
삭제가 추적된 모든 데이터베이스 테이블(부모 테이블)을 수집합니다.
이는 config/gitlab_loose_foreign_keys.yml 파일을 읽어서 수행됩니다.
-
테이블에 느슨한 외래 키 정의가 존재하고
DELETE트리거가 설치된 경우 해당 테이블은 "추적됨"으로 간주됩니다. -
worker_class속성을 통해 커스텀 워커를 사용하는 경우, 각 워커는 다른 워커에 할당된 테이블을 필터링하여 자신에게 특별히 할당된 테이블만 처리합니다. -
무한 루프로 테이블을 순환합니다.
-
각 테이블에 대해, 정리할 삭제된 부모 레코드의 배치를 로드합니다.
-
YAML 설정에 따라 참조된 자식 테이블에 대한
DELETE또는UPDATE(null 설정) 쿼리를 작성합니다. -
쿼리를 실행합니다.
-
모든 자식 레코드가 정리되거나 최대 한도에 도달할 때까지 반복합니다.
-
모든 자식 레코드가 정리되면 삭제된 부모 레코드를 제거합니다.
데이터베이스 구조#
이 기능은 부모 테이블에 설치된 트리거에 의존합니다. 부모 레코드가 삭제될 때, 트리거가 자동으로 loose_foreign_keys_deleted_records 데이터베이스 테이블에 새 레코드를 삽입합니다.
삽입된 레코드는 삭제된 레코드에 대한 다음 정보를 저장합니다:
-
fully_qualified_table_name: 레코드가 위치했던 데이터베이스 테이블의 이름. -
primary_key_value: 레코드의 ID, 이 값은 자식 테이블에 외래 키 값으로 존재합니다. 현재 복합 기본 키는 지원되지 않습니다. 부모 테이블에는 각 행을 고유하게 식별하는 단일 칼럼이 있어야 하며, 그 값이 자식 테이블에서 외래 키로 저장됩니다. 기본적으로, 추적 헬퍼는 이 칼럼의 이름이id라고 가정합니다. 다른 칼럼 이름을 사용하는 테이블의 경우,column파라미터와 함께track_record_deletions_with_custom_column을 사용합니다. -
status: 기본값은 pending으로, 정리 프로세스의 상태를 나타냅니다. -
consume_after: 기본값은 현재 시간입니다. -
cleanup_attempts: 기본값은 0입니다. 워커가 이 레코드를 정리하려고 시도한 횟수입니다. 0이 아닌 숫자는 이 레코드에 많은 자식 레코드가 있어 정리에 여러 번의 실행이 필요함을 의미합니다.
데이터베이스 분해#
데이터베이스 분해 이후, loose_foreign_keys_deleted_records 테이블은 두 데이터베이스 서버(ci 및 main) 모두에 존재합니다. 워커는 lib/gitlab/database/gitlab_schemas.yml YAML 파일을 읽어 어떤 부모 테이블이 어느 데이터베이스에 속하는지 결정합니다.
예시:
- Main 데이터베이스 테이블
projects
-
namespaces -
merge_requests -
CI 데이터베이스 테이블
ci_builds
ci_pipelines
ci 데이터베이스에 대해 워커가 호출되면, 워커는 ci_builds 및 ci_pipelines 테이블에서만 삭제된 레코드를 로드합니다. 정리 프로세스 중에, DELETE 및 UPDATE 쿼리는 주로 Main 데이터베이스에 위치한 테이블에서 실행됩니다. 이 예시에서, 하나의 UPDATE 쿼리가 merge_requests.head_pipeline_id 칼럼을 null로 설정합니다.
데이터베이스 파티셔닝#
데이터베이스 테이블이 매일 받는 대용량 삽입으로 인해, 데이터 비대화 문제를 해결하기 위해 특수한 파티셔닝 전략이 구현되었습니다. 원래 이 기능에는 시간 감쇠(time-decay) 전략이 고려되었지만, 대용량 데이터로 인해 새로운 전략을 구현하기로 결정했습니다.
삭제된 레코드는 모든 직접 자식 레코드가 정리된 경우 완전히 처리된 것으로 간주됩니다. 이 때 느슨한 외래 키 워커가 삭제된 레코드의 status 칼럼을 업데이트합니다. 이 단계 이후, 레코드는 더 이상 필요하지 않습니다.
슬라이딩 파티셔닝 전략은 특정 조건이 충족될 때 새 데이터베이스 파티션을 추가하고 이전 파티션을 제거하여 오래된 미사용 데이터를 효율적으로 정리하는 방법을 제공합니다. loose_foreign_keys_deleted_records 데이터베이스 테이블은 리스트 파티셔닝으로, 대부분의 시간 동안 테이블에 하나의 파티션만 연결되어 있습니다.
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;
파티션 "슬라이딩" 프로세스는 정기적으로 실행되는 두 개의 콜백으로 제어됩니다. 이 콜백들은 LooseForeignKeys::DeletedRecord 모델 내에 정의됩니다.
next_partition_if 콜백은 새 파티션 생성 시기를 제어합니다. 현재 파티션에 24시간 이상 된 레코드가 하나 이상 있으면 새 파티션이 생성됩니다. 새 파티션은 다음 단계를 사용하여 PartitionManager가 추가합니다:
-
새 파티션을 만들며, 파티션의
VALUE는CURRENT_PARTITION + 1입니다. -
partition칼럼의 기본값을CURRENT_PARTITION + 1로 업데이트합니다.
이 단계들을 통해, 트리거를 통한 모든 새로운 INSERT 쿼리는 새 파티션에 저장됩니다. 이 시점에서 데이터베이스 테이블에는 두 개의 파티션이 있습니다.
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(null 설정) 쿼리 예시:
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는 반복을 위해 고유 칼럼이 필요합니다. -
레코드 순서는 정리에 중요하지 않습니다.
두 개의 루프가 있다는 점을 주목하세요. 초기 루프는 SKIP LOCKED 절과 함께 레코드를 처리합니다. 쿼리는 다른 애플리케이션 프로세스에 의해 잠긴 행을 건너뜁니다. 이렇게 하면 정리 워커가 차단될 가능성이 줄어듭니다. 두 번째 루프는 모든 레코드가 처리되었는지 확인하기 위해 SKIP LOCKED 없이 데이터베이스 쿼리를 실행합니다.
처리 한도#
지속적인 대량의 레코드 업데이트 또는 삭제는 인시던트를 유발하고 GitLab의 가용성에 영향을 미칠 수 있습니다:
-
테이블 비대화 증가.
-
보류 중인 WAL 파일 수 증가.
-
바쁜 테이블, 락 획득 어려움.
이러한 문제를 완화하기 위해, 워커 실행 시 여러 한도가 적용됩니다.
-
각 쿼리에는
LIMIT가 있어, 쿼리는 제한 없는 수의 행을 처리할 수 없습니다. -
레코드 삭제 및 레코드 업데이트의 최대 수가 제한됩니다.
-
데이터베이스 쿼리의 최대 실행 시간(30초)이 제한됩니다.
한도 규칙은 LooseForeignKeys::ModificationTracker 클래스에 구현됩니다. 한도(레코드 수정 수, 시간 한도) 중 하나에 도달하면 처리가 즉시 중지됩니다. 일정 시간 후, 다음으로 예약된 워커가 정리 프로세스를 계속합니다.
성능 특성#
부모 테이블의 데이터베이스 트리거는 레코드 삭제 속도를 감소시킵니다. 부모 테이블에서 행을 제거하는 각 문은 loose_foreign_keys_deleted_records 테이블에 레코드를 삽입하기 위해 트리거를 호출합니다.
정리 워커 내의 쿼리는 상당히 효율적인 인덱스 스캔이며, 한도가 적용되어 있어 애플리케이션의 다른 부분에 영향을 미칠 가능성이 낮습니다.
데이터베이스 쿼리는 트랜잭션에서 실행되지 않으므로, 문 타임아웃이나 워커 충돌 같은 오류가 발생하면 다음 job이 처리를 계속합니다.
문제 해결#
삭제된 레코드의 누적#
워커가 비정상적으로 많은 양의 데이터를 처리해야 하는 경우가 있을 수 있습니다. 이는 대형 프로젝트나 그룹이 삭제되는 경우와 같이 일반적인 사용 중에 발생할 수 있습니다. 이 시나리오에서는 수백만 개의 행을 삭제하거나 null로 설정해야 할 수 있습니다. 워커가 적용하는 한도로 인해, 이 데이터를 처리하는 데 시간이 걸립니다.
"대량 처리"를 정리할 때, 이 기능은 더 큰 배치를 나중에 다시 예약하여 공정한 처리를 보장합니다. 이렇게 하면 다른 삭제된 레코드가 처리될 시간이 생깁니다.
예를 들어, 수백만 개의 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번의 정리 시도 후 나중에 다시 예약된 삭제된 레코드의 수.
PromQL 쿼리 예시:
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
쿼리에는 파티션 번호가 포함되어 있어 정리 프로세스가 크게 지연되고 있는지 감지하는 데 유용합니다. 목록에 여러 다른 파티션 값이 있으면, 일부 삭제된 레코드의 정리가 며칠 동안 완료되지 않았음을 의미합니다(하루에 하나의 새 파티션이 추가됨).
문제 진단 단계:
-
어떤 레코드가 누적되고 있는지 확인합니다.
-
남은 레코드 수의 추정값을 구합니다.
-
워커 성능 통계를 확인합니다(Kibana 또는 Grafana).
가능한 해결책:
-
단기: 배치 크기를 늘립니다.
-
장기: 워커를 더 자주 호출합니다. 워커를 병렬화합니다.
일회성 수정을 위해, rails 콘솔에서 정리 워커를 여러 번 실행할 수 있습니다. 워커는 병렬로 실행될 수 있지만, 이는 락 경합을 유발하고 워커 실행 시간을 늘릴 수 있습니다.
LooseForeignKeys::CleanupWorker.new.perform
정리가 완료되면, 이전 파티션은 PartitionManager에 의해 자동으로 분리됩니다.
PartitionManager 버그#
이 문제는 과거에 Staging에서 발생했으며 완화되었습니다.
새 파티션을 추가할 때, 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;