InfoGrab DocsInfoGrab Docs

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

요약

GitLab은 사용자가 애플리케이션과 상호 작용하는 동안 활동 데이터를 기록합니다. 여러 기능이 활동 데이터를 사용합니다: 활동 데이터는 보통 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다. 위 방식은 "대부분" 일관된 events 스트림을 제공합니다.

기존 구현 개요#

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

# If a crash happens here, the event will not be recorded.
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_id 칼럼과 group_id 칼럼은 상호 배타적입니다. 내부적으로는 이를 리소스 부모라고 부릅니다.
    • 예시 1: 이슈 열림 이벤트에서는 project_id 필드가 채워집니다.
    • 예시 2: 에픽 관련 이벤트에서는 group_id 필드가 채워집니다(에픽은 항상 그룹에 속합니다).
  • target_id 칼럼과 target_type 칼럼 쌍이 타깃 레코드를 식별합니다.
    • 예시: target_id=1, target_type=Issue.
    • 두 칼럼이 null이면 데이터베이스에 표현이 없는 이벤트를 가리킵니다. 리포지터리 push 작업이 그러한 예입니다.
  • fingerprint는 메타데이터 변경에 따라 이후에 이벤트를 수정해야 하는 일부 경우에 사용됩니다. 주로 Wiki 페이지에 쓰입니다.

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

대부분의 데이터는 한 번만 기록됩니다. 그러나 이 테이블이 추가 전용이라고는 할 수 없습니다. 실제로 행 업데이트와 삭제가 일어나는 사용 사례는 다음과 같습니다:

  • 특정 Wiki 페이지 레코드의 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 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 테이블에 적용할 수 있는 몇 가지 방안은 다음과 같습니다:

즉시 데이터 기록#

이 방식은 ClickHouse 데이터베이스로 데이터를 보내면서도 기존 events 테이블을 그대로 유지하는 단순한 방법입니다. 이벤트 레코드는 트랜잭션 밖에서 생성되도록 합니다. 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_id 칼럼과 project_id 칼럼은 선택도가 높은 칼럼으로 봅니다. 즉, 데이터베이스 쿼리 성능을 확보하려면 이 두 칼럼의 필터링을 최적화하는 것이 바람직합니다.

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

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

PlantUML 다이어그램 (14줄)
소스 코드 보기
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은 카디널리티가 낮은 칼럼 값에 대한 최적화를 사용합니다.
  • 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이었던 action은 0이 됩니다.
  • 지정하지 않은 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_id 칼럼과 created_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 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.4
원문 보기

요약

GitLab은 사용자가 애플리케이션과 상호 작용하는 동안 활동 데이터를 기록합니다. 여러 기능이 활동 데이터를 사용합니다: 활동 데이터는 보통 사용자가 특정 작업을 실행할 때 서비스 레이어에서 생성됩니다. 위 방식은 "대부분" 일관된 events 스트림을 제공합니다.

기존 구현 개요#

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

# If a crash happens here, the event will not be recorded.
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_id 칼럼과 group_id 칼럼은 상호 배타적입니다. 내부적으로는 이를 리소스 부모라고 부릅니다.
    • 예시 1: 이슈 열림 이벤트에서는 project_id 필드가 채워집니다.
    • 예시 2: 에픽 관련 이벤트에서는 group_id 필드가 채워집니다(에픽은 항상 그룹에 속합니다).
  • target_id 칼럼과 target_type 칼럼 쌍이 타깃 레코드를 식별합니다.
    • 예시: target_id=1, target_type=Issue.
    • 두 칼럼이 null이면 데이터베이스에 표현이 없는 이벤트를 가리킵니다. 리포지터리 push 작업이 그러한 예입니다.
  • fingerprint는 메타데이터 변경에 따라 이후에 이벤트를 수정해야 하는 일부 경우에 사용됩니다. 주로 Wiki 페이지에 쓰입니다.

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

대부분의 데이터는 한 번만 기록됩니다. 그러나 이 테이블이 추가 전용이라고는 할 수 없습니다. 실제로 행 업데이트와 삭제가 일어나는 사용 사례는 다음과 같습니다:

  • 특정 Wiki 페이지 레코드의 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 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 테이블에 적용할 수 있는 몇 가지 방안은 다음과 같습니다:

즉시 데이터 기록#

이 방식은 ClickHouse 데이터베이스로 데이터를 보내면서도 기존 events 테이블을 그대로 유지하는 단순한 방법입니다. 이벤트 레코드는 트랜잭션 밖에서 생성되도록 합니다. 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_id 칼럼과 project_id 칼럼은 선택도가 높은 칼럼으로 봅니다. 즉, 데이터베이스 쿼리 성능을 확보하려면 이 두 칼럼의 필터링을 최적화하는 것이 바람직합니다.

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

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

PlantUML 다이어그램 (14줄)
소스 코드 보기
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은 카디널리티가 낮은 칼럼 값에 대한 최적화를 사용합니다.
  • 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이었던 action은 0이 됩니다.
  • 지정하지 않은 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_id 칼럼과 created_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 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