ClickHouse에 GitLab 활동 데이터 저장

기존 구현 개요

GitLab 활동 데이터란

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

예시 이벤트:

  • 이슈 개설
  • 이슈 재개
  • 사용자가 프로젝트에 가입
  • Merge Request이 Merge됨
  • 리포지터리 푸시
  • 스니펫 생성

활동 데이터 사용처

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

  • 프로필 페이지의 사용자 기여 캘린더.
  • 사용자 기여 디렉터리의 페이지네이션된 디렉터리.
  • 프로젝트 및 그룹의 사용자 활동에 대한 페이지네이션된 디렉터리.
  • 기여 분석.

활동 데이터 생성 방식

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

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

상기한 메커니즘은 “대부분” 일관된 이벤트 스트림을 제공합니다.

예를 들어 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 테이블은 다형 연관을 사용하여 다른 데이터베이스 테이블(이슈, 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_idgroup_id 열은 상호 배타적이며, 내부적으로 리소스 부모라고 합니다.
    • 예시 1: 이슈가 개설된 이벤트의 경우, project_id 필드가 채워집니다.
    • 예시 2: epic 관련 이벤트의 경우, group_id 필드가 채워집니다(에픽은 항상 그룹의 일부입니다).
  • target_idtarget_type 열 쌍은 대상 레코드를 식별합니다.
    • 예시: target_id=1target_type=Issue.
    • 열이 null인 경우 데이터베이스에 대표 없는 이벤트를 참조합니다. 예를 들어 리포지터리 push 작업과 관련된 경우입니다.
  • Fingerprint는 일부 메타데이터 변경에 따라 이벤트를 나중에 변경하는 데 사용됩니다. 이 접근 방식은 주로 위키 페이지에 사용됩니다.

데이터베이스 레코드 수정

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

  • 특정 위키 페이지 레코드에 대한 Fingerprint 기반 업데이트.
  • 사용자 또는 관련 리소스가 삭제될 때, 이벤트 행도 삭제됩니다.
    • 연관된 events 레코드의 삭제는 일괄 처리됩니다.

현재의 성능 문제

  • 테이블이 상당한 디스크 공간을 사용합니다.
  • 새 이벤트 추가는 데이터베이스 레코드 수를 상당히 늘릴 수 있습니다.
  • 데이터 정리 로직 구현이 어렵습니다.
  • 시간 대 기반 집계가 성능적으로 충분하지 않아 일부 기능이 느린 데이터베이스 쿼리로 인해 손상될 수 있습니다.

쿼리 예시

note
이러한 쿼리는 실제 프로덕션 쿼리로부터 크게 단순화되었습니다.

사용자의 기여 그래프를 위한 데이터베이스 쿼리:

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 값은 앱/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)

사용자별 그룹 기여를 위한 쿼리:

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 열의 필터링을 최적화하는 것이 데이터베이스 조회의 성능을 향상시키는 데 유용합니다.

가장 최근 활동 데이터가 더 자주 조회됩니다. 언젠가는 오래된 데이터를 삭제하거나 이동할 수 있습니다. 대부분의 기능은 최대 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 기본값은 값이 없음을 의미합니다. 관련 best practices를 확인하세요.
  • 데이터가 누락될 때 항상 기본값을 사용하도록하기 위해 NOT NULL을 추가했습니다 (PostgreSQL과는 다른 동작).
  • “primary” 키는 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에 대한 첫 번째 값을 가져옵니다. 즉, 값이 없을 때 기본값을 사용할 때의 문제입니다(NOW()가 사용되고 아예 created_at을 동기화하지 않을 때 발생하는 문제).
  • 가장 최근 updated_at 값을 가져옵니다.

중복 처리 논리 때문에 쿼리가 더 복잡해 보입니다. 이러한 복잡성은 데이터베이스 뷰 뒤에 숨길 수 있습니다.

성능 최적화

이전 섹션의 집계 쿼리는 데이터 양이 많기 때문에 프로덕션 환경에서는 성능이 충분하지 않을 수 있습니다.

이벤트 테이블에 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에서 기여 그래프 아래에서는 사용자의 순서대로 정렬된 기여 디렉터리을 효율적으로 가져오는 것이 좋을 것입니다.

이러한 이유로, 이 이슈들을 처리하는 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 -- 테이블을 위해 다른 정렬 순서
);

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