다중 데이터베이스
GitLab v19.1GitLab이 더욱 확장될 수 있도록 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 | 패키지 메타데이터를 저장하는 모든 테이블 | gitlab_main의 별칭으로, gitlab_sec로 교체 예정 |
| gitlab_sec | sec: 데이터베이스에 저장될 모든 보안 및 취약점 기능 테이블 | 분리 작업 진행 중 |
| 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)을 설명합니다.
주요 목적은 일부 애플리케이션에서 정의된 gitlab_shared 테이블(예: loose_foreign_keys_deleted_records)이
없을 수 있지만 유효한 Rails 데이터베이스인 Geo와 같은 다른 데이터베이스를 지원하는 것입니다.
gitlab_pm의 특별한 목적#
gitlab_pm은 공개 리포지터리를 설명하는 패키지 메타데이터를 저장합니다. 이 데이터는 라이선스 컴플라이언스 및 종속성 스캐닝 제품 카테고리에 사용되며, Composition Analysis Group에서 관리합니다. 이는 향후 다른 데이터베이스로 라우팅하기 쉽게 하기 위한 gitlab_main의 별칭입니다.
마이그레이션#
다중 데이터베이스를 위한 마이그레이션을 참조하세요.
CI 및 Sec 데이터베이스#
단일 데이터베이스 구성#
기본적으로 GDK는 여러 데이터베이스로 실행되도록 구성되어 있습니다.
동일한 개발 인스턴스에서 단일 데이터베이스와 여러 데이터베이스 간에 전환하는 것은 권장하지 않습니다.
ci 또는 sec 데이터베이스의 모든 데이터는 단일 데이터베이스 모드에서 접근할 수 없습니다.
단일 데이터베이스를 사용하려면 별도의 개발 인스턴스를 사용해야 합니다.
GDK를 단일 데이터베이스로 사용하도록 구성하려면:
- GDK 루트 디렉터리에서 다음을 실행합니다:
gdk config set gitlab.rails.databases.ci.enabled false
gdk config set gitlab.rails.databases.sec.enabled false
- GDK를 재구성합니다:
gdk reconfigure
여러 데이터베이스 사용으로 다시 전환하려면 gitlab.rails.databases.<db_name>.enabled를 true로 설정하고 gdk reconfigure를 실행합니다.
main 테이블과 non-main 테이블 간의 조인 제거#
데이터베이스에 걸쳐 조인하는 쿼리는 오류를 발생시킵니다. GitLab 14.3에서 새로운 쿼리에 한해 도입되었습니다. 기존 쿼리는 오류를 발생시키지 않습니다.
GitLab은 여러 개의 별도 데이터베이스로 실행될 수 있기 때문에,
단일 쿼리에서 main 테이블을 main이 아닌 테이블과 함께 참조하는 것은 불가능합니다.
따라서 SQL 쿼리에서 어떠한 종류의 JOIN도 사용할 수 없습니다.
크로스 데이터베이스 조인 제거 방법#
다음 섹션에서는 데이터베이스에 걸쳐 조인하는 것으로 확인된 실제 예시와 이를 수정하는 방법에 대한 제안을 설명합니다.
코드 제거#
지금까지 여러 차례 확인된 가장 간단한 해결책은 사용되지 않는 기존 스코프입니다. 이것이 가장 쉽게 수정할 수 있는 예시입니다. 따라서 첫 번째 단계는 코드가 사용되지 않는지 조사한 다음 제거하는 것입니다. 다음은 실제 예시입니다:
코드가 사용되고 있는 경우도 있을 수 있지만, 필요한지 또는 기능이 이러한 방식으로 동작해야 하는지 평가할 수 있습니다.
새로운 칼럼과 테이블을 추가하여 복잡성을 높이기 전에,
솔루션을 단순화하면서도 요구 사항을 충족할 수 있는지 고려해보세요.
현재 평가 중인 한 가지 사례는
https://gitlab.com/gitlab-org/gitlab/-/issues/336170에서 조인 쿼리를 제거하기 위해 특정 UsageData의 계산 방식을 변경하는 것입니다.
이는 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이 오랫동안 데이터베이스 쿼리 성능을 향상시키기 위해 사용해온 일반적인 기법입니다. 위의 문제는 다음 조건이 충족될 때 완화됩니다:
-
데이터가 많지 않은 경우(예: 정수 칼럼에 불과한 경우).
-
데이터가 자주 업데이트되지 않는 경우(예:
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라는 2개의 칼럼을 가진
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 기능입니다.
또한 기능 플래그로 disable_joins를 활성화하기 위한 람다 구문을 허용하도록 기능을 확장했습니다.
이 기능을 사용하는 경우, 심각한 성능 저하가 있는 경우 위험을 완화하기 위해 기능 플래그를 사용할 것을 권장합니다.
이 방법이 사용된 예시는 https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66709/diffs에서 확인할 수 있습니다.
DB 쿼리에 대한 모든 변경 사항에서 변경 전후의 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
다음의 2개 쿼리가 생성됩니다:
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 파일에서 gitlab_main_org 스키마로 표시함으로써
notification_settings 테이블을 해당 스키마로 이동했습니다.
파이프라인은 다음 오류와 함께 실패했습니다:
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
연관을 재정의하면 의도치 않은 결과가 발생할 수 있으며, 이슈 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을 참조하세요.
크로스 데이터베이스 트랜잭션 제거#
여러 데이터베이스를 처리할 때 둘 이상의 데이터베이스에 영향을 미치는 데이터 수정에 주의해야 합니다. GitLab 14.4에서 도입된 자동화된 검사는 크로스 데이터베이스 수정을 방지합니다.
어떤 데이터베이스 서버에서 시작된 트랜잭션 내에서 두 개 이상의 서로 다른 데이터베이스가 수정될 때, 애플리케이션은 크로스 데이터베이스 수정 오류를 발생시킵니다(테스트 환경에서만).
예시:
# 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 그룹에 문의하세요.
데이터베이스에 걸친 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는 필요한 경우 테이블을 잠급니다.lock_tables_in_monitoring기능 플래그로 비활성화할 수 있습니다.
Gitlab::Database::MigrationHelpers::AutomaticLockWritesOnTables는 각 데이터베이스 마이그레이션 실행 전후의 테이블 목록을 비교합니다.
그런 다음 관련 데이터베이스에서 새로 추가된 테이블을 잠급니다.
이는 모든 경우를 포함하지는 않습니다. 일부 마이그레이션은 동일한 트랜잭션 마이그레이션 내에서 테이블을 다시 생성해야 하기 때문입니다.
예를 들어, 표준 비파티셔닝 테이블을 파티셔닝된 테이블로 변환하는 경우입니다.
이 예시를 참조하세요.
이러한 마이그레이션은 다시 생성된 테이블을 잠금 해제된 상태로 남겨둡니다.
그러나 Database::MonitorLockedTablesWorker의 일일 크론 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 태스크를 제공합니다:
데이터베이스의 테이블이 쓰기에 대해 잠겨 있는 경우에만 이 태스크를 실행할 수 있습니다.
-
gitlab:db:truncate_legacy_tables:main은 Main 데이터베이스의 레거시 테이블을 트런케이션합니다. -
gitlab:db:truncate_legacy_tables:ci는 CI 데이터베이스의 레거시 테이블을 트런케이션합니다. -
gitlab:db:truncate_legacy_tables:sec는 Sec 데이터베이스의 레거시 테이블을 트런케이션합니다.
이 섹션의 예시에서는 DRY_RUN=true를 사용합니다. 이렇게 하면 실제로 데이터가 트런케이션되지 않습니다.
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