데이터베이스 사례 연구: 네임스페이스 스토리지 통계
GitLab v19.4요약
그룹의 스토리지 및 제한 관리에서는 그룹이 사용하는 스토리지 양을 쉽게 확인하고 손쉽게 관리할 수 있는 방법을 제공하고자 합니다. GitLab에서는 프로젝트가 저장될 때마다 콜백 을 통해 프로젝트 스토리지 통계를 업데이트합니다.
개요#
그룹의 스토리지 및 제한 관리에서는 그룹이 사용하는 스토리지 양을 쉽게 확인하고 손쉽게 관리할 수 있는 방법을 제공하고자 합니다.
제안#
- 네임스페이스 통계를 집계된 형태로 보관할 새 ActiveRecord 모델을 만듭니다(루트 네임스페이스만 해당).
- 이 네임스페이스에 속한 프로젝트가 변경될 때마다 이 모델의 통계를 갱신합니다.
문제#
GitLab에서는 프로젝트가 저장될 때마다 콜백 을 통해 프로젝트 스토리지 통계를 업데이트합니다.
네임스페이스별 통계 요약은 이어서
Namespaces#with_statistics 스코프로 조회합니다. 이 쿼리를 분석한 결과 다음을 확인했습니다:
- 프로젝트가
15k개를 넘는 네임스페이스에서는 최대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에서 다릅니다. 이 기능을 작업하던 당시 GitLab은 MySQL도 지원하고 있었습니다.
- Rails는 구체화된 뷰를 기본으로 지원하지 않습니다. 데이터베이스 뷰 관리를 담당하는 전용 gem을 사용해야 하며, 이는 추가 작업으로 이어집니다.
시도 B: CTE를 통한 업데이트#
시도 A와 유사합니다. 공통 테이블 표현식을 사용하는 갱신 전략으로 모델을 업데이트합니다
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은 이미 아키텍처의 일부로 Redis를 포함하고 있으므로, 이는 지루한 해법이면서 가장 빠르게 구현할 수 있는 방법입니다.
이 접근 방식의 단점은 Redis가 PostgreSQL과 같은 수준의 영속성 및 일관성 보장을 제공하지 않는다는 점이며, 이 정보는 Redis 장애로 잃어서는 안 되는 정보입니다.
시도 D: 루트 네임스페이스와 하위 네임스페이스에 태그 지정#
루트 네임스페이스를 하위 네임스페이스와 직접 연결해, 부모 없이 네임스페이스가 생성될 때마다 해당 네임스페이스에 루트 네임스페이스 ID를 태그로 지정합니다:
| ID | root ID | parent 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 job과 별도 트랜잭션으로 통계를 갱신하는 것입니다:
id와namespace_id두 칼럼을 가진 두 번째 테이블(namespace_aggregation_schedules)을 만듭니다.- 프로젝트의 통계가 변경될 때마다
namespace_aggregation_schedules에 행을 삽입합니다- 루트 네임스페이스와 관련된 행이 이미 있으면 새 행을 삽입하지 않습니다.
project_statistics업데이트가 포함된 트랜잭션의 길이(https://gitlab.com/gitlab-org/gitlab/-/issues/29070)를 고려하여, 삽입은 별도 트랜잭션에서 Sidekiq Job을 통해 수행해야 합니다.
- 행을 삽입한 뒤에는 다른 워커를 서로 다른 두 시점에 비동기로 실행하도록 예약합니다:
- 하나는 즉시 실행되도록 큐에 넣고, 다른 하나는
1.5h뒤에 실행되도록 예약합니다. - 루트 네임스페이스 ID를 기반으로 한 키에 대해 Redis에서
1.5h리스를 획득할 수 있을 때만 job을 예약합니다. - 리스를 획득할 수 없다면 이미 진행 중인 집계가 있거나
1.5h이내에 예약된 집계가 있다는 뜻입니다.
- 하나는 즉시 실행되도록 큐에 넣고, 다른 하나는
- 이 워커는 다음을 수행합니다:
- 서비스를 통해 모든 네임스페이스를 조회해 루트 네임스페이스 스토리지 통계를 업데이트합니다.
- 업데이트 후 관련
namespace_aggregation_schedules행을 삭제합니다.
namespace_aggregation_schedules테이블에 남아 있는 행을 순회하면서 대기 중인 모든 행에 대해 job을 예약하는 Sidekiq job도 함께 포함됩니다.- 이 job은 cron으로 매일 밤(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에 게시했습니다. 지금까지 보고된 문제는 없습니다.