InfoGrab DocsInfoGrab Docs

다중 데이터베이스

요약

GitLab을 더욱 확장할 수 있도록 GitLab 애플리케이션 데이터베이스를 여러 데이터베이스로 분리했습니다. 서로 다른 데이터베이스 간에 허용되는 패턴을 올바르게 파악하기 위해 GitLab 애플리케이션은 데이터베이스 딕셔너리를 구현합니다.

GitLab을 더욱 확장할 수 있도록 GitLab 애플리케이션 데이터베이스를 여러 데이터베이스로 분리했습니다. 주요 데이터베이스는 main, ci, sec입니다. GitLab은 데이터베이스 한 개, 두 개, 세 개로 실행하는 것을 지원합니다. GitLab.com에서는 별도의 main, ci, sec 데이터베이스를 사용하고 있습니다.

GitLab 스키마#

서로 다른 데이터베이스 간에 허용되는 패턴을 올바르게 파악하기 위해 GitLab 애플리케이션은 데이터베이스 딕셔너리를 구현합니다.

데이터베이스 딕셔너리는 테이블을 gitlab_schema로 가상 분류하는 기능을 제공하며, 이는 개념적으로 PostgreSQL 스키마와 유사합니다. CI 분리 기능을 더 잘 격리하기 위해 데이터베이스 스키마를 사용하는 방안의 일환으로, 복잡한 마이그레이션 절차 때문에 PostgreSQL 스키마를 사용할 수 없다고 결정했습니다. 대신 애플리케이션 수준 분류 개념을 구현했습니다. GitLab의 모든 테이블에는 gitlab_schema가 할당되어야 합니다:

스키마 설명 비고
gitlab_main Cells / Organizations 스키마를 참고합니다
gitlab_main_org Cells / Organizations 스키마를 참고합니다
gitlab_main_cell_setting Cells / Organizations 스키마를 참고합니다
gitlab_main_cell_local Cells / Organizations 스키마를 참고합니다
gitlab_ci ci: 데이터베이스에 저장되는 모든 CI 테이블(예: ci_pipelines, ci_builds)
gitlab_ci_cell_local Cells / Organizations 스키마를 참고합니다
gitlab_geo geo: 데이터베이스에 저장되는 모든 Geo 테이블(예: project_registry, secondary_usage_data)
gitlab_internal Rails 및 PostgreSQL의 모든 내부 테이블(예: ar_internal_metadata, schema_migrations, pg_*)
gitlab_pm package_metadata를 저장하는 모든 테이블 gitlab_main의 별칭이며 gitlab_sec로 대체될 예정입니다
gitlab_sec sec: 데이터베이스에 저장되는 모든 보안 및 취약점 기능 테이블
gitlab_sec_cell_local Cells / Organizations 스키마를 참고합니다 샤딩 키가 없는, sec: 데이터베이스의 cell-local 비고객 참조 데이터(예: 멀웨어 권고문)
gitlab_shared 사용 중단되었습니다. gitlab_shared_cell_local 또는 gitlab_shared_org를 참고합니다
gitlab_shared_cell_local Cells / Organizations 스키마를 참고합니다
gitlab_shared_org Cells / Organizations 스키마를 참고합니다

추가로 분리되는 데이터베이스와 함께 더 많은 스키마가 도입될 예정입니다.

스키마 사용은 사용해야 하는 기본 클래스를 강제합니다:

  • ApplicationRecord — gitlab_main_org용
  • Ci::ApplicationRecord — gitlab_ci용
  • Geo::TrackingBase — gitlab_geo용
  • Gitlab::Database::SharedModel — gitlab_shared용
  • PackageMetadata::ApplicationRecord — gitlab_pm용
  • SecApplicationRecord — gitlab_sec용

모든 cell-local 테이블에 대한 샤딩 키 정의#

이 콘텐츠는 새 위치로 이동했습니다

gitlab_schema의 영향#

gitlab_schema 사용은 애플리케이션에 상당한 영향을 미칩니다. gitlab_schema의 주된 목적은 서로 다른 데이터 접근 패턴 사이에 장벽을 도입하는 것입니다.

다음 항목의 주요 분류 소스로 사용됩니다:

gitlab_shared의 특별한 목적#

gitlab_shared는 설계상 모든 분리된 데이터베이스에 걸쳐 데이터를 포함하는 테이블 또는 뷰를 설명하는 특수한 경우입니다. 이 분류는 애플리케이션이 정의한 테이블(loose_foreign_keys_deleted_records 등)을 설명합니다.

gitlab_shared는 데이터에 접근할 때 특별한 처리가 필요하므로 주의해서 사용합니다. gitlab_shared는 구조뿐 아니라 데이터도 공유하므로, 애플리케이션은 모든 데이터베이스의 모든 데이터를 순차적으로 순회하는 방식으로 작성해야 합니다.

Gitlab::Database::EachDatabase.each_model_connection([MySharedModel]) do |connection, connection_name|
  MySharedModel.select_all_data...
end

따라서 gitlab_shared 테이블의 데이터를 수정하는 마이그레이션은 모든 분리된 데이터베이스에서 실행되어야 합니다.

gitlab_internal의 특별한 목적#

gitlab_internal은 Rails가 정의한 테이블(schema_migrations, ar_internal_metadata 등)과 PostgreSQL 내부 테이블(예: pg_attribute)을 설명합니다. 주된 목적은 Geo처럼 다른 데이터베이스를 지원하는 것입니다. 이런 데이터베이스는 애플리케이션이 정의한 gitlab_shared 테이블(loose_foreign_keys_deleted_records 등) 일부가 없을 수 있지만 유효한 Rails 데이터베이스입니다.

gitlab_pm의 특별한 목적#

gitlab_pm은 공개 리포지터리를 설명하는 패키지 메타데이터를 저장합니다. 이 데이터는 라이선스 컴플라이언스와 의존성 스캐닝 제품 카테고리에 사용되며 Composition Analysis Group이 관리합니다. 향후 다른 데이터베이스로 라우팅하기 쉽게 하려는 의도로 만든 gitlab_main의 별칭입니다.

마이그레이션#

다중 데이터베이스 마이그레이션을 참고합니다.

CI 및 Sec 데이터베이스#

단일 데이터베이스 구성#

기본적으로 GDK는 다중 데이터베이스로 실행되도록 구성되어 있습니다.

Warning

같은 개발 인스턴스에서 단일 데이터베이스와 다중 데이터베이스를 오가며 전환하는 것은 권장하지 않습니다. ci 또는 sec 데이터베이스의 데이터는 단일 데이터베이스 모드에서 접근할 수 없습니다. 단일 데이터베이스에는 별도의 개발 인스턴스를 사용해야 합니다.

GDK가 단일 데이터베이스를 사용하도록 구성하려면 다음과 같이 합니다:

  1. GDK 루트 디렉터리에서 다음을 실행합니다:

    gdk config set gitlab.rails.databases.ci.enabled false
    gdk config set gitlab.rails.databases.sec.enabled false
    
  2. GDK를 재구성합니다:

    gdk reconfigure
    

다중 데이터베이스 사용으로 되돌리려면 gitlab.rails.databases.<db_name>.enabled를 true로 설정하고 gdk reconfigure를 실행합니다.

main 테이블과 non main 테이블 간의 조인 제거#

데이터베이스 간에 조인하는 쿼리는 오류를 발생시킵니다. GitLab 14.3에서 도입되었으며, 새 쿼리에만 적용됩니다. 기존 쿼리는 오류를 발생시키지 않습니다.

GitLab은 여러 개의 분리된 데이터베이스로 실행될 수 있으므로, 하나의 쿼리에서 main 테이블과 non main 테이블을 함께 참조하는 것은 불가능합니다. 따라서 SQL 쿼리에서 어떤 종류의 JOIN도 동작하지 않습니다.

크로스 데이터베이스 조인 제거 방안#

다음 섹션은 데이터베이스 간 조인으로 확인된 실제 사례와 가능한 수정 방법을 함께 제시합니다.

코드 제거#

지금까지 여러 번 확인한 가장 단순한 해결책은 사용되지 않는 기존 스코프를 제거하는 것이었습니다. 가장 쉽게 수정할 수 있는 사례입니다. 따라서 첫 단계는 해당 코드가 사용되지 않는지 조사한 뒤 제거하는 것입니다. 실제 사례는 다음과 같습니다:

코드가 사용되는 사례가 더 있을 수 있지만, 그것이 필요한지 또는 기능이 그렇게 동작해야 하는지 평가할 수 있습니다. 새 칼럼과 테이블을 추가해 일을 복잡하게 만들기 전에, 요구 사항을 충족하면서 해결책을 단순화할 수 있는지 검토합니다. 평가 중인 사례 중 하나는 특정 UsageData의 계산 방식을 변경해 조인 쿼리를 제거하는 것입니다. 해당 이슈는 https://gitlab.com/gitlab-org/gitlab/-/issues/336170입니다. UsageData는 사용자에게 중요하지 않고 더 단순한 접근으로 비슷하게 유용한 지표를 얻을 수 있을 가능성이 있어, 평가하기 좋은 후보입니다. 또는 아무도 이 지표를 사용하지 않는다는 사실을 확인해 제거할 수도 있습니다.

includes 대신 preload 사용#

Rails의 includes와 preload 메서드는 모두 N+1 쿼리를 피하는 방법입니다. Rails의 includes 메서드는 테이블에 조인해야 하는지, 아니면 별도 쿼리로 모든 레코드를 로드할 수 있는지 판단하기 위해 휴리스틱 방식을 사용합니다. 이 메서드는 다른 테이블의 칼럼을 쿼리해야 한다고 판단하면 조인이 필요하다고 가정하지만, 때로는 잘못 판단해 필요하지 않을 때도 조인을 실행합니다. 이런 경우 preload를 사용해 별도 쿼리로 데이터를 명시적으로 로드하면 N+1 쿼리를 피하면서 조인도 피할 수 있습니다.

이 해결책을 실제로 사용한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67655.

불필요한 조인 제거#

쿼리가 과도한(또는 불필요한) 조인을 수행하는 경우가 있습니다.

일반적인 예는 두 외래 키를 모두 가진 어떤 테이블 B를 거쳐 A에서 C로 조인하는 쿼리입니다. C에 행이 몇 개 있는지만 중요하고 B의 외래 키에 외래 키와 NOT NULL 제약이 있다면, 그 행들을 세는 것만으로 충분할 수 있습니다. 예를 들어 MR 71811에서는 이전에 project.runners.count를 사용했고, 다음과 같은 쿼리를 생성했습니다:

select count(*) from projects
inner join ci_runner_projects on ci_runner_projects.project_id = projects.id
where ci_runner_projects.runner_id IN (1, 2, 3)

크로스 조인을 피하기 위해 코드를 project.runner_projects.count로 변경했습니다. 다음 쿼리로 동일한 응답을 생성합니다:

select count(*) from ci_runner_projects
where ci_runner_projects.runner_id IN (1, 2, 3)

또 다른 흔한 불필요한 조인은 외래 키로 필터링할 수 있는데도 다른 테이블까지 끝까지 조인한 뒤 기본 키로 필터링하는 경우입니다. 예시를 살펴봅니다. MR 71614의 이전 코드는 joins(scan: :build).where(ci_builds: { id: build_ids })였고, 다음과 같은 쿼리를 생성했습니다:

select ...
inner join security_scans
inner join ci_builds on security_scans.build_id = ci_builds.id
where ci_builds.id IN (1, 2, 3)

그러나 security_scans에는 이미 build_id 외래 키가 있으므로, 코드를 joins(:scan).where(security_scans: { build_id: build_ids })로 변경할 수 있고, 다음 쿼리로 동일한 응답을 생성합니다:

select ...
inner join security_scans
where security_scans.build_id IN (1, 2, 3)

불필요한 조인을 제거한 이 두 예시는 크로스 조인을 없애면서, 더 단순하고 빠른 쿼리를 만든다는 이점도 함께 얻습니다.

제한된 pluck 후 find 사용#

pluck이나 pick으로 id 배열을 얻는 방식은 반환되는 배열의 크기가 제한된다고 보장되지 않는 한 권장하지 않습니다. 결과가 최대 1개임을 알고 있는 경우, 또는 같은 크기의 다른 목록에 매핑해야 하는 메모리상의 id(또는 사용자 이름) 목록이 있는 경우에는 보통 좋은 패턴입니다. 일대다 관계의 id 목록을 매핑할 때는 결과가 제한되지 않으므로 적합하지 않습니다. 이후 반환된 id를 사용해 관련 레코드를 가져올 수 있습니다:

allowed_user_id = board_user_finder
  .where(user_id: params['assignee_id'])
  .pick(:user_id)

User.find_by(id: allowed_user_id)

이 방식을 사용한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/126856

조인을 pluck으로 바꾸는 것이 쉬워 보일 수 있지만, 그렇게 하면 제한 없는 양의 id를 메모리에 로드한 뒤 이어지는 쿼리에서 다시 직렬화해 Postgres로 보내는 결과가 되는 경우가 많습니다. 이런 경우는 확장성이 없으므로 다른 방법 중 하나를 시도할 것을 권장합니다. 메모리를 제한하기 위해 pluck한 데이터에 limit을 적용하는 것이 좋은 방법처럼 보일 수 있지만, 이는 사용자에게 예측할 수 없는 결과를 가져오고 GitLab 자체를 포함한 가장 큰 고객에게 가장 큰 문제가 되는 경우가 많으므로, 권장하지 않습니다.

테이블에 외래 키 비정규화#

비정규화는 특정 쿼리를 단순화하거나 성능을 개선하기 위해 미리 계산된 중복 데이터를 테이블에 추가하는 것을 말합니다. 이 경우에는 중간 테이블을 거쳐 조인하는 세 개의 테이블이 관여하는 조인을 수행할 때 유용할 수 있습니다.

일반적으로 데이터베이스 스키마를 모델링할 때는 다음과 같은 이유로 "정규화된" 구조를 선호합니다:

  • 중복 데이터는 추가 스토리지를 사용합니다.
  • 중복 데이터는 동기화를 유지해야 합니다.

정규화된 데이터가 성능이 떨어지는 경우도 있어, 비정규화는 GitLab이 데이터베이스 쿼리 성능을 개선하기 위해 오랫동안 사용해 온 일반적인 기법입니다. 앞서 언급한 문제는 다음 조건이 충족될 때 완화됩니다:

  1. 데이터가 많지 않습니다(예: 정수 칼럼 하나).
  2. 데이터가 자주 갱신되지 않습니다(예: 대부분의 테이블에서 project_id 칼럼은 거의 갱신되지 않습니다).

발견한 예시 중 하나는 terraform_state_versions 테이블입니다. 이 테이블에는 빌드에 조인할 수 있게 하는 외래 키 terraform_state_versions.ci_build_id가 있습니다. 따라서 다음과 같이 프로젝트에 조인할 수 있습니다:

select projects.* from terraform_state_versions
inner join ci_builds on terraform_state_versions.ci_build_id = ci_builds.id
inner join projects on ci_builds.project_id = projects.id

이 쿼리의 문제는 ci_builds가 다른 두 테이블과 다른 데이터베이스에 있다는 점입니다.

이 경우의 해결책은 terraform_state_versions에 project_id 칼럼을 추가하는 것입니다. 추가 스토리지를 많이 사용하지 않으며, 이 기능들의 동작 방식상 갱신되는 일이 없습니다(빌드는 프로젝트를 옮기지 않습니다).

이렇게 하면 쿼리가 다음과 같이 단순해집니다:

select projects.* from terraform_state_versions
inner join projects on terraform_state_versions.project_id = projects.id

또한 추가 테이블을 거쳐 조인할 필요가 없으므로 성능도 개선됩니다.

이 방식을 구현한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66963. 이 MR은 유사한 쿼리를 수정하기 위해 pipeline_id도 비정규화합니다.

추가 테이블로 비정규화#

앞의 비정규화(칼럼 추가)가 특정 상황에는 맞지 않는 경우도 있습니다. 데이터가 1:1이 아니거나, 추가하려는 테이블이 이미 너무 넓기 때문일 수 있습니다(예: projects 테이블에는 칼럼을 더 추가하지 않아야 합니다).

이 경우에는 추가 데이터를 별도 테이블에 저장하기로 결정할 수 있습니다.

이 방식을 사용하는 예시 중 하나는 Project.with_code_coverage 스코프를 구현한 경우입니다. 이 스코프는 본질적으로 프로젝트 목록을 한 번이라도 코드 커버리지 기능을 사용한 프로젝트로만 좁히는 데 사용됐습니다. 이 쿼리를 단순화하면 다음과 같습니다:

select projects.* from projects
inner join ci_daily_build_group_report_results on ci_daily_build_group_report_results.project_id = projects.id
where ((data->'coverage') is not null)
and ci_daily_build_group_report_results.default_branch = true
group by projects.id

이 작업은 아직 진행 중이지만, 현재 계획은 project_id와 ci_feature 두 칼럼을 가진 projects_with_ci_feature_usage라는 새 테이블을 도입하는 것입니다. 이 테이블은 프로젝트가 코드 커버리지를 위해 ci_daily_build_group_report_results를 처음 생성할 때 기록됩니다. 따라서 새 쿼리는 다음과 같습니다:

select projects.* from projects
inner join projects_with_ci_feature_usage on projects_with_ci_feature_usage.project_id = projects.id
where projects_with_ci_feature_usage.ci_feature = 'code_coverage'

위 예시는 단순함을 위해 텍스트 칼럼을 사용하지만, 공간을 절약하려면 enum을 사용하는 것이 좋습니다.

이 새 설계의 단점은 갱신이 필요할 수 있다는 점입니다 (ci_daily_build_group_report_results가 삭제되면 함께 삭제해야 합니다). 다만 도메인에 따라 삭제가 예외적인 경우이거나 불가능하기 때문에, 또는 목록 페이지에 프로젝트가 보이는 것이 사용자에게 문제가 되지 않기 때문에 필요하지 않을 수도 있습니다. 도메인에서 필요할 때 이 행들을 삭제하는 로직을 구현하는 것도 가능합니다.

마지막으로, 이 비정규화와 새 쿼리는 조인이 더 적고 필터링이 덜 필요하므로 성능도 개선합니다.

has_one 또는 has_many through: 관계에 disable_joins 사용#

서로 다른 데이터베이스에 걸친 테이블에서 has_one ... through: 또는 has_many ... through:를 사용해 조인 쿼리가 발생하는 경우가 있습니다. 이런 조인은 다음을 추가해 해결할 수 있는 경우가 있습니다. disable_joins:true. 이는 Rails 기능이며 GitLab에서 백포트했습니다. 또한 기능 플래그로 disable_joins를 활성화할 수 있도록 람다 문법을 허용하게 기능을 확장했습니다. 이 기능을 사용한다면 심각한 성능 저하가 발생할 때 위험을 줄일 수 있으므로 기능 플래그 사용을 권장합니다.

이를 사용한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66709/diffs.

데이터베이스 쿼리를 변경할 때는 변경 전후의 SQL을 분석하고 비교하는 것이 중요합니다. disable_joins는 has_many 또는 has_one 관계의 실제 로직에 따라 성능이 매우 나쁜 코드를 만들 수 있습니다. 핵심적으로 확인할 점은 최종 결과 집합을 구성하는 데 사용되는 중간 결과 집합 중 제한 없는 양의 데이터를 로드하는 것이 있는지 여부입니다. 가장 좋은 확인 방법은 생성된 SQL을 보고 각각이 어떤 식으로든 제한되어 있는지 확인하는 것입니다. LIMIT 1 절이나 고유 칼럼을 기준으로 제한하는 WHERE 절로 판단할 수 있습니다. 제한되지 않은 중간 데이터셋은 너무 많은 ID를 메모리에 로드하는 결과로 이어질 수 있습니다.

성능이 매우 나빠지는 예시는 다음과 같은 가상 코드입니다:

class Project
  has_many :pipelines
  has_many :builds, through: :pipelines
end

class Pipeline
  has_many :builds
end

class Build
  belongs_to :pipeline
end

def some_action
  @builds = Project.find(5).builds.order(created_at: :desc).limit(10)
end

위 경우 some_action은 다음과 같은 쿼리를 생성합니다:

select * from builds
inner join pipelines on builds.pipeline_id = pipelines.id
where pipelines.project_id = 5
order by builds.created_at desc
limit 10

그러나 관계를 다음과 같이 변경하면:

class Project
  has_many :pipelines
  has_many :builds, through: :pipelines, disable_joins: true
end

다음 두 개의 쿼리가 생성됩니다:

select id from pipelines where project_id = 5;

select * from builds where pipeline_id in (...)
order by created_at desc
limit 10;

첫 번째 쿼리는 고유 칼럼으로 제한하지도 않고 LIMIT 절도 없으므로, 제한 없는 수의 파이프라인 ID를 메모리에 로드할 수 있고 이들이 이어지는 쿼리로 전달됩니다. 이는 Rails 애플리케이션과 데이터베이스의 성능을 크게 떨어뜨릴 수 있습니다. 이런 경우에는 쿼리를 다시 작성하거나 앞에서 설명한 크로스 조인 제거 패턴을 살펴봐야 할 수 있습니다.

크로스 조인을 올바르게 제거했는지 검증하는 방법#

RSpec은 모든 SQL 쿼리가 데이터베이스 간에 조인하지 않는지 자동으로 검증하도록 구성되어 있습니다. spec/support/database/cross-join-allowlist.yml에서 이 검증이 비활성화되어 있어도, with_cross_joins_prevented를 사용해 격리된 코드 블록을 검증할 수 있습니다.

이 메서드는 다음과 같이 사용합니다:

it 'does not join across databases' do
  with_cross_joins_prevented do
    ::Ci::Build.joins(:project).to_a
  end
end

쿼리가 두 데이터베이스에 걸쳐 조인하면 예외가 발생합니다. 앞의 예시는 다음과 같이 조인을 제거해 수정합니다:

it 'does not join across databases' do
  with_cross_joins_prevented do
    ::Ci::Build.preload(:project).to_a
  end
end

이 메서드로 크로스 조인을 수정한 실제 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67655.

기존 크로스 조인 허용 목록#

크로스 조인을 가장 쉽게 확인하는 방법은 실패한 파이프라인입니다.

예를 들어 !130038에서는 db/docs/notification_settings.yml 파일에 표시하는 방식으로 notification_settings 테이블을 gitlab_main_org 스키마로 옮겼습니다.

파이프라인은 다음 오류와 함께 실패했습니다:

Database::PreventCrossJoins::CrossJoinAcrossUnsupportedTablesError:

Unsupported cross-join across 'users, notification_settings' querying 'gitlab_main_user, gitlab_main_org' discovered when executing query 'SELECT "users".* FROM "users" WHERE "users"."id" IN (SELECT "notification_settings"."user_id" FROM ((SELECT "notification_settings"."user_id" FROM "notification_settings" WHERE "notification_settings"."source_id" = 119 AND "notification_settings"."source_type" = 'Project' AND (("notification_settings"."level" = 3 AND EXISTS (SELECT true FROM "notification_settings" "notification_settings_2" WHERE "notification_settings_2"."user_id" = "notification_settings"."user_id" AND "notification_settings_2"."source_id" IS NULL AND "notification_settings_2"."source_type" IS NULL AND "notification_settings_2"."level" = 2)) OR "notification_settings"."level" = 2))) notification_settings)'

파이프라인을 통과시키려면 이 크로스 조인 쿼리를 허용 목록에 추가해야 합니다.

데이터베이스 간 크로스 조인은 코드를 ::Gitlab::Database.allow_cross_joins_across_databases 헬퍼 메서드로 감싸서 명시적으로 허용할 수 있습니다. 또 다른 방법은 해당 릴레이션을 relation.allow_cross_joins_across_databases로 표시하는 것입니다.

이 메서드는 다음 경우에만 사용해야 합니다:

  • 기존 코드.
  • 크로스 조인에서 벗어나는 마이그레이션에 코드가 필요한 경우. 예를 들어 크로스 조인을 제거하기 위해 향후 사용할 데이터를 백필하는 마이그레이션.

allow_cross_joins_across_databases 헬퍼 메서드는 다음과 같이 사용할 수 있습니다:

# Scope the block executing an object from database
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336590') do
  subject.perform(1, 4)
end
# Mark a relation as allowed to cross-join databases
def find_diff_head_pipeline
  all_pipelines
    .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891')
    .for_sha_or_source_sha(diff_head_sha)
    .first
end

모델 연관 관계나 스코프에서는 다음 예시와 같이 사용할 수 있습니다:

class Group < Namespace
 has_many :users, -> {
    allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405")
  }, through: :group_members
end
Warning

연관 관계를 재정의하면 의도하지 않은 결과가 발생할 수 있고, 이슈 424307에서 확인한 것처럼 데이터 손실로 이어질 수도 있습니다. 아래 예시처럼 크로스 조인을 허용하려고 기존 ActiveRecord 연관 관계를 재정의하지 않습니다.

class Group < Namespace
  has_many :users, through: :group_members

  # DO NOT override an association like this.
  def users
    super.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405")
  end
end

url 파라미터는 크로스 조인을 수정할 예정인 마일스톤이 지정된 이슈를 가리켜야 합니다. 크로스 조인이 마이그레이션에서 사용되는 경우에는 코드를 수정할 필요가 없습니다. 자세한 내용은 https://gitlab.com/gitlab-org/gitlab/-/issues/340017을 참고합니다.

크로스 데이터베이스 트랜잭션 제거#

여러 데이터베이스를 다룰 때는 두 개 이상의 데이터베이스에 영향을 주는 데이터 수정에 세심하게 주의를 기울이는 것이 중요합니다. 자동 검사가 크로스 데이터베이스 수정을 방지합니다.

어떤 데이터베이스 서버에서 시작된 트랜잭션 중에 서로 다른 데이터베이스가 두 개 이상 수정되면, 애플리케이션은 크로스 데이터베이스 수정 오류를 발생시킵니다(테스트 환경에서만).

예시:

# Open transaction on Main DB
ApplicationRecord.transaction do
  ci_build.update!(updated_at: Time.current) # UPDATE on CI DB
  ci_build.project.update!(updated_at: Time.current) # UPDATE on Main DB
end
# raises error: Cross-database data modification of 'main, ci' were detected within
# a transaction modifying the 'ci_build, projects' tables

위 코드 예시는 트랜잭션 안에서 두 레코드의 타임스탬프를 갱신합니다. CI 데이터베이스 분리 작업이 진행 중이므로, 데이터베이스 트랜잭션의 동작 방식을 보장할 수 없습니다. 두 번째 갱신 쿼리가 실패하면 첫 번째 갱신 쿼리는 롤백되지 않습니다. ci_build 레코드가 다른 데이터베이스 서버에 있기 때문입니다. 자세한 내용은 다음 트랜잭션 가이드라인 페이지를 참고합니다.

크로스 데이터베이스 트랜잭션 수정#

데이터베이스에 걸친 트랜잭션은 코드를 Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction 헬퍼 메서드로 감싸서 명시적으로 허용할 수 있습니다.

이 메서드는 기존 코드에만 사용해야 합니다.

temporary_ignore_tables_in_transaction 헬퍼 메서드는 다음과 같이 사용할 수 있습니다:

class GroupMember < Member
   def update_two_factor_requirement
     return unless user

     # To mark and ignore cross-database transactions involving members and users/user_details/user_preferences
     Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction(
       %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288'
     ) do
       user.update_two_factor_requirement
     end
   end
end
트랜잭션 블록 제거#

열린 트랜잭션이 없으면 크로스 데이터베이스 수정 검사는 오류를 발생시킬 수 없습니다. 이렇게 변경하면 일관성을 희생하게 됩니다. 첫 번째 UPDATE 쿼리 이후 애플리케이션에 장애가 발생하면, 두 번째 UPDATE 쿼리는 실행되지 않습니다.

transaction 블록이 없는 동일한 코드:

ci_build.update!(updated_at: Time.current) # CI DB
ci_build.project.update!(updated_at: Time.current) # Main DB
비동기 처리#

작업이 일관되게 완료된다는 보장을 더 강하게 얻어야 한다면 백그라운드 job에서 실행할 수 있습니다. 백그라운드 job은 비동기로 예약되고 오류가 발생하면 여러 번 재시도됩니다. 그럼에도 불일치가 발생할 가능성은 아주 작게 남아 있습니다.

예시:

current_time = Time.current

MyAsyncConsistencyJob.perform_async(cu_build.id)

ci_build.update!(updated_at: current_time)
ci_build.project.update!(updated_at: current_time)

MyAsyncConsistencyJob도 타임스탬프가 다르면 갱신을 시도합니다.

완벽한 일관성 지향#

현재는 데이터베이스가 하나였을 때와 비슷한 일관성 특성을 보장할 도구가 없습니다 (필요하지 않을 수도 있습니다). 작업 중인 코드에 이런 속성이 필요하다고 판단되면, 문제가 되는 테스트 코드를 블록으로 감싸 테스트에서 크로스 데이터베이스 수정 검사를 비활성화하고 후속 이슈를 생성할 수 있습니다.

allow_cross_database_modification_within_transaction(url: 'gitlab issue URL') do
  ApplicationRecord.transaction do
    ci_build.update!(updated_at: Time.current) # UPDATE on CI DB
    ci_build.project.update!(updated_at: Time.current) # UPDATE on Main DB
  end
end

조언이 필요하면 언제든지 Tenant Scale group에 문의합니다.

데이터베이스에 걸친 dependent: :nullify 및 dependent: :destroy 사용 지양#

데이터베이스에 걸쳐 dependent: :nullify 또는 dependent: :destroy를 사용하고 싶은 경우가 있을 수 있습니다. 기술적으로는 가능하지만 문제가 됩니다. 이 훅들은 #destroy 호출에서 시작된 외부 트랜잭션의 컨텍스트에서 실행되어 크로스 데이터베이스 트랜잭션을 만드는데, 이는 피하려는 대상입니다. 이렇게 발생한 크로스 데이터베이스 트랜잭션은 분리 구성으로 전환할 때 혼란스러운 결과로 이어질 수 있습니다. 트랜잭션 외부에서 실행되는 쿼리가 생기고, 외부 트랜잭션이 실패하는 동안 그 쿼리가 부분적으로 적용될 수 있어 예상치 못한 버그로 이어질 수 있습니다.

데이터베이스 외부의 데이터(예: 오브젝트 스토리지)를 정리해야 하는 복잡한 객체에는 다음 설정을 권장합니다. dependent: :restrict_with_error. 그런 객체는 미리 명시적으로 제거해야 합니다. dependent: :restrict_with_error를 사용하면 정리되지 않은 것이 있을 때 부모 객체 삭제를 금지할 수 있습니다.

PostgreSQL에서 자식 레코드 자체만 정리하면 되는 경우에는 루즈 외래 키 사용을 검토합니다.

데이터베이스에 걸친 외래 키#

두 데이터베이스에 걸쳐 참조하는 외래 키를 사용하는 곳이 많습니다. 분리된 두 개의 PostgreSQL 데이터베이스에서는 이것이 불가능하므로, PostgreSQL에서 얻던 동작을 성능이 좋은 방식으로 재현해야 합니다. 잘못된 참조 생성을 막아 주는 PostgreSQL의 데이터 보장은 재현할 수도 없고 재현해서도 안 되지만, 고아 데이터나 아무 곳도 가리키지 않는 레코드가 남아 버그로 이어지지 않도록 캐스케이드 삭제를 대체할 방법은 여전히 필요합니다. 그래서 고아 레코드를 비동기로 정리하는 과정인 "루즈 외래 키"를 만들었습니다.

기존 크로스 데이터베이스 외래 키 허용 목록#

크로스 데이터베이스 외래 키를 가장 쉽게 확인하는 방법은 실패한 파이프라인입니다.

예를 들어 !130038에서는 db/docs/notification_settings.yml 파일에 표시하는 방식으로 notification_settings 테이블을 gitlab_main_org 스키마로 옮겼습니다.

notification_settings.user_id는 users를 가리키는 칼럼이지만 users 테이블은 다른 데이터베이스에 속하므로, 이제 크로스 데이터베이스 외래 키로 취급됩니다.

이런 크로스 데이터베이스 외래 키 사례를 잡아내는 스펙이 no_cross_db_foreign_keys_spec.rb에 있으며, 그런 크로스 데이터베이스 외래 키가 발견되면 실패합니다.

파이프라인을 통과시키려면 이 크로스 데이터베이스 외래 키를 허용 목록에 추가해야 합니다.

이를 위해 같은 스펙에 예외로 추가해 기존 크로스 데이터베이스 외래 키의 존재를 명시적으로 허용합니다(이 예시 참고). 이렇게 하면 스펙이 실패하지 않습니다.

이후 !130080에서 했던 것처럼 이 외래 키를 루즈 외래 키로 변환할 수 있습니다.

다중 데이터베이스 테스트#

테스트 CI 파이프라인에서는 기본적으로 main과 ci 데이터베이스를 모두 사용하는 다중 데이터베이스 구성으로 GitLab을 테스트합니다. 그러나 머지 리퀘스트에서 데이터베이스 관련 코드를 수정하거나 MR에 ~"pipeline:run-single-db" 레이블을 추가하는 경우에는 다른 두 가지 데이터베이스 모드에서도 테스트를 실행합니다: single-db와 single-db-ci-connection입니다.

테스트가 특정 데이터베이스 모드에서 실행되어야 하는 상황을 처리하기 위해, 테스트를 실행할 수 있는 모드를 제한하고 다른 모드에서는 건너뛰는 RSpec 헬퍼를 제공합니다.

헬퍼 이름 테스트 실행 조건
skip_if_shared_database(:ci) 다중 데이터베이스에서
skip_if_database_exists(:ci) single-db 및 single-db-ci-connection에서
skip_if_multiple_databases_are_setup(:ci) single-db에서만
skip_if_multiple_databases_not_setup(:ci) single-db-ci-connection 및 다중 데이터베이스에서

데이터베이스 스키마에 속하지 않는 테이블에 대한 쓰기 잠금#

별도의 데이터베이스가 승격되어 main에서 분리될 때, 스플릿 브레인 상황을 만들지 않기 위한 추가 안전장치로 gitlab:db:lock_writes Rake 태스크를 실행합니다. 이 명령은 다음에 대한 쓰기를 잠급니다:

  • Main 또는 Sec 데이터베이스에 속하는 gitlab_ci의 레거시 테이블.
  • CI 또는 Sec 데이터베이스에 속하는 gitlab_main의 레거시 테이블.
  • CI 또는 Main 데이터베이스에 속하는 gitlab_sec의 레거시 테이블.

이 Rake 태스크는 모든 테이블에 트리거를 추가해, 잠겨야 하는 테이블에 대해 INSERT, UPDATE, DELETE, TRUNCATE 문이 실행되지 않도록 막습니다.

gitlab_main과 gitlab_ci 테이블 모두에 단일 데이터베이스만 사용하는 GitLab 구성에서 이 태스크를 실행하면, 잠기는 테이블은 없습니다.

작업을 되돌리려면 반대 Rake 태스크인 gitlab:db:unlock_writes를 실행합니다.

모니터링#

테이블 잠금 상태는 Database::MonitorLockedTablesWorker로 확인합니다. 필요하면 테이블을 잠급니다.

이 스크립트의 결과는 Kibana에서 확인할 수 있습니다. 카운트가 0이 아니면 잠겨야 하는데 잠기지 않은 테이블이 있습니다. json.extra.database_monitor_locked_tables_worker.results.ci.tables_need_locks와 json.extra.database_monitor_locked_tables_worker.results.main.tables_need_locks 필드에는 상태가 잘못된 테이블 목록이 들어 있습니다.

로깅은 Elasticsearch Watcher로 모니터링합니다. 이 watcher의 이름은 table_locks_needed이며 소스 코드는 GitLab Runbook 리포지터리에 있습니다. 알림은 #g_tenant-scale Slack 채널로 전송됩니다.

자동화#

테이블을 자동으로 잠그는 프로세스는 두 가지입니다:

Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables는 각 데이터베이스 마이그레이션 실행 전후의 테이블 목록을 비교합니다. 그다음 해당 데이터베이스에서 새로 추가된 테이블을 잠급니다. 일부 마이그레이션은 같은 트랜잭션 마이그레이션 안에서 테이블을 다시 생성해야 하므로, 모든 경우를 다루지는 못합니다. 예를 들어 파티션되지 않은 표준 테이블을 파티션된 테이블로 변환하는 경우가 그렇습니다. 이 예시를 참고합니다. 그런 마이그레이션은 다시 생성된 테이블을 잠기지 않은 상태로 둡니다. 그러나 Database::MonitorLockedTablesWorker의 일일 cron job이 Slack으로 이를 알리고, 이 테이블들의 쓰기를 자동으로 잠급니다.

수동으로 테이블 잠금#

테이블을 수동으로 잠가야 한다면 데이터베이스 마이그레이션을 사용합니다. 일반 마이그레이션을 생성하고 테이블을 잠그는 코드를 추가합니다. 예를 들어 CI 데이터베이스의 shards 테이블에 쓰기 잠금을 설정합니다:

class EnableWriteLocksOnShards < Gitlab::Database::Migration[2.2]
  def up
    # On main database, the migration should be skipped
    # We can't use restrict_gitlab_migration in DDL migrations
    return if Gitlab::Database.db_config_name(connection) != 'ci'

    Gitlab::Database::LockWritesManager.new(
      table_name: 'shards',
      connection: connection,
      database_name: :ci,
      with_retries: false
    ).lock_writes
  end

  def down
    # no-op
  end
end

테이블 트런케이션#

main에서 분리된 데이터베이스가 완전히 분할되면, 테이블을 트런케이트해 디스크 공간을 확보할 수 있습니다. 그 결과 데이터 세트가 작아집니다. 예를 들어 CI 데이터베이스의 users 테이블 데이터는 더 이상 읽히지도 갱신되지도 않습니다. 따라서 테이블을 트런케이트해 이 데이터를 제거할 수 있습니다.

이를 위해 GitLab은 데이터베이스별로 별도의 Rake 태스크를 제공합니다:

Note

이 태스크는 데이터베이스의 테이블이 쓰기 잠금 상태일 때만 실행할 수 있습니다.

  • gitlab:db:truncate_legacy_tables:main은 Main 데이터베이스의 레거시 테이블을 트런케이트합니다.
  • gitlab:db:truncate_legacy_tables:ci는 CI 데이터베이스의 레거시 테이블을 트런케이트합니다.
  • gitlab:db:truncate_legacy_tables:sec는 Sec 데이터베이스의 레거시 테이블을 트런케이트합니다.
Warning

이 섹션의 예시는 DRY_RUN=true를 사용합니다. 이렇게 하면 실제로 데이터가 트런케이트되지 않습니다. GitLab은 DRY_RUN=true 없이 이 태스크를 실행하기 전에 백업을 준비해 둘 것을 강력히 권장합니다.

이 태스크에는 실제로 데이터를 변경하지 않고 무엇을 하는지 확인하는 옵션이 있습니다:

$ sudo DRY_RUN=true gitlab-rake gitlab:db:truncate_legacy_tables:main
I, [2023-07-14T17:08:06.665151 #92505]  INFO -- : DRY RUN:
I, [2023-07-14T17:08:06.761586 #92505]  INFO -- : Truncating legacy tables for the database main
I, [2023-07-14T17:08:06.761709 #92505]  INFO -- : SELECT set_config('lock_writes.ci_build_needs', 'false', false)
I, [2023-07-14T17:08:06.765272 #92505]  INFO -- : SELECT set_config('lock_writes.ci_build_pending_states', 'false', false)
I, [2023-07-14T17:08:06.768220 #92505]  INFO -- : SELECT set_config('lock_writes.ci_build_report_results', 'false', false)
[...]
I, [2023-07-14T17:08:06.957294 #92505]  INFO -- : TRUNCATE TABLE ci_build_needs, ci_build_pending_states, ci_build_report_results, ci_build_trace_chunks, ci_build_trace_metadata, ci_builds, ci_builds_metadata, ci_builds_runner_session, ci_cost_settings, ci_daily_build_group_report_results, ci_deleted_objects, ci_freeze_periods, ci_group_variables, ci_instance_variables, ci_job_artifact_states, ci_job_artifacts, ci_job_token_project_scope_links, ci_job_variables, ci_minutes_additional_packs, ci_namespace_mirrors, ci_namespace_monthly_usages, ci_partitions, ci_pending_builds, ci_pipeline_artifacts, ci_pipeline_chat_data, ci_pipeline_messages, ci_pipeline_metadata, ci_pipeline_schedule_variables, ci_pipeline_schedules, ci_pipeline_variables, ci_pipelines, ci_pipelines_config, ci_platform_metrics, ci_project_mirrors, ci_project_monthly_usages, ci_refs, ci_resource_groups, ci_resources, ci_runner_machines, ci_runner_namespaces, ci_runner_projects, ci_runner_versions, ci_runners, ci_running_builds, ci_secure_file_states, ci_secure_files, ci_sources_pipelines, ci_sources_projects, ci_stages, ci_subscriptions_projects, ci_trigger_requests, ci_triggers, ci_unit_test_failures, ci_unit_tests, ci_variables, external_pull_requests, p_ci_builds, p_ci_builds_metadata, p_ci_job_annotations, p_ci_runner_machine_builds, taggings, tags RESTRICT

태스크는 먼저 트런케이트해야 하는 테이블을 찾습니다. 하나의 데이터베이스 트랜잭션에서 제거하는 데이터 양을 제한해야 하므로 트런케이션은 단계적으로 진행됩니다. 테이블은 외래 키 정의에 따라 정해진 순서로 처리됩니다. 한 단계에서 처리하는 테이블 수는 태스크를 호출할 때 숫자를 추가해 변경할 수 있습니다. 기본값은 5입니다:

sudo DRY_RUN=true gitlab-rake gitlab:db:truncate_legacy_tables:main\[10\]

UNTIL_TABLE 변수를 설정해 트런케이트할 테이블 수를 제한할 수도 있습니다. 예를 들어 다음의 경우 ci_unit_test_failures가 트런케이트되면 프로세스가 중단됩니다:

sudo DRY_RUN=true UNTIL_TABLE=ci_unit_test_failures gitlab-rake gitlab:db:truncate_legacy_tables:main

다중 데이터베이스

GitLab v19.4
원문 보기

요약

GitLab을 더욱 확장할 수 있도록 GitLab 애플리케이션 데이터베이스를 여러 데이터베이스로 분리했습니다. 서로 다른 데이터베이스 간에 허용되는 패턴을 올바르게 파악하기 위해 GitLab 애플리케이션은 데이터베이스 딕셔너리를 구현합니다.

GitLab을 더욱 확장할 수 있도록 GitLab 애플리케이션 데이터베이스를 여러 데이터베이스로 분리했습니다. 주요 데이터베이스는 main, ci, sec입니다. GitLab은 데이터베이스 한 개, 두 개, 세 개로 실행하는 것을 지원합니다. GitLab.com에서는 별도의 main, ci, sec 데이터베이스를 사용하고 있습니다.

GitLab 스키마#

서로 다른 데이터베이스 간에 허용되는 패턴을 올바르게 파악하기 위해 GitLab 애플리케이션은 데이터베이스 딕셔너리를 구현합니다.

데이터베이스 딕셔너리는 테이블을 gitlab_schema로 가상 분류하는 기능을 제공하며, 이는 개념적으로 PostgreSQL 스키마와 유사합니다. CI 분리 기능을 더 잘 격리하기 위해 데이터베이스 스키마를 사용하는 방안의 일환으로, 복잡한 마이그레이션 절차 때문에 PostgreSQL 스키마를 사용할 수 없다고 결정했습니다. 대신 애플리케이션 수준 분류 개념을 구현했습니다. GitLab의 모든 테이블에는 gitlab_schema가 할당되어야 합니다:

스키마 설명 비고
gitlab_main Cells / Organizations 스키마를 참고합니다
gitlab_main_org Cells / Organizations 스키마를 참고합니다
gitlab_main_cell_setting Cells / Organizations 스키마를 참고합니다
gitlab_main_cell_local Cells / Organizations 스키마를 참고합니다
gitlab_ci ci: 데이터베이스에 저장되는 모든 CI 테이블(예: ci_pipelines, ci_builds)
gitlab_ci_cell_local Cells / Organizations 스키마를 참고합니다
gitlab_geo geo: 데이터베이스에 저장되는 모든 Geo 테이블(예: project_registry, secondary_usage_data)
gitlab_internal Rails 및 PostgreSQL의 모든 내부 테이블(예: ar_internal_metadata, schema_migrations, pg_*)
gitlab_pm package_metadata를 저장하는 모든 테이블 gitlab_main의 별칭이며 gitlab_sec로 대체될 예정입니다
gitlab_sec sec: 데이터베이스에 저장되는 모든 보안 및 취약점 기능 테이블
gitlab_sec_cell_local Cells / Organizations 스키마를 참고합니다 샤딩 키가 없는, sec: 데이터베이스의 cell-local 비고객 참조 데이터(예: 멀웨어 권고문)
gitlab_shared 사용 중단되었습니다. gitlab_shared_cell_local 또는 gitlab_shared_org를 참고합니다
gitlab_shared_cell_local Cells / Organizations 스키마를 참고합니다
gitlab_shared_org Cells / Organizations 스키마를 참고합니다

추가로 분리되는 데이터베이스와 함께 더 많은 스키마가 도입될 예정입니다.

스키마 사용은 사용해야 하는 기본 클래스를 강제합니다:

  • ApplicationRecord — gitlab_main_org용
  • Ci::ApplicationRecord — gitlab_ci용
  • Geo::TrackingBase — gitlab_geo용
  • Gitlab::Database::SharedModel — gitlab_shared용
  • PackageMetadata::ApplicationRecord — gitlab_pm용
  • SecApplicationRecord — gitlab_sec용

모든 cell-local 테이블에 대한 샤딩 키 정의#

이 콘텐츠는 새 위치로 이동했습니다

gitlab_schema의 영향#

gitlab_schema 사용은 애플리케이션에 상당한 영향을 미칩니다. gitlab_schema의 주된 목적은 서로 다른 데이터 접근 패턴 사이에 장벽을 도입하는 것입니다.

다음 항목의 주요 분류 소스로 사용됩니다:

gitlab_shared의 특별한 목적#

gitlab_shared는 설계상 모든 분리된 데이터베이스에 걸쳐 데이터를 포함하는 테이블 또는 뷰를 설명하는 특수한 경우입니다. 이 분류는 애플리케이션이 정의한 테이블(loose_foreign_keys_deleted_records 등)을 설명합니다.

gitlab_shared는 데이터에 접근할 때 특별한 처리가 필요하므로 주의해서 사용합니다. gitlab_shared는 구조뿐 아니라 데이터도 공유하므로, 애플리케이션은 모든 데이터베이스의 모든 데이터를 순차적으로 순회하는 방식으로 작성해야 합니다.

Gitlab::Database::EachDatabase.each_model_connection([MySharedModel]) do |connection, connection_name|
  MySharedModel.select_all_data...
end

따라서 gitlab_shared 테이블의 데이터를 수정하는 마이그레이션은 모든 분리된 데이터베이스에서 실행되어야 합니다.

gitlab_internal의 특별한 목적#

gitlab_internal은 Rails가 정의한 테이블(schema_migrations, ar_internal_metadata 등)과 PostgreSQL 내부 테이블(예: pg_attribute)을 설명합니다. 주된 목적은 Geo처럼 다른 데이터베이스를 지원하는 것입니다. 이런 데이터베이스는 애플리케이션이 정의한 gitlab_shared 테이블(loose_foreign_keys_deleted_records 등) 일부가 없을 수 있지만 유효한 Rails 데이터베이스입니다.

gitlab_pm의 특별한 목적#

gitlab_pm은 공개 리포지터리를 설명하는 패키지 메타데이터를 저장합니다. 이 데이터는 라이선스 컴플라이언스와 의존성 스캐닝 제품 카테고리에 사용되며 Composition Analysis Group이 관리합니다. 향후 다른 데이터베이스로 라우팅하기 쉽게 하려는 의도로 만든 gitlab_main의 별칭입니다.

마이그레이션#

다중 데이터베이스 마이그레이션을 참고합니다.

CI 및 Sec 데이터베이스#

단일 데이터베이스 구성#

기본적으로 GDK는 다중 데이터베이스로 실행되도록 구성되어 있습니다.

Warning

같은 개발 인스턴스에서 단일 데이터베이스와 다중 데이터베이스를 오가며 전환하는 것은 권장하지 않습니다. ci 또는 sec 데이터베이스의 데이터는 단일 데이터베이스 모드에서 접근할 수 없습니다. 단일 데이터베이스에는 별도의 개발 인스턴스를 사용해야 합니다.

GDK가 단일 데이터베이스를 사용하도록 구성하려면 다음과 같이 합니다:

  1. GDK 루트 디렉터리에서 다음을 실행합니다:

    gdk config set gitlab.rails.databases.ci.enabled false
    gdk config set gitlab.rails.databases.sec.enabled false
    
  2. GDK를 재구성합니다:

    gdk reconfigure
    

다중 데이터베이스 사용으로 되돌리려면 gitlab.rails.databases.<db_name>.enabled를 true로 설정하고 gdk reconfigure를 실행합니다.

main 테이블과 non main 테이블 간의 조인 제거#

데이터베이스 간에 조인하는 쿼리는 오류를 발생시킵니다. GitLab 14.3에서 도입되었으며, 새 쿼리에만 적용됩니다. 기존 쿼리는 오류를 발생시키지 않습니다.

GitLab은 여러 개의 분리된 데이터베이스로 실행될 수 있으므로, 하나의 쿼리에서 main 테이블과 non main 테이블을 함께 참조하는 것은 불가능합니다. 따라서 SQL 쿼리에서 어떤 종류의 JOIN도 동작하지 않습니다.

크로스 데이터베이스 조인 제거 방안#

다음 섹션은 데이터베이스 간 조인으로 확인된 실제 사례와 가능한 수정 방법을 함께 제시합니다.

코드 제거#

지금까지 여러 번 확인한 가장 단순한 해결책은 사용되지 않는 기존 스코프를 제거하는 것이었습니다. 가장 쉽게 수정할 수 있는 사례입니다. 따라서 첫 단계는 해당 코드가 사용되지 않는지 조사한 뒤 제거하는 것입니다. 실제 사례는 다음과 같습니다:

코드가 사용되는 사례가 더 있을 수 있지만, 그것이 필요한지 또는 기능이 그렇게 동작해야 하는지 평가할 수 있습니다. 새 칼럼과 테이블을 추가해 일을 복잡하게 만들기 전에, 요구 사항을 충족하면서 해결책을 단순화할 수 있는지 검토합니다. 평가 중인 사례 중 하나는 특정 UsageData의 계산 방식을 변경해 조인 쿼리를 제거하는 것입니다. 해당 이슈는 https://gitlab.com/gitlab-org/gitlab/-/issues/336170입니다. UsageData는 사용자에게 중요하지 않고 더 단순한 접근으로 비슷하게 유용한 지표를 얻을 수 있을 가능성이 있어, 평가하기 좋은 후보입니다. 또는 아무도 이 지표를 사용하지 않는다는 사실을 확인해 제거할 수도 있습니다.

includes 대신 preload 사용#

Rails의 includes와 preload 메서드는 모두 N+1 쿼리를 피하는 방법입니다. Rails의 includes 메서드는 테이블에 조인해야 하는지, 아니면 별도 쿼리로 모든 레코드를 로드할 수 있는지 판단하기 위해 휴리스틱 방식을 사용합니다. 이 메서드는 다른 테이블의 칼럼을 쿼리해야 한다고 판단하면 조인이 필요하다고 가정하지만, 때로는 잘못 판단해 필요하지 않을 때도 조인을 실행합니다. 이런 경우 preload를 사용해 별도 쿼리로 데이터를 명시적으로 로드하면 N+1 쿼리를 피하면서 조인도 피할 수 있습니다.

이 해결책을 실제로 사용한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67655.

불필요한 조인 제거#

쿼리가 과도한(또는 불필요한) 조인을 수행하는 경우가 있습니다.

일반적인 예는 두 외래 키를 모두 가진 어떤 테이블 B를 거쳐 A에서 C로 조인하는 쿼리입니다. C에 행이 몇 개 있는지만 중요하고 B의 외래 키에 외래 키와 NOT NULL 제약이 있다면, 그 행들을 세는 것만으로 충분할 수 있습니다. 예를 들어 MR 71811에서는 이전에 project.runners.count를 사용했고, 다음과 같은 쿼리를 생성했습니다:

select count(*) from projects
inner join ci_runner_projects on ci_runner_projects.project_id = projects.id
where ci_runner_projects.runner_id IN (1, 2, 3)

크로스 조인을 피하기 위해 코드를 project.runner_projects.count로 변경했습니다. 다음 쿼리로 동일한 응답을 생성합니다:

select count(*) from ci_runner_projects
where ci_runner_projects.runner_id IN (1, 2, 3)

또 다른 흔한 불필요한 조인은 외래 키로 필터링할 수 있는데도 다른 테이블까지 끝까지 조인한 뒤 기본 키로 필터링하는 경우입니다. 예시를 살펴봅니다. MR 71614의 이전 코드는 joins(scan: :build).where(ci_builds: { id: build_ids })였고, 다음과 같은 쿼리를 생성했습니다:

select ...
inner join security_scans
inner join ci_builds on security_scans.build_id = ci_builds.id
where ci_builds.id IN (1, 2, 3)

그러나 security_scans에는 이미 build_id 외래 키가 있으므로, 코드를 joins(:scan).where(security_scans: { build_id: build_ids })로 변경할 수 있고, 다음 쿼리로 동일한 응답을 생성합니다:

select ...
inner join security_scans
where security_scans.build_id IN (1, 2, 3)

불필요한 조인을 제거한 이 두 예시는 크로스 조인을 없애면서, 더 단순하고 빠른 쿼리를 만든다는 이점도 함께 얻습니다.

제한된 pluck 후 find 사용#

pluck이나 pick으로 id 배열을 얻는 방식은 반환되는 배열의 크기가 제한된다고 보장되지 않는 한 권장하지 않습니다. 결과가 최대 1개임을 알고 있는 경우, 또는 같은 크기의 다른 목록에 매핑해야 하는 메모리상의 id(또는 사용자 이름) 목록이 있는 경우에는 보통 좋은 패턴입니다. 일대다 관계의 id 목록을 매핑할 때는 결과가 제한되지 않으므로 적합하지 않습니다. 이후 반환된 id를 사용해 관련 레코드를 가져올 수 있습니다:

allowed_user_id = board_user_finder
  .where(user_id: params['assignee_id'])
  .pick(:user_id)

User.find_by(id: allowed_user_id)

이 방식을 사용한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/126856

조인을 pluck으로 바꾸는 것이 쉬워 보일 수 있지만, 그렇게 하면 제한 없는 양의 id를 메모리에 로드한 뒤 이어지는 쿼리에서 다시 직렬화해 Postgres로 보내는 결과가 되는 경우가 많습니다. 이런 경우는 확장성이 없으므로 다른 방법 중 하나를 시도할 것을 권장합니다. 메모리를 제한하기 위해 pluck한 데이터에 limit을 적용하는 것이 좋은 방법처럼 보일 수 있지만, 이는 사용자에게 예측할 수 없는 결과를 가져오고 GitLab 자체를 포함한 가장 큰 고객에게 가장 큰 문제가 되는 경우가 많으므로, 권장하지 않습니다.

테이블에 외래 키 비정규화#

비정규화는 특정 쿼리를 단순화하거나 성능을 개선하기 위해 미리 계산된 중복 데이터를 테이블에 추가하는 것을 말합니다. 이 경우에는 중간 테이블을 거쳐 조인하는 세 개의 테이블이 관여하는 조인을 수행할 때 유용할 수 있습니다.

일반적으로 데이터베이스 스키마를 모델링할 때는 다음과 같은 이유로 "정규화된" 구조를 선호합니다:

  • 중복 데이터는 추가 스토리지를 사용합니다.
  • 중복 데이터는 동기화를 유지해야 합니다.

정규화된 데이터가 성능이 떨어지는 경우도 있어, 비정규화는 GitLab이 데이터베이스 쿼리 성능을 개선하기 위해 오랫동안 사용해 온 일반적인 기법입니다. 앞서 언급한 문제는 다음 조건이 충족될 때 완화됩니다:

  1. 데이터가 많지 않습니다(예: 정수 칼럼 하나).
  2. 데이터가 자주 갱신되지 않습니다(예: 대부분의 테이블에서 project_id 칼럼은 거의 갱신되지 않습니다).

발견한 예시 중 하나는 terraform_state_versions 테이블입니다. 이 테이블에는 빌드에 조인할 수 있게 하는 외래 키 terraform_state_versions.ci_build_id가 있습니다. 따라서 다음과 같이 프로젝트에 조인할 수 있습니다:

select projects.* from terraform_state_versions
inner join ci_builds on terraform_state_versions.ci_build_id = ci_builds.id
inner join projects on ci_builds.project_id = projects.id

이 쿼리의 문제는 ci_builds가 다른 두 테이블과 다른 데이터베이스에 있다는 점입니다.

이 경우의 해결책은 terraform_state_versions에 project_id 칼럼을 추가하는 것입니다. 추가 스토리지를 많이 사용하지 않으며, 이 기능들의 동작 방식상 갱신되는 일이 없습니다(빌드는 프로젝트를 옮기지 않습니다).

이렇게 하면 쿼리가 다음과 같이 단순해집니다:

select projects.* from terraform_state_versions
inner join projects on terraform_state_versions.project_id = projects.id

또한 추가 테이블을 거쳐 조인할 필요가 없으므로 성능도 개선됩니다.

이 방식을 구현한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66963. 이 MR은 유사한 쿼리를 수정하기 위해 pipeline_id도 비정규화합니다.

추가 테이블로 비정규화#

앞의 비정규화(칼럼 추가)가 특정 상황에는 맞지 않는 경우도 있습니다. 데이터가 1:1이 아니거나, 추가하려는 테이블이 이미 너무 넓기 때문일 수 있습니다(예: projects 테이블에는 칼럼을 더 추가하지 않아야 합니다).

이 경우에는 추가 데이터를 별도 테이블에 저장하기로 결정할 수 있습니다.

이 방식을 사용하는 예시 중 하나는 Project.with_code_coverage 스코프를 구현한 경우입니다. 이 스코프는 본질적으로 프로젝트 목록을 한 번이라도 코드 커버리지 기능을 사용한 프로젝트로만 좁히는 데 사용됐습니다. 이 쿼리를 단순화하면 다음과 같습니다:

select projects.* from projects
inner join ci_daily_build_group_report_results on ci_daily_build_group_report_results.project_id = projects.id
where ((data->'coverage') is not null)
and ci_daily_build_group_report_results.default_branch = true
group by projects.id

이 작업은 아직 진행 중이지만, 현재 계획은 project_id와 ci_feature 두 칼럼을 가진 projects_with_ci_feature_usage라는 새 테이블을 도입하는 것입니다. 이 테이블은 프로젝트가 코드 커버리지를 위해 ci_daily_build_group_report_results를 처음 생성할 때 기록됩니다. 따라서 새 쿼리는 다음과 같습니다:

select projects.* from projects
inner join projects_with_ci_feature_usage on projects_with_ci_feature_usage.project_id = projects.id
where projects_with_ci_feature_usage.ci_feature = 'code_coverage'

위 예시는 단순함을 위해 텍스트 칼럼을 사용하지만, 공간을 절약하려면 enum을 사용하는 것이 좋습니다.

이 새 설계의 단점은 갱신이 필요할 수 있다는 점입니다 (ci_daily_build_group_report_results가 삭제되면 함께 삭제해야 합니다). 다만 도메인에 따라 삭제가 예외적인 경우이거나 불가능하기 때문에, 또는 목록 페이지에 프로젝트가 보이는 것이 사용자에게 문제가 되지 않기 때문에 필요하지 않을 수도 있습니다. 도메인에서 필요할 때 이 행들을 삭제하는 로직을 구현하는 것도 가능합니다.

마지막으로, 이 비정규화와 새 쿼리는 조인이 더 적고 필터링이 덜 필요하므로 성능도 개선합니다.

has_one 또는 has_many through: 관계에 disable_joins 사용#

서로 다른 데이터베이스에 걸친 테이블에서 has_one ... through: 또는 has_many ... through:를 사용해 조인 쿼리가 발생하는 경우가 있습니다. 이런 조인은 다음을 추가해 해결할 수 있는 경우가 있습니다. disable_joins:true. 이는 Rails 기능이며 GitLab에서 백포트했습니다. 또한 기능 플래그로 disable_joins를 활성화할 수 있도록 람다 문법을 허용하게 기능을 확장했습니다. 이 기능을 사용한다면 심각한 성능 저하가 발생할 때 위험을 줄일 수 있으므로 기능 플래그 사용을 권장합니다.

이를 사용한 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66709/diffs.

데이터베이스 쿼리를 변경할 때는 변경 전후의 SQL을 분석하고 비교하는 것이 중요합니다. disable_joins는 has_many 또는 has_one 관계의 실제 로직에 따라 성능이 매우 나쁜 코드를 만들 수 있습니다. 핵심적으로 확인할 점은 최종 결과 집합을 구성하는 데 사용되는 중간 결과 집합 중 제한 없는 양의 데이터를 로드하는 것이 있는지 여부입니다. 가장 좋은 확인 방법은 생성된 SQL을 보고 각각이 어떤 식으로든 제한되어 있는지 확인하는 것입니다. LIMIT 1 절이나 고유 칼럼을 기준으로 제한하는 WHERE 절로 판단할 수 있습니다. 제한되지 않은 중간 데이터셋은 너무 많은 ID를 메모리에 로드하는 결과로 이어질 수 있습니다.

성능이 매우 나빠지는 예시는 다음과 같은 가상 코드입니다:

class Project
  has_many :pipelines
  has_many :builds, through: :pipelines
end

class Pipeline
  has_many :builds
end

class Build
  belongs_to :pipeline
end

def some_action
  @builds = Project.find(5).builds.order(created_at: :desc).limit(10)
end

위 경우 some_action은 다음과 같은 쿼리를 생성합니다:

select * from builds
inner join pipelines on builds.pipeline_id = pipelines.id
where pipelines.project_id = 5
order by builds.created_at desc
limit 10

그러나 관계를 다음과 같이 변경하면:

class Project
  has_many :pipelines
  has_many :builds, through: :pipelines, disable_joins: true
end

다음 두 개의 쿼리가 생성됩니다:

select id from pipelines where project_id = 5;

select * from builds where pipeline_id in (...)
order by created_at desc
limit 10;

첫 번째 쿼리는 고유 칼럼으로 제한하지도 않고 LIMIT 절도 없으므로, 제한 없는 수의 파이프라인 ID를 메모리에 로드할 수 있고 이들이 이어지는 쿼리로 전달됩니다. 이는 Rails 애플리케이션과 데이터베이스의 성능을 크게 떨어뜨릴 수 있습니다. 이런 경우에는 쿼리를 다시 작성하거나 앞에서 설명한 크로스 조인 제거 패턴을 살펴봐야 할 수 있습니다.

크로스 조인을 올바르게 제거했는지 검증하는 방법#

RSpec은 모든 SQL 쿼리가 데이터베이스 간에 조인하지 않는지 자동으로 검증하도록 구성되어 있습니다. spec/support/database/cross-join-allowlist.yml에서 이 검증이 비활성화되어 있어도, with_cross_joins_prevented를 사용해 격리된 코드 블록을 검증할 수 있습니다.

이 메서드는 다음과 같이 사용합니다:

it 'does not join across databases' do
  with_cross_joins_prevented do
    ::Ci::Build.joins(:project).to_a
  end
end

쿼리가 두 데이터베이스에 걸쳐 조인하면 예외가 발생합니다. 앞의 예시는 다음과 같이 조인을 제거해 수정합니다:

it 'does not join across databases' do
  with_cross_joins_prevented do
    ::Ci::Build.preload(:project).to_a
  end
end

이 메서드로 크로스 조인을 수정한 실제 예시는 다음에서 확인할 수 있습니다: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67655.

기존 크로스 조인 허용 목록#

크로스 조인을 가장 쉽게 확인하는 방법은 실패한 파이프라인입니다.

예를 들어 !130038에서는 db/docs/notification_settings.yml 파일에 표시하는 방식으로 notification_settings 테이블을 gitlab_main_org 스키마로 옮겼습니다.

파이프라인은 다음 오류와 함께 실패했습니다:

Database::PreventCrossJoins::CrossJoinAcrossUnsupportedTablesError:

Unsupported cross-join across 'users, notification_settings' querying 'gitlab_main_user, gitlab_main_org' discovered when executing query 'SELECT "users".* FROM "users" WHERE "users"."id" IN (SELECT "notification_settings"."user_id" FROM ((SELECT "notification_settings"."user_id" FROM "notification_settings" WHERE "notification_settings"."source_id" = 119 AND "notification_settings"."source_type" = 'Project' AND (("notification_settings"."level" = 3 AND EXISTS (SELECT true FROM "notification_settings" "notification_settings_2" WHERE "notification_settings_2"."user_id" = "notification_settings"."user_id" AND "notification_settings_2"."source_id" IS NULL AND "notification_settings_2"."source_type" IS NULL AND "notification_settings_2"."level" = 2)) OR "notification_settings"."level" = 2))) notification_settings)'

파이프라인을 통과시키려면 이 크로스 조인 쿼리를 허용 목록에 추가해야 합니다.

데이터베이스 간 크로스 조인은 코드를 ::Gitlab::Database.allow_cross_joins_across_databases 헬퍼 메서드로 감싸서 명시적으로 허용할 수 있습니다. 또 다른 방법은 해당 릴레이션을 relation.allow_cross_joins_across_databases로 표시하는 것입니다.

이 메서드는 다음 경우에만 사용해야 합니다:

  • 기존 코드.
  • 크로스 조인에서 벗어나는 마이그레이션에 코드가 필요한 경우. 예를 들어 크로스 조인을 제거하기 위해 향후 사용할 데이터를 백필하는 마이그레이션.

allow_cross_joins_across_databases 헬퍼 메서드는 다음과 같이 사용할 수 있습니다:

# Scope the block executing an object from database
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336590') do
  subject.perform(1, 4)
end
# Mark a relation as allowed to cross-join databases
def find_diff_head_pipeline
  all_pipelines
    .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891')
    .for_sha_or_source_sha(diff_head_sha)
    .first
end

모델 연관 관계나 스코프에서는 다음 예시와 같이 사용할 수 있습니다:

class Group < Namespace
 has_many :users, -> {
    allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405")
  }, through: :group_members
end
Warning

연관 관계를 재정의하면 의도하지 않은 결과가 발생할 수 있고, 이슈 424307에서 확인한 것처럼 데이터 손실로 이어질 수도 있습니다. 아래 예시처럼 크로스 조인을 허용하려고 기존 ActiveRecord 연관 관계를 재정의하지 않습니다.

class Group < Namespace
  has_many :users, through: :group_members

  # DO NOT override an association like this.
  def users
    super.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/422405")
  end
end

url 파라미터는 크로스 조인을 수정할 예정인 마일스톤이 지정된 이슈를 가리켜야 합니다. 크로스 조인이 마이그레이션에서 사용되는 경우에는 코드를 수정할 필요가 없습니다. 자세한 내용은 https://gitlab.com/gitlab-org/gitlab/-/issues/340017을 참고합니다.

크로스 데이터베이스 트랜잭션 제거#

여러 데이터베이스를 다룰 때는 두 개 이상의 데이터베이스에 영향을 주는 데이터 수정에 세심하게 주의를 기울이는 것이 중요합니다. 자동 검사가 크로스 데이터베이스 수정을 방지합니다.

어떤 데이터베이스 서버에서 시작된 트랜잭션 중에 서로 다른 데이터베이스가 두 개 이상 수정되면, 애플리케이션은 크로스 데이터베이스 수정 오류를 발생시킵니다(테스트 환경에서만).

예시:

# Open transaction on Main DB
ApplicationRecord.transaction do
  ci_build.update!(updated_at: Time.current) # UPDATE on CI DB
  ci_build.project.update!(updated_at: Time.current) # UPDATE on Main DB
end
# raises error: Cross-database data modification of 'main, ci' were detected within
# a transaction modifying the 'ci_build, projects' tables

위 코드 예시는 트랜잭션 안에서 두 레코드의 타임스탬프를 갱신합니다. CI 데이터베이스 분리 작업이 진행 중이므로, 데이터베이스 트랜잭션의 동작 방식을 보장할 수 없습니다. 두 번째 갱신 쿼리가 실패하면 첫 번째 갱신 쿼리는 롤백되지 않습니다. ci_build 레코드가 다른 데이터베이스 서버에 있기 때문입니다. 자세한 내용은 다음 트랜잭션 가이드라인 페이지를 참고합니다.

크로스 데이터베이스 트랜잭션 수정#

데이터베이스에 걸친 트랜잭션은 코드를 Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction 헬퍼 메서드로 감싸서 명시적으로 허용할 수 있습니다.

이 메서드는 기존 코드에만 사용해야 합니다.

temporary_ignore_tables_in_transaction 헬퍼 메서드는 다음과 같이 사용할 수 있습니다:

class GroupMember < Member
   def update_two_factor_requirement
     return unless user

     # To mark and ignore cross-database transactions involving members and users/user_details/user_preferences
     Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction(
       %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288'
     ) do
       user.update_two_factor_requirement
     end
   end
end
트랜잭션 블록 제거#

열린 트랜잭션이 없으면 크로스 데이터베이스 수정 검사는 오류를 발생시킬 수 없습니다. 이렇게 변경하면 일관성을 희생하게 됩니다. 첫 번째 UPDATE 쿼리 이후 애플리케이션에 장애가 발생하면, 두 번째 UPDATE 쿼리는 실행되지 않습니다.

transaction 블록이 없는 동일한 코드:

ci_build.update!(updated_at: Time.current) # CI DB
ci_build.project.update!(updated_at: Time.current) # Main DB
비동기 처리#

작업이 일관되게 완료된다는 보장을 더 강하게 얻어야 한다면 백그라운드 job에서 실행할 수 있습니다. 백그라운드 job은 비동기로 예약되고 오류가 발생하면 여러 번 재시도됩니다. 그럼에도 불일치가 발생할 가능성은 아주 작게 남아 있습니다.

예시:

current_time = Time.current

MyAsyncConsistencyJob.perform_async(cu_build.id)

ci_build.update!(updated_at: current_time)
ci_build.project.update!(updated_at: current_time)

MyAsyncConsistencyJob도 타임스탬프가 다르면 갱신을 시도합니다.

완벽한 일관성 지향#

현재는 데이터베이스가 하나였을 때와 비슷한 일관성 특성을 보장할 도구가 없습니다 (필요하지 않을 수도 있습니다). 작업 중인 코드에 이런 속성이 필요하다고 판단되면, 문제가 되는 테스트 코드를 블록으로 감싸 테스트에서 크로스 데이터베이스 수정 검사를 비활성화하고 후속 이슈를 생성할 수 있습니다.

allow_cross_database_modification_within_transaction(url: 'gitlab issue URL') do
  ApplicationRecord.transaction do
    ci_build.update!(updated_at: Time.current) # UPDATE on CI DB
    ci_build.project.update!(updated_at: Time.current) # UPDATE on Main DB
  end
end

조언이 필요하면 언제든지 Tenant Scale group에 문의합니다.

데이터베이스에 걸친 dependent: :nullify 및 dependent: :destroy 사용 지양#

데이터베이스에 걸쳐 dependent: :nullify 또는 dependent: :destroy를 사용하고 싶은 경우가 있을 수 있습니다. 기술적으로는 가능하지만 문제가 됩니다. 이 훅들은 #destroy 호출에서 시작된 외부 트랜잭션의 컨텍스트에서 실행되어 크로스 데이터베이스 트랜잭션을 만드는데, 이는 피하려는 대상입니다. 이렇게 발생한 크로스 데이터베이스 트랜잭션은 분리 구성으로 전환할 때 혼란스러운 결과로 이어질 수 있습니다. 트랜잭션 외부에서 실행되는 쿼리가 생기고, 외부 트랜잭션이 실패하는 동안 그 쿼리가 부분적으로 적용될 수 있어 예상치 못한 버그로 이어질 수 있습니다.

데이터베이스 외부의 데이터(예: 오브젝트 스토리지)를 정리해야 하는 복잡한 객체에는 다음 설정을 권장합니다. dependent: :restrict_with_error. 그런 객체는 미리 명시적으로 제거해야 합니다. dependent: :restrict_with_error를 사용하면 정리되지 않은 것이 있을 때 부모 객체 삭제를 금지할 수 있습니다.

PostgreSQL에서 자식 레코드 자체만 정리하면 되는 경우에는 루즈 외래 키 사용을 검토합니다.

데이터베이스에 걸친 외래 키#

두 데이터베이스에 걸쳐 참조하는 외래 키를 사용하는 곳이 많습니다. 분리된 두 개의 PostgreSQL 데이터베이스에서는 이것이 불가능하므로, PostgreSQL에서 얻던 동작을 성능이 좋은 방식으로 재현해야 합니다. 잘못된 참조 생성을 막아 주는 PostgreSQL의 데이터 보장은 재현할 수도 없고 재현해서도 안 되지만, 고아 데이터나 아무 곳도 가리키지 않는 레코드가 남아 버그로 이어지지 않도록 캐스케이드 삭제를 대체할 방법은 여전히 필요합니다. 그래서 고아 레코드를 비동기로 정리하는 과정인 "루즈 외래 키"를 만들었습니다.

기존 크로스 데이터베이스 외래 키 허용 목록#

크로스 데이터베이스 외래 키를 가장 쉽게 확인하는 방법은 실패한 파이프라인입니다.

예를 들어 !130038에서는 db/docs/notification_settings.yml 파일에 표시하는 방식으로 notification_settings 테이블을 gitlab_main_org 스키마로 옮겼습니다.

notification_settings.user_id는 users를 가리키는 칼럼이지만 users 테이블은 다른 데이터베이스에 속하므로, 이제 크로스 데이터베이스 외래 키로 취급됩니다.

이런 크로스 데이터베이스 외래 키 사례를 잡아내는 스펙이 no_cross_db_foreign_keys_spec.rb에 있으며, 그런 크로스 데이터베이스 외래 키가 발견되면 실패합니다.

파이프라인을 통과시키려면 이 크로스 데이터베이스 외래 키를 허용 목록에 추가해야 합니다.

이를 위해 같은 스펙에 예외로 추가해 기존 크로스 데이터베이스 외래 키의 존재를 명시적으로 허용합니다(이 예시 참고). 이렇게 하면 스펙이 실패하지 않습니다.

이후 !130080에서 했던 것처럼 이 외래 키를 루즈 외래 키로 변환할 수 있습니다.

다중 데이터베이스 테스트#

테스트 CI 파이프라인에서는 기본적으로 main과 ci 데이터베이스를 모두 사용하는 다중 데이터베이스 구성으로 GitLab을 테스트합니다. 그러나 머지 리퀘스트에서 데이터베이스 관련 코드를 수정하거나 MR에 ~"pipeline:run-single-db" 레이블을 추가하는 경우에는 다른 두 가지 데이터베이스 모드에서도 테스트를 실행합니다: single-db와 single-db-ci-connection입니다.

테스트가 특정 데이터베이스 모드에서 실행되어야 하는 상황을 처리하기 위해, 테스트를 실행할 수 있는 모드를 제한하고 다른 모드에서는 건너뛰는 RSpec 헬퍼를 제공합니다.

헬퍼 이름 테스트 실행 조건
skip_if_shared_database(:ci) 다중 데이터베이스에서
skip_if_database_exists(:ci) single-db 및 single-db-ci-connection에서
skip_if_multiple_databases_are_setup(:ci) single-db에서만
skip_if_multiple_databases_not_setup(:ci) single-db-ci-connection 및 다중 데이터베이스에서

데이터베이스 스키마에 속하지 않는 테이블에 대한 쓰기 잠금#

별도의 데이터베이스가 승격되어 main에서 분리될 때, 스플릿 브레인 상황을 만들지 않기 위한 추가 안전장치로 gitlab:db:lock_writes Rake 태스크를 실행합니다. 이 명령은 다음에 대한 쓰기를 잠급니다:

  • Main 또는 Sec 데이터베이스에 속하는 gitlab_ci의 레거시 테이블.
  • CI 또는 Sec 데이터베이스에 속하는 gitlab_main의 레거시 테이블.
  • CI 또는 Main 데이터베이스에 속하는 gitlab_sec의 레거시 테이블.

이 Rake 태스크는 모든 테이블에 트리거를 추가해, 잠겨야 하는 테이블에 대해 INSERT, UPDATE, DELETE, TRUNCATE 문이 실행되지 않도록 막습니다.

gitlab_main과 gitlab_ci 테이블 모두에 단일 데이터베이스만 사용하는 GitLab 구성에서 이 태스크를 실행하면, 잠기는 테이블은 없습니다.

작업을 되돌리려면 반대 Rake 태스크인 gitlab:db:unlock_writes를 실행합니다.

모니터링#

테이블 잠금 상태는 Database::MonitorLockedTablesWorker로 확인합니다. 필요하면 테이블을 잠급니다.

이 스크립트의 결과는 Kibana에서 확인할 수 있습니다. 카운트가 0이 아니면 잠겨야 하는데 잠기지 않은 테이블이 있습니다. json.extra.database_monitor_locked_tables_worker.results.ci.tables_need_locks와 json.extra.database_monitor_locked_tables_worker.results.main.tables_need_locks 필드에는 상태가 잘못된 테이블 목록이 들어 있습니다.

로깅은 Elasticsearch Watcher로 모니터링합니다. 이 watcher의 이름은 table_locks_needed이며 소스 코드는 GitLab Runbook 리포지터리에 있습니다. 알림은 #g_tenant-scale Slack 채널로 전송됩니다.

자동화#

테이블을 자동으로 잠그는 프로세스는 두 가지입니다:

Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables는 각 데이터베이스 마이그레이션 실행 전후의 테이블 목록을 비교합니다. 그다음 해당 데이터베이스에서 새로 추가된 테이블을 잠급니다. 일부 마이그레이션은 같은 트랜잭션 마이그레이션 안에서 테이블을 다시 생성해야 하므로, 모든 경우를 다루지는 못합니다. 예를 들어 파티션되지 않은 표준 테이블을 파티션된 테이블로 변환하는 경우가 그렇습니다. 이 예시를 참고합니다. 그런 마이그레이션은 다시 생성된 테이블을 잠기지 않은 상태로 둡니다. 그러나 Database::MonitorLockedTablesWorker의 일일 cron job이 Slack으로 이를 알리고, 이 테이블들의 쓰기를 자동으로 잠급니다.

수동으로 테이블 잠금#

테이블을 수동으로 잠가야 한다면 데이터베이스 마이그레이션을 사용합니다. 일반 마이그레이션을 생성하고 테이블을 잠그는 코드를 추가합니다. 예를 들어 CI 데이터베이스의 shards 테이블에 쓰기 잠금을 설정합니다:

class EnableWriteLocksOnShards < Gitlab::Database::Migration[2.2]
  def up
    # On main database, the migration should be skipped
    # We can't use restrict_gitlab_migration in DDL migrations
    return if Gitlab::Database.db_config_name(connection) != 'ci'

    Gitlab::Database::LockWritesManager.new(
      table_name: 'shards',
      connection: connection,
      database_name: :ci,
      with_retries: false
    ).lock_writes
  end

  def down
    # no-op
  end
end

테이블 트런케이션#

main에서 분리된 데이터베이스가 완전히 분할되면, 테이블을 트런케이트해 디스크 공간을 확보할 수 있습니다. 그 결과 데이터 세트가 작아집니다. 예를 들어 CI 데이터베이스의 users 테이블 데이터는 더 이상 읽히지도 갱신되지도 않습니다. 따라서 테이블을 트런케이트해 이 데이터를 제거할 수 있습니다.

이를 위해 GitLab은 데이터베이스별로 별도의 Rake 태스크를 제공합니다:

Note

이 태스크는 데이터베이스의 테이블이 쓰기 잠금 상태일 때만 실행할 수 있습니다.

  • gitlab:db:truncate_legacy_tables:main은 Main 데이터베이스의 레거시 테이블을 트런케이트합니다.
  • gitlab:db:truncate_legacy_tables:ci는 CI 데이터베이스의 레거시 테이블을 트런케이트합니다.
  • gitlab:db:truncate_legacy_tables:sec는 Sec 데이터베이스의 레거시 테이블을 트런케이트합니다.
Warning

이 섹션의 예시는 DRY_RUN=true를 사용합니다. 이렇게 하면 실제로 데이터가 트런케이트되지 않습니다. GitLab은 DRY_RUN=true 없이 이 태스크를 실행하기 전에 백업을 준비해 둘 것을 강력히 권장합니다.

이 태스크에는 실제로 데이터를 변경하지 않고 무엇을 하는지 확인하는 옵션이 있습니다:

$ sudo DRY_RUN=true gitlab-rake gitlab:db:truncate_legacy_tables:main
I, [2023-07-14T17:08:06.665151 #92505]  INFO -- : DRY RUN:
I, [2023-07-14T17:08:06.761586 #92505]  INFO -- : Truncating legacy tables for the database main
I, [2023-07-14T17:08:06.761709 #92505]  INFO -- : SELECT set_config('lock_writes.ci_build_needs', 'false', false)
I, [2023-07-14T17:08:06.765272 #92505]  INFO -- : SELECT set_config('lock_writes.ci_build_pending_states', 'false', false)
I, [2023-07-14T17:08:06.768220 #92505]  INFO -- : SELECT set_config('lock_writes.ci_build_report_results', 'false', false)
[...]
I, [2023-07-14T17:08:06.957294 #92505]  INFO -- : TRUNCATE TABLE ci_build_needs, ci_build_pending_states, ci_build_report_results, ci_build_trace_chunks, ci_build_trace_metadata, ci_builds, ci_builds_metadata, ci_builds_runner_session, ci_cost_settings, ci_daily_build_group_report_results, ci_deleted_objects, ci_freeze_periods, ci_group_variables, ci_instance_variables, ci_job_artifact_states, ci_job_artifacts, ci_job_token_project_scope_links, ci_job_variables, ci_minutes_additional_packs, ci_namespace_mirrors, ci_namespace_monthly_usages, ci_partitions, ci_pending_builds, ci_pipeline_artifacts, ci_pipeline_chat_data, ci_pipeline_messages, ci_pipeline_metadata, ci_pipeline_schedule_variables, ci_pipeline_schedules, ci_pipeline_variables, ci_pipelines, ci_pipelines_config, ci_platform_metrics, ci_project_mirrors, ci_project_monthly_usages, ci_refs, ci_resource_groups, ci_resources, ci_runner_machines, ci_runner_namespaces, ci_runner_projects, ci_runner_versions, ci_runners, ci_running_builds, ci_secure_file_states, ci_secure_files, ci_sources_pipelines, ci_sources_projects, ci_stages, ci_subscriptions_projects, ci_trigger_requests, ci_triggers, ci_unit_test_failures, ci_unit_tests, ci_variables, external_pull_requests, p_ci_builds, p_ci_builds_metadata, p_ci_job_annotations, p_ci_runner_machine_builds, taggings, tags RESTRICT

태스크는 먼저 트런케이트해야 하는 테이블을 찾습니다. 하나의 데이터베이스 트랜잭션에서 제거하는 데이터 양을 제한해야 하므로 트런케이션은 단계적으로 진행됩니다. 테이블은 외래 키 정의에 따라 정해진 순서로 처리됩니다. 한 단계에서 처리하는 테이블 수는 태스크를 호출할 때 숫자를 추가해 변경할 수 있습니다. 기본값은 5입니다:

sudo DRY_RUN=true gitlab-rake gitlab:db:truncate_legacy_tables:main\[10\]

UNTIL_TABLE 변수를 설정해 트런케이트할 테이블 수를 제한할 수도 있습니다. 예를 들어 다음의 경우 ci_unit_test_failures가 트런케이트되면 프로세스가 중단됩니다:

sudo DRY_RUN=true UNTIL_TABLE=ci_unit_test_failures gitlab-rake gitlab:db:truncate_legacy_tables:main