ClickHouse에 GitLab 활동 데이터 저장
기존 구현의 개요
GitLab 활동 데이터란
GitLab은 사용자가 애플리케이션과 상호 작용하는 동안 활동 데이터를 기록합니다. 이러한 상호 작용 대부분은 프로젝트, 이슈, 및 Merge Request 도메인 객체를 중심으로 합니다. 사용자는 여러 가지 다른 작업을 수행할 수 있으며 그 중 일부는 events
라는 별도의 PostgreSQL 데이터베이스 테이블에 기록됩니다.
예시 이벤트:
- 이슈가 열림
- 이슈가 다시 열림
- 사용자가 프로젝트에 참여함
- Merge Request이 Merge됨
- 리포지터리 푸시됨
- 스니펫이 생성됨
활동 데이터 사용처
다양한 기능이 활동 데이터를 사용합니다:
활동 데이터 생성 방법
활동 데이터는 일반적으로 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다. events
레코드의 지속적인 특성은 서비스의 구현에 따라 달라집니다. 두 가지 주요 접근 방식이 존재합니다:
- 실제 이벤트가 발생하는 데이터베이스 트랜잭션에서.
- 데이터베이스 트랜잭션 이후 (지연될 수 있음).
상기된 메커니즘은 “대부분” 일관된 events
스트림을 제공합니다.
예를 들어, events
레코드를 일관되게 기록하는 방법:
ApplicationRecord.transaction do
issue.closed!
Event.create!(action: :closed, target: issue)
end
안전하지 않은 events
레코드 기록의 예시:
ApplicationRecord.transaction do
issue.closed!
end
# 여기서 충돌이 발생하면 이벤트가 기록되지 않을 것입니다.
Event.create!(action: :closed, target: issue)
데이터베이스 테이블 구조
events
테이블은 polymorphic association을 사용하여 다른 데이터베이스 테이블(이슈, Merge Request 등)과 레코드를 연관시킬 수 있습니다. 간단한 데이터베이스 구조는 다음과 같습니다:
Column | Type | Nullable | Default | Storage |
-------------+--------------------------+-----------+----------+------------------------------------+
project_id | integer | | | plain |
author_id | integer | not null | | plain |
target_id | integer | | | plain |
created_at | timestamp with time zone | not null | | plain |
updated_at | timestamp with time zone | not null | | plain |
action | smallint | not null | | plain |
target_type | character varying | | | extended |
group_id | bigint | | | plain |
fingerprint | bytea | | | extended |
id | bigint | not null | nextval('events_id_seq'::regclass) | plain |
식별되지 않은 데이터베이스 디자인으로 인한 예기치 못한 특성들:
-
project_id
와group_id
열은 상호 배타적이며, 내부적으로는 리소스 부모라고 합니다.- 예시 1: 이슈가 열린 이벤트의 경우
project_id
필드가 채워집니다. - 예시 2: 에픽 관련 이벤트의 경우
group_id
필드가 채워집니다(에픽은 항상 그룹의 일부입니다).
- 예시 1: 이슈가 열린 이벤트의 경우
-
target_id
및target_type
열 쌍은 대상 레코드를 식별합니다.- 예시:
target_id=1
이고target_type=Issue
입니다. - 열이
null
인 경우 데이터베이스에 표현이 없는 이벤트를 참조합니다. 예를 들어 리포지터리push
작업과 같이요.
- 예시:
- Fingerprint는 메타데이터 변경을 기반으로 나중에 이벤트를 변경하는 데 사용됩니다. 이 접근 방식은 대부분 위키 페이지에 사용됩니다.
데이터베이스 레코드 수정
데이터의 대부분은 한 번 작성되지만, 테이블이 추가만으로만 구성된 것은 아닙니다. 실제 행 업데이트 및 삭제가 발생하는 몇 가지 사용 사례:
- 특정 위키 페이지 레코드를 기반으로 한 Fingerprint 기반 업데이트.
- 사용자 또는 연관된 리소스가 삭제될 때, 이벤트 행도 삭제됩니다.
- 연관된
events
레코드의 삭제는 일괄적으로 처리됩니다.
- 연관된
현재의 성능 문제
- 테이블은 상당한 디스크 공간을 사용합니다.
- 새로운 이벤트 추가는 데이터베이스 레코드 수를 크게 증가시킬 수 있습니다.
- 데이터 정리 로직을 구현하는 것이 어렵습니다.
- 시간 범위 기반 집계는 성능이 충분하지 않습니다. 몇 가지 기능이 느린 데이터베이스 쿼리로 인해 중단될 수 있습니다.
쿼리 예시
사용자의 기여 그래프를 위한 데이터베이스 쿼리:
SELECT DATE(events.created_at), COUNT(*)
FROM events
WHERE events.author_id = 1
AND events.created_at BETWEEN '2022-01-17 23:00:00' AND '2023-01-18 22:59:59.999999'
AND (
(
events.action = 5
) OR
(
events.action IN (1, 3) -- Enum 값은 Event 모델의 ACTIONS 상수에서 문서화되어 있습니다. app/models/event.rb 참조
AND events.target_type IN ('Issue', 'WorkItem')
) OR
(
events.action IN (7, 1, 3)
AND events.target_type = 'MergeRequest'
) OR
(
events.action = 6
)
)
GROUP BY DATE(events.created_at)
사용자별 그룹 기여량을 위한 쿼리:
SELECT events.author_id, events.target_type, events.action, COUNT(*)
FROM events
WHERE events.created_at BETWEEN '2022-01-17 23:00:00' AND '2023-03-18 22:59:59.999999'
AND events.project_id IN (1, 2, 3) -- 그룹 내 프로젝트 ID 디렉터리
GROUP BY events.author_id, events.target_type, events.action
ClickHouse에서 활동 데이터 저장
데이터 지속성
현재는 PostgreSQL 데이터베이스에서 ClickHouse로 데이터를 복제하는 방법에 대한 공통된 합의가 없습니다. events
테이블에 대한 몇 가지 아이디어:
데이터 즉시 기록
이 접근 방식은 기존의 events
테이블을 유지하는 간단한 방법을 제공하고 동시에 ClickHouse 데이터베이스로 데이터를 전송합니다. 이벤트 레코드가 생성될 때 트랜잭션 외부에 생성됨을 보장합니다. PostgreSQL에 데이터를 유지한 후 ClickHouse에도 유지합니다.
ApplicationRecord.transaction do
issue.update!(state: :closed)
end
# 복잡성을 숨기기 위한 메소드일 수 있음
Event.create!(action: :closed, target: issue)
ClickHouse::Event.create(action: :closed, target: issue)
ClickHouse::Event
의 구현 내용은 아직 결정되지 않았으며, 다음 중 하나일 수 있습니다:
- ClickHouse 데이터베이스에 직접 연결된 ActiveRecord 모델.
- 중간 서비스에 대한 REST API 호출.
- Kafka와 같은 이벤트 스트리밍 도구에 이벤트를 큐잉함.
events
행의 복제
events
레코드의 생성이 시스템의 필수적인 부분이라고 가정하면, 다른 리포지터리 호출을 추가하면 다양한 코드 경로에서 성능 저하가 발생할 수 있습니다. 또는 상당한 복잡성을 도입할 수 있습니다.
이벤트 생성 시 ClickHouse로 데이터를 전송하는 대신, events
테이블을 반복하고 최근에 생성된 데이터베이스 레코드를 보내는 백그라운드 처리로 이 작업을 이동할 수 있습니다.
ClickHouse에 전송된 기록을 추적함으로써 데이터를 점진적으로 보낼 수 있습니다.
last_updated_at = SyncProcess.last_updated_at
# 너무 단순화된 루프이며, 대부분을 배치 처리할 것입니다...
Event.where(updated_at > last_updated_at).each do |row|
last_row = ClickHouse::Event.create(row)
end
SyncProcess.last_updated_at = last_row.updated_at
ClickHouse 데이터베이스 테이블 구조
초기 데이터베이스 구조를 설정할 때, 데이터가 쿼리되는 방식을 고려해야 합니다.
주요 사용 사례는 두 가지입니다:
- 특정 사용자의 시간 범위 내 데이터 조회.
WHERE author_id = 1 AND created_at BETWEEN '2021-01-01' AND '2021-12-31'
- 추가로 접근 제어 확인을 위한 추가
project_id
조건이 있을 수 있습니다.
- 프로젝트 또는 그룹의 데이터를 시간 범위 내 조회.
WHERE project_id IN (1, 2) AND created_at BETWEEN '2021-01-01' AND '2021-12-31'
author_id
와 project_id
열은 고 선택성 열로 간주됩니다. 이는 author_id
및 project_id
열의 필터링을 최적화하는 것이 데이터베이스 쿼리를 효과적으로 처리하는 데에 바람직하다는 의미입니다.
가장 최근의 활동 데이터가 더 자주 쿼리됩니다. 어느 시점에서는 오래된 데이터를 삭제하거나 이동할 수 있습니다. 대부분의 기능은 1년만을 참조합니다.
이러한 이유로, 저수준 events
데이터를 저장하는 데이터베이스 테이블부터 시작할 수 있습니다:
테이블을 생성하는 SQL 문:
CREATE TABLE events
(
`id` UInt64,
`project_id` UInt64 DEFAULT 0 NOT NULL,
`group_id` UInt64 DEFAULT 0 NOT NULL,
`author_id` UInt64 DEFAULT 0 NOT NULL,
`target_id` UInt64 DEFAULT 0 NOT NULL,
`target_type` LowCardinality(String) DEFAULT '' NOT NULL,
`action` UInt8 DEFAULT 0 NOT NULL,
`fingerprint` UInt64 DEFAULT 0 NOT NULL,
`created_at` DateTime64(6, 'UTC') DEFAULT now() NOT NULL,
`updated_at` DateTime64(6, 'UTC') DEFAULT now() NOT NULL
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id;
PostgreSQL 버전과 비교하여 몇 가지 변경 사항:
-
target_type
은 낮은 cardinality 열 값에 대한 최적화를 사용합니다. -
fingerprint
는 integer가 되고 xxHash64와 같은 성능이 우수한 정수 기반 해싱 함수를 활용합니다. - 모든 열에는 기본값이 있으며, 정수 열의 0 기본값은 값이 없음을 의미합니다.
- 데이터가 없을 때 항상 기본값을 사용하도록 하기 위해
NOT NULL
을 사용합니다(이는 PostgreSQL과 다른 동작을 합니다). - “primary” 키는
ORDER BY
절로 인해 자동으로id
열이 됩니다.
동일한 primary key 값을 두 번 삽입해 봅시다:
INSERT INTO events (id, project_id, target_id, author_id, target_type, action) VALUES (1, 2, 3, 4, 'Issue', null);
INSERT INTO events (id, project_id, target_id, author_id, target_type, action) VALUES (1, 20, 30, 5, 'Issue', null);
결과를 검사해 봅시다:
SELECT * FROM events
- 동일한
id
값(primay key)을 가진 두 개의 행이 있습니다. -
null
action
은0
이 됩니다. - 지정되지 않은 fingerprint 열은
0
이 됩니다. -
DateTime
열은 삽입 타임스탬프를 가집니다.
ClickHouse는 결국 백그라운드에서 동일한 primary key를 가진 행을 “교체”합니다. 이 작업을 실행하면 updated_at
값이 우위를 차지하는데, 동일한 작업은 final
키워드로 시뮬레이트할 수 있습니다:
SELECT * FROM events FINAL
쿼리에 FINAL
을 추가하면 성능적인 영향이 상당할 수 있으며, 이와 관련된 몇 가지 문제는 ClickHouse 문서에 문서화되어 있습니다.
테이블에서 중복 값을 항상 기대해야하므로 쿼리 시 중복 제거에 유의해야 합니다.
ClickHouse 데이터베이스 쿼리
ClickHouse는 데이터 쿼리에 SQL을 사용하며, 일부 경우에는 기본 데이터베이스 구조가 매우 유사하다면 PostgreSQL 쿼리를 주요 수정 없이 ClickHouse에서 사용할 수 있습니다.
각 사용자의 그룹 기여 정보에 대한 쿼리(PostgreSQL):
SELECT events.author_id, events.target_type, events.action, COUNT(*)
FROM events
WHERE events.created_at BETWEEN '2022-01-17 23:00:00' AND '2023-03-18 22:59:59.999999'
AND events.project_id IN (1, 2, 3) -- 그룹 내 프로젝트 ID 디렉터리
GROUP BY events.author_id, events.target_type, events.action
PostgreSQL에서도 위와 같은 쿼리가 작동하지만 ClickHouse에서는 테이블 엔진 작동 방식 때문에 중복된 값이 나타날 수 있습니다. 중복 제거는 중첩 FROM
문을 사용하여 달성할 수 있습니다.
SELECT author_id, target_type, action, count(*)
FROM (
SELECT
id,
argMax(events.project_id, events.updated_at) AS project_id,
argMax(events.group_id, events.updated_at) AS group_id,
argMax(events.author_id, events.updated_at) AS author_id,
argMax(events.target_type, events.updated_at) AS target_type,
argMax(events.target_id, events.updated_at) AS target_id,
argMax(events.action, events.updated_at) AS action,
argMax(events.fingerprint, events.updated_at) AS fingerprint,
FIRST_VALUE(events.created_at) AS created_at,
MAX(events.updated_at) AS updated_at
FROM events
WHERE events.created_at BETWEEN '2022-01-17 23:00:00' AND '2023-03-18 22:59:59.999999'
AND events.project_id IN (1, 2, 3) -- 그룹 내 프로젝트 ID 디렉터리
GROUP BY id
) AS events
GROUP BY author_id, target_type, action
-
updated_at
열을 기준으로 가장 최신의 열 값을 가져옵니다. -
created_at
에 대해 첫 번째 값으로 가져옵니다. 첫 번째INSERT
가 올바른 값을 포함하는 경우입니다. - 가장 최근의
updated_at
값을 가져옵니다.
중복 제거 논리 때문에 쿼리가 더 복잡해 보입니다. 이러한 복잡성은 데이터베이스 뷰 뒤에 숨길 수 있습니다.
성능 최적화
이전 섹션의 집계 쿼리는 데이터 양이 많아서 프로덕션 환경에서 충분히 성능이 나오지 않을 수 있습니다.
events
테이블에 100만개의 추가 행을 추가해봅시다:
INSERT INTO events (id, project_id, author_id, target_id, target_type, action) SELECT id, project_id, author_id, target_id, 'Issue' AS target_type, action FROM generateRandom('id UInt64, project_id UInt64, author_id UInt64, target_id UInt64, action UInt64') LIMIT 1000000;
콘솔에서 이전 집계 쿼리를 실행하면 성능 데이터가 출력됩니다:
1 row in set. Elapsed: 0.122 sec. Processed 1.00 million rows, 42.00 MB (8.21 million rows/s., 344.96 MB/s.)
쿼리는 1개의 행을 반환했지만 전체 테이블인 100만개의 행을 처리해야 했습니다. project_id
열에 대한 인덱스를 추가하여 쿼리를 최적화할 수 있습니다:
ALTER TABLE events ADD INDEX project_id_index project_id TYPE minmax GRANULARITY 10;
ALTER TABLE events MATERIALIZE INDEX project_id_index;
쿼리 실행 결과가 훨씬 더 나아졌습니다:
Read 2 rows, 107.00 B in 0.005616811 sec., 356 rows/sec., 18.60 KiB/sec.
created_at
열의 날짜 범위 필터를 최적화하기 위해 created_at
열에 추가적인 인덱스를 시도할 수 있습니다.
기여 그래프에 대한 쿼리
다음은 PostgreSQL 쿼리입니다.
SELECT DATE(events.created_at), COUNT(*)
FROM events
WHERE events.author_id = 1
AND events.created_at BETWEEN '2022-01-17 23:00:00' AND '2023-01-18 22:59:59.999999'
AND (
(
events.action = 5
) OR
(
events.action IN (1, 3) -- Enum 값은 Event 모델의 ACTIONS 상수에서 문서화되어 있습니다. app/models/event.rb 참조
AND events.target_type IN ('Issue', 'WorkItem')
) OR
(
events.action IN (7, 1, 3)
AND events.target_type = 'MergeRequest'
) OR
(
events.action = 6
)
)
GROUP BY DATE(events.created_at)
필터링 및 총계 집계가 주로 author_id
및 created_at
열에서 수행됩니다. 이 두 열을 기준으로 데이터를 그룹화하면 충분한 성능이 나올 것입니다.
author_id
열에 인덱스를 추가해볼 수 있지만, 이 쿼리를 올바르게 커버하기 위해 created_at
열에 추가적인 인덱스가 필요합니다. 또한, 기여 그래프에서 GitLab은 사용자의 순서대로 정렬된 기여 디렉터리을 표시하며, 이를 ORDER BY
절을 사용하여 효율적으로 얻을 수 있는 다른 쿼리를 통해 얻는 것이 좋을 것입니다.
이러한 이유로 ClickHouse 프로젝션을 사용하는 것이 더 나을 수 있습니다. 이 프로젝션은 이벤트 행을 중복으로 저장하지만 다른 정렬 순서를 지정할 수 있습니다.
ClickHouse 쿼리는 다음과 같을 것입니다 (날짜 범위를 약간 조정):
SELECT DATE(events.created_at) AS date, COUNT(*) AS count
FROM (
SELECT
id,
argMax(events.created_at, events.updated_at) AS created_at
FROM events
WHERE events.author_id = 4
AND events.created_at BETWEEN '2023-01-01 23:00:00' AND '2024-01-01 22:59:59.999999'
AND (
(
events.action = 5
) OR
(
events.action IN (1, 3) -- Enum 값은 Event 모델의 ACTIONS 상수에서 문서화되어 있습니다. app/models/event.rb 참조
AND events.target_type IN ('Issue', 'WorkItem')
) OR
(
events.action IN (7, 1, 3)
AND events.target_type = 'MergeRequest'
) OR
(
events.action = 6
)
)
GROUP BY id
) AS events
GROUP BY DATE(events.created_at)
쿼리가 전체 테이블을 스캔하므로 최적화해봅시다:
ALTER TABLE events ADD PROJECTION events_by_authors (
SELECT * ORDER BY author_id, created_at -- 테이블의 다른 정렬 순서
);
ALTER TABLE events MATERIALIZE PROJECTION events_by_authors;
기여의 페이지네이션
사용자의 기여를 나열하는 것은 다음과 같이 쿼리할 수 있습니다:
SELECT events.*
FROM (
SELECT
id,
argMax(events.project_id, events.updated_at) AS project_id,
argMax(events.group_id, events.updated_at) AS group_id,
argMax(events.author_id, events.updated_at) AS author_id,
argMax(events.target_type, events.updated_at) AS target_type,
argMax(events.target_id, events.updated_at) AS target_id,
argMax(events.action, events.updated_at) AS action,
argMax(events.fingerprint, events.updated_at) AS fingerprint,
FIRST_VALUE(events.created_at) AS created_at,
MAX(events.updated_at) AS updated_at
FROM events
WHERE events.author_id = 4
GROUP BY id
ORDER BY created_at DESC, id DESC
) AS events
LIMIT 20
ClickHouse는 표준 LIMIT N OFFSET M
절을 지원하므로 다음 페이지를 요청할 수 있습니다:
SELECT events.*
FROM (
SELECT
id,
argMax(events.project_id, events.updated_at) AS project_id,
argMax(events.group_id, events.updated_at) AS group_id,
argMax(events.author_id, events.updated_at) AS author_id,
argMax(events.target_type, events.updated_at) AS target_type,
argMax(events.target_id, events.updated_at) AS target_id,
argMax(events.action, events.updated_at) AS action,
argMax(events.fingerprint, events.updated_at) AS fingerprint,
FIRST_VALUE(events.created_at) AS created_at,
MAX(events.updated_at) AS updated_at
FROM events
WHERE events.author_id = 4
GROUP BY id
ORDER BY created_at DESC, id DESC
) AS events
LIMIT 20 OFFSET 20