ClickHouse에 GitLab 활동 데이터 저장

기존 구현 개요

GitLab 활동 데이터란

GitLab은 사용자가 응용프로그램과 상호 작용하는 과정에서 활동 데이터를 기록합니다. 이러한 상호 작용의 대부분은 프로젝트, 이슈, 그리고 병합 요청 도메인 객체를 중심으로 합니다. 사용자는 여러 가지 다른 조작을 수행할 수 있으며, 이러한 조작 중 일부는 events라는 별도의 PostgreSQL 데이터베이스 테이블에 기록됩니다.

예시 이벤트:

  • 이슈가 열렸을 때
  • 이슈가 다시 열렸을 때
  • 사용자가 프로젝트에 가입했을 때
  • 병합 요청이 병합되었을 때
  • 저장소가 푸시되었을 때
  • 스니펫이 생성되었을 때

활동 데이터 사용처

여러 기능에서 활동 데이터를 사용합니다:

  • 사용자의 기여 캘린더 프로필 페이지.
  • 사용자의 기여 목록 (개별 페이지로 분할).
  • 프로젝트 및 그룹에 대한 사용자 활동 목록 (개별 페이지로 분할).
  • 기여 분석.

활동 데이터 생성 방법

활동 데이터는 보통 사용자가 특정 조작을 실행할 때 서비스 레이어에서 생성됩니다. events 레코드의 지속 특성은 서비스의 구현에 따라 달라집니다. 두 가지 주요 접근 방식이 있습니다:

  1. 실제 이벤트가 발생하는 데이터베이스 트랜잭션에서.
  2. 데이터베이스 트랜잭션 이후 (지연될 수 있음).

상기한 메커니즘은 “대부분” 일관된 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 테이블은 다른 데이터베이스 테이블 (이슈, 병합 요청 등)을 레코드와 연결할 수 있도록 다형 연관을 사용합니다. 간소화된 데이터베이스 구조:

   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_idgroup_id 열은 서로 상쇄되며, 내부적으로 리소스 상위 부모라고 부릅니다.
    • 예시 1: 이슈가 열리면 project_id 필드가 채워집니다.
    • 예시 2: epic 관련 이벤트의 경우 group_id 필드가 채워집니다 (에픽은 항상 그룹의 일부입니다).
  • target_idtarget_type 열 쌍은 대상 레코드를 식별합니다.
    • 예시: target_id=1이고 target_type=Issue입니다.
    • 열이 null이면 데이터베이스에서 표현이 없는 이벤트를 참조합니다. 예를 들어 저장소 push 조작이 있는 경우입니다.
  • Fingerprint는 메타데이터 변경에 따라 나중에 이벤트를 변경하는 데 사용됩니다. 이 접근 방식은 대부분 위키 페이지에 사용됩니다.

데이터베이스 레코드 수정

대부분의 데이터는 한 번씩만 기록되지만, 테이블이 append-only라고 말할 수는 없습니다. 실제 행 업데이트와 삭제가 발생하는 몇 가지 사용 사례:

  • 특정 위키 페이지 레코드에 대한 지문 기반 업데이트.
  • 사용자나 연결된 리소스가 삭제될 때, 이벤트 레코드도 삭제됩니다.
    • 연결된 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) -- 그룹 내 프로젝트 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 호출
  • 카프카와 같은 이벤트 스트리밍 도구에 이벤트를 인큐하는 것

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_idproject_id 열은 고선별성 열로 간주됩니다. 이는 author_idproject_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은 낮은 카디널리티 열 값에 대한 최적화를 사용합니다.
  • 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 action0이 됩니다.
  • 지정되지 않은 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) -- 그룹 내 프로젝트 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가 올바른 값을 포함한다고 가정하여 첫 번째 값을 가져옵니다. created_at을 전혀 동기화하지 않고 기본값(NOW())을 사용하는 경우에만 문제가 됩니다.
  • 가장 최근의 updated_at 값을 가져옵니다.

중복 제거 논리로 인해 이제 쿼리가 더 복잡해 보입니다. 이 복잡성은 데이터베이스 뷰 뒤에 숨길 수 있습니다.

성능 최적화

이전 섹션의 집계 쿼리는 데이터 양이 많아서 제대로된 성능을 보장하기 어려울 수 있습니다.

events 테이블에 1백만 개의 추가 행을 추가해 봅시다.

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개의 행을 반환했지만, 1백만 행(전체 테이블)을 처리해야 했습니다. 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 모델에서 문서화되어 있으며 app/models/event.rb의 ACTIONS 상수를 참조하세요
    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_idcreated_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 모델에서 문서화되어 있으며 app/models/event.rb의 ACTIONS 상수를 참조하세요
      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