데이터베이스 사례 연구: 네임스페이스 저장 통계
소개
우리는 그룹이 소비하는 저장 용량을 쉽게 볼 수 있는 방법을 제공하고
손쉬운 관리가 가능하도록 하려 합니다.
제안
- 네임스페이스 통계를 집계된 형태로 보유할 새로운 ActiveRecord 모델을 생성합니다. (루트 네임스페이스만 해당)
- 이 네임스페이스에 속하는 프로젝트가 변경될 때마다 이 모델의 통계를 새로 고칩니다.
문제
GitLab에서는 프로젝트가 저장될 때마다 프로젝트 저장 통계를 콜백 통해 업데이트합니다.
그 후, 해당 네임스페이스의 통계 요약은 Namespaces#with_statistics
범위를 통해 검색됩니다. 이 쿼리를 분석해보니:
- 15,000개 이상의 프로젝트가 있는 네임스페이스의 경우 최대
1.2
초가 소요됩니다. - ChatOps로 분석할 수 없으며, 타임아웃이 발생합니다.
또한, 현재 프로젝트 통계를 업데이트하는 데 사용되는 패턴(콜백)은 적절하게 확장되지 않습니다. 현재 이 패턴은 가장 많은 시간을 소요하는 데이터베이스 쿼리 트랜잭션 중 하나입니다.
한 개의 쿼리를 더 추가할 수 없으며, 그렇게 할 경우 트랜잭션의 길이가 증가합니다.
이 모든 이유로, namespaces
테이블이 GitLab.com에서 가장 큰 테이블 중 하나이기 때문에 같은 패턴을 사용하여
네임스페이스 통계를 저장하고 업데이트할 수 없습니다.
따라서 우리는 성능이 뛰어나고 대안적인 방법을 찾아야 했습니다.
시도
시도 A: PostgreSQL 물리화된 뷰
모델은 프로젝트 경로 SQL과 물리화된 뷰를 기반으로 하는 새로 고침 전략을 통해 업데이트할 수 있습니다:
SELECT split_part("rs".path, '/', 1) as root_path,
COALESCE(SUM(ps.storage_size), 0) AS storage_size,
COALESCE(SUM(ps.repository_size), 0) AS repository_size,
COALESCE(SUM(ps.wiki_size), 0) AS wiki_size,
COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size,
COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size,
COALESCE(SUM(ps.pipeline_artifacts_size), 0) AS pipeline_artifacts_size,
COALESCE(SUM(ps.packages_size), 0) AS packages_size,
COALESCE(SUM(ps.snippets_size), 0) AS snippets_size,
COALESCE(SUM(ps.uploads_size), 0) AS uploads_size
FROM "projects"
INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'
INNER JOIN project_statistics ps ON ps.project_id = projects.id
GROUP BY root_path
그 후 다음 쿼리를 실행할 수 있습니다:
REFRESH MATERIALIZED VIEW root_namespace_storage_statistics;
이것은 단일 쿼리 업데이트(아마도 빠른 쿼리)를 의미하지만, 몇 가지 단점이 있습니다:
- 물리화된 뷰의 구문은 PostgreSQL과 MySQL에서 다릅니다. 이 기능이 작업 중일 때 MySQL은 여전히 GitLab에서 지원되고 있었습니다.
- Rails는 물리화된 뷰에 대한 네이티브 지원이 없습니다. 우리는 데이터베이스 뷰 관리를 처리하기 위해 전문 gem을 사용해야 하며, 이는 추가 작업을 의미합니다.
시도 B: CTE를 통한 업데이트
시도 A와 유사하게: Common Table Expression을 사용하여 새로 고침 전략을 통해 모델 업데이트 수행
WITH refresh AS (
SELECT split_part("rs".path, '/', 1) as root_path,
COALESCE(SUM(ps.storage_size), 0) AS storage_size,
COALESCE(SUM(ps.repository_size), 0) AS repository_size,
COALESCE(SUM(ps.wiki_size), 0) AS wiki_size,
COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size,
COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size,
COALESCE(SUM(ps.pipeline_artifacts_size), 0) AS pipeline_artifacts_size,
COALESCE(SUM(ps.packages_size), 0) AS packages_size,
COALESCE(SUM(ps.snippets_size), 0) AS snippets_size,
COALESCE(SUM(ps.uploads_size), 0) AS uploads_size
FROM "projects"
INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'
INNER JOIN project_statistics ps ON ps.project_id = projects.id
GROUP BY root_path)
UPDATE namespace_storage_statistics
SET storage_size = refresh.storage_size,
repository_size = refresh.repository_size,
wiki_size = refresh.wiki_size,
lfs_objects_size = refresh.lfs_objects_size,
build_artifacts_size = refresh.build_artifacts_size,
pipeline_artifacts_size = refresh.pipeline_artifacts_size,
packages_size = refresh.packages_size,
snippets_size = refresh.snippets_size,
uploads_size = refresh.uploads_size
FROM refresh
INNER JOIN routes rs ON rs.path = refresh.root_path AND rs.source_type = 'Namespace'
WHERE namespace_storage_statistics.namespace_id = rs.source_id
시도 A와 동일한 장점과 단점이 있습니다.
시도 C: 모델을 없애고 통계를 Redis에 저장하기
집계 형태로 통계를 저장하는 모델을 없애고 대신 Redis Set을 사용할 수 있습니다.
이것은 따분한 솔루션이며 구현 속도가 가장 빠릅니다.
왜냐하면 GitLab은 이미 Architecture의 일부로 Redis를 포함하고 있기 때문입니다.
이 접근 방식의 단점은 Redis가 PostgreSQL과 동일한 지속성/일관성 보장을 제공하지 않으며, 이는 Redis 실패 시 잃을 여유가 없는 정보입니다.
시도 D: 루트 네임스페이스와 그 자식 네임스페이스 태그하기
루트 네임스페이스를 그 자식 네임스페이스와 직접 연관시키면, 부모 없이 네임스페이스가 생성될 때마다 이 네임스페이스는 루트 네임스페이스 ID로 태그됩니다:
ID | 루트 ID | 부모 ID |
---|---|---|
1 | 1 | NULL |
2 | 1 | 1 |
3 | 1 | 2 |
네임스페이스 내부에서 통계를 집계하려면 다음과 같은 쿼리를 실행합니다:
SELECT COUNT(...)
FROM projects
WHERE namespace_id IN (
SELECT id
FROM namespaces
WHERE root_id = X
)
비록 이 접근 방식이 집계를 훨씬 쉽게 만들겠지만, 몇 가지 주요 단점이 있습니다:
-
모든 네임스페이스를 마이그레이션해야 하며, 새로운 열을 추가하고 채워야 합니다. 테이블의 크기로 인해 시간/비용 문제가 상당할 것입니다. 백그라운드 마이그레이션은 약
153h
가 소요됩니다. https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/29772를 참조하세요. - 백그라운드 마이그레이션은 한 릴리스 전에 제공되어야 하며, 기능이 또 다른 마일스톤으로 지연됩니다.
시도 E (최종): 네임스페이스 저장소 통계 비동기 업데이트
이 접근 방식은 우리가 이미 가지고 있는 증분 통계 업데이트를 계속 사용하되, Sidekiq 작업을 통해 그리고 서로 다른 트랜잭션에서 이를 새로 고치는 것입니다:
-
두 개의 열
id
와namespace_id
가 있는 두 번째 테이블(namespace_aggregation_schedules
)을 생성합니다. - 프로젝트의 통계가 변경될 때마다
namespace_aggregation_schedules
에 행을 삽입합니다.- 루트 네임스페이스와 관련된 행이 이미 있으면 새로운 행을 삽입하지 않습니다.
-
project_statistics
(https://gitlab.com/gitlab-org/gitlab/-/issues/29070) 업데이트와 관련된 트랜잭션의 길이를 염두에 두고, 삽입은 다른 트랜잭션에서 Sidekiq 작업을 통해 수행해야 합니다.
- 행을 삽입한 후, 두 가지 다른 시간에 비동기로 실행될 작업자를 예약합니다:
- 즉시 실행될 작업자 하나와
1.5h
시간 후에 예약된 작업자 하나입니다. - 루트 네임스페이스 ID를 기반으로 Redis에서
1.5h
리스를 획득할 수 있는 경우에만 작업을 예약합니다. - 리스를 획득할 수 없다면, 이는 다른 집계가 이미 진행 중이거나,
1.5h
이내에 예약되어 있다는 것을 나타냅니다.
- 즉시 실행될 작업자 하나와
- 이 작업자는:
- 서비스의 모든 네임스페이스를 쿼리하여 루트 네임스페이스 저장소 통계를 업데이트합니다.
- 업데이트 후 관련된
namespace_aggregation_schedules
를 삭제합니다.
- 또한,
namespace_aggregation_schedules
테이블의 남아 있는 행을 순회하고 각 보류 중인 행에 대한 작업을 예약하는 다른 Sidekiq 작업이 포함됩니다.- 이 작업은 매일 밤(UTC) 실행되도록 크론으로 예약됩니다.
이 구현의 이점은 다음과 같습니다:
- 모든 업데이트가 비동기로 수행되므로
project_statistics
의 트랜잭션 길이를 늘리지 않습니다. - 단일 SQL 쿼리에서 업데이트를 수행합니다.
- PostgreSQL 및 MySQL과 호환됩니다.
- 백그라운드 마이그레이션이 필요하지 않습니다.
이 접근 방식의 유일한 단점은 네임스페이스 통계가 변경 후 최대 1.5
시간까지 업데이트된다는 것으로,
따라서 통계가 부정확한 시간 창이 존재하게 됩니다. 우리가 아직도
저장소 한도를 적용하고 있지 않기 때문에, 이는 큰 문제가 아닙니다.
결론
비동기로 저장소 통계를 업데이트하는 것은 루트 네임스페이스를 집계하는 데 문제점이 덜하고 성능이 좋은 접근 방식이었습니다.
이 사용 사례에 대한 모든 세부 정보는 다음에서 확인할 수 있습니다:
- https://gitlab.com/gitlab-org/gitlab-foss/-/issues/62214
- 구현이 포함된 병합 요청: https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/28996
네임스페이스 저장소 통계의 성능은 스테이징 및 프로덕션(GitLab.com)에서 측정되었습니다. 모든 결과는 https://gitlab.com/gitlab-org/gitlab-foss/-/issues/64092에 게시되었습니다: 지금까지 문제는 보고되지 않았습니다.