그룹 계층 쿼리 최적화
GitLab v19.5요약
이 문서는 대규모 그룹 계층에서 모든 하위 항목(하위 그룹 또는 프로젝트)을 최소한의 오버헤드로 로드하는 데 도움이 되는 계층 캐시 최적화 전략을 설명합니다. 이 최적화는 하위 항목(프로젝트 및 그룹) 수가 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 스코프 중 하나를 사용하면 최적화가 자동으로 적용됩니다.
# Loading all groups:
group.self_and_descendants
# Using the IDs in subqueries:
group.self_and_descendant_ids
NamespaceSetting.where(namespace_id: group.self_and_descendant_ids)
# Loading all projects:
group.all_projects
# Using the IDs in subqueries
MergeRequest.where(target_project_id: group.all_project_ids)
캐시 무효화#
예를 들어 새 프로젝트나 하위 그룹이 추가되어 그룹 계층이 변경되면, 같은 트랜잭션 안에서 캐시가 무효화됩니다. Namespaces::ProcessOutdatedNamespaceDescendantsCronWorker라는 주기적 워커가 약간의 지연을 두고 캐시를 갱신합니다. 무효화는 ActiveRecord 콜백으로 구현되어 있습니다.
캐시가 무효화된 동안에도 계층 데이터베이스 쿼리는 캐시를 쓰지 않는(최적화되지 않은) traversal_ids 기반 쿼리를 사용해 일관된 값을 계속 반환합니다.
일관된 쿼리#
조회 쿼리는 SQL에서 ||(또는) 기능을 구현해 캐시된 값을 먼저 확인합니다. 캐시된 값이 없으면 계층의 모든 그룹 또는 프로젝트를 전체 조회하는 방식으로 대체합니다.
이해를 돕기 위해 같은 조회를 Ruby로 구현하면 다음과 같습니다.
if cached? && cache_up_to_date?
return cached_project_ids
else
return Project.where(...).pluck(:id)
end
SQL에서는 표현식 목록 중 NULL 이 아닌 첫 번째 표현식을 반환하는 COALESCE 함수를 활용합니다. 첫 번째 표현식이 NULL 이 아니면 이후 표현식은 평가되지 않습니다.
SELECT COALESCE(
(SELECT 1), -- cached query
(SELECT 2 FROM pg_sleep(5)) -- non-cached query
)
위 쿼리는 즉시 반환됩니다. 다만 첫 번째 서브쿼리가 null을 반환하면 데이터베이스는 두 번째 쿼리를 실행합니다.
SELECT COALESCE(
(SELECT NULL), -- cached query
(SELECT 2 FROM pg_sleep(5)) -- non-cached query
)
namespace_descendants 데이터베이스 테이블#
캐시된 하위 그룹 및 프로젝트 ID는 namespace_descendants 데이터베이스 테이블에 배열로 저장됩니다. 주요 칼럼은 다음과 같습니다.
namespace_id: 기본 키이며, 최상위 그룹 ID 또는 하위 그룹 ID 입니다.self_and_descendant_group_ids: 모든 그룹 ID의 배열입니다.all_project_ids: 모든 프로젝트 ID의 배열입니다.outdated_at: 캐시가 만료되었음을 나타냅니다.
캐시된 데이터베이스 쿼리#
쿼리는 세 부분으로 구성됩니다.
- 캐시된 쿼리
- 캐시를 쓰지 않는 대체 쿼리
- 추가 필터링과 데이터 로딩(
JOIN)을 수행할 수 있는 외부 쿼리
캐시된 쿼리:
SELECT ids -- One row, array of 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
캐시가 만료되었거나 캐시 레코드가 존재하지 않으면 이 쿼리는 NULL을 반환합니다.
traversal_ids 조회에 기반한 대체 쿼리:
SELECT ids -- One row, array of 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
)
두 쿼리를 하나로 결합한 최종 쿼리:
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)