느슨한 외래 키

문제 진술

관계형 데이터베이스(포함하여 PostgreSQL)에서 외래 키는 두 개의 데이터베이스 테이블을 연결하는 방법을 제공하고, 그들 간의 데이터 일관성을 보장합니다. GitLab에서는 외래 키가 데이터베이스 설계 프로세스의 중요한 부분입니다. 대부분의 데이터베이스 테이블에는 외래 키가 있습니다.

진행 중인 데이터베이스 분해 작업으로 인해, 연결된 레코드가 두 개의 서로 다른 데이터베이스 서버에 존재할 수 있습니다. 두 데이터베이스 간의 데이터 일관성을 보장하는 것은 표준 PostgreSQL 외래 키로는 불가능합니다. PostgreSQL은 여러 데이터베이스 서버에서 작동하는 외래 키를 지원하지 않습니다.

예시:

  • 데이터베이스 “Main”: projects 테이블
  • 데이터베이스 “CI”: ci_pipelines 테이블

하나의 프로젝트에는 여러 개의 파이프라인이 있을 수 있습니다. 프로젝트가 삭제될 때, 관련된 ci_pipeline(via project_id 열) 레코드도 삭제되어야 합니다.

다중 데이터베이스 설정에서는 외래 키로 이를 달성할 수 없습니다.

비동기 접근 방식

이 문제에 대한 우리의 선호하는 접근 방식은 최종 일관성입니다. 느슨한 외래 키 기능을 통해 우리는 애플리케이션 성능에 부정적인 영향을 미치지 않고 지연된 연결 정리를 구성할 수 있습니다.

작동 방식

이전 예시에서 projects 테이블의 레코드는 여러 개의 ci_pipeline 레코드를 가질 수 있습니다. 정리 프로세스를 실제 부모 레코드 삭제와 분리하기 위해 우리는:

  1. projects 테이블에 DELETE 트리거를 생성합니다.
    삭제된 내용을 별도의 테이블(deleted_records)에 기록합니다.

  2. 작업이 매분 또는 2분마다 deleted_records 테이블을 확인합니다.

  3. 테이블의 각 레코드에 대해 연결된 ci_pipelines 레코드를 project_id 열을 사용하여 삭제합니다.

참고:
이 절차가 작동하려면 비동기적으로 정리할 테이블을 등록해야 합니다.

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

사용법: scripts/decomposition/generate-loose-foreign-key [옵션] <필터...>
    -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

테이블이나 열의 정확한 이름과 일치하도록 하려면 정규 표현식 위치 앵커 ^$를 사용할 수 있습니다. 예를 들어, 이 명령은 events 테이블의 외래 키만 일치시키지만 incident_management_timeline_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 트리거를 구성합니다. 트리거는 한 번만 구성하면 됩니다. 만약 모델에 이미 하나 이상의 loose_foreign_key 정의가 있다면 이 단계를 건너뛰어도 됩니다:

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

외래 키 제거

기존 외래 키가 있는 경우, 데이터베이스에서 제거할 수 있습니다. 이 외래 키는 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. 구성에서 느슨한 외래 키 정의를 제거합니다 (config/gitlab_loose_foreign_keys.yml).

부모 테이블이 더 이상 느슨한 외래 키를 사용하지 않을 때만 삭제 추적 트리거를 제거해야 합니다. 만약 모델에 아직 하나 이상의 loose_foreign_key 정의가 남아 있다면, 이 단계는 건너뛰어도 됩니다:

  1. 부모 테이블에서 트리거를 제거합니다(부모 테이블이 여전히 존재하는 경우).
  2. 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

테스트

해외 키가 느슨하다” 공유 예제를 사용하여 ON DELETE 트리거의 존재와 느슨한 외래 키 정의를 테스트할 수 있습니다.

모델 테스트 파일에 추가하세요:

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

외래 키 제거 후에, “느슨한 외래 키에 의한 정리” 공유 예제를 사용하여 추가된 느슨한 외래 키를 통해 자식 레코드의 삭제 또는 nullification을 테스트하세요:

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
  # 나쁨, 피하세요
  pipeline = Ci::Pipeline.find(params[:id]) # 100
end

부모 Project 모델이 삭제되었을 때 이 엔드포인트는 여전히 작동합니다. 이는 일반적인 상황에서 발생해서는 안 되는 데이터 유출로 간주될 수 있습니다:

def show
  # 좋음
  project = Project.find(params[:project_id])
  pipeline = project.pipelines.find(params[:pipeline_id]) # 100
end

참고:
이 예시는 GitLab에서 발생하기 어렵습니다. 보통은 부모 모델을 조회하여 권한 확인을 수행하기 때문입니다.

dependent: :destroydependent: :nullify에 대한 주의사항

우리는 외래 키의 대안으로 이러한 Rails 기능을 사용하는 것을 고려했지만 여러 가지 문제점이 있습니다:

  1. 이들은 트랜잭션의 맥락에서 다른 연결에서 실행됩니다 우리는 이를 허용하지 않습니다.
  2. PostgreSQL에서 모든 레코드를 로드하고, Ruby에서 이를 반복하며, 개별 DELETE 쿼리를 호출하므로 심각한 성능 저하를 초래할 수 있습니다.
  3. 이러한 방법은 직접 모델에서 destroy 메소드가 호출될 때의 경우만 다루므로 데이터를 놓칠 수 있습니다. delete_all 및 다른 부모 테이블에서의 cascading delete와 같은 다른 경우도 있을 수 있으므로 이러한 경우에는 놓칠 수 있습니다.

데이터베이스 외부에서 데이터를 정리해야 하는 복잡한 객체의 경우 (dependent: :destroy 사용을 원할 수도 있음)
대안에 대해서는
데이터베이스 간 dependent: :nullifydependent: :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 사용자 또는 운영자에게 가시적인 문제를 일으킬 수 있습니다.
우리는 이러한 거래를 수용 가능하다고 간주하지만,
문제가 너무 자주 발생하거나 심각한 경우 완화 전략을 구현해야 합니다.
일반적인 완화 전략은 더 높은 영향을 미치는 기록의 정리를 위해
“긴급” 큐를 두고 지연된 정리를 하는 것일 수 있습니다.

아래는 발생할 수 있는 문제에 대한 몇 가지 구체적인 예시와 이를 완화할 수 있는 방법입니다.
나열된 모든 경우에서 우리는 여전히
정 described된 문제를 낮은 위험 및 낮은 영향으로 간주할 수 있으며,
그럴 경우 완화를 구현하지 않기로 선택할 것입니다.

기록이 삭제되어야 하지만 뷰에 표시되는 경우

이 가상의 예시는 다음과 같은 외래 키와 함께 발생할 수 있습니다:

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 작업자에서 접근되어 작업 실패를 유발하는 경우

이 가상의 예시는 다음과 같은 외래 키와 함께 발생할 수 있습니다:

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 작업이 실패할 수 있습니다.

완화 방안

Sidekiq 작업자에서 취약점 발생을 반복할 때
해당 파이프라인을 로드하려고 시도하고,
파이프라인을 찾을 수 없는 경우에는 해당 발생 처리를 건너뛰기를 선택할 수 있습니다.

아키텍처

Loose foreign keys 기능은 LooseForeignKeys Ruby 네임스페이스 내에 구현됩니다.

코드는 핵심 애플리케이션 코드와 격리되어 있으며, 이론적으로 독립형 라이브러리가 될 수 있습니다.

이 기능은 오직 LooseForeignKeys::CleanupWorker 워커 클래스에서 호출됩니다.

워커는 GitLab 인스턴스의 구성에 따라 스케줄링된 cron 작업을 통해 실행됩니다.

  • 비분해 GitLab (1 데이터베이스): 매분 호출됩니다.
  • 분해 GitLab (2 데이터베이스, CI 및 Main): 매분 호출되며, 매번 하나의 데이터베이스만 정리합니다. 예를 들어, 메인 데이터베이스의 정리 워커는 매 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: 기본값은 보류 중이며, 정리 프로세스의 상태를 나타냅니다.
  • consume_after: 기본값은 현재 시간입니다.
  • cleanup_attempts: 기본값은 0입니다. 워커가 이 레코드를 정리하려고 시도한 횟수입니다. 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 쿼리는 주로 메인 데이터베이스에 있는 테이블에서 실행됩니다. 이 예제에서, 하나의 UPDATE 쿼리는 merge_requests.head_pipeline_id 열을 nullify합니다.

데이터베이스 파티셔닝

데이터베이스 테이블이 매일 받는 대량의 삽입으로 인해 데이터 증가 문제를 해결하기 위해 특별한 파티셔닝 전략이 구현되었습니다. 처음에 이 기능을 위해 시간 감소 전략이 고려되었으나, 대량의 데이터로 인해 새로운 전략을 구현하기로 결정했습니다.

삭제된 레코드는 모든 직접 자식 레코드가 정리되었을 때 완전히 처리된 것으로 간주됩니다. 이 작업이 완료되면, 느슨한 외래 키 워커가 삭제된 레코드의 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를 사용하여 다음 단계를 통해 추가됩니다:

  1. 새 파티션 생성, 여기서 파티션에 대한 VALUECURRENT_PARTITION + 1입니다.
  2. partition 열의 기본 값을 CURRENT_PARTITION + 1로 업데이트합니다.

이 단계에 따라 트리거를 통한 모든 새로운 INSERT 쿼리는 새 파티션에 들어가게 됩니다. 이 시점에서 데이터베이스 테이블은 두 개의 파티션을 가집니다.

detach_partition_if 콜백은 오래된 파티션을 테이블에서 분리할 수 있는지를 결정합니다. 파티션은 파티션에 대기(처리되지 않은) 레코드가 없는 경우 분리 가능(status = 1)합니다. 분리된 파티션은 일정 시간 동안 사용 가능하며, detached_partitions 테이블에서 분리된 파티션 목록을 확인할 수 있습니다:

select * from detached_partitions;

정리 쿼리

LooseForeignKeys::CleanupWorkerArel에 의존하는 데이터베이스 쿼리 빌더를 가지고 있습니다.

이 기능은 예상치 못한 부작용을 피하기 위해 애플리케이션 특정 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 테이블에 기록을 삽입하기 위해 트리거를 호출합니다.

정리 작업자 내의 쿼리는 비교적 효율적인 인덱스 스캔으로, 한도가 설정된 경우 애플리케이션의 다른 부분에 영향을 미칠 가능성이 적습니다.

데이터베이스 쿼리는 트랜잭션 내에서 실행되지 않으며, 예를 들어 문장 시간 초과 또는 작업자 충돌과 같은 오류가 발생하면 다음 작업이 프로세스를 계속 진행합니다.

문제 해결

삭제된 레코드의 누적

작업자가 비정상적으로 대량의 데이터를 처리해야 하는 경우가 있을 수 있습니다. 이는 예를 들어 대규모 프로젝트나 그룹이 삭제될 때와 같이 일반적인 사용 하에서도 발생할 수 있습니다. 이 경우 삭제되거나 무효화해야 할 수백만 개의 행이 존재할 수 있습니다. 작업자가 적용하는 한계로 인해 이 데이터를 처리하는 데 다소 시간이 걸립니다.

“헤비 히터”를 정리할 때, 기능은 더 큰 배치를 나중에 재일정화하여 공정한 처리를 보장합니다. 이를 통해 다른 삭제된 레코드가 처리될 시간을 줍니다.

예를 들어, 수백만 개의 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번의 청소 시도가 있었던 후에 나중에 재일정화해야 했던 삭제된 레코드 수.

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)를 살펴보세요.

가능한 해결책:

  • 단기: 배치 크기를 늘립니다.

  • 장기: 작업자를 더 자주 호출합니다. 작업자를 병렬화합니다.

일회성 수정을 위해, 레일 콘솔에서 청소 작업자를 여러 번 실행할 수 있습니다. 작업자는 병렬로 실행될 수 있으나, 이는 잠금 경합이 발생할 수 있으며 작업자 실행 시간이 증가할 수 있습니다.

LooseForeignKeys::CleanupWorker.new.perform

정리가 완료되면, 오래된 파티션은 PartitionManager에 의해 자동으로 분리됩니다.

PartitionManager 버그

note
이 문제는 과거 Staging에서 발생했으며 완화되었습니다.

새 파티션을 추가할 때 partition 열의 기본값도 업데이트됩니다. 이는 새 파티션 생성과 동일한 트랜잭션에서 실행되는 스키마 변경입니다. partition 열이 구식이 되는 것은 매우unlikely합니다.

그러나 이런 일이 발생하면 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 vs 3)과 비교하세요. 값이 4인 파티션은 존재하지 않습니다. 문제를 완화하기 위해 비상 스키마 변경이 필요합니다:

ALTER TABLE loose_foreign_keys_deleted_records ALTER COLUMN partition SET DEFAULT 3;