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 필드가 채워집니다 (epic은 항상 그룹에 속합니다).
  • target_idtarget_type 열 쌍으로 대상 레코드를 식별합니다.
    • 예시: target_id=1target_type=Issue.
    • 이 열이 null일 때는 데이터베이스에 대한 레프리젠테이션이 없는 이벤트를 참조합니다. 예를 들어 저장소 push 작업입니다.
  • Fingerprint는 일부 경우에 사용되어 나중에 일부 메타데이터 변경에 따라 이벤트를 변경하는 데 사용됩니다. 이 접근 방식은 주로 위키 페이지에 사용됩니다.

데이터베이스 레코드 수정

대부분의 데이터는 한 번 작성되지만, 테이블이 append-only인 것은 아닙니다. 실제 행 업데이트 및 삭제가 발생하는 몇 가지 사용 사례가 있습니다:

  • 특정 위키 페이지 레코드에 대한 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_idproject_id 열은 고 선택적 열로 간주됩니다. 이는 author_idproject_id 열의 필터링을 최적화하는 것이 데이터베이스 쿼리의 성능을 향상시키기 위해서 바람직합니다.

가장 최근의 활동 데이터가 더 자주 쿼리됩니다. 어느 시점에서는 이전 데이터를 삭제하거나 다른 곳으로 이동할 수도 있습니다. 대부분의 기능은 작년 데이터만 조회합니다.

이러한 이유로 낮은 수준의 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 기본값은 아무 값도 없음을 의미합니다. 관련 best practices를 참조하십시오.
  • 데이터가 누락될 때 항상 기본값을 사용하도록 하기 위해 NOT NULL을 사용합니다 (PostgreSQL과는 다른 동작).
  • ORDER BY 절 때문에 “primary” 키는 자동으로 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 documentation에 문서화되어 있습니다.

테이블에서 항상 중복된 값이 나타날 것이므로, 쿼리 시에 중복을 처리해야 합니다.

ClickHouse 데이터베이스 쿼리

ClickHouse는 데이터를 쿼리하기 위해 SQL을 사용하는데, 경우에 따라 underlying 데이터베이스 구조가 매우 유사하다면 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 테이블에 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_idcreated_at 열에서 수행됩니다. 이 두 열을 기준으로 데이터를 그룹화하면 적절한 성능이 나올 것으로 예상됩니다.

그러나 이 쿼리를 올바르게 다루기 위해 author_id 열에 대한 추가적인 인덱스와 created_at 열에 대한 추가적인 인덱스가 필요합니다. 또한, GitLab의 기여 그래프에서는 사용자의 순서대로 정렬된 기여 목록을 보여주는데, 이를 효율적으로 가져오기 위해 ORDER BY 절이 있는 다른 쿼리로 가져오는 것이 좋을 것입니다.

이러한 이유로, 이 쿼리에 적합한 성능을 낼 수 있도록 이벤트 행을 중복 저장하고 다른 정렬 순서로 지정할 수 있는 ClickHouse projection을 사용하는 것이 더 나을 것으로 생각됩니다.

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 -- 다른 테이블 정렬 순서
);

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