데이터베이스 사례 연구: 네임스페이스 저장소 통계

소개

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

제안

  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에서도 지원되기는 했지만 이 기능 역시 지원되었습니다.
  • Rails에는 재집계 뷰에 대한 내장 지원이 없습니다. 데이터베이스 뷰 관리를 처리하는 전문화된 gem을 사용하여 데이터베이스 뷰의 관리를 처리해야 하므로 추가 작업이 필요합니다.

시도 B: CTE를 통한 업데이트

시도 A와 유사함: 공통 테이블 표현식(CTE)을 사용한 갱신 전략을 통해 모델을 갱신할 수 있습니다.

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 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를 업데이트하는 트랜잭션의 길이를 고려하여(https://gitlab.com/gitlab-org/gitlab/-/issues/29070) 삽입은 다른 트랜잭션에서 Sidekiq 작업을 통해 처리되어야 합니다.
  3. 행을 삽입한 후, 다른 두 가지 서로 다른 시간에 대해 비동기적으로 실행될 다른 워커를 예약합니다.
    • 즉시 실행을 위해 enqueued 된 작업과 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에 게시되었습니다.