InfoGrab Docs

그룹 계층 쿼리 최적화

요약

이 문서는 최소한의 오버헤드로 대규모 그룹 계층에서 모든 하위 항목(서브그룹 또는 프로젝트)을 로드하는 데 도움이 되는 계층 캐시 최적화 전략을 설명합니다. 최적화는 700개 이상의 하위 항목(프로젝트 및 그룹) 수를 가진 그룹 계층에 대해 Namespaces::EnableDescendantsCacheCronWorker 워커를 통해 자동으로 활성화됩니다.

이 문서는 최소한의 오버헤드로 대규모 그룹 계층에서 모든 하위 항목(서브그룹 또는 프로젝트)을 로드하는 데 도움이 되는 계층 캐시 최적화 전략을 설명합니다. 이 최적화는 GitLab 에픽 내에서 구현되었습니다.

최적화는 700개 이상의 하위 항목(프로젝트 및 그룹) 수를 가진 그룹 계층에 대해 Namespaces::EnableDescendantsCacheCronWorker 워커를 통해 자동으로 활성화됩니다. 더 작은 그룹에 대해 수동으로 최적화를 활성화하면 눈에 띄는 효과가 없을 수 있습니다.

성능 비교#

gitlab-org 그룹의 모든 그룹 ID 로드(해당 그룹 자체 및 하위 항목 포함).

버퍼 풀에서 42 버퍼 (~336.00 KiB)

SELECT "namespaces"."id" FROM UNNEST(
  COALESCE(
    (
      SELECT ids FROM (
        SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
        FROM "namespace_descendants"
        WHERE "namespace_descendants"."outdated_at" IS NULL AND
        "namespace_descendants"."namespace_id" = 22
      ) cached_query
    ),
    (
      SELECT ids
      FROM (
        SELECT ARRAY_AGG("namespaces"."id") AS ids
        FROM (
          SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
          FROM "namespaces"
          WHERE "namespaces"."type" = 'Group' AND
          (traversal_ids @> ('{22}'))
        ) namespaces
      ) consistent_query
    )
  )
) AS namespaces(id)
 Function Scan on unnest namespaces  (cost=1296.82..1296.92 rows=10 width=8) (actual time=0.193..0.236 rows=GROUP_COUNT loops=1)
   Buffers: shared hit=42
   I/O Timings: read=0.000 write=0.000
   InitPlan 1 (returns $0)
     ->  Index Scan using namespace_descendants_12_pkey on gitlab_partitions_static.namespace_descendants_12 namespace_descendants  (cost=0.14..3.16 rows=1 width=769) (actual time=0.022..0.023 rows=1 loops=1)
           Index Cond: (namespace_descendants.namespace_id = 9970)
           Filter: (namespace_descendants.outdated_at IS NULL)
           Rows Removed by Filter: 0
           Buffers: shared hit=5
           I/O Timings: read=0.000 write=0.000
   InitPlan 2 (returns $1)
     ->  Aggregate  (cost=1293.62..1293.63 rows=1 width=32) (actual time=0.000..0.000 rows=0 loops=0)
           I/O Timings: read=0.000 write=0.000
           ->  Bitmap Heap Scan on public.namespaces namespaces_1  (cost=62.00..1289.72 rows=781 width=28) (actual time=0.000..0.000 rows=0 loops=0)
                 I/O Timings: read=0.000 write=0.000
                 ->  Bitmap Index Scan using index_namespaces_on_traversal_ids_for_groups  (cost=0.00..61.81 rows=781 width=0) (actual time=0.000..0.000 rows=0 loops=0)
                       Index Cond: (namespaces_1.traversal_ids @> '{9970}'::integer[])
                       I/O Timings: read=0.000 write=0.000
Settings: seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off', work_mem = '100MB', random_page_cost = '1.5'

버퍼 풀에서 1037 버퍼 (~8.10 MiB)

SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
FROM "namespaces"
WHERE "namespaces"."type" = 'Group' AND
(traversal_ids @> ('{22}'))
 Bitmap Heap Scan on public.namespaces  (cost=62.00..1291.67 rows=781 width=4) (actual time=0.670..2.273 rows=GROUP_COUNT loops=1)
   Buffers: shared hit=1037
   I/O Timings: read=0.000 write=0.000
   ->  Bitmap Index Scan using index_namespaces_on_traversal_ids_for_groups  (cost=0.00..61.81 rows=781 width=0) (actual time=0.561..0.561 rows=1154 loops=1)
         Index Cond: (namespaces.traversal_ids @> '{9970}'::integer[])
         Buffers: shared hit=34
         I/O Timings: read=0.000 write=0.000
Settings: work_mem = '100MB', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off'

최적화 사용 방법#

다음 ActiveRecord 스코프 중 하나를 사용하면 최적화가 자동으로 사용됩니다:

# 모든 그룹 로드:
group.self_and_descendants

# 서브쿼리에서 ID 사용:
group.self_and_descendant_ids

NamespaceSetting.where(namespace_id: group.self_and_descendant_ids)

# 모든 프로젝트 로드:
group.all_projects

# 서브쿼리에서 ID 사용
MergeRequest.where(target_project_id: group.all_project_ids)

캐시 무효화#

그룹 계층이 변경될 때(예: 새 프로젝트 또는 서브그룹이 추가될 때), 캐시는 동일한 트랜잭션 내에서 무효화됩니다. Namespaces::ProcessOutdatedNamespaceDescendantsCronWorker라는 주기적 워커가 약간의 지연으로 캐시를 업데이트합니다. 무효화는 ActiveRecord 콜백을 사용하여 구현됩니다.

캐시가 무효화되는 동안, 계층 데이터베이스 쿼리는 캐시되지 않은(최적화되지 않은) traversal_ids 기반 쿼리를 사용하여 일관된 값을 계속 반환합니다.

일관된 쿼리#

조회 쿼리는 SQL에서 ||(OR) 기능을 구현하여 먼저 캐시된 값을 확인할 수 있습니다. 캐시가 없으면, 계층의 모든 그룹 또는 프로젝트의 전체 조회로 폴백합니다.

단순화를 위해, 이것이 Ruby에서 조회를 구현하는 방법입니다:

if cached? && cache_up_to_date?
  return cached_project_ids
else
  return Project.where(...).pluck(:id)
end

SQL에서는 표현식 목록에서 첫 번째 non-NULL 표현식을 반환하는 COALESCE 함수를 활용합니다. 첫 번째 표현식이 NULL이 아니면 후속 표현식은 평가되지 않습니다.

SELECT COALESCE(
  (SELECT 1), -- 캐시된 쿼리
  (SELECT 2 FROM pg_sleep(5)) -- 캐시되지 않은 쿼리
)

위 쿼리는 즉시 반환되지만, 첫 번째 서브쿼리가 null을 반환하면 DB가 두 번째 쿼리를 실행합니다:

SELECT COALESCE(
  (SELECT NULL), -- 캐시된 쿼리
  (SELECT 2 FROM pg_sleep(5)) -- 캐시되지 않은 쿼리
)

namespace_descendants 데이터베이스 테이블#

캐시된 서브그룹 및 프로젝트 ID는 namespace_descendants 데이터베이스 테이블에 배열로 저장됩니다. 가장 중요한 컬럼:

  • namespace_id: 기본 키, 최상위 그룹 ID 또는 서브그룹 ID가 될 수 있습니다.
  • self_and_descendant_group_ids: 배열로 저장된 모든 그룹 ID
  • all_project_ids: 배열로 저장된 모든 프로젝트 ID
  • outdated_at: 캐시가 만료되었음을 나타냄

캐시된 데이터베이스 쿼리#

쿼리는 세 부분으로 구성됩니다:

  • 캐시된 쿼리
  • 폴백, 캐시되지 않은 쿼리
  • 추가 필터링 및 데이터 로드(JOIN)를 수행할 수 있는 외부 쿼리

캐시된 쿼리:

SELECT ids -- 하나의 행, ID 배열
FROM (
  SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
  FROM "namespace_descendants"
  WHERE "namespace_descendants"."outdated_at" IS NULL AND
  "namespace_descendants"."namespace_id" = 22
) cached_query

캐시가 만료되거나 캐시 레코드가 없으면 쿼리는 NULL을 반환합니다.

traversal_ids 조회를 기반으로 하는 폴백 쿼리:

SELECT ids -- 하나의 행, ID 배열
FROM (
  SELECT ARRAY_AGG("namespaces"."id") AS ids
  FROM (
    SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
    FROM "namespaces"
    WHERE "namespaces"."type" = 'Group' AND
    (traversal_ids @> ('{22}'))
  ) namespaces
)

쿼리를 하나로 결합한 최종 쿼리:

SELECT "namespaces"."id" FROM UNNEST(
  COALESCE(
    (
      SELECT ids FROM (
        SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
        FROM "namespace_descendants"
        WHERE "namespace_descendants"."outdated_at" IS NULL AND
        "namespace_descendants"."namespace_id" = 22
      ) cached_query
    ),
    (
      SELECT ids
      FROM (
        SELECT ARRAY_AGG("namespaces"."id") AS ids
        FROM (
          SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
          FROM "namespaces"
          WHERE "namespaces"."type" = 'Group' AND
          (traversal_ids @> ('{22}'))
        ) namespaces
      ) consistent_query
    )
  )
) AS namespaces(id)

그룹 계층 쿼리 최적화

원문 보기
요약

이 문서는 최소한의 오버헤드로 대규모 그룹 계층에서 모든 하위 항목(서브그룹 또는 프로젝트)을 로드하는 데 도움이 되는 계층 캐시 최적화 전략을 설명합니다. 최적화는 700개 이상의 하위 항목(프로젝트 및 그룹) 수를 가진 그룹 계층에 대해 Namespaces::EnableDescendantsCacheCronWorker 워커를 통해 자동으로 활성화됩니다.

이 문서는 최소한의 오버헤드로 대규모 그룹 계층에서 모든 하위 항목(서브그룹 또는 프로젝트)을 로드하는 데 도움이 되는 계층 캐시 최적화 전략을 설명합니다. 이 최적화는 GitLab 에픽 내에서 구현되었습니다.

최적화는 700개 이상의 하위 항목(프로젝트 및 그룹) 수를 가진 그룹 계층에 대해 Namespaces::EnableDescendantsCacheCronWorker 워커를 통해 자동으로 활성화됩니다. 더 작은 그룹에 대해 수동으로 최적화를 활성화하면 눈에 띄는 효과가 없을 수 있습니다.

성능 비교#

gitlab-org 그룹의 모든 그룹 ID 로드(해당 그룹 자체 및 하위 항목 포함).

버퍼 풀에서 42 버퍼 (~336.00 KiB)

SELECT "namespaces"."id" FROM UNNEST(
  COALESCE(
    (
      SELECT ids FROM (
        SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
        FROM "namespace_descendants"
        WHERE "namespace_descendants"."outdated_at" IS NULL AND
        "namespace_descendants"."namespace_id" = 22
      ) cached_query
    ),
    (
      SELECT ids
      FROM (
        SELECT ARRAY_AGG("namespaces"."id") AS ids
        FROM (
          SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
          FROM "namespaces"
          WHERE "namespaces"."type" = 'Group' AND
          (traversal_ids @> ('{22}'))
        ) namespaces
      ) consistent_query
    )
  )
) AS namespaces(id)
 Function Scan on unnest namespaces  (cost=1296.82..1296.92 rows=10 width=8) (actual time=0.193..0.236 rows=GROUP_COUNT loops=1)
   Buffers: shared hit=42
   I/O Timings: read=0.000 write=0.000
   InitPlan 1 (returns $0)
     ->  Index Scan using namespace_descendants_12_pkey on gitlab_partitions_static.namespace_descendants_12 namespace_descendants  (cost=0.14..3.16 rows=1 width=769) (actual time=0.022..0.023 rows=1 loops=1)
           Index Cond: (namespace_descendants.namespace_id = 9970)
           Filter: (namespace_descendants.outdated_at IS NULL)
           Rows Removed by Filter: 0
           Buffers: shared hit=5
           I/O Timings: read=0.000 write=0.000
   InitPlan 2 (returns $1)
     ->  Aggregate  (cost=1293.62..1293.63 rows=1 width=32) (actual time=0.000..0.000 rows=0 loops=0)
           I/O Timings: read=0.000 write=0.000
           ->  Bitmap Heap Scan on public.namespaces namespaces_1  (cost=62.00..1289.72 rows=781 width=28) (actual time=0.000..0.000 rows=0 loops=0)
                 I/O Timings: read=0.000 write=0.000
                 ->  Bitmap Index Scan using index_namespaces_on_traversal_ids_for_groups  (cost=0.00..61.81 rows=781 width=0) (actual time=0.000..0.000 rows=0 loops=0)
                       Index Cond: (namespaces_1.traversal_ids @> '{9970}'::integer[])
                       I/O Timings: read=0.000 write=0.000
Settings: seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off', work_mem = '100MB', random_page_cost = '1.5'

버퍼 풀에서 1037 버퍼 (~8.10 MiB)

SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
FROM "namespaces"
WHERE "namespaces"."type" = 'Group' AND
(traversal_ids @> ('{22}'))
 Bitmap Heap Scan on public.namespaces  (cost=62.00..1291.67 rows=781 width=4) (actual time=0.670..2.273 rows=GROUP_COUNT loops=1)
   Buffers: shared hit=1037
   I/O Timings: read=0.000 write=0.000
   ->  Bitmap Index Scan using index_namespaces_on_traversal_ids_for_groups  (cost=0.00..61.81 rows=781 width=0) (actual time=0.561..0.561 rows=1154 loops=1)
         Index Cond: (namespaces.traversal_ids @> '{9970}'::integer[])
         Buffers: shared hit=34
         I/O Timings: read=0.000 write=0.000
Settings: work_mem = '100MB', random_page_cost = '1.5', seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off'

최적화 사용 방법#

다음 ActiveRecord 스코프 중 하나를 사용하면 최적화가 자동으로 사용됩니다:

# 모든 그룹 로드:
group.self_and_descendants

# 서브쿼리에서 ID 사용:
group.self_and_descendant_ids

NamespaceSetting.where(namespace_id: group.self_and_descendant_ids)

# 모든 프로젝트 로드:
group.all_projects

# 서브쿼리에서 ID 사용
MergeRequest.where(target_project_id: group.all_project_ids)

캐시 무효화#

그룹 계층이 변경될 때(예: 새 프로젝트 또는 서브그룹이 추가될 때), 캐시는 동일한 트랜잭션 내에서 무효화됩니다. Namespaces::ProcessOutdatedNamespaceDescendantsCronWorker라는 주기적 워커가 약간의 지연으로 캐시를 업데이트합니다. 무효화는 ActiveRecord 콜백을 사용하여 구현됩니다.

캐시가 무효화되는 동안, 계층 데이터베이스 쿼리는 캐시되지 않은(최적화되지 않은) traversal_ids 기반 쿼리를 사용하여 일관된 값을 계속 반환합니다.

일관된 쿼리#

조회 쿼리는 SQL에서 ||(OR) 기능을 구현하여 먼저 캐시된 값을 확인할 수 있습니다. 캐시가 없으면, 계층의 모든 그룹 또는 프로젝트의 전체 조회로 폴백합니다.

단순화를 위해, 이것이 Ruby에서 조회를 구현하는 방법입니다:

if cached? && cache_up_to_date?
  return cached_project_ids
else
  return Project.where(...).pluck(:id)
end

SQL에서는 표현식 목록에서 첫 번째 non-NULL 표현식을 반환하는 COALESCE 함수를 활용합니다. 첫 번째 표현식이 NULL이 아니면 후속 표현식은 평가되지 않습니다.

SELECT COALESCE(
  (SELECT 1), -- 캐시된 쿼리
  (SELECT 2 FROM pg_sleep(5)) -- 캐시되지 않은 쿼리
)

위 쿼리는 즉시 반환되지만, 첫 번째 서브쿼리가 null을 반환하면 DB가 두 번째 쿼리를 실행합니다:

SELECT COALESCE(
  (SELECT NULL), -- 캐시된 쿼리
  (SELECT 2 FROM pg_sleep(5)) -- 캐시되지 않은 쿼리
)

namespace_descendants 데이터베이스 테이블#

캐시된 서브그룹 및 프로젝트 ID는 namespace_descendants 데이터베이스 테이블에 배열로 저장됩니다. 가장 중요한 컬럼:

  • namespace_id: 기본 키, 최상위 그룹 ID 또는 서브그룹 ID가 될 수 있습니다.
  • self_and_descendant_group_ids: 배열로 저장된 모든 그룹 ID
  • all_project_ids: 배열로 저장된 모든 프로젝트 ID
  • outdated_at: 캐시가 만료되었음을 나타냄

캐시된 데이터베이스 쿼리#

쿼리는 세 부분으로 구성됩니다:

  • 캐시된 쿼리
  • 폴백, 캐시되지 않은 쿼리
  • 추가 필터링 및 데이터 로드(JOIN)를 수행할 수 있는 외부 쿼리

캐시된 쿼리:

SELECT ids -- 하나의 행, ID 배열
FROM (
  SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
  FROM "namespace_descendants"
  WHERE "namespace_descendants"."outdated_at" IS NULL AND
  "namespace_descendants"."namespace_id" = 22
) cached_query

캐시가 만료되거나 캐시 레코드가 없으면 쿼리는 NULL을 반환합니다.

traversal_ids 조회를 기반으로 하는 폴백 쿼리:

SELECT ids -- 하나의 행, ID 배열
FROM (
  SELECT ARRAY_AGG("namespaces"."id") AS ids
  FROM (
    SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
    FROM "namespaces"
    WHERE "namespaces"."type" = 'Group' AND
    (traversal_ids @> ('{22}'))
  ) namespaces
)

쿼리를 하나로 결합한 최종 쿼리:

SELECT "namespaces"."id" FROM UNNEST(
  COALESCE(
    (
      SELECT ids FROM (
        SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
        FROM "namespace_descendants"
        WHERE "namespace_descendants"."outdated_at" IS NULL AND
        "namespace_descendants"."namespace_id" = 22
      ) cached_query
    ),
    (
      SELECT ids
      FROM (
        SELECT ARRAY_AGG("namespaces"."id") AS ids
        FROM (
          SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id
          FROM "namespaces"
          WHERE "namespaces"."type" = 'Group' AND
          (traversal_ids @> ('{22}'))
        ) namespaces
      ) consistent_query
    )
  )
) AS namespaces(id)