GitLab 활동 데이터를 ClickHouse에 저장하기

기존 구현 개요

GitLab 활동 데이터란 무엇인가

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

예시 이벤트:

  • 이슈 열기
  • 이슈 다시 열기
  • 사용자가 프로젝트에 참여
  • Merge Request 병합
  • 레포지토리 푸시
  • 스니펫 생성

활동 데이터는 어디에 사용되는가

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

  • 프로필 페이지의 사용자의 기여 캘린더.
  • 사용자의 기여 목록의 페이지 매김된 리스트.
  • 프로젝트 및 그룹에 대한 사용자의 활동의 페이지 매김된 리스트.
  • 기여 분석.

활동 데이터는 어떻게 생성되는가

활동 데이터는 일반적으로 사용자가 특정 작업을 수행할 때 서비스 계층에서 생성됩니다. 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 테이블은 다형성 연관을 사용하여 다양한 데이터베이스 테이블(이슈, 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 열은 상호 배타적이며 내부적으로 리소스 부모(resource parent)라고 부릅니다.
    • 예시 1: 이슈 열기 이벤트의 경우 project_id 필드가 채워집니다.
    • 예시 2: 에픽 관련 이벤트의 경우 group_id 필드가 채워집니다(에픽은 항상 그룹의 일부입니다).
  • target_idtarget_type 열 쌍은 대상 레코드를 식별합니다.
    • 예시: target_id=1target_type=Issue.
    • 열이 null일 때, 데이터베이스에 표현이 없는 이벤트를 지칭합니다. 예를 들어, 레포지토리 push 동작.
  • 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 모델에 문서화되어 있으며, 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)

각 사용자의 그룹 기여도에 대한 쿼리:

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은 저카드inality 열 값에 대한 최적화를 사용합니다.
  • fingerprint는 정수가 되어 xxHash64와 같은 성능 좋은 정수 기반 해싱 함수를 활용합니다.
  • 모든 열에 기본 값이 지정되며, 정수 열의 0 기본 값은 값이 없음을 의미합니다. 관련 모범 사례를 참조하세요.
  • 데이터가 누락되었을 때 항상 기본 값을 사용하도록 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 열을 기반으로 가장 최근의 열 값 선택.
  • 첫 번째 INSERT가 올바른 값을 포함한다고 가정하여 created_at에 대한 첫 번째 값을 선택. 이는 전혀 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 값은 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