데이터베이스 케이스 스터디: 네임스페이스 저장소 통계

소개

그룹을 위한 저장소 및 제한 관리에서는 그룹이 소비하는 저장소량을 쉽게 볼 수 있는 방법을 제공하고 쉬운 관리를 허용하고자 합니다.

제안

  1. 루트 네임스페이스에 속하는 프로젝트의 통계를 집계 형태로 저장할 새로운 ActiveRecord 모델을 생성합니다.
  2. 이 모델의 통계를 해당 네임스페이스의 프로젝트가 변경될 때마다 갱신합니다.

문제

GitLab에서는 프로젝트 저장소 통계를 프로젝트가 저장될 때마다 콜백을 통해 업데이트합니다.

그런 다음, 네임스페이스별 통계의 요약은 Namespaces#with_statistics 스코프에서 검색합니다. 이 쿼리를 분석한 결과는 다음과 같습니다.

  • 프로젝트가 15,000개 이상인 네임스페이스에서 최대 1.2초가 걸립니다.
  • ChatOps으로 분석할 수 없는 시간 초과가 발생합니다.

또한, 현재 프로젝트 통계를 업데이트하는 데 사용 중인 패턴(콜백)은 적절하게 확장되지 않습니다. 현재 이는 가장 시간이 오래 걸리는 프로덕션 데이터베이스 쿼리 트랜잭션 중 하나로, 전반적으로 시간이 가장 많이 소요됩니다. 이에 더 많은 쿼리를 추가할 수 없으며 쿼리를 추가하면 트랜잭션의 길이가 증가합니다.

위와 같은 이유로 네임스페이스 통계를 저장하고 업데이트하는 데 동일한 패턴을 적용할 수 없으며, 네임스페이스 테이블은 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는 재집계 뷰를 네이티브로 지원하지 않습니다. 데이터베이스 뷰 관리를 처리하는 특수화된 젬을 사용하여 추가 작업이 필요합니다.

시도 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
)

이 접근 방식은 집계를 훨씬 쉽게 만들지만, 몇 가지 주요 단점이 있습니다:

  • 모든 네임스페이스를 새로운 열을 추가하고 채워넣는 식으로 마이그레이션해야 합니다. 테이블의 크기 때문에 시간/비용 처리가 상당히 심각할 것입니다. 백그라운드 마이그레이션이 약 153시간이 걸릴 것으로 예상됩니다. 자세한 내용은 https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/29772를 참조하세요.
  • 백그라운드 마이그레이션은 기능을 미루는 마일스톤을 하나 더 늦추기 때문에 출시 하나 전에 제공해야 합니다.

시도 E (최종): 네임스페이스 저장소 통계를 비동기적으로 업데이트

본 접근 방식은 이미 사용 중인 증분 통계 업데이트를 계속 사용하되, Sidekiq 작업과 다른 트랜잭션을 통해 해당 업데이트를 새롭게 갱신하는 것으로 이루어져 있습니다:

  1. 두 개의 열 idnamespace_id를 가진 두 번째 테이블(namespace_aggregation_schedules)을 생성합니다.
  2. 프로젝트의 통계가 변경될 때마다, namespace_aggregation_schedules에 행을 삽입합니다.
    • 루트 네임스페이스와 관련된 행이 이미 있는 경우 새로운 행을 삽입하지 않습니다.
    • project_statistics를 업데이트하는 트랜잭션의 길이를 고려하여, 삽입은 다른 트랜잭션 및 Sidekiq 작업을 통해 수행되어야 합니다.
  3. 행을 삽입한 후, 두 가지 다른 시간에 대해 비동기적으로 실행될 다른 워커를 예약합니다:
    • 즉시 실행되도록 예약하고 또다른 작업은 1.5시간 후에 예약합니다.
    • 루트 네임스페이스 ID를 기반으로 한 Redis 키에서 1.5시간 동안 대여할 수 있다면 작업을 예약합니다.
    • 대여를 얻을 수 없는 경우, 다른 집계가 이미 진행 중이거나 최대 1.5시간 내에 예정된 것이 있음을 나타냅니다.
  4. 이 워커는:
    • 서비스를 통해 모든 네임스페이스를 쿼리하여 루트 네임스페이스 저장소 통계를 업데이트합니다.
    • 업데이트 후 namespace_aggregation_schedules를 삭제합니다.
  5. namespace_aggregation_schedules 테이블에 남아있는 모든 행을 탐색하고 대기 중인 각 행에 대한 작업을 예약하는 또 다른 Sidekiq 작업이 포함됩니다.
    • UTC 기준으로 매일 밤 실행되도록 cron으로 예약됩니다.

이 구현은 다음과 같은 이점이 있습니다:

  • 모든 업데이트가 비동기적으로 이루어지므로 project_statistics의 트랜잭션 길이가 증가하지 않습니다.
  • 업데이트는 하나의 SQL 쿼리로 수행됩니다.
  • PostgreSQL 및 MySQL과 호환됩니다.
  • 백그라운드 마이그레이션이 필요하지 않습니다.

이 접근 방식의 유일한 단점은 네임스페이스 통계가 변경된 후 최대 1.5시간 동안 업데이트되지 않는다는 것이며, 이는 통계가 정확하지 않은 시간 창이 있음을 의미합니다. 그럼에도 불구하고 저장소 한도를 강제하지는 않아 현재는 주요한 문제가 아닙니다.

결론

저장소 통계를 비동기적으로 업데이트하는 것이 루트 네임스페이스를 집계하는 가장 문제가 적고 성능이 우수한 방식이었습니다.

이 사례에 대한 자세한 내용은 다음에서 확인할 수 있습니다:

네임스페이스 저장소 통계의 성능은 스테이징 및 프로덕션(GitLab.com)에서 측정되었습니다. 모든 결과는 https://gitlab.com/gitlab-org/gitlab-foss/-/issues/64092에 게시되었고, 지금까지 보고된 문제는 없습니다.