그룹 계층 쿼리 최적화
이 문서는 최소한의 오버헤드로 대규모 그룹 계층에서 모든 하위 항목(서브그룹 또는 프로젝트)을 로드하는 데 도움이 되는 계층 캐시 최적화 전략을 설명합니다. 최적화는 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: 배열로 저장된 모든 그룹 IDall_project_ids: 배열로 저장된 모든 프로젝트 IDoutdated_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)
