InfoGrab DocsInfoGrab Docs

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

요약

GitLab은 사용자가 애플리케이션과 상호 작용할 때 운영 중에 활동 데이터를 기록합니다. 여러 기능에서 활동 데이터를 사용합니다: 프로젝트 및 그룹에 대한 사용자 활동의 페이지네이션 목록. 활동 데이터는 보통 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다.

기존 구현 개요#

GitLab 활동 데이터란 무엇인가#

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

이벤트 예시:

  • 이슈 열림

  • 이슈 다시 열림

  • 사용자가 프로젝트에 참여함

  • 머지 리퀘스트 머지됨

  • 리포지터리 푸시됨

  • 스니펫 생성됨

활동 데이터가 사용되는 위치#

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

  • 프로필 페이지의 사용자 기여 달력.

  • 사용자 기여 목록 페이지네이션.

  • 프로젝트 및 그룹에 대한 사용자 활동의 페이지네이션 목록.

  • 기여 분석.

활동 데이터 생성 방식#

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

  • 실제 이벤트가 발생하는 데이터베이스 트랜잭션 내에서.

  • 데이터베이스 트랜잭션 이후(지연될 수 있음).

위에서 언급한 메커니즘은 "대부분" 일관된 events 스트림을 제공합니다.

예를 들어, events 레코드를 일관되게 기록하는 경우:

ApplicationRecord.transaction do
  issue.closed!
  Event.create!(action: :closed, target: issue)
end

events 레코드를 안전하지 않게 기록하는 예시:

ApplicationRecord.transaction do
  issue.closed!
end

# If a crash happens here, the event will not be recorded.
Event.create!(action: :closed, target: issue)

데이터베이스 테이블 구조#

events 테이블은 다형성 연관(polymorphic association)을 사용하여 다양한 데이터베이스 테이블(이슈, 머지 리퀘스트 등)을 레코드에 연결할 수 있도록 합니다. 간략한 데이터베이스 구조:

   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는 이후 일부 메타데이터 변경에 기반하여 이벤트를 수정하는 데 사용됩니다. 이 접근 방식은 주로 Wiki 페이지에 사용됩니다.

데이터베이스 레코드 수정#

대부분의 데이터는 한 번만 기록되지만, 해당 테이블이 추가 전용이라고는 할 수 없습니다. 실제 행 업데이트 및 삭제가 발생하는 몇 가지 사용 사례가 있습니다:

  • 특정 Wiki 페이지 레코드에 대한 Fingerprint 기반 업데이트.

  • 사용자 또는 연관된 리소스가 삭제될 때, 해당 events 행도 함께 삭제됩니다.

연관된 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) -- list of project ids in the group
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

# could be a method to hide complexity
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

# oversimplified loop, we would probably batch this...
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 칼럼은 높은 선택도(high-selectivity) 칼럼으로 간주됩니다. 즉, author_idproject_id 칼럼의 필터링을 최적화하는 것이 성능 좋은 데이터베이스 쿼리를 위해 바람직하다는 의미입니다.

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

이러한 이유로, 저수준 events 데이터를 저장하는 데이터베이스 테이블로 시작할 수 있습니다:

hide circle

entity "events" as events { id : UInt64 ("primary key")#

project_id : UInt64 group_id : UInt64 target_id : UInt64 target_type : String action : UInt8 fingerprint : UInt64 created_at : DateTime updated_at : DateTime }

테이블 생성을 위한 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 now64(6, 'UTC') NOT NULL,
    `updated_at` DateTime64(6, 'UTC') DEFAULT now64(6, 'UTC') NOT NULL
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id;

PostgreSQL 버전과 비교한 몇 가지 변경사항:

  • target_type은 낮은 카디널리티(low-cardinality) 칼럼 값에 대한 최적화를 사용합니다.

  • 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) -- list of project ids in the group
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) -- list of project ids in the group
  GROUP BY id
) AS events
GROUP BY author_id, target_type, action
  • updated_at 칼럼을 기준으로 가장 최근 칼럼 값을 가져옵니다.

  • created_at의 경우 첫 번째 INSERT에 올바른 값이 포함되어 있다는 가정 하에 첫 번째 값을 가져옵니다. created_at을 전혀 동기화하지 않고 기본값(now64(6, 'UTC'))이 사용될 때만 문제가 됩니다.

  • 가장 최근의 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 -- different sort order for the table
);

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

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

GitLab v19.1
원문 보기
요약

GitLab은 사용자가 애플리케이션과 상호 작용할 때 운영 중에 활동 데이터를 기록합니다. 여러 기능에서 활동 데이터를 사용합니다: 프로젝트 및 그룹에 대한 사용자 활동의 페이지네이션 목록. 활동 데이터는 보통 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다.

기존 구현 개요#

GitLab 활동 데이터란 무엇인가#

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

이벤트 예시:

  • 이슈 열림

  • 이슈 다시 열림

  • 사용자가 프로젝트에 참여함

  • 머지 리퀘스트 머지됨

  • 리포지터리 푸시됨

  • 스니펫 생성됨

활동 데이터가 사용되는 위치#

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

  • 프로필 페이지의 사용자 기여 달력.

  • 사용자 기여 목록 페이지네이션.

  • 프로젝트 및 그룹에 대한 사용자 활동의 페이지네이션 목록.

  • 기여 분석.

활동 데이터 생성 방식#

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

  • 실제 이벤트가 발생하는 데이터베이스 트랜잭션 내에서.

  • 데이터베이스 트랜잭션 이후(지연될 수 있음).

위에서 언급한 메커니즘은 "대부분" 일관된 events 스트림을 제공합니다.

예를 들어, events 레코드를 일관되게 기록하는 경우:

ApplicationRecord.transaction do
  issue.closed!
  Event.create!(action: :closed, target: issue)
end

events 레코드를 안전하지 않게 기록하는 예시:

ApplicationRecord.transaction do
  issue.closed!
end

# If a crash happens here, the event will not be recorded.
Event.create!(action: :closed, target: issue)

데이터베이스 테이블 구조#

events 테이블은 다형성 연관(polymorphic association)을 사용하여 다양한 데이터베이스 테이블(이슈, 머지 리퀘스트 등)을 레코드에 연결할 수 있도록 합니다. 간략한 데이터베이스 구조:

   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는 이후 일부 메타데이터 변경에 기반하여 이벤트를 수정하는 데 사용됩니다. 이 접근 방식은 주로 Wiki 페이지에 사용됩니다.

데이터베이스 레코드 수정#

대부분의 데이터는 한 번만 기록되지만, 해당 테이블이 추가 전용이라고는 할 수 없습니다. 실제 행 업데이트 및 삭제가 발생하는 몇 가지 사용 사례가 있습니다:

  • 특정 Wiki 페이지 레코드에 대한 Fingerprint 기반 업데이트.

  • 사용자 또는 연관된 리소스가 삭제될 때, 해당 events 행도 함께 삭제됩니다.

연관된 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) -- list of project ids in the group
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

# could be a method to hide complexity
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

# oversimplified loop, we would probably batch this...
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 칼럼은 높은 선택도(high-selectivity) 칼럼으로 간주됩니다. 즉, author_idproject_id 칼럼의 필터링을 최적화하는 것이 성능 좋은 데이터베이스 쿼리를 위해 바람직하다는 의미입니다.

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

이러한 이유로, 저수준 events 데이터를 저장하는 데이터베이스 테이블로 시작할 수 있습니다:

hide circle

entity "events" as events { id : UInt64 ("primary key")#

project_id : UInt64 group_id : UInt64 target_id : UInt64 target_type : String action : UInt8 fingerprint : UInt64 created_at : DateTime updated_at : DateTime }

테이블 생성을 위한 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 now64(6, 'UTC') NOT NULL,
    `updated_at` DateTime64(6, 'UTC') DEFAULT now64(6, 'UTC') NOT NULL
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id;

PostgreSQL 버전과 비교한 몇 가지 변경사항:

  • target_type은 낮은 카디널리티(low-cardinality) 칼럼 값에 대한 최적화를 사용합니다.

  • 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) -- list of project ids in the group
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) -- list of project ids in the group
  GROUP BY id
) AS events
GROUP BY author_id, target_type, action
  • updated_at 칼럼을 기준으로 가장 최근 칼럼 값을 가져옵니다.

  • created_at의 경우 첫 번째 INSERT에 올바른 값이 포함되어 있다는 가정 하에 첫 번째 값을 가져옵니다. created_at을 전혀 동기화하지 않고 기본값(now64(6, 'UTC'))이 사용될 때만 문제가 됩니다.

  • 가장 최근의 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 -- different sort order for the table
);

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