교차하는 외래 키들

문제 설명

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

진행 중인 데이터베이스 분해 작업으로 인해, 연결된 레코드는 두 개의 다른 데이터베이스 서버에 존재할 수 있습니다. 표준 포스트그레SQL 외래 키로는 두 데이터베이스 간의 데이터 일관성을 보장할 수 없습니다. 포스트그레SQL은 네트워크를 통해 두 개의 다른 데이터베이스 서버에서 두 데이터베이스 테이블 간의 연결을 정의하는 외래 키를 지원하지 않습니다.

예시:

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

프로젝트는 여러 파이프라인을 가질 수 있습니다. 프로젝트가 삭제될 때 연결된 ci_pipeline(via project_id 열) 레코드도 삭제되어야 합니다.

멀티 데이터베이스 설정에서는 이것을 외래 키로 달성할 수 없습니다.

비동기적 접근

이 문제에 대한 저희의 선호하는 방법은 최종적인 일관성입니다. 느슨한 외래 키(Loose Foreign Key) 기능을 통해 지연된 연관 정리를 설정하여 응용 프로그램 성능에 부정적인 영향을 미치지 않고, 일관성을 유지할 수 있습니다.

작동 방식

이전 예시에서 projects 테이블의 레코드는 여러 ci_pipeline 레코드를 가질 수 있습니다. 청소 과정을 실제 부모 레코드 삭제에서 분리하기 위해, 우리는:

  1. projects 테이블에 DELETE 트리거를 생성합니다. 별도의 테이블(deleted_records)에 삭제를 기록합니다.
  2. 작업이 1분 또는 2분마다 deleted_records 테이블을 확인합니다.
  3. 테이블의 각 레코드에 대해 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
현재 테스트 데이터베이스 다시 생성 중
데이터베이스 '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

명령어는 외래 키 생성 목적으로 FROM, TO 또는 COLUMN과 일치시키기 위해 정규 표현식 목록을 허용합니다. 예를 들어, 분해된 데이터베이스를 위해 ci_job_token_project_scope_links의 모든 외래 키를 교체하려면 다음을 실행하세요:

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

분해된 데이터베이스를 위해 ci_job_token_project_scope_linkssource_project_id만 교체하려면 다음을 실행하세요:

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

테이블이나 열의 정확한 이름을 일치시키기 위해 정규 표현식 위치 앵커 ^$을 사용할 수 있습니다. 예를 들어, 이 명령은 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

만약 YAML 파일에 ci_pipelines 키가 이미 존재한다면, 새 항목을 배열에 추가할 수 있습니다.

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

외부 키 제거

기존 외부 키가 있는 경우, 데이터베이스에서 외부 키를 제거할 수 있습니다. GitLab 14.5부터 다음 외부 키가 projectsci_pipelines 테이블 간의 연결을 설명합니다.

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

마이그레이션은 DELETE 트리거가 설치되고 느슨한 외부 키 정의가 배포된 후에 실행되어야 합니다. 따라서 이것은 트리거의 마이그레이션 이후에 날짜가 지정된 포스트-배포 마이그레이션이어야 합니다. 외부 키가 이전에 삭제되면 수동 정리가 필요한 데이터 불일치 가능성이 큽니다.

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

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

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

이 시점에서 설정 단계가 완료됩니다. 삭제된 projects 레코드는 예약된 정리 작업으로 자동으로 처리되어야 합니다.

느슨한 외부 키 제거

느슨한 외부 키 정의가 더 이상 필요하지 않은 경우(부모 테이블이 제거되거나 FK가 복원됨), YAML 파일에서 정의를 제거하고 데이터베이스에 잔류한 삭제된 레코드가 없도록 해야 합니다.

  1. 구성에서 느슨한 외부 키 정의를 제거합니다(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
    # 아무 작업도 필요하지 않음
  end
end

테스팅

it has loose foreign keys” 공유 예제는 ON DELETE 트리거와 loose foreign key 정의의 존재 여부를 테스트하는 데 사용할 수 있습니다.

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

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
  # 나쁨, 피하세요
  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 및 다른 부모 테이블에서의 하위 삭제)에서 누락될 수 있습니다.

데이터베이스 외 데이터를 정리해야 하는 중요한 객체에 대해서는 dependent: :destroy를 사용하고 싶을 수 있지만, Avoid dependent: :nullify and dependent: :destroy across databases에서 대안을 참조하세요.

외래 키에 대한 위험 및 가능한 완화책

일반적으로, 느슨한 외래 키 아키텍처는 최종적으로 일관성을 가지며 정리 지연 시간이 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 레코드를 삭제하기를 기대합니다. 이 경우에는 취약성 페이지에서 취약성의 발생을 보여주는 일부가 예상대로 나타날 수 있습니다. 그러나 해당 파이프라인에 대한 링크를 선택하려고 하면 삭제된 파이프라인 때문에 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 오류가 발생할 수 있습니다.

미타게이션(Mitigation)

취약점 페이지에서 취약점 발생을 렌더링할 때, 해당하는 파이프라인을 로드하고 파이프라인을 찾지 못한 경우에는 해당 발생을 표시하지 않도록 선택할 수 있습니다.

삭제된 부모 레코드는 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 작업이 실패할 수 있습니다.

미타게이션(Mitigation)

Sidekiq 워커에서 취약점 발생을 순환할 때, 해당하는 파이프라인을 로드하고 파이프라인을 찾지 못한 경우에는 해당 발생을 처리하지 않도록 선택할 수 있습니다.

구조

느슨한 외부 키(Loose Foreign Keys) 기능은 LooseForeignKeys Ruby 네임스페이스 내에 구현되어 있습니다. 코드는 핵심 애플리케이션 코드와 격리되어 있으며 이론적으로 독립적인 라이브러리가 될 수 있습니다.

해당 기능은 LooseForeignKeys::CleanupWorker 워커 클래스 내에서만 호출됩니다. 이 워커는 GitLab 인스턴스의 구성에 따라 스케줄된 크론 작업을 통해 실행됩니다.

  • 분해되지 않은 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 열을 무효화시킵니다.

데이터베이스 파티셔닝

매일 데이터베이스 테이블이 수신하는 대량의 삽입으로 인해 데이터 부풀림에 대한 우려가 있었습니다. 이에 특별한 파티셔닝 전략이 도입되었습니다. 원래는 time-decay 전략이 고려되었지만, 대량의 데이터로 인해 새로운 전략을 도입하기로 결정했습니다.

삭제된 레코드는 직접적인 하위 레코드들이 모두 정리되었을 때 완전히 처리된 것으로 간주됩니다. 이런 경우에 느슨한 외래 키(worker)가 삭제된 레코드의 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 값은 트리거를 통해 삭제된 행을 삽입할 파티션을 결정합니다. 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. CURRENT_PARTITION + 1을 파티션의 VALUE로 하는 새로운 파티션을 생성합니다.
  2. partition 열의 기본값을 CURRENT_PARTITION + 1로 업데이트합니다.

이러한 단계로 인해 모든 새로운 INSERT 쿼리가 새로운 파티션에 끝내게 됩니다. 이 시점에 데이터베이스 테이블에는 두 개의 파티션이 있게 됩니다.

detach_partition_if 콜백은 이전 파티션이 테이블에서 분리될 수 있는지를 결정합니다. 파티션이 분리 가능한 경우에는 파티션에 보류 중인(미처리) 레코드가 없을 때입니다. 분리된 파티션은 잠시 후에 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 클래스에 구현됩니다. 레코드 수정 수 또는 시간 제한 중 하나에 도달하면 처리가 즉시 중지됩니다. 일정 시간 후에 다음 예약된 worker가 정리 프로세스를 계속합니다.

성능 특성

부모 테이블의 데이터베이스 트리거는 레코드 삭제 속도를 낮춥니다. 부모 테이블의 행을 제거하는 각 문은 loose_foreign_keys_deleted_records 테이블에 레코드를 삽입하기 위해 트리거를 호출합니다.

정리 worker 내의 쿼리는 매우 효율적인 인덱스 스캔이며, 적절한 제한이 있기 때문에 응용 프로그램의 다른 부분에 영향을 미칠 가능성이 거의 없습니다.

데이터베이스 쿼리는 트랜잭션 내에서 실행되지 않습니다. 예를 들어 문 실행 시간 초과 또는 worker 충돌이 발생하는 경우 다음 작업이 처리를 계속합니다.

문제 해결

삭제된 레코드의 축적

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

“중요한” 요소를 정리할 때 기능은 더 큰 일괄 처리를 나중에 다시 예약하여 공정한 처리를 보장합니다. 이를 통해 다른 삭제된 레코드가 처리되는 데 있어 시간을 부여합니다.

예를 들어, 수백만 개의 ci_builds 레코드가 있는 프로젝트가 삭제됩니다. ci_builds 레코드는 loose foreign keys 기능에 의해 삭제됩니다.

  1. 정리 worker가 예약되어 삭제된 projects 레코드의 일괄 처리 중 하나를 가져옵니다. 큰 프로젝트가 일괄 처리의 일부입니다.
  2. 고아가 된 ci_builds 행의 삭제가 시작되었습니다.
  3. 시간 제한에 도달했지만 정리가 완료되지 않았습니다.
  4. 삭제된 레코드의 cleanup_attempts 열이 증가했습니다.
  5. 1단계로 이동합니다. 다음 정리 worker가 정리를 계속합니다.
  6. cleanup_attempts가 3이 되면 consume_after 열을 업데이트하여 10분 후에 일괄 처리를 다시 예약합니다.
  7. 다음 정리 worker가 다른 일괄 처리를 처리합니다.

삭제된 레코드의 정리를 모니터링하기 위해 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

이 쿼리에는 정리 프로세스가 심각하게 지연되고 있는지 여부를 감지하는 데 유용한 파티션 번호가 포함되어 있습니다. 목록에 여러 가지 다른 파티션 값이 표시되는 경우, 몇 일 동안 일부 삭제된 레코드의 정리가 완료되지 않았음을 의미합니다(매일 새로운 파티션이 추가됨).

문제를 진단하기 위한 단계:

  • 누적되는 레코드 확인
  • 잔여 레코드의 수를 추정해 보세요.
  • 작업자 성능 통계(Kibana 또는 Thanos)를 살펴보기

가능한 해결책:

  • 단기적: 일괄 크기 증가
  • 장기적: 작업자 더 자주 호출. 작업자 병렬화

일회성 해결책으로 레일스 콘솔에서 몇 번 LooseForeignKeys::CleanupWorker를 실행할 수 있습니다. 작업자는 병렬로 실행될 수 있지만, 이는 잠금 내용을 도입할 수 있으며 작업자 실행 시간을 증가시킬 수 있습니다.

LooseForeignKeys::CleanupWorker.new.perform

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

PartitionManager 버그

참고: 이 문제는 이전에 Staging에서 발생했으며 완화되었습니다.

새 파티션을 추가할 때 partition 열의 기본값도 업데이트됩니다. 이는 새 파티션 생성과 동일한 트랜잭션에서 실행되는 스키마 변경입니다. ‘partition’ 열이 오래된 정보를 가지고 있는 경우가 매우 희박합니다.

그러나 이러한 경우에 애플리케이션 전역 사고를 발생시킬 수 있습니다. 왜냐하면 ‘partition’ 값이 존재하지 않는 파티션을 가리키기 때문입니다. 증상: 삭제 트리거가 설치된 테이블에서 레코드 삭제에 실패합니다.

\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;