GitLab 활동 데이터를 ClickHouse에 저장하기
GitLab v19.4요약
GitLab은 사용자가 애플리케이션과 상호 작용하는 동안 활동 데이터를 기록합니다. 여러 기능이 활동 데이터를 사용합니다: 활동 데이터는 보통 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다. 위 방식은 "대부분" 일관된 events 스트림을 제공합니다.
기존 구현 개요#
GitLab 활동 데이터 개요#
GitLab은 사용자가 애플리케이션과 상호 작용하는 동안 활동 데이터를 기록합니다. 이러한 상호 작용은 대부분 프로젝트, 이슈, 머지 리퀘스트 도메인 객체를 중심으로 이루어집니다. 사용자는 여러 가지 작업을 수행할 수 있으며, 그중 일부 작업은 events라는 별도의 PostgreSQL 데이터베이스 테이블에 기록됩니다.
이벤트 예시:
- 이슈 열림
- 이슈 다시 열림
- 사용자가 프로젝트에 참여함
- 머지 리퀘스트 머지됨
- 리포지터리 푸시됨
- 스니펫 생성됨
활동 데이터 사용처#
여러 기능이 활동 데이터를 사용합니다:
활동 데이터 생성 방식#
활동 데이터는 보통 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다. events 레코드의 영속성 특성은 해당 서비스의 구현 방식에 따라 달라집니다. 주요 접근 방식은 두 가지입니다:
- 실제 이벤트가 발생하는 데이터베이스 트랜잭션 안에서 기록합니다.
- 데이터베이스 트랜잭션 이후에 기록합니다(지연될 수 있습니다).
위 방식은 "대부분" 일관된 events 스트림을 제공합니다.
예를 들어 events 레코드를 일관되게 기록하는 예시입니다:
ApplicationRecord.transaction do
issue.closed!
Event.create!(action: :closed, target: issue)
end
events 레코드를 안전하지 않게 기록하는 예시입니다:
ApplicationRecord.transaction do
issue.closed!
end
# If a crash happens here, the event will not be recorded.
Event.create!(action: :closed, target: issue)
데이터베이스 테이블 구조#
events 테이블은 다형성 연관을 사용해 서로 다른 데이터베이스 테이블(이슈, 머지 리퀘스트 등)을 레코드에 연결합니다. 데이터베이스 구조를 단순화하면 다음과 같습니다:
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는 메타데이터 변경에 따라 이후에 이벤트를 수정해야 하는 일부 경우에 사용됩니다. 주로 Wiki 페이지에 쓰입니다.
데이터베이스 레코드 수정#
대부분의 데이터는 한 번만 기록됩니다. 그러나 이 테이블이 추가 전용이라고는 할 수 없습니다. 실제로 행 업데이트와 삭제가 일어나는 사용 사례는 다음과 같습니다:
- 특정 Wiki 페이지 레코드의 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 values are documented in the Event model, see the ACTIONS constant in 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) -- list of project ids in the group
GROUP BY events.author_id, events.target_type, events.action
활동 데이터를 ClickHouse에 저장#
데이터 영속성#
현재 PostgreSQL 데이터베이스의 데이터를 ClickHouse로 복제하는 방식은 아직 합의되지 않았습니다. events 테이블에 적용할 수 있는 몇 가지 방안은 다음과 같습니다:
즉시 데이터 기록#
이 방식은 ClickHouse 데이터베이스로 데이터를 보내면서도 기존 events 테이블을 그대로 유지하는 단순한 방법입니다. 이벤트 레코드는 트랜잭션 밖에서 생성되도록 합니다. PostgreSQL에 데이터를 저장한 뒤 ClickHouse에도 저장합니다.
ApplicationRecord.transaction do
issue.update!(state: :closed)
end
# could be a method to hide complexity
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
# oversimplified loop, we would probably batch this...
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 칼럼은 선택도가 높은 칼럼으로 봅니다. 즉, 데이터베이스 쿼리 성능을 확보하려면 이 두 칼럼의 필터링을 최적화하는 것이 바람직합니다.
가장 최근 활동 데이터가 더 자주 조회됩니다. 어느 시점에는 오래된 데이터를 삭제하거나 다른 곳으로 옮길 수도 있습니다. 대부분의 기능은 1년 이내 데이터만 조회합니다.
이런 이유로, 저수준 events 데이터를 저장하는 데이터베이스 테이블에서 시작할 수 있습니다:
소스 코드 보기
hide circle
entity "events" as events {
id : UInt64 ("primary key")#
project_id : UInt64
group_id : UInt64
target_id : UInt64
target_type : String
action : UInt8
fingerprint : UInt64
created_at : DateTime
updated_at : DateTime
}
테이블을 생성하는 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 now64(6, 'UTC') NOT NULL,
`updated_at` DateTime64(6, 'UTC') DEFAULT now64(6, 'UTC') NOT NULL
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id;
PostgreSQL 버전과 비교한 변경 사항입니다:
target_type은 카디널리티가 낮은 칼럼 값에 대한 최적화를 사용합니다.fingerprint는 정수형으로 바뀌고, xxHash64와 같이 성능이 좋은 정수 기반 해시 함수를 활용합니다.- 모든 칼럼에 기본값이 생기며, 정수 칼럼의 기본값 0은 값이 없음을 뜻합니다. 관련 권장 사항을 참고합니다.
NOT NULL을 지정해 데이터가 없을 때 항상 기본값을 사용하도록 합니다(PostgreSQL과 동작이 다릅니다).ORDER BY절 때문에 "기본" 키는 자동으로id칼럼이 됩니다.
같은 기본 키 값을 두 번 삽입합니다:
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값(기본 키)이 같은 행이 두 개 있습니다.null이었던action은0이 됩니다.- 지정하지 않은 fingerprint 칼럼은
0이 됩니다. DateTime칼럼에는 삽입 시각이 들어갑니다.
ClickHouse는 기본 키가 같은 행을 백그라운드에서 최종적으로 "대체"합니다. 이 작업에서는 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) -- list of project ids in the group
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) -- list of project ids in the group
GROUP BY id
) AS events
GROUP BY author_id, target_type, action
updated_at칼럼을 기준으로 가장 최근 칼럼 값을 가져옵니다.created_at은 첫INSERT에 올바른 값이 들어 있다고 가정하고 첫 값을 가져옵니다. 이는created_at을 전혀 동기화하지 않아 기본값(now64(6, 'UTC'))이 사용될 때만 문제가 됩니다.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 values are documented in the Event model, see the ACTIONS constant in 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 values are documented in the Event model, see the ACTIONS constant in 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 -- different sort order for the table
);
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