- 문제 설명
- 비동기적 접근 방식
scripts/decomposition/generate-loose-foreign-key
- 마이그레이션 및 구성 예
- 테스트
- 유연한 외래 키의 주의사항
dependent: :destroy
및dependent: :nullify
에 대한 참고사항- 대상 열을 값을로 업데이트
- 느슨한 외래 키의 위험과 가능한 완화책
- 아키텍쳐
- 문제 해결
외부 키 자유롭게 사용하기
문제 설명
관계형 데이터베이스(포스트그리SQL 포함)에서 외부 키는 두 개의 데이터베이스 테이블을 연결하고 그들 간의 데이터 일관성을 보장하는 방법을 제공합니다. GitLab에서 외부 키는 데이터베이스 디자인 프로세스의 중요한 부분입니다. 대부분의 데이터베이스 테이블에는 외부 키가 있습니다.
진행 중인 데이터베이스 분해 작업에 따라 연결된 레코드가 두 개의 다른 데이터베이스 서버에 존재할 수 있습니다. 표준 포스트그리SQL 외부 키로는 두 개의 데이터베이스 간에 데이터 일치성을 보장할 수 없습니다. 포스트그리SQL은 여러 데이터베이스 서버 간에서 작동하는 외부 키를 지원하지 않습니다.
예시:
- 데이터베이스 “Main”:
projects
테이블 - 데이터베이스 “CI”:
ci_pipelines
테이블
프로젝트는 여러 파이프라인을 가질 수 있습니다. 프로젝트가 삭제될 때 연관된 ci_pipeline
(via the project_id
column) 레코드도 삭제되어야 합니다.
다중 데이터베이스 설정에서는 외부 키로 이를 달성할 수 없습니다.
비동기적 접근 방식
이 문제에 대한 우리의 선호하는 접근 방식은 최종적인 일관성입니다. 외부 키 자유롭게 사용하기 기능을 사용하여 지연된 연관 정리를 구성할 수 있으며 애플리케이션 성능에 부정적인 영향을 미치지 않습니다.
작동 방식
이전 예에서 projects
테이블의 레코드는 여러 ci_pipeline
레코드를 가질 수 있습니다. 정리 프로세스를 실제 상위 레코드 삭제와 별도로 유지하기 위해 우리는:
-
projects
테이블에DELETE
트리거를 생성합니다. 별도의 테이블(deleted_records
)에 삭제 내역을 기록합니다. - 작업은 1분 또는 2분마다
deleted_records
테이블을 확인합니다. - 테이블의 각 레코드에 대해
project_id
열을 사용하여 연관된ci_pipelines
레코드를 삭제합니다.
참고: 이 절차가 작동하려면 비동기적으로 정리해야 할 테이블을 등록해야 합니다.
scripts/decomposition/generate-loose-foreign-key
우리는 외부 키를 느슨한 외부 키로 변경하기 위한 이주(migration) 자동화 도구를 개발했습니다. 이는 분해 작업의 핵심 부분으로 기존 키를 제시하고 선택한 외부 키를 자동으로 느슨한 외부 키로 변환할 수 있습니다. 이는 외부 키와 느슨한 외부 키 정의 사이의 일관성을 확보하고 적절하게 테스트되도록 보장합니다.
경고: 우리는 강력히 어떤 외부 키도 느슨한 외부 키로 교체할 때 이 자동화 스크립트를 사용하는 것을 권장합니다.
이 도구는 외부 키를 교체하는 모든 측면을 보장합니다. 이는 다음을 포함합니다:
- 외부 키를 제거하는 이주 생성
- 새로운 이주로
db/structure.sql
업데이트 - 새로운 느슨한 외부 키를 추가하기 위해
config/gitlab_loose_foreign_keys.yml
업데이트 - 느슨한 외부 키가 적절하게 지원되도록 모델의 스펙을 생성하거나 업데이트하기
이 도구는 scripts/decomposition/generate-loose-foreign-key
위치해 있습니다:
$ scripts/decomposition/generate-loose-foreign-key -h
사용법: scripts/decomposition/generate-loose-foreign-key [options] <filters...>
-c, --cross-schema 교차 스키마만 표시
-n, --dry-run 어떤 명령도 실행하지 않습니다 (드라이 런)
-r, --[no-]rspec RSpec 테스트를 자동으로 생성하거나 생성하지 않음
-h, --help 이 도움말 출력
교차 스키마 외부 키의 이주에 대해 우리는 -c
수정자를 사용하여 아직 이주되지 않은 외부 키를 표시합니다:
$ scripts/decomposition/generate-loose-foreign-key -c
현재 테스트 데이터베이스 다시 생성 중
데이터베이스 'gitlabhq_test_ee' 삭제됨
데이터베이스 'gitlabhq_geo_test_ee' 삭제됨
데이터베이스 'gitlabhq_test_ee' 생성됨
데이터베이스 'gitlabhq_geo_test_ee' 생성됨
교차 스키마 외부 키 표시(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
외부 키 (FK)를 맞추려면 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
레코드 변경 추적
projects
테이블에서 삭제에 대해 알아보려면 포스트-배포 마이그레이션을 사용하여 DELETE
트리거를 구성합니다. 이 트리거는 한 번만 구성하면 됩니다. 모델에 이미 하나 이상의 유연한 외래 키
정의가 있는 경우에는 이 단계를 건너뛸 수 있습니다.
class TrackProjectRecordChanges < Gitlab::Database::Migration[2.1]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
def up
track_record_deletions(:projects)
end
def down
untrack_record_deletions(:projects)
end
end
외래 키 제거
기존 외래 키가 있는 경우, 데이터베이스에서 외래 키를 제거할 수 있습니다. 이 외래 키는 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
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
외래 키를 제거한 후에 removing a foreign key,
“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
및 다른 부모 테이블에서의 cascading deletes와 같은 다른 사례를 놓칠 수 있습니다.
데이터베이스 외부의 데이터를 정리해야 하는 비트 데이터의 경우(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;
이 예에서는 ‘vulnerability_occurrence_pipelines’ 레코드와 연결된 모든 연결 레코드를 삭제하는 것으로 예상됩니다. 우리가 이들과 연결된 ‘ci_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 워커에서 액세스되어 실패한 작업을 발생시킵니다
이 가상의 예에서 외래 키로 이러한 문제가 발생할 수 있습니다.
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 작업이 실패합니다.
완화책
Sidekiq 워커에서 취약점 발생을 처리하는 동안, 해당 파이프라인을 로드하고 파이프라인을 찾지 못하면 해당 발생을 처리하지 않도록 선택할 수 있습니다.
아키텍쳐
느슨한 외래 키 기능은 LooseForeignKeys
루비 네임스페이스 내에서 구현됩니다.
이 코드는 핵심 응용프로그램 코드로부터 분리되어 이론적으로 독립적인 라이브러리가 될 수 있습니다.
이 기능은 LooseForeignKeys::CleanupWorker
만에서 호출됩니다. 작업자 클래스입니다. 작업자는 GitLab 인스턴스의 구성에 따라 스케줄링되며 크론 작업을 통해 예정됩니다.
- 분해되지 않은 GitLab(1개의 데이터베이스): 매 분마다 호출합니다.
- 분해된 GitLab(CI 및 Main에서 2개의 데이터베이스): 매 분마다 호출하며, 한 번에 한 데이터베이스를 정리합니다. 예를 들어, 메인 데이터베이스에 대한 정리 작업자는 매 두 분마다 실행됩니다.
잠금 경합과 동일한 데이터베이스 레코드 처리를 피하기 위해 작업자는 병렬로 실행되지 않습니다. 이 동작은 Redis 잠금으로 보장됩니다.
레코드 정리 절차:
- Redis 잠금 획득.
- 정리할 데이터베이스 선택.
- 삭제를 추적하는 모든 데이터베이스 테이블 수집 (부모 테이블).
- 이는
config/gitlab_loose_foreign_keys.yml
파일을 읽어 달성됩니다. - 테이블이 ‘tracked’로 간주되어 해당 테이블에 대한 느슨한 외래 키 정의가 존재하고
DELETE
트리거가 설치되어 있는 경우 “tracked”로 간주됩니다.
- 이는
- 무한 루프를 통해 테이블을 순환합니다.
- 각 테이블마다 삭제된 부모 레코드의 일괄 처리를 로드합니다.
- 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으로, 작업자가 이 레코드를 정리하기 위해 시도한 횟수를 나타냅니다. 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
열을 무효화시킵니다.
데이터베이스 파티셔닝
데이터베이스 테이블이 매일 받는 대량의 삽입으로 인해 데이터 부풀림에 대한 특별한 파티셔닝 전략이 구현되었습니다. 원래는 기능에 대해 시간 감쇠 전략이 고려되었지만, 대량 데이터로 인해 새 전략을 구현하기로 결정했습니다.
삭제된 레코드는 직접 자식 레코드가 모두 정리되면 완전히 처리된 것으로 간주됩니다. 이런 경우에는 loose foreign key 작업자가 삭제된 레코드의 status
열을 업데이트합니다. 이 단계 이후에는 레코드가 더 이상 필요하지 않습니다.
슬라이딩 파티셔닝 전략은 일정 조건이 충족되는 경우 새 데이터베이스 파티션을 추가하고 이전 파티션을 삭제하여 오래된 사용되지 않는 데이터를 효율적으로 정리하는 방법을 제공합니다. loose_foreign_keys_deleted_records
데이터베이스 테이블은 리스트 파티션으로 이루어져 있으며, 대부분의 경우 테이블에 연결된 파티션이 하나뿐입니다.
파티션화된 테이블 "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
값은 트리거를 통해 삭제된 행을 어느 파티션에 삽입할지 결정합니다. 기본값은 리스트 파티션의 값과 일치합니다 (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
에 의해 추가됩니다:
- ‘CURRENT_PARTITION + 1
을 파티션의
VALUE`로 하는 새 파티션을 생성합니다. -
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
(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
는 반복을 위해 고유한 열이 필요합니다. - 레코드 순서는 정리에 중요하지 않습니다.
두 개의 루프가 있는 점에 유의하세요. 초기 루프는 SKIP LOCKED
절을 사용하여 레코드를 처리합니다. 이 쿼리는 다른 응용프로그램 프로세스에 의해 잠겨진 행을 건너뛰어 정리 작업 프로세스가 차단될 가능성을 줄입니다. 두 번째 루프는 SKIP LOCKED
없이 데이터베이스 쿼리를 실행하여 모든 레코드가 처리되었는지 확인합니다.
처리 제한
일정 수의 대규모 레코드 업데이트 또는 삭제는 사고를 유발하고 GitLab의 가용성에 영향을 줄 수 있습니다.
- 테이블 블로트 증가.
- 보류 중인 WAL 파일 수 증가.
- 테이블이 바쁘고 잠금을 획들기 어려움.
이러한 문제를 완화하기 위해 작업자가 실행될 때 여러 제한이 적용됩니다.
- 각 쿼리에는
LIMIT
가 있어서 무한한 행 처리를 할 수 없습니다. - 최대 레코드 삭제 및 업데이트 수가 제한됩니다.
- 데이터베이스 쿼리의 최대 실행 시간(30초)이 제한됩니다.
제한 규칙은 LooseForeignKeys::ModificationTracker
클래스에 구현됩니다. 레코드 수정 횟수 또는 시간 제한 중 하나에 도달하면 처리가 즉시 중단됩니다. 시간이 지난 후 예정된 작업자가 정리 작업을 계속합니다.
성능 특성
부모 테이블의 데이터베이스 트리거는 레코드 삭제 속도를 감소시킵니다. 부모 테이블에서 행을 제거하는 각 문은 loose_foreign_keys_deleted_records
테이블에 레코드를 삽입하기 위해 트리거를 활성화합니다.
정리 작업자 내의 쿼리는 상당히 효율적인 인덱스 스캔이며, 제한이 있어서 응용프로그램의 다른 부분에 영향을 미치지 않을 것입니다.
데이터베이스 쿼리는 트랜잭션 내에서 실행되지 않으며, 예를 들어 문 실행 시간 초과 또는 작업자 충돌이 발생하는 경우 다음 작업이 처리를 계속합니다.
문제 해결
삭제된 레코드의 축적
일반적인 사용 중에는 작업자가 비정상적으로 많은 데이터를 처리해야 하는 경우가 있을 수 있습니다. 예를 들어, 대규모 프로젝트 또는 그룹을 삭제할 때 발생할 수 있습니다. 이러한 시나리오에서 몇 백만 개의 레코드를 삭제하거나 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
이 쿼리에는 정리 프로세스가 상당히 지연되고 있는지 여부를 감지하는 데 유용한 파티션 번호가 포함되어 있습니다. 목록에 여러 다른 파티션 값이 있는 경우, 몇 일 동안 일부 삭제된 레코드의 정리가 완료되지 않았음을 의미합니다(하루에 1개의 새 파티션이 추가됩니다).
문제를 진단하기 위한 단계:
- 축적되는 레코드를 확인합니다.
- 남은 레코드 수에 대한 추정치를 얻으려고 시도합니다.
- 작업자 성능 통계(Kibana 또는 Grafana)를 살펴봅니다.
가능한 해결책:
- 단기적: 일괄 크기를 늘립니다.
- 장기적: 작업자를 더 자주 실행합니다. 작업자를 병렬화합니다.
일회성으로 수정하는 경우, 레일스 콘솔에서 정리 작업자를 여러 번 실행할 수 있습니다. 작업자는 병렬로 실행될 수 있지만, 이는 잠금 충돌을 발생시키고 작업자 실행 시간을 증가시킬 수 있습니다.
LooseForeignKeys::CleanupWorker.new.perform
정리가 완료되면 이전 파티션은 자동으로 PartitionManager
에 의해 분리됩니다.
PartitionManager 버그
참고: 이 문제는 이전에 스테이징에서 발생했으며 완화되었습니다.
새 파티션을 추가할 때 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;