ClickHouse에 GitLab 활동 데이터 저장

기존 구현의 개요

GitLab 활동 데이터란

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

예시 이벤트:

  • 이슈가 열림
  • 이슈가 다시 열림
  • 사용자가 프로젝트에 참여함
  • Merge Request이 Merge됨
  • 리포지터리 푸시됨
  • 스니펫이 생성됨

활동 데이터 사용처

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

  • 프로필 페이지의 사용자 기여 달력.
  • 사용자 기여 디렉터리의 페이지별 디렉터리.
  • 프로젝트 및 그룹의 사용자 활동에 대한 페이지별 디렉터리.
  • 기여 분석.

활동 데이터 생성 방법

활동 데이터는 일반적으로 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다. 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 테이블은 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_idgroup_id 열은 상호 배타적이며, 내부적으로는 리소스 부모라고 합니다.
    • 예시 1: 이슈가 열린 이벤트의 경우 project_id 필드가 채워집니다.
    • 예시 2: 에픽 관련 이벤트의 경우 group_id 필드가 채워집니다(에픽은 항상 그룹의 일부입니다).
  • target_idtarget_type 열 쌍은 대상 레코드를 식별합니다.
    • 예시: target_id=1이고 target_type=Issue입니다.
    • 열이 null인 경우 데이터베이스에 표현이 없는 이벤트를 참조합니다. 예를 들어 리포지터리 push 작업과 같이요.
  • Fingerprint는 메타데이터 변경을 기반으로 나중에 이벤트를 변경하는 데 사용됩니다. 이 접근 방식은 대부분 위키 페이지에 사용됩니다.

데이터베이스 레코드 수정

데이터의 대부분은 한 번 작성되지만, 테이블이 추가만으로만 구성된 것은 아닙니다. 실제 행 업데이트 및 삭제가 발생하는 몇 가지 사용 사례:

  • 특정 위키 페이지 레코드를 기반으로 한 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 값은 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 열의 필터링을 최적화하는 것이 데이터베이스 쿼리를 효과적으로 처리하는 데에 바람직하다는 의미입니다.

가장 최근의 활동 데이터가 더 자주 쿼리됩니다. 어느 시점에서는 오래된 데이터를 삭제하거나 이동할 수 있습니다. 대부분의 기능은 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 action0이 됩니다.
  • 지정되지 않은 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_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 모델의 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