InfoGrab DocsInfoGrab Docs

GitLab 내 ClickHouse

요약

이 문서는 GitLab Rails 애플리케이션에서 ClickHouse를 사용하여 기능을 개발하는 방법에 대한 개괄적인 개요를 제공합니다. 대부분의 도구와 API는 불안정한 것으로 간주됩니다. ClickHouse 설치 문서에 설명된 대로 로컬에 ClickHouse를 설치합니다.

이 문서는 GitLab Rails 애플리케이션에서 ClickHouse를 사용하여 기능을 개발하는 방법에 대한 개괄적인 개요를 제공합니다.

Note

대부분의 도구와 API는 불안정한 것으로 간주됩니다.

GDK 설정#

ClickHouse 서버 설정#

  1. ClickHouse 설치 문서에 설명된 대로 로컬에 ClickHouse를 설치합니다. QuickInstall을 사용하면 현재 디렉터리에 설치되고, Homebrew를 사용하면 /opt/homebrew/bin/clickhouse에 설치됩니다.

  2. gdk.yml에 ClickHouse 섹션을 추가합니다. gdk.example.yml을 참고합니다.

  3. gdk.yml ClickHouse 구성 파일이 로컬 ClickHouse 설치 경로와 로컬 데이터 저장 경로를 가리키도록 조정합니다. 예를 들면 다음과 같습니다.

    clickhouse:
      bin: "/opt/homebrew/bin/clickhouse"
      enabled: true
      # these are optional if we have more than one GDK:
      # http_port: 8123
      # interserver_http_port: 9009
      # tcp_port: 9001
    
  4. gdk reconfigure를 실행합니다.

  5. gdk start clickhouse로 ClickHouse를 시작합니다.

Rails 애플리케이션 구성#

  1. 예시 파일을 복사하고 자격 증명을 구성합니다.

    cp config/click_house.yml.example config/click_house.yml
    
  2. 번들된 clickhouse client를 사용하여 데이터베이스를 생성합니다.

    gdk clickhouse
    
    create database gitlab_clickhouse_development;
    create database gitlab_clickhouse_test;
    

설정 유효성 검사#

Rails 콘솔을 실행하고 간단한 쿼리를 호출합니다.

ClickHouse::Client.select('SELECT 1', :main)
# => [{"1"=>1}]

데이터베이스 스키마 및 마이그레이션#

ClickHouse 데이터베이스 마이그레이션을 생성하려면 다음을 실행합니다.

bundle exec rails generate gitlab:click_house:migration MIGRATION_CLASS_NAME

데이터베이스 마이그레이션을 실행하려면 다음을 실행합니다.

bundle exec rake gitlab:clickhouse:migrate

gitlab:clickhouse:migrate job은 적용된 각 마이그레이션에 대해 db/click_house/schema_migrations/main/<version> 아래에 스키마 버전 마커 파일도 생성합니다. 이 마커 파일을 마이그레이션과 함께 커밋합니다. clickhouse:check-schema CI job은 마커 파일 없이 마이그레이션이 머지되면 실패하며, 파일을 커밋할 때까지 GitLab은 마이그레이션을 실행할 때마다 마커 파일을 추적되지 않는 파일로 다시 생성합니다. 로컬 ClickHouse 인스턴스 없이 마이그레이션을 작성하는 경우, 커밋하기 전에 마이그레이션을 실행하여 마커 파일을 생성합니다.

마지막 N개의 마이그레이션을 롤백하려면 다음을 실행합니다.

bundle exec rake gitlab:clickhouse:rollback:main STEP=N

또는 다음 명령을 사용하여 모든 마이그레이션을 롤백합니다.

bundle exec rake gitlab:clickhouse:rollback:main VERSION=0

db/click_house/migrate 폴더에 Ruby 마이그레이션 파일을 만들어 마이그레이션을 생성할 수 있습니다. 파일 이름은 YYYYMMDDHHMMSS_description_of_migration.rb 형식의 타임스탬프로 시작해야 합니다.

# 20230811124511_create_issues.rb
# frozen_string_literal: true

class CreateIssues < ClickHouse::Migration
  def up
    execute <<~SQL
      CREATE TABLE IF NOT EXISTS issues
      (
        id UInt64 DEFAULT 0,
        title String DEFAULT ''
      )
      ENGINE = MergeTree
      PRIMARY KEY (id)
    SQL
  end

  def down
    execute <<~SQL
      DROP TABLE IF EXISTS issues
    SQL
  end
end

딕셔너리 생성#

ClickHouse 딕셔너리는 비용이 많이 드는 JOIN 연산 없이 외부 소스의 조회로 데이터를 보강할 수 있게 하여 쿼리 속도를 크게 향상시킵니다. 참조 데이터를 메모리에 캐시하거나 최적화된 레이아웃으로 캐시하여, 실시간 데이터 분석에 거의 즉각적인 접근을 가능하게 합니다.

GitLab 내에서는 자체(main) ClickHouse 데이터베이스를 참조하는 CLICKHOUSE 소스만 지원하며, 그 외의 외부 딕셔너리 참조는 지원하지 않습니다.

예를 들어, 특정 project_id 값에 대한 traversal_path를 조회하는 딕셔너리를 생성하는 방법은 다음과 같습니다.

class DictTest < ClickHouse::Migration
  def up
    definition = <<~SQL
      CREATE DICTIONARY project_traversal_paths_dictionary
      (
          `id` UInt64,
          `traversal_path` String
      )
      PRIMARY KEY id
        SOURCE(
          CLICKHOUSE(
            QUERY 'SELECT id, traversal_path FROM (
              SELECT id, traversal_path
              FROM (
                SELECT
                  id,
                  argMax(traversal_path, version) AS traversal_path,
                  argMax(deleted, version) AS deleted
                  FROM project_namespace_traversal_paths
                GROUP BY id
              )
              WHERE deleted = false
            )'
          )
        )
        LIFETIME(MIN 300 MAX 500)
        LAYOUT(CACHE(SIZE_IN_CELLS 1000000))
    SQL

    create_dictionary(definition, source_tables: ['project_namespace_traversal_paths'])
  end

  def down
    execute('DROP DICTIONARY project_traversal_paths_dictionary')
  end
end

project_namespace_traversal_paths 테이블은 project_id를 traversal_path 값에 매핑하는 비정규화된 ReplacingMergeTree 테이블입니다. 딕셔너리 정의에서 이 테이블을 사용하면, project_id로 traversal_path 값을 조회할 때 거의 O(1) 수준의 조회 성능을 얻을 수 있습니다.

ClickHouse 딕셔너리는 데이터베이스 자격 증명을 인수로 전달해야 하며, QUERY 인수에 데이터베이스 테이블에 대한 전체 참조가 필요합니다. 이 과정을 단순화하기 위해 create_dictionary 메서드는 다음을 수행합니다.

  • 구성된 ClickHouse 자격 증명을 CREATE DICTIONARY 구문에 자동으로 주입합니다.
  • QUERY의 테이블 앞에 데이터베이스 이름을 붙입니다. 이를 위해 source_tables 인수에 참여 테이블 목록이 올바르게 설정되어 있어야 합니다.

마이그레이션 후에는 딕셔너리를 다음과 같이 조회할 수 있습니다.

select dictGetOrDefault('project_traversal_paths_dictionary', 'traversal_path', 3, '0/');
  • project_traversal_paths_dictionary: 딕셔너리 이름
  • traversal_path: 요청된 칼럼
  • 3: 프로젝트 ID 값
  • 0/: 딕셔너리에서 레코드를 찾을 수 없는 경우의 기본값
Note

딕셔너리 구성에 따라 딕셔너리가 로드한 데이터가 오래된 상태일 수 있습니다. 딕셔너리를 사용할 때는 항상 일관성 요구 사항과 결국 일관성이 깨진 데이터를 수정하는 방법을 함께 고려합니다.

배포 후 마이그레이션#

ClickHouse 데이터베이스 배포 후 마이그레이션을 생성하려면 다음을 실행합니다.

bundle exec rails generate gitlab:click_house:post_deployment_migration MIGRATION_CLASS_NAME

이 마이그레이션은 기본적으로 일반 마이그레이션과 함께 실행되지만, 예를 들어 프로덕션에 배포하기 전에 SKIP_POST_DEPLOYMENT_MIGRATIONS 환경 변수를 사용하는 등의 방법으로 건너뛸 수 있습니다. 예를 들면 다음과 같습니다.

export SKIP_POST_DEPLOYMENT_MIGRATIONS=true
bundle exec rake gitlab:clickhouse:migrate

칼럼 압축 가이드라인#

새 테이블을 생성할 때 스토리지 효율성을 높이기 위해 특정 칼럼의 압축 설정을 조정하는 것을 고려합니다. 기본적으로 ClickHouse는 Self-managed 인스턴스에서 LZ4를 사용해 데이터를 압축하며, ClickHouse Cloud는 ZSTD를 사용합니다. 칼럼 유형과 내용에 따라 특정 코덱을 사용하면 훨씬 더 나은 압축률을 얻을 수 있습니다.

데이터 유형별 권장 코덱#

기본 키(또는 정렬된 칼럼)#

  • 정수 / 타임스탬프: CODEC(DoubleDelta, ZSTD) - 단조 증가하는 시퀀스에 최적화되어 있습니다.
  • 문자열: CODEC(ZSTD(3)) - 엔트로피가 높은 문자열에 더 높은 압축률을 제공합니다.

표준 칼럼#

  • 불리언: CODEC(ZSTD(1))
  • 증분 타임스탬프(created_at, updated_at): CODEC(Delta, ZSTD(1)) - 델타 인코딩으로 ZSTD가 압축하기 전에 증분 값을 훨씬 작게 만듭니다.
  • UUID / 해시(문자열로): CODEC(ZSTD(1))
  • 긴 텍스트 / JSON: CODEC(ZSTD(3)) - 더 높은 수준(최대 22)도 사용할 수 있지만, 성능과 압축률 면에서는 3이 이상적인 설정입니다.

구현#

코덱은 각 칼럼의 CREATE TABLE 구문에서 직접 정의합니다.

CREATE TABLE example_table (
  id         UInt64 CODEC(DoubleDelta, ZSTD),
  created_at DateTime64(3) CODEC(Delta, ZSTD(1)),
  payload    String CODEC(ZSTD(3))
) ENGINE = MergeTree()
ORDER BY id;

효율성 측정#

어떤 코덱을 사용할지 확실하지 않다면, 프로덕션과 유사한 데이터로 테스트 테이블을 만들고 다음 쿼리를 실행하여 압축률을 확인합니다.

SELECT
  name AS column_name,
  formatReadableSize(sum(data_compressed_bytes)) AS compressed,
  formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
  round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'your_table_name'
GROUP BY name
ORDER BY ratio DESC;
Note

과도하게 최적화하지 않습니다. 더 높은 압축 수준(예: ZSTD 10 이상)은 디스크 공간을 절약하지만, 쓰기와 읽기 양쪽에서 CPU 오버헤드를 늘립니다. 스토리지 절감 효과가 크지 않다면 기본값을 유지합니다.

데이터베이스 쿼리 작성#

ClickHouse 데이터베이스에는 ORM(Object Relational Mapping)을 사용하지 않습니다. 주된 이유는 GitLab 애플리케이션이 ActiveRecord PostgreSQL 어댑터에 많은 커스터마이징을 적용해 두었고, 애플리케이션이 일반적으로 모든 데이터베이스가 PostgreSQL을 사용한다고 가정하기 때문입니다. ClickHouse 관련 기능은 아직 초기 개발 단계에 있으므로, 여러 ActiveRecord 어댑터를 다룰 때 발견하기 어려운 버그와 긴 디버깅 시간을 피하기 위해 간단한 HTTP 클라이언트를 구현하기로 결정했습니다.

또한 ClickHouse는 ActiveRecord의 다른 어댑터와 같은 방식으로 사용되지 않을 수 있습니다. 접근 패턴이 기존 트랜잭션 데이터베이스와 다른데, ClickHouse는 다음과 같은 특징이 있습니다.

  • GROUP BY 절을 사용한 중첩 집계 SELECT 쿼리를 사용합니다.
  • 단일 INSERT 구문을 사용하지 않습니다. 데이터는 백그라운드 job을 통해 배치로 삽입됩니다.
  • 일관성 특성이 다르며, 트랜잭션이 없습니다.
  • 데이터베이스 수준의 유효성 검사가 거의 없습니다.

데이터베이스 쿼리는 ClickHouse::Client gem의 도움으로 작성하고 실행합니다.

events 테이블에 대한 간단한 쿼리입니다.

rows = ClickHouse::Client.select('SELECT * FROM events', :main)

플레이스홀더가 있는 쿼리를 사용할 때는 플레이스홀더 이름과 데이터 유형을 지정하는 ClickHouse::Query 객체를 사용할 수 있습니다. 실제 변수 교체, 따옴표 처리, 이스케이프는 ClickHouse 서버가 수행합니다.

raw_query = 'SELECT * FROM events WHERE id > {min_id:UInt64}'
placeholders = { min_id: Integer(100) }
query = ClickHouse::Client::Query.new(raw_query: raw_query, placeholders: placeholders)

rows = ClickHouse::Client.select(query, :main)

플레이스홀더를 사용하면 클라이언트가 로깅 시스템에서 수집할 수 있도록 플레이스홀더 값을 편집(redact)한 쿼리를 제공할 수 있습니다. to_redacted_sql 메서드를 호출하면 쿼리의 편집된 버전을 확인할 수 있습니다.

puts query.to_redacted_sql

ClickHouse는 요청당 하나의 구문만 허용합니다. 즉 구문을 ; 문자로 종료한 뒤 다른 쿼리를 "주입"하는 일반적인 SQL 인젝션 취약점은 악용할 수 없습니다.

ClickHouse::Client.select('SELECT 1; SELECT 2', :main)

# ClickHouse::Client::DatabaseError: Code: 62. DB::Exception: Syntax error (Multi-statements are not allowed): failed at position 9 (end of query): ; SELECT 2. . (SYNTAX_ERROR) (version 23.4.2.11 (official build))

서브쿼리#

ClickHouse::Client::Query 클래스로 복잡한 쿼리를 구성할 때, 쿼리 플레이스홀더에 특수 Subquery 유형을 지정할 수 있습니다. 라이브러리는 쿼리와 플레이스홀더를 올바르게 병합합니다.

subquery = ClickHouse::Client::Query.new(raw_query: 'SELECT id FROM events WHERE id = {id:UInt64}', placeholders: { id: Integer(10) })

raw_query = 'SELECT * FROM events WHERE id > {id:UInt64} AND id IN ({q:Subquery})'
placeholders = { id: Integer(10), q: subquery }

query = ClickHouse::Client::Query.new(raw_query: raw_query, placeholders: placeholders)
rows = ClickHouse::Client.select(query, :main)

# ClickHouse will replace the placeholders
puts query.to_sql # SELECT * FROM events WHERE id > {id:UInt64} AND id IN (SELECT id FROM events WHERE id = {id:UInt64})

puts query.to_redacted_sql # SELECT * FROM events WHERE id > $1 AND id IN (SELECT id FROM events WHERE id = $2)

puts query.placeholders # { id: 10 }

이름은 같지만 값이 다른 플레이스홀더가 있으면 쿼리에서 오류가 발생합니다.

쿼리 조건 작성#

여러 필터 조건이 있는 복잡한 폼을 다룰 때, 쿼리 조각을 문자열로 연결해 쿼리를 작성하면 금방 손을 쓸 수 없는 상태가 될 수 있습니다. 조건이 여러 개인 쿼리에는 ClickHouse::Client::QueryBuilder 클래스를 사용할 수 있습니다. 이 클래스는 Arel gem을 사용해 쿼리를 생성하며 ActiveRecord와 유사한 쿼리 인터페이스를 제공합니다.

builder = ClickHouse::Client::QueryBuilder.new('events')

query = builder
  .where(builder.table[:created_at].lteq(Date.today))
  .where(id: [1,2,3])

rows = ClickHouse::Client.select(query, :main)

데이터 삽입#

ClickHouse 클라이언트는 표준 쿼리 인터페이스를 통한 데이터 삽입을 지원합니다.

raw_query = 'INSERT INTO events (id, target_type) VALUES ({id:UInt64}, {target_type:String})'
placeholders = { id: 1, target_type: 'Issue' }

query = ClickHouse::Client::Query.new(raw_query: raw_query, placeholders: placeholders)
rows = ClickHouse::Client.execute(query, :main)

이 방식으로 데이터를 삽입하는 것은 다음 경우에 적합합니다.

  • 테이블에 설정이나 구성 데이터를 담아 행을 하나만 추가하면 되는 경우.
  • 테스트를 위해 데이터베이스에 테스트 데이터를 준비해야 하는 경우.

데이터를 삽입할 때는 항상 여러 행을 한 번에 삽입하는 배치 처리를 사용하도록 합니다. 메모리에서 대용량 INSERT 쿼리를 빌드하는 방식은 메모리 사용량이 늘어나므로 권장하지 않습니다. 또한 이런 쿼리에 지정된 값은 클라이언트가 자동으로 편집할 수 없습니다.

데이터를 압축해 메모리 사용량을 줄이려면 CSV 데이터를 삽입합니다. 내부 CsvBuilder gem으로 이를 수행할 수 있습니다.

iterator = Event.find_each

# insert from events table using only the id and the target_type columns
column_mapping = {
  id: :id,
  target_type: :target_type
}

CsvBuilder::Gzip.new(iterator, column_mapping).render do |tempfile|
  query = 'INSERT INTO events (id, target_type) FORMAT CSV'
  ClickHouse::Client.insert_csv(query, File.open(tempfile.path), :main)
end
Note

PostgreSQL의 데이터베이스 레코드를 효율적으로 배치 처리하는지 테스트하고 검증하는 것이 중요합니다. 테이블을 배치로 반복하기에 설명된 기법을 사용하는 것을 고려합니다.

테이블 반복#

ClickHouse에서 대용량 데이터를 배치 처리하려면 ClickHouse::Iterator 클래스를 사용할 수 있습니다. 이 이터레이터는 데이터베이스 인덱스에 의존하지 않고 고정 크기의 숫자 범위를 사용한다는 점에서, PostgreSQL 데이터베이스에 대한 기존 도구(테이블을 배치로 반복하기 문서 참고)와 약간 다르게 동작합니다.

사전 요구 사항:

  • 단일 정수 칼럼.
  • 칼럼 값 사이에 큰 공백이 없어야 하며, 이상적인 칼럼은 자동 증가하는 PostgreSQL 기본 키입니다.
  • 데이터 중복이 최소한이라면 중복된 값은 문제가 되지 않습니다.

사용법:

connection = ClickHouse::Connection.new(:main)
builder = ClickHouse::Client::QueryBuilder.new('events')

iterator = ClickHouse::Iterator.new(query_builder: builder, connection: connection)
iterator.each_batch(column: :id, of: 100_000) do |scope|
  records = connection.select(scope.to_sql)
end

특정 행만 반복하려면 쿼리 빌더 객체에 필터를 추가할 수 있습니다. 효율적인 필터링과 반복에는 사용 사례에 맞게 최적화된 다른 데이터베이스 테이블 스키마가 필요할 수 있다는 점에 유의합니다. 이런 반복을 도입할 때는 데이터베이스 쿼리가 전체 데이터베이스 테이블을 스캔하지 않는지 항상 확인합니다.

connection = ClickHouse::Connection.new(:main)
builder = ClickHouse::Client::QueryBuilder.new('events')

# filtering by target type and stringified traversal ids/path
builder = builder.where(target_type: 'Issue')
builder = builder.where(path: '96/97/') # points to a specific project

iterator = ClickHouse::Iterator.new(query_builder: builder, connection: connection)
iterator.each_batch(column: :id, of: 10) do |scope, min, max|
  puts "processing range: #{min} - #{max}"
  puts scope.to_sql
  records = connection.select(scope.to_sql)
end

최솟값-최댓값 전략#

이터레이터는 첫 단계로 반복 데이터베이스 쿼리에서 조건으로 사용할 데이터 범위를 결정합니다. 이 데이터 범위는 MIN(column)과 MAX(column) 집계로 결정됩니다. 일부 데이터베이스 테이블에서는 이 전략이 비효율적인 데이터베이스 쿼리(전체 테이블 스캔)를 유발합니다. 파티셔닝된 데이터베이스 테이블이 그 예입니다.

예시 쿼리:

SELECT MIN(id) AS min, MAX(id) AS max FROM events;

대안으로, 데이터 범위를 결정할 때 ORDER BY + LIMIT을 사용하는 다른 최솟값-최댓값 전략을 쓸 수 있습니다.

iterator = ClickHouse::Iterator.new(query_builder: builder, connection: connection, min_max_strategy: :order_limit)

예시 쿼리:

SELECT (SELECT id FROM events ORDER BY id ASC LIMIT 1) AS min, (SELECT id FROM events ORDER BY id DESC LIMIT 1) AS max;

Sidekiq 워커 구현#

ClickHouse 데이터베이스를 사용하는 Sidekiq 워커는 ClickHouseWorker 모듈을 포함해야 합니다. 이를 통해 데이터베이스 마이그레이션이 실행되는 동안 워커가 일시 중지되고, 워커가 활성 상태인 동안에는 마이그레이션이 실행되지 않도록 합니다.

# events_sync_worker.rb
# frozen_string_literal: true

module ClickHouse
  class EventsSyncWorker
    include ApplicationWorker
    include ClickHouseWorker

    ...
  end
end

ClickHouse 워커 태깅#

ClickHouse 관련 Sidekiq 워커에는 모두 clickhouse 태그가 지정되어 있어, 고객이 더 나은 리소스 격리와 성능 최적화를 위해 이러한 워커를 별도의 Sidekiq 샤드로 옮길 수 있습니다.

ClickHouse와 상호 작용하는 모든 워커에는 tags 메타데이터 필드를 추가해야 합니다.

# events_sync_worker.rb
# frozen_string_literal: true

module ClickHouse
  class EventsSyncWorker
    include ApplicationWorker
    include ClickHouseWorker

    idempotent!
    queue_namespace :cronjob
    data_consistency :delayed
    feature_category :value_stream_management
    tags :clickhouse

    def perform
      # Worker implementation
    end
  end
end

이 태깅을 통해 고객은 다음을 할 수 있습니다.

  • ClickHouse 워커를 전용 Sidekiq 프로세스나 서버로 라우팅합니다
  • ClickHouse 워크로드에 다른 리소스 제한과 스케일링 정책을 적용합니다
  • ClickHouse 관련 백그라운드 job을 별도로 모니터링하고 문제를 해결합니다
  • ClickHouse 작업에 사용자 지정 재시도 정책이나 오류 처리를 구현합니다

Sidekiq 워커 태깅과 라우팅에 대한 자세한 내용은 Sidekiq 문서를 참고합니다.

GraphQL 사용#

GraphQL을 사용해 ActiveRecord 쿼리와 동일한 외부 인터페이스(키셋 페이지네이션)로 ClickHouse 쿼리를 페이지네이션합니다.

페이지네이션 인터페이스에는 다음이 포함됩니다.

  • 페이지네이션 관련 데이터(endCursor, startCursor)를 위한 PageInfo.
  • 다음이나 이전 페이지를 로드하기 위한 after, before, first, last 인수.

ClickHouse와 함께 GraphQL 페이지네이션을 사용하려면, 쿼리가 다음 요구 사항을 충족해야 합니다.

  • ORDER BY 칼럼은 NOT NULL이어야 합니다.
  • ORDER BY 칼럼 값은 정확히 하나의 행을 식별해야 합니다(키셋 페이지네이션 요구 사항).

리졸버 구현 예시#

GraphQL 리졸버는 ClickHouse::Client::QueryBuilder 객체를 반환해야 합니다.

def resolve
  ClickHouse::Client::QueryBuilder
    .new('events')
    .order(:created_at, :asc)
    .order(:id, :asc)
end

페이지네이션 라이브러리가 커서 인코딩과 디코딩을 처리합니다. 반환되는 데이터는 직접 ClickHouse 쿼리에서 얻는 형식(해시 배열)과 일치합니다. GraphQL 응답에 맞게 데이터를 형식화하려면, GraphQL 타입에 형식화 로직을 구현합니다.

중복 제거 쿼리를 사용한 리졸버 구현#

version과 deleted 칼럼이 있는 ReplacingMergeTree 엔진을 쿼리할 때는 기본 키로 행을 중복 제거해야 합니다. 중복 제거 로직에는 GROUP BY와 argMax를 사용하는 중첩 SELECT를 사용합니다.

다음 예시는 hierarchy_work_items 구체화된 뷰 테이블에서 gitlab-org 그룹으로 필터링된 이슈를 나열합니다.

def resolve
  builder = ClickHouse::Client::QueryBuilder.new('hierarchy_work_items')

  columns = %i[id title traversal_path work_item_type_id created_at]
  deleted_column = :deleted
  version_column = :version
  group_by_columns = %i[traversal_path work_item_type_id id]

  # Use argMax to determine the latest column value based on the version column.
  inner_projections = columns.map do |column|
    if group_by_columns.include?(column)
      builder.table[column]
    else
      Arel::Nodes::NamedFunction.new('argMax', [
        builder.table[column],
        builder.table[version_column]
      ]).as(column.to_s)
    end
  end

  # Add the deleted column to filter deleted rows later.
  inner_projections << Arel::Nodes::NamedFunction.new('argMax', [
    builder.table[deleted_column],
    builder.table[version_column]
  ]).as(deleted_column.to_s)

  # Select all issues within the gitlab-org group (9970).
  inner_query = builder
    .select(*inner_projections)
    .where(Arel::Nodes::NamedFunction.new('startsWith', [builder.table[:traversal_path], Arel.sql("'1/9970/'")]))
    .where(work_item_type_id: 1)
    .group(*group_by_columns)

  builder
    .select(*columns)
    .from(inner_query, 'hierarchy_work_items')
    .where(deleted: false)
    .order(:created_at, :desc)
    .order(:id, :desc)
end

이 코드는 다음 SQL 쿼리를 생성합니다.

SELECT
    `hierarchy_work_items`.`id`,
    `hierarchy_work_items`.`title`,
    `hierarchy_work_items`.`traversal_path`,
    `hierarchy_work_items`.`work_item_type_id`,
    `hierarchy_work_items`.`created_at`
FROM
    (
        SELECT
            `hierarchy_work_items`.`id`,
            argMax(
                `hierarchy_work_items`.`title`,
                `hierarchy_work_items`.`version`
            ) AS title,
            `hierarchy_work_items`.`traversal_path`,
            `hierarchy_work_items`.`work_item_type_id`,
            argMax(
                `hierarchy_work_items`.`created_at`,
                `hierarchy_work_items`.`version`
            ) AS created_at,
            argMax(
                `hierarchy_work_items`.`deleted`,
                `hierarchy_work_items`.`version`
            ) AS deleted
        FROM
            `hierarchy_work_items`
        WHERE
            startsWith(
                `hierarchy_work_items`.`traversal_path`,
                '1/9970/'
            )
            AND `hierarchy_work_items`.`work_item_type_id` = 1
        GROUP BY
            traversal_path,
            work_item_type_id,
            id
    ) hierarchy_work_items
WHERE
    `hierarchy_work_items`.`deleted` = 'false'
ORDER BY
    `hierarchy_work_items`.`created_at` DESC,
    `hierarchy_work_items`.`id` DESC
LIMIT
    21

모범 사례#

ClickHouse의 데이터가 필요한 기능을 만들 때는 먼저 Sidekiq 워커나 다른 전략을 사용해 PostgreSQL 테이블(예: 이벤트나 이슈)에서 원시 데이터를 복제해야 합니다. 그런 다음 그 데이터 위에 별도의 집계를 구축합니다. PostgreSQL에서 직접 집계하는 방식을 피하면 유지보수성을 높이고 데이터 재처리를 가능하게 할 수 있습니다.

테스트#

ClickHouse는 CI/CD에서 활성화되어 있지만, 파이프라인 실행 시간에 크게 영향을 주지 않기 위해 :click_house 태그가 지정된 테스트 케이스에서만 ClickHouse 서버를 실행하기로 했습니다.

:click_house 태그는 모든 테스트 케이스 전에 데이터베이스 스키마가 올바르게 설정되도록 합니다.

RSpec.describe MyClickHouseFeature, :click_house do
  it 'returns rows' do
    rows = ClickHouse::Client.select('SELECT 1', :main)
    expect(rows.size).to eq(1)
  end
end

다중 데이터베이스#

설계상 ClickHouse::Client 라이브러리는 다중 데이터베이스 구성을 지원합니다. 아직 개발 초기 단계이므로 main이라는 데이터베이스 하나만 있습니다.

다중 데이터베이스 구성 예시:

development:
  main:
    database: gitlab_clickhouse_main_development
    url: 'http://localhost:8123'
    username: clickhouse
    password: clickhouse

  user_analytics: # made up database
    database: gitlab_clickhouse_user_analytics_development
    url: 'http://localhost:8123'
    username: clickhouse
    password: clickhouse

관찰 가능성#

ClickHouse::Client 라이브러리로 실행하는 모든 쿼리는 ActiveSupport::Notifications를 통해 성능 메트릭(타이밍, 읽은 바이트 수)과 함께 쿼리를 노출합니다.

ActiveSupport::Notifications.subscribe('sql.click_house') do |_, _, _, _, data|
  puts data.inspect
end

또한 웹 인터랙션에서 실행된 ClickHouse 쿼리를 확인하려면, 성능 표시줄에서 ch 레이블 옆의 카운트를 선택합니다.

log_comment를 사용한 쿼리 어트리뷰션#

GitLab이 ClickHouse로 보내는 모든 쿼리에는 log_comment 설정이 붙으며, 이는 ClickHouse::HttpClient.build_post_proc에서 요청 URL에 추가됩니다. ClickHouse는 이 값을 system.query_log의 log_comment 칼럼에 저장하므로, 기록된 쿼리를 그 쿼리를 발생시킨 GitLab 요청까지 추적할 수 있습니다.

이 값은 JSON 객체입니다. 값을 사용할 수 없는 키는 생략됩니다.

키 의미
correlation_id 요청 correlation ID입니다. 다른 GitLab 로그에서 사용하는 값과 동일합니다.
user_id 현재 사용자의 숫자 ID입니다.
root_namespace_id 루트 네임스페이스의 숫자 ID입니다.
organization_id 현재 조직의 숫자 ID입니다.
application web, sidekiq, console, test 중 하나입니다.
feature_category 요청의 기능 카테고리입니다.

예를 들어 웹 요청은 다음을 생성합니다.

{
  "correlation_id":"4b809c12c639dbec87b37274337aae0d",
  "user_id":1,
  "root_namespace_id":22,
  "organization_id":1,
  "application":"web",
  "feature_category":"database"
}

사용자 요청 쿼리의 네임스페이스 어트리뷰션#

root_namespace_id는 애플리케이션 컨텍스트에 네임스페이스가 이미 존재할 때만 자동으로 채워집니다. 그룹 범위의 컨트롤러 요청이 이런 경우에 해당하는데, ApplicationController#set_current_context가 @group 인스턴스 변수에서 네임스페이스를 푸시하고, 그룹을 푸시하는 REST API 요청도 마찬가지입니다.

GraphQL 요청은 다릅니다. GraphQL 엔드포인트는 @group이나 @project를 전혀 설정하지 않으므로, 여기서는 자동으로 어트리뷰션되는 것이 없습니다. 프로젝트 범위 요청도 일관되지 않습니다. 네임스페이스는 프로젝트의 namespace 연관 관계가 이미 로드된 경우에만 채워지는데, 이런 경우는 드뭅니다.

사용자 요청 ClickHouse 쿼리를 추가할 때는 네임스페이스가 게시되도록 감쌉니다. 요청의 나머지 부분에 적용되는 push 대신, 블록에만 범위가 한정되는 Gitlab::ApplicationContext.with_context를 사용합니다.

예를 들어 ee/lib/gitlab/contribution_analytics/click_house_data_collector.rb는 이런 방식으로 쿼리를 감쌉니다.

def totals_by_author_target_type_action
  ::Gitlab::ApplicationContext.with_context(namespace: group) do
    query = ::ClickHouse::Client::Query.new(raw_query: clickhouse_query, placeholders: placeholders)
    ::ClickHouse::Client.select(query, :main)
  end
end

네임스페이스를 항상 사용할 수 있는 것은 아닙니다. 백그라운드 동기화와 인제스트 워커의 쿼리는 인스턴스 전체에 걸친 것이라 어트리뷰션할 네임스페이스가 없습니다.

주석을 다시 읽으려면 다음 쿼리를 실행할 수 있습니다.

SELECT JSONExtractString(log_comment, 'correlation_id') AS correlation_id,
       query_duration_ms,
       query
FROM system.query_log
WHERE type = 'QueryFinish' AND log_comment != ''
ORDER BY query_duration_ms DESC
LIMIT 20

Siphon을 사용한 데이터 동기화#

GitLab은 변경 데이터 캡처(CDC) 도구인 Siphon을 사용해 PostgreSQL 테이블의 데이터를 ClickHouse로 지속적으로 동기화합니다. Siphon은 PostgreSQL 논리적 복제 스트림을 읽고, 모든 행 변경 사항을 관례상 siphon_ 접두사가 붙는 ClickHouse 테이블에 적용합니다.

복제되는 각 테이블에는 db/siphon/tables에 구성 파일이 있습니다. 테이블을 복제하고 ClickHouse 스키마를 설계하는 방법은 Siphon을 사용한 ClickHouse 테이블 설계를 참고합니다.

Siphon 복제 가능 여부 확인#

Gitlab::ClickHouse.enabled_for_analytics?는 ClickHouse가 분석용으로 구성되어 켜져 있는지 알려줍니다. 이 값은 사용하려는 기능이 읽는 Siphon 복제 데이터가 실제로 존재하는지는 알려주지 않습니다. ClickHouse에는 연결할 수 있지만 특정 테이블에 대해 Siphon이 잘못 구성되어 있거나 일시 중지된 상태일 수 있습니다.

이 두 번째 질문에 답하려면 Gitlab::ClickHouse.siphon_enabled?를 사용합니다. 이 메서드는 복제된 siphon_* 테이블을 직접 확인하는 대신, Siphon 자체의 복제 메타데이터 테이블인 siphon_internal_events를 확인합니다.

  • Gitlab::ClickHouse.siphon_enabled?는 Siphon이 무엇이든 복제한 적이 있는지 확인합니다.
  • Gitlab::ClickHouse.siphon_enabled?('duo_workflows_workflows')는 Siphon이 해당 PostgreSQL 테이블을 복제한 적이 있는지 확인합니다. 인수는 PostgreSQL 테이블 이름이며, siphon_ 접두사가 붙은 ClickHouse 테이블 이름이 아니라 db/siphon/tables/<table>.yml에서 사용하는 이름과 같습니다.

이 메서드는 ClickHouse가 구성되어 있지 않으면 false를 반환하며, ClickHouse 쿼리가 실패해도 false를 반환합니다. 실패한 쿼리는 예외 로그에 기록됩니다.

true 결과는 Ruby 프로세스가 살아 있는 동안 캐시됩니다. false 결과는 캐시되지 않으므로, 나중에 복제를 시작하는 테이블은 다음 호출에서 반영됩니다. 어떤 단일 테이블에 대한 true 결과는 다른 쿼리를 실행하지 않고도 인수 없는 전역 검사를 그대로 충족시킵니다.

이 검사에는 세 가지 한계가 있습니다.

  • 복제 지연을 감지하지 않습니다. true 결과는 Siphon이 어느 시점에 그 테이블을 복제했다는 의미일 뿐, 데이터가 최신이라는 의미는 아닙니다.
  • 복제가 중단된 것을 감지하지 않습니다. 한 번 복제된 테이블은 계속 true를 반환합니다.
  • 파티셔닝된 테이블을 처리하지 않습니다. Siphon은 각 파티션을 별도로 추적하므로, p_ci_builds와 같은 라우팅 테이블 이름을 전달하면 절대 일치하지 않습니다.

Siphon이 복제한 테이블을 읽는 기능의 시작 부분에서 가드로 사용합니다.

return unless Gitlab::ClickHouse.siphon_enabled?('duo_workflows_workflows')

데이터베이스 마이그레이션#

PostgreSQL 스키마와 ClickHouse 스키마를 동기화된 상태로 유지합니다. 복제되는 PostgreSQL 테이블에 칼럼을 추가할 때는 ClickHouse 마이그레이션으로 ClickHouse 테이블에도 동일한 칼럼을 추가합니다.

ClickHouse 테이블에서는 칼럼을 제외할 수 있지만, 그 경우에는 복제에서도 반드시 제외해야 합니다.

  1. db/siphon/tables/<table>.yml의 ignored_columns에 칼럼을 추가합니다.
  2. spec/db/clickhouse_siphon_tables_spec.rb의 skip_fields 목록에 칼럼 이름을 추가합니다.

예를 들어 db/siphon/tables/milestones.yml은 캐시된 두 HTML 칼럼을 제외합니다.

table: milestones
database: main
ignored_columns:
  - title_html
  - description_html

토큰이나 암호화된 속성처럼 민감해 보이는 칼럼은 항상 ignored_columns에 나열해야 합니다. 이런 칼럼이 구성 파일에서 빠지면 테스트가 실패합니다.

테스트에서 Siphon 오류 처리#

GitLab을 개발하는 중에 ClickHouse에 대응하는 칼럼을 추가하지 않고 PostgreSQL에 새 칼럼을 추가하면 테스트가 다음 오류로 실패합니다.

This table is synchronised to ClickHouse and you've added a new column!

이를 해결하려면 ClickHouse에도 칼럼을 추가하는 마이그레이션을 추가해야 합니다.

예시#

  1. milestones처럼 ClickHouse로 동기화되는 테이블에 int4 유형의 새 칼럼 new_int를 추가합니다.

  2. CI가 다음 오류로 실패하는 것을 확인합니다.

    This table is synchronised to ClickHouse and you've added a new column!
    
  3. 새 칼럼을 추가하는 ClickHouse 마이그레이션을 생성합니다. ClickHouse 테이블에는 siphon_ 접두사가 붙습니다.

    bundle exec rails generate gitlab:click_house:migration add_new_int_to_siphon_milestones
    
  4. 생성된 파일에서 칼럼을 추가·제거하는 up/down 메서드를 정의합니다. ClickHouse 데이터 유형은 PostgreSQL에 대략 대응됩니다. 새 칼럼에 적절한 매핑은 Gitlab::ClickHouse::SiphonGenerator::PG_TYPE_MAP에서 확인합니다. 잘못된 유형을 사용하면 다른 오류가 발생합니다. 또한 적절한 경우 LowCardinality를 활용하고, Nullable은 가능하면 기본값을 대신 선택해 드물게만 사용합니다.

     class AddNewIntToSiphonMilestones < ClickHouse::Migration
       def up
         execute <<~SQL
           ALTER TABLE siphon_milestones ADD COLUMN new_int Int64 DEFAULT 42;
         SQL
       end
    
       def down
         execute <<~SQL
           ALTER TABLE siphon_milestones DROP COLUMN new_int;
         SQL
       end
     end
    

추가 지원이 필요하면 내부적으로 #f_siphon에 문의합니다.

테이블 이름 변경 또는 교체#

논리적 복제는 DDL 변경 사항을 캡처하지 않으므로, Siphon은 테이블의 이름이 변경되거나 교체될 때 이를 알아채지 못합니다. 이런 변경에는 Siphon 구성 파일에서 추가 작업이 필요하며, 그렇지 않으면 해당 테이블의 복제가 중단됩니다.

이름 변경이나 교체 자체는 필수 중지 중에 일어나므로, 작업을 두 릴리스에 걸쳐 나누어야 합니다.

  1. 필수 중지 전에 db/siphon/tables/<table>.yml을 업데이트합니다.
    • 이름 변경 후 테이블이 갖게 될 이름으로 renamed_table_name을 추가합니다. Siphon과 테스트는 두 이름 모두 허용하므로, 이름 변경 전후 모두 구성이 유효한 상태로 유지됩니다.
    • 현재 테이블 이름으로 original_table_name을 추가합니다. Siphon은 테이블 이름에서 NATS 주제를 파생시키므로, 주제를 이전 이름으로 고정해 두면 이름이 바뀌어도 데이터 흐름이 유지됩니다. renamed_table_name을 설정할 때는 이 키가 항상 필요합니다.
  2. 필수 중지 중에 테이블의 이름을 바꾸거나 교체합니다.
  3. 필수 중지 후에 구성 파일을 정리합니다.
    • table을 새 테이블 이름으로 설정합니다.
    • renamed_table_name을 제거합니다.
    • NATS 주제가 그대로 유지되도록 original_table_name은 바꾸지 않고 둡니다.
    • 선택 사항입니다. 새 테이블 이름을 반영하도록 파일 이름을 바꿉니다. 파일 이름 자체는 중요하지 않으며, 소스 테이블을 정하는 것은 table 키입니다.

예를 들어 merge_request_diff_files_99208b8fac가 merge_request_diff_files와 교체된다고 합니다. 필수 중지 전의 구성 파일은 다음과 같습니다.

table: merge_request_diff_files_99208b8fac
renamed_table_name: merge_request_diff_files
original_table_name: merge_request_diff_files_99208b8fac
database: main
replication_targets:
  - name: clickhouse_main
    target: siphon_merge_request_diff_files

교체 후에는 같은 파일이 다음과 같이 바뀝니다.

table: merge_request_diff_files
original_table_name: merge_request_diff_files_99208b8fac
database: main
replication_targets:
  - name: clickhouse_main
    target: siphon_merge_request_diff_files

파티셔닝된 테이블#

앞의 단계는 파티셔닝된 테이블에도 그대로 적용됩니다. 파티션이 상위 테이블과 함께 이름이 바뀌더라도, 파티션마다 구성 파일이 따로 필요하지는 않습니다.

Siphon은 상위 테이블이 아니라 개별 파티션을 복제하므로, 각 파티션을 이전 이름과 새 이름 양쪽으로 인식해야 합니다. Siphon은 이 이름들을 상위 테이블 이름에서 파생시키는데, PostgreSQL이 동적으로 생성된 파티션의 이름을 <parent>_<suffix> 형식으로 짓고 이름 변경 시에도 접미사가 그대로 유지되기 때문입니다. 상위 테이블 이름이 merge_request_diff_files_99208b8fac에서 merge_request_diff_files로 바뀌는 경우는 다음과 같습니다.

이름 변경 전 파티션 이름 변경 후 파티션
merge_request_diff_files_99208b8fac_1 merge_request_diff_files_1
merge_request_diff_files_99208b8fac_1600000001 merge_request_diff_files_1600000001

두 이름 모두 같은 복제 테이블로 확인되므로 NATS 주제, ClickHouse 대상, 스키마 버전 해시는 바뀌지 않습니다. 상위 테이블만 이름이 바뀌든 파티션까지 이름이 바뀌든, 복제는 중단 없이, 재스냅샷 없이 계속됩니다.

이름 변경 후에 만들어지는 새 파티션은 다른 파티셔닝된 테이블과 마찬가지로 자동으로 인식됩니다.

이는 PostgreSQL이 강제하지는 않는 <parent>_<suffix> 명명 규칙에 의존합니다. 상위 테이블 이름으로 시작하지 않는 파티션은 대상에서 빠지므로, 다음을 담은 자체 구성 파일이 필요합니다.

  • table과 renamed_table_name: 이름 변경 전후의 파티션 이름.
  • schema: 기본값이 public이므로, 해당 파티션이 속한 스키마.
  • original_table_name: 모든 파티션이 같은 주제로 게시되게 하는 상위 테이블 이름.

상위 테이블과 그 파티션을 동시에 구성하지 않습니다. 그렇게 하면 파티션이 두 번 복제되고, 각 파티션에 대해 초기 스냅샷도 두 번 실행됩니다.

문제 해결#

쿼리를 실행할 때 MEMORY_LIMIT_EXCEEDED 오류가 발생하면, gdk.yml 파일에서 clickhouse.max_memory_usage와 clickhouse.max_server_memory_usage 설정을 늘립니다.

기본 설정은 gdk.example.yml 파일을 참고합니다. 변경 사항을 적용하려면 GDK를 재구성해야 합니다.

도움 받기#

추가 정보나 특정 질문은 #f_clickhouse Slack 채널의 ClickHouse Datastore 워킹 그룹에 문의하거나, GitLab.com의 댓글에서 @gitlab-org/maintainers/clickhouse를 언급합니다.

GitLab 내 ClickHouse

GitLab v19.4
원문 보기

요약

이 문서는 GitLab Rails 애플리케이션에서 ClickHouse를 사용하여 기능을 개발하는 방법에 대한 개괄적인 개요를 제공합니다. 대부분의 도구와 API는 불안정한 것으로 간주됩니다. ClickHouse 설치 문서에 설명된 대로 로컬에 ClickHouse를 설치합니다.

이 문서는 GitLab Rails 애플리케이션에서 ClickHouse를 사용하여 기능을 개발하는 방법에 대한 개괄적인 개요를 제공합니다.

Note

대부분의 도구와 API는 불안정한 것으로 간주됩니다.

GDK 설정#

ClickHouse 서버 설정#

  1. ClickHouse 설치 문서에 설명된 대로 로컬에 ClickHouse를 설치합니다. QuickInstall을 사용하면 현재 디렉터리에 설치되고, Homebrew를 사용하면 /opt/homebrew/bin/clickhouse에 설치됩니다.

  2. gdk.yml에 ClickHouse 섹션을 추가합니다. gdk.example.yml을 참고합니다.

  3. gdk.yml ClickHouse 구성 파일이 로컬 ClickHouse 설치 경로와 로컬 데이터 저장 경로를 가리키도록 조정합니다. 예를 들면 다음과 같습니다.

    clickhouse:
      bin: "/opt/homebrew/bin/clickhouse"
      enabled: true
      # these are optional if we have more than one GDK:
      # http_port: 8123
      # interserver_http_port: 9009
      # tcp_port: 9001
    
  4. gdk reconfigure를 실행합니다.

  5. gdk start clickhouse로 ClickHouse를 시작합니다.

Rails 애플리케이션 구성#

  1. 예시 파일을 복사하고 자격 증명을 구성합니다.

    cp config/click_house.yml.example config/click_house.yml
    
  2. 번들된 clickhouse client를 사용하여 데이터베이스를 생성합니다.

    gdk clickhouse
    
    create database gitlab_clickhouse_development;
    create database gitlab_clickhouse_test;
    

설정 유효성 검사#

Rails 콘솔을 실행하고 간단한 쿼리를 호출합니다.

ClickHouse::Client.select('SELECT 1', :main)
# => [{"1"=>1}]

데이터베이스 스키마 및 마이그레이션#

ClickHouse 데이터베이스 마이그레이션을 생성하려면 다음을 실행합니다.

bundle exec rails generate gitlab:click_house:migration MIGRATION_CLASS_NAME

데이터베이스 마이그레이션을 실행하려면 다음을 실행합니다.

bundle exec rake gitlab:clickhouse:migrate

gitlab:clickhouse:migrate job은 적용된 각 마이그레이션에 대해 db/click_house/schema_migrations/main/<version> 아래에 스키마 버전 마커 파일도 생성합니다. 이 마커 파일을 마이그레이션과 함께 커밋합니다. clickhouse:check-schema CI job은 마커 파일 없이 마이그레이션이 머지되면 실패하며, 파일을 커밋할 때까지 GitLab은 마이그레이션을 실행할 때마다 마커 파일을 추적되지 않는 파일로 다시 생성합니다. 로컬 ClickHouse 인스턴스 없이 마이그레이션을 작성하는 경우, 커밋하기 전에 마이그레이션을 실행하여 마커 파일을 생성합니다.

마지막 N개의 마이그레이션을 롤백하려면 다음을 실행합니다.

bundle exec rake gitlab:clickhouse:rollback:main STEP=N

또는 다음 명령을 사용하여 모든 마이그레이션을 롤백합니다.

bundle exec rake gitlab:clickhouse:rollback:main VERSION=0

db/click_house/migrate 폴더에 Ruby 마이그레이션 파일을 만들어 마이그레이션을 생성할 수 있습니다. 파일 이름은 YYYYMMDDHHMMSS_description_of_migration.rb 형식의 타임스탬프로 시작해야 합니다.

# 20230811124511_create_issues.rb
# frozen_string_literal: true

class CreateIssues < ClickHouse::Migration
  def up
    execute <<~SQL
      CREATE TABLE IF NOT EXISTS issues
      (
        id UInt64 DEFAULT 0,
        title String DEFAULT ''
      )
      ENGINE = MergeTree
      PRIMARY KEY (id)
    SQL
  end

  def down
    execute <<~SQL
      DROP TABLE IF EXISTS issues
    SQL
  end
end

딕셔너리 생성#

ClickHouse 딕셔너리는 비용이 많이 드는 JOIN 연산 없이 외부 소스의 조회로 데이터를 보강할 수 있게 하여 쿼리 속도를 크게 향상시킵니다. 참조 데이터를 메모리에 캐시하거나 최적화된 레이아웃으로 캐시하여, 실시간 데이터 분석에 거의 즉각적인 접근을 가능하게 합니다.

GitLab 내에서는 자체(main) ClickHouse 데이터베이스를 참조하는 CLICKHOUSE 소스만 지원하며, 그 외의 외부 딕셔너리 참조는 지원하지 않습니다.

예를 들어, 특정 project_id 값에 대한 traversal_path를 조회하는 딕셔너리를 생성하는 방법은 다음과 같습니다.

class DictTest < ClickHouse::Migration
  def up
    definition = <<~SQL
      CREATE DICTIONARY project_traversal_paths_dictionary
      (
          `id` UInt64,
          `traversal_path` String
      )
      PRIMARY KEY id
        SOURCE(
          CLICKHOUSE(
            QUERY 'SELECT id, traversal_path FROM (
              SELECT id, traversal_path
              FROM (
                SELECT
                  id,
                  argMax(traversal_path, version) AS traversal_path,
                  argMax(deleted, version) AS deleted
                  FROM project_namespace_traversal_paths
                GROUP BY id
              )
              WHERE deleted = false
            )'
          )
        )
        LIFETIME(MIN 300 MAX 500)
        LAYOUT(CACHE(SIZE_IN_CELLS 1000000))
    SQL

    create_dictionary(definition, source_tables: ['project_namespace_traversal_paths'])
  end

  def down
    execute('DROP DICTIONARY project_traversal_paths_dictionary')
  end
end

project_namespace_traversal_paths 테이블은 project_id를 traversal_path 값에 매핑하는 비정규화된 ReplacingMergeTree 테이블입니다. 딕셔너리 정의에서 이 테이블을 사용하면, project_id로 traversal_path 값을 조회할 때 거의 O(1) 수준의 조회 성능을 얻을 수 있습니다.

ClickHouse 딕셔너리는 데이터베이스 자격 증명을 인수로 전달해야 하며, QUERY 인수에 데이터베이스 테이블에 대한 전체 참조가 필요합니다. 이 과정을 단순화하기 위해 create_dictionary 메서드는 다음을 수행합니다.

  • 구성된 ClickHouse 자격 증명을 CREATE DICTIONARY 구문에 자동으로 주입합니다.
  • QUERY의 테이블 앞에 데이터베이스 이름을 붙입니다. 이를 위해 source_tables 인수에 참여 테이블 목록이 올바르게 설정되어 있어야 합니다.

마이그레이션 후에는 딕셔너리를 다음과 같이 조회할 수 있습니다.

select dictGetOrDefault('project_traversal_paths_dictionary', 'traversal_path', 3, '0/');
  • project_traversal_paths_dictionary: 딕셔너리 이름
  • traversal_path: 요청된 칼럼
  • 3: 프로젝트 ID 값
  • 0/: 딕셔너리에서 레코드를 찾을 수 없는 경우의 기본값
Note

딕셔너리 구성에 따라 딕셔너리가 로드한 데이터가 오래된 상태일 수 있습니다. 딕셔너리를 사용할 때는 항상 일관성 요구 사항과 결국 일관성이 깨진 데이터를 수정하는 방법을 함께 고려합니다.

배포 후 마이그레이션#

ClickHouse 데이터베이스 배포 후 마이그레이션을 생성하려면 다음을 실행합니다.

bundle exec rails generate gitlab:click_house:post_deployment_migration MIGRATION_CLASS_NAME

이 마이그레이션은 기본적으로 일반 마이그레이션과 함께 실행되지만, 예를 들어 프로덕션에 배포하기 전에 SKIP_POST_DEPLOYMENT_MIGRATIONS 환경 변수를 사용하는 등의 방법으로 건너뛸 수 있습니다. 예를 들면 다음과 같습니다.

export SKIP_POST_DEPLOYMENT_MIGRATIONS=true
bundle exec rake gitlab:clickhouse:migrate

칼럼 압축 가이드라인#

새 테이블을 생성할 때 스토리지 효율성을 높이기 위해 특정 칼럼의 압축 설정을 조정하는 것을 고려합니다. 기본적으로 ClickHouse는 Self-managed 인스턴스에서 LZ4를 사용해 데이터를 압축하며, ClickHouse Cloud는 ZSTD를 사용합니다. 칼럼 유형과 내용에 따라 특정 코덱을 사용하면 훨씬 더 나은 압축률을 얻을 수 있습니다.

데이터 유형별 권장 코덱#

기본 키(또는 정렬된 칼럼)#

  • 정수 / 타임스탬프: CODEC(DoubleDelta, ZSTD) - 단조 증가하는 시퀀스에 최적화되어 있습니다.
  • 문자열: CODEC(ZSTD(3)) - 엔트로피가 높은 문자열에 더 높은 압축률을 제공합니다.

표준 칼럼#

  • 불리언: CODEC(ZSTD(1))
  • 증분 타임스탬프(created_at, updated_at): CODEC(Delta, ZSTD(1)) - 델타 인코딩으로 ZSTD가 압축하기 전에 증분 값을 훨씬 작게 만듭니다.
  • UUID / 해시(문자열로): CODEC(ZSTD(1))
  • 긴 텍스트 / JSON: CODEC(ZSTD(3)) - 더 높은 수준(최대 22)도 사용할 수 있지만, 성능과 압축률 면에서는 3이 이상적인 설정입니다.

구현#

코덱은 각 칼럼의 CREATE TABLE 구문에서 직접 정의합니다.

CREATE TABLE example_table (
  id         UInt64 CODEC(DoubleDelta, ZSTD),
  created_at DateTime64(3) CODEC(Delta, ZSTD(1)),
  payload    String CODEC(ZSTD(3))
) ENGINE = MergeTree()
ORDER BY id;

효율성 측정#

어떤 코덱을 사용할지 확실하지 않다면, 프로덕션과 유사한 데이터로 테스트 테이블을 만들고 다음 쿼리를 실행하여 압축률을 확인합니다.

SELECT
  name AS column_name,
  formatReadableSize(sum(data_compressed_bytes)) AS compressed,
  formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
  round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'your_table_name'
GROUP BY name
ORDER BY ratio DESC;
Note

과도하게 최적화하지 않습니다. 더 높은 압축 수준(예: ZSTD 10 이상)은 디스크 공간을 절약하지만, 쓰기와 읽기 양쪽에서 CPU 오버헤드를 늘립니다. 스토리지 절감 효과가 크지 않다면 기본값을 유지합니다.

데이터베이스 쿼리 작성#

ClickHouse 데이터베이스에는 ORM(Object Relational Mapping)을 사용하지 않습니다. 주된 이유는 GitLab 애플리케이션이 ActiveRecord PostgreSQL 어댑터에 많은 커스터마이징을 적용해 두었고, 애플리케이션이 일반적으로 모든 데이터베이스가 PostgreSQL을 사용한다고 가정하기 때문입니다. ClickHouse 관련 기능은 아직 초기 개발 단계에 있으므로, 여러 ActiveRecord 어댑터를 다룰 때 발견하기 어려운 버그와 긴 디버깅 시간을 피하기 위해 간단한 HTTP 클라이언트를 구현하기로 결정했습니다.

또한 ClickHouse는 ActiveRecord의 다른 어댑터와 같은 방식으로 사용되지 않을 수 있습니다. 접근 패턴이 기존 트랜잭션 데이터베이스와 다른데, ClickHouse는 다음과 같은 특징이 있습니다.

  • GROUP BY 절을 사용한 중첩 집계 SELECT 쿼리를 사용합니다.
  • 단일 INSERT 구문을 사용하지 않습니다. 데이터는 백그라운드 job을 통해 배치로 삽입됩니다.
  • 일관성 특성이 다르며, 트랜잭션이 없습니다.
  • 데이터베이스 수준의 유효성 검사가 거의 없습니다.

데이터베이스 쿼리는 ClickHouse::Client gem의 도움으로 작성하고 실행합니다.

events 테이블에 대한 간단한 쿼리입니다.

rows = ClickHouse::Client.select('SELECT * FROM events', :main)

플레이스홀더가 있는 쿼리를 사용할 때는 플레이스홀더 이름과 데이터 유형을 지정하는 ClickHouse::Query 객체를 사용할 수 있습니다. 실제 변수 교체, 따옴표 처리, 이스케이프는 ClickHouse 서버가 수행합니다.

raw_query = 'SELECT * FROM events WHERE id > {min_id:UInt64}'
placeholders = { min_id: Integer(100) }
query = ClickHouse::Client::Query.new(raw_query: raw_query, placeholders: placeholders)

rows = ClickHouse::Client.select(query, :main)

플레이스홀더를 사용하면 클라이언트가 로깅 시스템에서 수집할 수 있도록 플레이스홀더 값을 편집(redact)한 쿼리를 제공할 수 있습니다. to_redacted_sql 메서드를 호출하면 쿼리의 편집된 버전을 확인할 수 있습니다.

puts query.to_redacted_sql

ClickHouse는 요청당 하나의 구문만 허용합니다. 즉 구문을 ; 문자로 종료한 뒤 다른 쿼리를 "주입"하는 일반적인 SQL 인젝션 취약점은 악용할 수 없습니다.

ClickHouse::Client.select('SELECT 1; SELECT 2', :main)

# ClickHouse::Client::DatabaseError: Code: 62. DB::Exception: Syntax error (Multi-statements are not allowed): failed at position 9 (end of query): ; SELECT 2. . (SYNTAX_ERROR) (version 23.4.2.11 (official build))

서브쿼리#

ClickHouse::Client::Query 클래스로 복잡한 쿼리를 구성할 때, 쿼리 플레이스홀더에 특수 Subquery 유형을 지정할 수 있습니다. 라이브러리는 쿼리와 플레이스홀더를 올바르게 병합합니다.

subquery = ClickHouse::Client::Query.new(raw_query: 'SELECT id FROM events WHERE id = {id:UInt64}', placeholders: { id: Integer(10) })

raw_query = 'SELECT * FROM events WHERE id > {id:UInt64} AND id IN ({q:Subquery})'
placeholders = { id: Integer(10), q: subquery }

query = ClickHouse::Client::Query.new(raw_query: raw_query, placeholders: placeholders)
rows = ClickHouse::Client.select(query, :main)

# ClickHouse will replace the placeholders
puts query.to_sql # SELECT * FROM events WHERE id > {id:UInt64} AND id IN (SELECT id FROM events WHERE id = {id:UInt64})

puts query.to_redacted_sql # SELECT * FROM events WHERE id > $1 AND id IN (SELECT id FROM events WHERE id = $2)

puts query.placeholders # { id: 10 }

이름은 같지만 값이 다른 플레이스홀더가 있으면 쿼리에서 오류가 발생합니다.

쿼리 조건 작성#

여러 필터 조건이 있는 복잡한 폼을 다룰 때, 쿼리 조각을 문자열로 연결해 쿼리를 작성하면 금방 손을 쓸 수 없는 상태가 될 수 있습니다. 조건이 여러 개인 쿼리에는 ClickHouse::Client::QueryBuilder 클래스를 사용할 수 있습니다. 이 클래스는 Arel gem을 사용해 쿼리를 생성하며 ActiveRecord와 유사한 쿼리 인터페이스를 제공합니다.

builder = ClickHouse::Client::QueryBuilder.new('events')

query = builder
  .where(builder.table[:created_at].lteq(Date.today))
  .where(id: [1,2,3])

rows = ClickHouse::Client.select(query, :main)

데이터 삽입#

ClickHouse 클라이언트는 표준 쿼리 인터페이스를 통한 데이터 삽입을 지원합니다.

raw_query = 'INSERT INTO events (id, target_type) VALUES ({id:UInt64}, {target_type:String})'
placeholders = { id: 1, target_type: 'Issue' }

query = ClickHouse::Client::Query.new(raw_query: raw_query, placeholders: placeholders)
rows = ClickHouse::Client.execute(query, :main)

이 방식으로 데이터를 삽입하는 것은 다음 경우에 적합합니다.

  • 테이블에 설정이나 구성 데이터를 담아 행을 하나만 추가하면 되는 경우.
  • 테스트를 위해 데이터베이스에 테스트 데이터를 준비해야 하는 경우.

데이터를 삽입할 때는 항상 여러 행을 한 번에 삽입하는 배치 처리를 사용하도록 합니다. 메모리에서 대용량 INSERT 쿼리를 빌드하는 방식은 메모리 사용량이 늘어나므로 권장하지 않습니다. 또한 이런 쿼리에 지정된 값은 클라이언트가 자동으로 편집할 수 없습니다.

데이터를 압축해 메모리 사용량을 줄이려면 CSV 데이터를 삽입합니다. 내부 CsvBuilder gem으로 이를 수행할 수 있습니다.

iterator = Event.find_each

# insert from events table using only the id and the target_type columns
column_mapping = {
  id: :id,
  target_type: :target_type
}

CsvBuilder::Gzip.new(iterator, column_mapping).render do |tempfile|
  query = 'INSERT INTO events (id, target_type) FORMAT CSV'
  ClickHouse::Client.insert_csv(query, File.open(tempfile.path), :main)
end
Note

PostgreSQL의 데이터베이스 레코드를 효율적으로 배치 처리하는지 테스트하고 검증하는 것이 중요합니다. 테이블을 배치로 반복하기에 설명된 기법을 사용하는 것을 고려합니다.

테이블 반복#

ClickHouse에서 대용량 데이터를 배치 처리하려면 ClickHouse::Iterator 클래스를 사용할 수 있습니다. 이 이터레이터는 데이터베이스 인덱스에 의존하지 않고 고정 크기의 숫자 범위를 사용한다는 점에서, PostgreSQL 데이터베이스에 대한 기존 도구(테이블을 배치로 반복하기 문서 참고)와 약간 다르게 동작합니다.

사전 요구 사항:

  • 단일 정수 칼럼.
  • 칼럼 값 사이에 큰 공백이 없어야 하며, 이상적인 칼럼은 자동 증가하는 PostgreSQL 기본 키입니다.
  • 데이터 중복이 최소한이라면 중복된 값은 문제가 되지 않습니다.

사용법:

connection = ClickHouse::Connection.new(:main)
builder = ClickHouse::Client::QueryBuilder.new('events')

iterator = ClickHouse::Iterator.new(query_builder: builder, connection: connection)
iterator.each_batch(column: :id, of: 100_000) do |scope|
  records = connection.select(scope.to_sql)
end

특정 행만 반복하려면 쿼리 빌더 객체에 필터를 추가할 수 있습니다. 효율적인 필터링과 반복에는 사용 사례에 맞게 최적화된 다른 데이터베이스 테이블 스키마가 필요할 수 있다는 점에 유의합니다. 이런 반복을 도입할 때는 데이터베이스 쿼리가 전체 데이터베이스 테이블을 스캔하지 않는지 항상 확인합니다.

connection = ClickHouse::Connection.new(:main)
builder = ClickHouse::Client::QueryBuilder.new('events')

# filtering by target type and stringified traversal ids/path
builder = builder.where(target_type: 'Issue')
builder = builder.where(path: '96/97/') # points to a specific project

iterator = ClickHouse::Iterator.new(query_builder: builder, connection: connection)
iterator.each_batch(column: :id, of: 10) do |scope, min, max|
  puts "processing range: #{min} - #{max}"
  puts scope.to_sql
  records = connection.select(scope.to_sql)
end

최솟값-최댓값 전략#

이터레이터는 첫 단계로 반복 데이터베이스 쿼리에서 조건으로 사용할 데이터 범위를 결정합니다. 이 데이터 범위는 MIN(column)과 MAX(column) 집계로 결정됩니다. 일부 데이터베이스 테이블에서는 이 전략이 비효율적인 데이터베이스 쿼리(전체 테이블 스캔)를 유발합니다. 파티셔닝된 데이터베이스 테이블이 그 예입니다.

예시 쿼리:

SELECT MIN(id) AS min, MAX(id) AS max FROM events;

대안으로, 데이터 범위를 결정할 때 ORDER BY + LIMIT을 사용하는 다른 최솟값-최댓값 전략을 쓸 수 있습니다.

iterator = ClickHouse::Iterator.new(query_builder: builder, connection: connection, min_max_strategy: :order_limit)

예시 쿼리:

SELECT (SELECT id FROM events ORDER BY id ASC LIMIT 1) AS min, (SELECT id FROM events ORDER BY id DESC LIMIT 1) AS max;

Sidekiq 워커 구현#

ClickHouse 데이터베이스를 사용하는 Sidekiq 워커는 ClickHouseWorker 모듈을 포함해야 합니다. 이를 통해 데이터베이스 마이그레이션이 실행되는 동안 워커가 일시 중지되고, 워커가 활성 상태인 동안에는 마이그레이션이 실행되지 않도록 합니다.

# events_sync_worker.rb
# frozen_string_literal: true

module ClickHouse
  class EventsSyncWorker
    include ApplicationWorker
    include ClickHouseWorker

    ...
  end
end

ClickHouse 워커 태깅#

ClickHouse 관련 Sidekiq 워커에는 모두 clickhouse 태그가 지정되어 있어, 고객이 더 나은 리소스 격리와 성능 최적화를 위해 이러한 워커를 별도의 Sidekiq 샤드로 옮길 수 있습니다.

ClickHouse와 상호 작용하는 모든 워커에는 tags 메타데이터 필드를 추가해야 합니다.

# events_sync_worker.rb
# frozen_string_literal: true

module ClickHouse
  class EventsSyncWorker
    include ApplicationWorker
    include ClickHouseWorker

    idempotent!
    queue_namespace :cronjob
    data_consistency :delayed
    feature_category :value_stream_management
    tags :clickhouse

    def perform
      # Worker implementation
    end
  end
end

이 태깅을 통해 고객은 다음을 할 수 있습니다.

  • ClickHouse 워커를 전용 Sidekiq 프로세스나 서버로 라우팅합니다
  • ClickHouse 워크로드에 다른 리소스 제한과 스케일링 정책을 적용합니다
  • ClickHouse 관련 백그라운드 job을 별도로 모니터링하고 문제를 해결합니다
  • ClickHouse 작업에 사용자 지정 재시도 정책이나 오류 처리를 구현합니다

Sidekiq 워커 태깅과 라우팅에 대한 자세한 내용은 Sidekiq 문서를 참고합니다.

GraphQL 사용#

GraphQL을 사용해 ActiveRecord 쿼리와 동일한 외부 인터페이스(키셋 페이지네이션)로 ClickHouse 쿼리를 페이지네이션합니다.

페이지네이션 인터페이스에는 다음이 포함됩니다.

  • 페이지네이션 관련 데이터(endCursor, startCursor)를 위한 PageInfo.
  • 다음이나 이전 페이지를 로드하기 위한 after, before, first, last 인수.

ClickHouse와 함께 GraphQL 페이지네이션을 사용하려면, 쿼리가 다음 요구 사항을 충족해야 합니다.

  • ORDER BY 칼럼은 NOT NULL이어야 합니다.
  • ORDER BY 칼럼 값은 정확히 하나의 행을 식별해야 합니다(키셋 페이지네이션 요구 사항).

리졸버 구현 예시#

GraphQL 리졸버는 ClickHouse::Client::QueryBuilder 객체를 반환해야 합니다.

def resolve
  ClickHouse::Client::QueryBuilder
    .new('events')
    .order(:created_at, :asc)
    .order(:id, :asc)
end

페이지네이션 라이브러리가 커서 인코딩과 디코딩을 처리합니다. 반환되는 데이터는 직접 ClickHouse 쿼리에서 얻는 형식(해시 배열)과 일치합니다. GraphQL 응답에 맞게 데이터를 형식화하려면, GraphQL 타입에 형식화 로직을 구현합니다.

중복 제거 쿼리를 사용한 리졸버 구현#

version과 deleted 칼럼이 있는 ReplacingMergeTree 엔진을 쿼리할 때는 기본 키로 행을 중복 제거해야 합니다. 중복 제거 로직에는 GROUP BY와 argMax를 사용하는 중첩 SELECT를 사용합니다.

다음 예시는 hierarchy_work_items 구체화된 뷰 테이블에서 gitlab-org 그룹으로 필터링된 이슈를 나열합니다.

def resolve
  builder = ClickHouse::Client::QueryBuilder.new('hierarchy_work_items')

  columns = %i[id title traversal_path work_item_type_id created_at]
  deleted_column = :deleted
  version_column = :version
  group_by_columns = %i[traversal_path work_item_type_id id]

  # Use argMax to determine the latest column value based on the version column.
  inner_projections = columns.map do |column|
    if group_by_columns.include?(column)
      builder.table[column]
    else
      Arel::Nodes::NamedFunction.new('argMax', [
        builder.table[column],
        builder.table[version_column]
      ]).as(column.to_s)
    end
  end

  # Add the deleted column to filter deleted rows later.
  inner_projections << Arel::Nodes::NamedFunction.new('argMax', [
    builder.table[deleted_column],
    builder.table[version_column]
  ]).as(deleted_column.to_s)

  # Select all issues within the gitlab-org group (9970).
  inner_query = builder
    .select(*inner_projections)
    .where(Arel::Nodes::NamedFunction.new('startsWith', [builder.table[:traversal_path], Arel.sql("'1/9970/'")]))
    .where(work_item_type_id: 1)
    .group(*group_by_columns)

  builder
    .select(*columns)
    .from(inner_query, 'hierarchy_work_items')
    .where(deleted: false)
    .order(:created_at, :desc)
    .order(:id, :desc)
end

이 코드는 다음 SQL 쿼리를 생성합니다.

SELECT
    `hierarchy_work_items`.`id`,
    `hierarchy_work_items`.`title`,
    `hierarchy_work_items`.`traversal_path`,
    `hierarchy_work_items`.`work_item_type_id`,
    `hierarchy_work_items`.`created_at`
FROM
    (
        SELECT
            `hierarchy_work_items`.`id`,
            argMax(
                `hierarchy_work_items`.`title`,
                `hierarchy_work_items`.`version`
            ) AS title,
            `hierarchy_work_items`.`traversal_path`,
            `hierarchy_work_items`.`work_item_type_id`,
            argMax(
                `hierarchy_work_items`.`created_at`,
                `hierarchy_work_items`.`version`
            ) AS created_at,
            argMax(
                `hierarchy_work_items`.`deleted`,
                `hierarchy_work_items`.`version`
            ) AS deleted
        FROM
            `hierarchy_work_items`
        WHERE
            startsWith(
                `hierarchy_work_items`.`traversal_path`,
                '1/9970/'
            )
            AND `hierarchy_work_items`.`work_item_type_id` = 1
        GROUP BY
            traversal_path,
            work_item_type_id,
            id
    ) hierarchy_work_items
WHERE
    `hierarchy_work_items`.`deleted` = 'false'
ORDER BY
    `hierarchy_work_items`.`created_at` DESC,
    `hierarchy_work_items`.`id` DESC
LIMIT
    21

모범 사례#

ClickHouse의 데이터가 필요한 기능을 만들 때는 먼저 Sidekiq 워커나 다른 전략을 사용해 PostgreSQL 테이블(예: 이벤트나 이슈)에서 원시 데이터를 복제해야 합니다. 그런 다음 그 데이터 위에 별도의 집계를 구축합니다. PostgreSQL에서 직접 집계하는 방식을 피하면 유지보수성을 높이고 데이터 재처리를 가능하게 할 수 있습니다.

테스트#

ClickHouse는 CI/CD에서 활성화되어 있지만, 파이프라인 실행 시간에 크게 영향을 주지 않기 위해 :click_house 태그가 지정된 테스트 케이스에서만 ClickHouse 서버를 실행하기로 했습니다.

:click_house 태그는 모든 테스트 케이스 전에 데이터베이스 스키마가 올바르게 설정되도록 합니다.

RSpec.describe MyClickHouseFeature, :click_house do
  it 'returns rows' do
    rows = ClickHouse::Client.select('SELECT 1', :main)
    expect(rows.size).to eq(1)
  end
end

다중 데이터베이스#

설계상 ClickHouse::Client 라이브러리는 다중 데이터베이스 구성을 지원합니다. 아직 개발 초기 단계이므로 main이라는 데이터베이스 하나만 있습니다.

다중 데이터베이스 구성 예시:

development:
  main:
    database: gitlab_clickhouse_main_development
    url: 'http://localhost:8123'
    username: clickhouse
    password: clickhouse

  user_analytics: # made up database
    database: gitlab_clickhouse_user_analytics_development
    url: 'http://localhost:8123'
    username: clickhouse
    password: clickhouse

관찰 가능성#

ClickHouse::Client 라이브러리로 실행하는 모든 쿼리는 ActiveSupport::Notifications를 통해 성능 메트릭(타이밍, 읽은 바이트 수)과 함께 쿼리를 노출합니다.

ActiveSupport::Notifications.subscribe('sql.click_house') do |_, _, _, _, data|
  puts data.inspect
end

또한 웹 인터랙션에서 실행된 ClickHouse 쿼리를 확인하려면, 성능 표시줄에서 ch 레이블 옆의 카운트를 선택합니다.

log_comment를 사용한 쿼리 어트리뷰션#

GitLab이 ClickHouse로 보내는 모든 쿼리에는 log_comment 설정이 붙으며, 이는 ClickHouse::HttpClient.build_post_proc에서 요청 URL에 추가됩니다. ClickHouse는 이 값을 system.query_log의 log_comment 칼럼에 저장하므로, 기록된 쿼리를 그 쿼리를 발생시킨 GitLab 요청까지 추적할 수 있습니다.

이 값은 JSON 객체입니다. 값을 사용할 수 없는 키는 생략됩니다.

키 의미
correlation_id 요청 correlation ID입니다. 다른 GitLab 로그에서 사용하는 값과 동일합니다.
user_id 현재 사용자의 숫자 ID입니다.
root_namespace_id 루트 네임스페이스의 숫자 ID입니다.
organization_id 현재 조직의 숫자 ID입니다.
application web, sidekiq, console, test 중 하나입니다.
feature_category 요청의 기능 카테고리입니다.

예를 들어 웹 요청은 다음을 생성합니다.

{
  "correlation_id":"4b809c12c639dbec87b37274337aae0d",
  "user_id":1,
  "root_namespace_id":22,
  "organization_id":1,
  "application":"web",
  "feature_category":"database"
}

사용자 요청 쿼리의 네임스페이스 어트리뷰션#

root_namespace_id는 애플리케이션 컨텍스트에 네임스페이스가 이미 존재할 때만 자동으로 채워집니다. 그룹 범위의 컨트롤러 요청이 이런 경우에 해당하는데, ApplicationController#set_current_context가 @group 인스턴스 변수에서 네임스페이스를 푸시하고, 그룹을 푸시하는 REST API 요청도 마찬가지입니다.

GraphQL 요청은 다릅니다. GraphQL 엔드포인트는 @group이나 @project를 전혀 설정하지 않으므로, 여기서는 자동으로 어트리뷰션되는 것이 없습니다. 프로젝트 범위 요청도 일관되지 않습니다. 네임스페이스는 프로젝트의 namespace 연관 관계가 이미 로드된 경우에만 채워지는데, 이런 경우는 드뭅니다.

사용자 요청 ClickHouse 쿼리를 추가할 때는 네임스페이스가 게시되도록 감쌉니다. 요청의 나머지 부분에 적용되는 push 대신, 블록에만 범위가 한정되는 Gitlab::ApplicationContext.with_context를 사용합니다.

예를 들어 ee/lib/gitlab/contribution_analytics/click_house_data_collector.rb는 이런 방식으로 쿼리를 감쌉니다.

def totals_by_author_target_type_action
  ::Gitlab::ApplicationContext.with_context(namespace: group) do
    query = ::ClickHouse::Client::Query.new(raw_query: clickhouse_query, placeholders: placeholders)
    ::ClickHouse::Client.select(query, :main)
  end
end

네임스페이스를 항상 사용할 수 있는 것은 아닙니다. 백그라운드 동기화와 인제스트 워커의 쿼리는 인스턴스 전체에 걸친 것이라 어트리뷰션할 네임스페이스가 없습니다.

주석을 다시 읽으려면 다음 쿼리를 실행할 수 있습니다.

SELECT JSONExtractString(log_comment, 'correlation_id') AS correlation_id,
       query_duration_ms,
       query
FROM system.query_log
WHERE type = 'QueryFinish' AND log_comment != ''
ORDER BY query_duration_ms DESC
LIMIT 20

Siphon을 사용한 데이터 동기화#

GitLab은 변경 데이터 캡처(CDC) 도구인 Siphon을 사용해 PostgreSQL 테이블의 데이터를 ClickHouse로 지속적으로 동기화합니다. Siphon은 PostgreSQL 논리적 복제 스트림을 읽고, 모든 행 변경 사항을 관례상 siphon_ 접두사가 붙는 ClickHouse 테이블에 적용합니다.

복제되는 각 테이블에는 db/siphon/tables에 구성 파일이 있습니다. 테이블을 복제하고 ClickHouse 스키마를 설계하는 방법은 Siphon을 사용한 ClickHouse 테이블 설계를 참고합니다.

Siphon 복제 가능 여부 확인#

Gitlab::ClickHouse.enabled_for_analytics?는 ClickHouse가 분석용으로 구성되어 켜져 있는지 알려줍니다. 이 값은 사용하려는 기능이 읽는 Siphon 복제 데이터가 실제로 존재하는지는 알려주지 않습니다. ClickHouse에는 연결할 수 있지만 특정 테이블에 대해 Siphon이 잘못 구성되어 있거나 일시 중지된 상태일 수 있습니다.

이 두 번째 질문에 답하려면 Gitlab::ClickHouse.siphon_enabled?를 사용합니다. 이 메서드는 복제된 siphon_* 테이블을 직접 확인하는 대신, Siphon 자체의 복제 메타데이터 테이블인 siphon_internal_events를 확인합니다.

  • Gitlab::ClickHouse.siphon_enabled?는 Siphon이 무엇이든 복제한 적이 있는지 확인합니다.
  • Gitlab::ClickHouse.siphon_enabled?('duo_workflows_workflows')는 Siphon이 해당 PostgreSQL 테이블을 복제한 적이 있는지 확인합니다. 인수는 PostgreSQL 테이블 이름이며, siphon_ 접두사가 붙은 ClickHouse 테이블 이름이 아니라 db/siphon/tables/<table>.yml에서 사용하는 이름과 같습니다.

이 메서드는 ClickHouse가 구성되어 있지 않으면 false를 반환하며, ClickHouse 쿼리가 실패해도 false를 반환합니다. 실패한 쿼리는 예외 로그에 기록됩니다.

true 결과는 Ruby 프로세스가 살아 있는 동안 캐시됩니다. false 결과는 캐시되지 않으므로, 나중에 복제를 시작하는 테이블은 다음 호출에서 반영됩니다. 어떤 단일 테이블에 대한 true 결과는 다른 쿼리를 실행하지 않고도 인수 없는 전역 검사를 그대로 충족시킵니다.

이 검사에는 세 가지 한계가 있습니다.

  • 복제 지연을 감지하지 않습니다. true 결과는 Siphon이 어느 시점에 그 테이블을 복제했다는 의미일 뿐, 데이터가 최신이라는 의미는 아닙니다.
  • 복제가 중단된 것을 감지하지 않습니다. 한 번 복제된 테이블은 계속 true를 반환합니다.
  • 파티셔닝된 테이블을 처리하지 않습니다. Siphon은 각 파티션을 별도로 추적하므로, p_ci_builds와 같은 라우팅 테이블 이름을 전달하면 절대 일치하지 않습니다.

Siphon이 복제한 테이블을 읽는 기능의 시작 부분에서 가드로 사용합니다.

return unless Gitlab::ClickHouse.siphon_enabled?('duo_workflows_workflows')

데이터베이스 마이그레이션#

PostgreSQL 스키마와 ClickHouse 스키마를 동기화된 상태로 유지합니다. 복제되는 PostgreSQL 테이블에 칼럼을 추가할 때는 ClickHouse 마이그레이션으로 ClickHouse 테이블에도 동일한 칼럼을 추가합니다.

ClickHouse 테이블에서는 칼럼을 제외할 수 있지만, 그 경우에는 복제에서도 반드시 제외해야 합니다.

  1. db/siphon/tables/<table>.yml의 ignored_columns에 칼럼을 추가합니다.
  2. spec/db/clickhouse_siphon_tables_spec.rb의 skip_fields 목록에 칼럼 이름을 추가합니다.

예를 들어 db/siphon/tables/milestones.yml은 캐시된 두 HTML 칼럼을 제외합니다.

table: milestones
database: main
ignored_columns:
  - title_html
  - description_html

토큰이나 암호화된 속성처럼 민감해 보이는 칼럼은 항상 ignored_columns에 나열해야 합니다. 이런 칼럼이 구성 파일에서 빠지면 테스트가 실패합니다.

테스트에서 Siphon 오류 처리#

GitLab을 개발하는 중에 ClickHouse에 대응하는 칼럼을 추가하지 않고 PostgreSQL에 새 칼럼을 추가하면 테스트가 다음 오류로 실패합니다.

This table is synchronised to ClickHouse and you've added a new column!

이를 해결하려면 ClickHouse에도 칼럼을 추가하는 마이그레이션을 추가해야 합니다.

예시#

  1. milestones처럼 ClickHouse로 동기화되는 테이블에 int4 유형의 새 칼럼 new_int를 추가합니다.

  2. CI가 다음 오류로 실패하는 것을 확인합니다.

    This table is synchronised to ClickHouse and you've added a new column!
    
  3. 새 칼럼을 추가하는 ClickHouse 마이그레이션을 생성합니다. ClickHouse 테이블에는 siphon_ 접두사가 붙습니다.

    bundle exec rails generate gitlab:click_house:migration add_new_int_to_siphon_milestones
    
  4. 생성된 파일에서 칼럼을 추가·제거하는 up/down 메서드를 정의합니다. ClickHouse 데이터 유형은 PostgreSQL에 대략 대응됩니다. 새 칼럼에 적절한 매핑은 Gitlab::ClickHouse::SiphonGenerator::PG_TYPE_MAP에서 확인합니다. 잘못된 유형을 사용하면 다른 오류가 발생합니다. 또한 적절한 경우 LowCardinality를 활용하고, Nullable은 가능하면 기본값을 대신 선택해 드물게만 사용합니다.

     class AddNewIntToSiphonMilestones < ClickHouse::Migration
       def up
         execute <<~SQL
           ALTER TABLE siphon_milestones ADD COLUMN new_int Int64 DEFAULT 42;
         SQL
       end
    
       def down
         execute <<~SQL
           ALTER TABLE siphon_milestones DROP COLUMN new_int;
         SQL
       end
     end
    

추가 지원이 필요하면 내부적으로 #f_siphon에 문의합니다.

테이블 이름 변경 또는 교체#

논리적 복제는 DDL 변경 사항을 캡처하지 않으므로, Siphon은 테이블의 이름이 변경되거나 교체될 때 이를 알아채지 못합니다. 이런 변경에는 Siphon 구성 파일에서 추가 작업이 필요하며, 그렇지 않으면 해당 테이블의 복제가 중단됩니다.

이름 변경이나 교체 자체는 필수 중지 중에 일어나므로, 작업을 두 릴리스에 걸쳐 나누어야 합니다.

  1. 필수 중지 전에 db/siphon/tables/<table>.yml을 업데이트합니다.
    • 이름 변경 후 테이블이 갖게 될 이름으로 renamed_table_name을 추가합니다. Siphon과 테스트는 두 이름 모두 허용하므로, 이름 변경 전후 모두 구성이 유효한 상태로 유지됩니다.
    • 현재 테이블 이름으로 original_table_name을 추가합니다. Siphon은 테이블 이름에서 NATS 주제를 파생시키므로, 주제를 이전 이름으로 고정해 두면 이름이 바뀌어도 데이터 흐름이 유지됩니다. renamed_table_name을 설정할 때는 이 키가 항상 필요합니다.
  2. 필수 중지 중에 테이블의 이름을 바꾸거나 교체합니다.
  3. 필수 중지 후에 구성 파일을 정리합니다.
    • table을 새 테이블 이름으로 설정합니다.
    • renamed_table_name을 제거합니다.
    • NATS 주제가 그대로 유지되도록 original_table_name은 바꾸지 않고 둡니다.
    • 선택 사항입니다. 새 테이블 이름을 반영하도록 파일 이름을 바꿉니다. 파일 이름 자체는 중요하지 않으며, 소스 테이블을 정하는 것은 table 키입니다.

예를 들어 merge_request_diff_files_99208b8fac가 merge_request_diff_files와 교체된다고 합니다. 필수 중지 전의 구성 파일은 다음과 같습니다.

table: merge_request_diff_files_99208b8fac
renamed_table_name: merge_request_diff_files
original_table_name: merge_request_diff_files_99208b8fac
database: main
replication_targets:
  - name: clickhouse_main
    target: siphon_merge_request_diff_files

교체 후에는 같은 파일이 다음과 같이 바뀝니다.

table: merge_request_diff_files
original_table_name: merge_request_diff_files_99208b8fac
database: main
replication_targets:
  - name: clickhouse_main
    target: siphon_merge_request_diff_files

파티셔닝된 테이블#

앞의 단계는 파티셔닝된 테이블에도 그대로 적용됩니다. 파티션이 상위 테이블과 함께 이름이 바뀌더라도, 파티션마다 구성 파일이 따로 필요하지는 않습니다.

Siphon은 상위 테이블이 아니라 개별 파티션을 복제하므로, 각 파티션을 이전 이름과 새 이름 양쪽으로 인식해야 합니다. Siphon은 이 이름들을 상위 테이블 이름에서 파생시키는데, PostgreSQL이 동적으로 생성된 파티션의 이름을 <parent>_<suffix> 형식으로 짓고 이름 변경 시에도 접미사가 그대로 유지되기 때문입니다. 상위 테이블 이름이 merge_request_diff_files_99208b8fac에서 merge_request_diff_files로 바뀌는 경우는 다음과 같습니다.

이름 변경 전 파티션 이름 변경 후 파티션
merge_request_diff_files_99208b8fac_1 merge_request_diff_files_1
merge_request_diff_files_99208b8fac_1600000001 merge_request_diff_files_1600000001

두 이름 모두 같은 복제 테이블로 확인되므로 NATS 주제, ClickHouse 대상, 스키마 버전 해시는 바뀌지 않습니다. 상위 테이블만 이름이 바뀌든 파티션까지 이름이 바뀌든, 복제는 중단 없이, 재스냅샷 없이 계속됩니다.

이름 변경 후에 만들어지는 새 파티션은 다른 파티셔닝된 테이블과 마찬가지로 자동으로 인식됩니다.

이는 PostgreSQL이 강제하지는 않는 <parent>_<suffix> 명명 규칙에 의존합니다. 상위 테이블 이름으로 시작하지 않는 파티션은 대상에서 빠지므로, 다음을 담은 자체 구성 파일이 필요합니다.

  • table과 renamed_table_name: 이름 변경 전후의 파티션 이름.
  • schema: 기본값이 public이므로, 해당 파티션이 속한 스키마.
  • original_table_name: 모든 파티션이 같은 주제로 게시되게 하는 상위 테이블 이름.

상위 테이블과 그 파티션을 동시에 구성하지 않습니다. 그렇게 하면 파티션이 두 번 복제되고, 각 파티션에 대해 초기 스냅샷도 두 번 실행됩니다.

문제 해결#

쿼리를 실행할 때 MEMORY_LIMIT_EXCEEDED 오류가 발생하면, gdk.yml 파일에서 clickhouse.max_memory_usage와 clickhouse.max_server_memory_usage 설정을 늘립니다.

기본 설정은 gdk.example.yml 파일을 참고합니다. 변경 사항을 적용하려면 GDK를 재구성해야 합니다.

도움 받기#

추가 정보나 특정 질문은 #f_clickhouse Slack 채널의 ClickHouse Datastore 워킹 그룹에 문의하거나, GitLab.com의 댓글에서 @gitlab-org/maintainers/clickhouse를 언급합니다.