ClickHouse и работа с ним на Go

go dev.to

1. Вступление: какую проблему решает ClickHouse

ClickHouse нужен там, где обычная OLTP-база начинает мешать аналитике: большие объемы событий, логов, метрик, биллинга, product analytics, ad-tech, observability, отчеты по десяткам миллионов строк и near real-time агрегации. Его сильная сторона — дешево сканировать много данных и быстро выполнять агрегаты, фильтрацию и time-series запросы за счет колоночного хранения, сортировки данных и sparse primary index. При этом производительность в ClickHouse определяется не только SQL, но и физическим дизайном таблицы: ORDER BY, PARTITION BY, стратегия вставки, типы колонок и модель обновления данных. ([ClickHouse][1])

С точки зрения Go-разработчика это важно по двум причинам:

  1. ClickHouse — не “PostgreSQL для аналитики”. Многие привычки из OLTP здесь вредны: частые UPDATE, мелкие INSERT, избыточные JOIN, попытка строить нормализованную модель как в transactional DB. ClickHouse сам рекомендует избегать частых и крупных mutation-операций и проектировать append-only потоки данных там, где это возможно. ([ClickHouse][2])
  2. В Go значительную часть latency и стоимости ingestion вы контролируете сами: batching, transport, context timeout, backpressure, idempotency, retry policy, query settings. Для максимальной производительности официальный clickhouse-go рекомендует native ClickHouse API, а database/sql — скорее для совместимости с инфраструктурой и библиотеками. ([ClickHouse][3])

Типовые кейсы, где связка Go + ClickHouse особенно уместна:

  • event ingestion из API/gRPC/Kafka consumers;
  • хранение audit/security событий;
  • product analytics и clickstream;
  • observability: traces/logs/metrics;
  • агрегаты для BI и внутренних дашбордов;
  • anti-fraud и near real-time scoring по историческим данным.

2. Под капотом: как это устроено

Ключевая модель хранения

В большинстве production-сценариев вы работаете с движками семейства MergeTree. Они:

  • хранят данные по колонкам;
  • сортируют данные по ORDER BY;
  • строят sparse primary index по marks/granules, а не B-Tree по каждой строке;
  • в фоне сливают data parts внутри partition. ([ClickHouse][4])

Это дает важное следствие:

В ClickHouse primary key — это не про уникальность, а про порядок хранения и сокращение объема сканируемых данных.

ClickHouse прямо указывает, что primary key не обязан быть уникальным, а его выбор влияет и на query performance, и на compression efficiency. ([ClickHouse][5])

ORDER BY важнее, чем кажется

ORDER BY определяет физическую сортировку данных в part’ах. Если ваши запросы обычно фильтруют по (tenant_id, event_date, event_type), то именно такой префикс часто дает лучший баланс между pruning и компрессией. Хороший ключ сортировки ускоряет чтение и снижает I/O. ([ClickHouse][5])

PARTITION BY — не индекс

Партиционирование нужно для coarse-grained сегментации данных: обычно по месяцу, иногда по дню для observability/time-series. ClickHouse рекомендует не делать слишком гранулярные partition key и в большинстве случаев не использовать partitioning тоньше месяца, кроме сценариев вроде observability, где день — нормальный вариант. Нельзя партиционировать по high-cardinality полям вроде client_id. ([ClickHouse][6])

Вставки: чем крупнее и реже, тем лучше

ClickHouse рекомендует вставлять данные батчами и, для максимальной производительности, использовать Native format. Маленькие частые вставки приводят к разрастанию количества parts и лишней нагрузке на merge-процессы. Если клиентское батчирование неудобно, можно использовать asynchronous inserts: сервер сам буферизует записи и флашит их по порогам. ([ClickHouse][7])

Go-клиенты

Официальная документация выделяет два Go-клиента:

  • clickhouse-go — high-level клиент, поддерживает native API и database/sql;
  • ch-go — low-level native-only клиент для максимального контроля и columnar streaming. ([ClickHouse][3])

Практическое правило:

Сценарий Что брать
Нужен максимальный throughput, batching, нативные фичи ClickHouse clickhouse-go native API
Есть инфраструктурная завязка на database/sql clickhouse-go через OpenDB / sql.Open
Очень горячий ingestion path, columnar streaming, тонкий контроль памяти/CPU ch-go

3. Production-ready примеры

Ниже — реалистичный сценарий: сервис принимает audit events и пишет их в ClickHouse.


3.1. Схема таблицы под audit/event ingestion

CREATE TABLE audit_events
(
    tenant_id       UInt64,
    event_time      DateTime64(3, 'UTC'),
    event_date      Date MATERIALIZED toDate(event_time),
    event_type      LowCardinality(String),
    actor_id        UInt64,
    resource_type   LowCardinality(String),
    resource_id     String,
    request_id      UUID,
    trace_id        String,
    ip              IPv6,
    user_agent      String,
    payload_json    String
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, event_type, actor_id, event_time, request_id)
TTL event_time + INTERVAL 180 DAY
SETTINGS index_granularity = 8192;
Enter fullscreen mode Exit fullscreen mode

Почему так

  • PARTITION BY toYYYYMM(event_date) — нормальная гранулярность для большинства event-данных. ClickHouse рекомендует избегать слишком мелкого partitioning. ([ClickHouse][6])
  • ORDER BY начинается с полей, по которым чаще всего фильтруют: tenant/date/type.
  • event_type и resource_type как LowCardinality(String) — хороший выбор для повторяющихся строковых значений.
  • payload_json оставлен строкой: это осознанный компромисс. Не все поля из payload должны становиться отдельными колонками.
  • TTL позволяет избежать тяжелых ручных удалений для hot storage.

3.2. Подключение из Go через native API

package clickhousex

import (
    "context"
    "crypto/tls"
    "fmt"
    "time"

    clickhouse "github.com/ClickHouse/clickhouse-go/v2"
)

type Config struct {
    Addr     []string
    Database string
    Username string
    Password string
    Secure   bool
}

func NewConn(cfg Config) (clickhouse.Conn, error) {
    opts := &clickhouse.Options{
        Addr: cfg.Addr,
        Auth: clickhouse.Auth{
            Database: cfg.Database,
            Username: cfg.Username,
            Password: cfg.Password,
        },
        DialTimeout: 5 * time.Second,
        MaxOpenConns: 20,
        MaxIdleConns: 10,
        ConnMaxLifetime: 30 * time.Minute,
        Compression: &clickhouse.Compression{
            Method: clickhouse.CompressionLZ4,
        },
        ClientInfo: clickhouse.ClientInfo{
            Products: []struct {
                Name    string
                Version string
            }{
                {Name: "audit-api", Version: "1.0.0"},
            },
        },
    }

    if cfg.Secure {
        opts.TLS = &tls.Config{
            MinVersion: tls.VersionTLS12,
        }
    }

    conn := clickhouse.Open(opts)

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    if err := conn.Ping(ctx); err != nil {
        return nil, fmt.Errorf("clickhouse ping: %w", err)
    }

    return conn, nil
}
Enter fullscreen mode Exit fullscreen mode

Что здесь важно

  • Для ClickHouse в Go лучше сразу выставлять DialTimeout, pool limits и compression.
  • Ping на старте полезен, но не превращайте его в healthcheck на каждый запрос.
  • Native API предпочтителен по производительности и функциональности; database/sql официально считается более медленным вариантом. ([ClickHouse][3])

3.3. Репозиторий с батчевой вставкой

package repository

import (
    "context"
    "encoding/json"
    "fmt"
    "net"
    "time"

    clickhouse "github.com/ClickHouse/clickhouse-go/v2"
)

type AuditEvent struct {
    TenantID     uint64
    EventTime    time.Time
    EventType    string
    ActorID      uint64
    ResourceType string
    ResourceID   string
    RequestID    string
    TraceID      string
    IP           net.IP
    UserAgent    string
    Payload      map[string]any
}

type AuditRepository struct {
    conn clickhouse.Conn
}

func NewAuditRepository(conn clickhouse.Conn) *AuditRepository {
    return &AuditRepository{conn: conn}
}

func (r *AuditRepository) InsertBatch(ctx context.Context, events []AuditEvent) error {
    if len(events) == 0 {
        return nil
    }

    batch, err := r.conn.PrepareBatch(ctx, `
        INSERT INTO audit_events (
            tenant_id,
            event_time,
            event_type,
            actor_id,
            resource_type,
            resource_id,
            request_id,
            trace_id,
            ip,
            user_agent,
            payload_json
        )
    `)
    if err != nil {
        return fmt.Errorf("prepare batch: %w", err)
    }

    for _, e := range events {
        payloadJSON, err := json.Marshal(e.Payload)
        if err != nil {
            return fmt.Errorf("marshal payload: %w", err)
        }

        if err := batch.Append(
            e.TenantID,
            e.EventTime.UTC(),
            e.EventType,
            e.ActorID,
            e.ResourceType,
            e.ResourceID,
            e.RequestID,
            e.TraceID,
            e.IP,
            e.UserAgent,
            string(payloadJSON),
        ); err != nil {
            return fmt.Errorf("append batch row: %w", err)
        }
    }

    if err := batch.Send(); err != nil {
        return fmt.Errorf("send batch: %w", err)
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Почему это production-grade

  • батч отправляется одной логической операцией;
  • сериализация JSON делается один раз на row-level;
  • ctx пробрасывается в драйвер;
  • нет string-concatenation SQL;
  • batching соответствует рекомендациям ClickHouse по insert strategy. ([ClickHouse][8])

3.4. Сервис с буферизацией и flush loop

В реальном API вы редко хотите писать в ClickHouse по одной записи на HTTP request. Обычно нужен буфер.

package ingest

import (
    "context"
    "log"
    "sync"
    "time"

    "example/internal/repository"
)

type AuditWriter struct {
    repo          *repository.AuditRepository
    inputCh       chan repository.AuditEvent
    maxBatchSize  int
    flushInterval time.Duration
    wg            sync.WaitGroup
}

func NewAuditWriter(repo *repository.AuditRepository, maxBatchSize int, flushInterval time.Duration) *AuditWriter {
    return &AuditWriter{
        repo:          repo,
        inputCh:       make(chan repository.AuditEvent, 10000),
        maxBatchSize:  maxBatchSize,
        flushInterval: flushInterval,
    }
}

func (w *AuditWriter) Start(ctx context.Context) {
    w.wg.Add(1)
    go func() {
        defer w.wg.Done()

        ticker := time.NewTicker(w.flushInterval)
        defer ticker.Stop()

        buffer := make([]repository.AuditEvent, 0, w.maxBatchSize)

        flush := func() {
            if len(buffer) == 0 {
                return
            }

            flushCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
            defer cancel()

            if err := w.repo.InsertBatch(flushCtx, buffer); err != nil {
                log.Printf("clickhouse flush failed: %v", err)
                // Здесь можно добавить retry / DLQ / fallback в Kafka.
            }

            buffer = buffer[:0]
        }

        for {
            select {
            case <-ctx.Done():
                flush()
                return

            case ev := <-w.inputCh:
                buffer = append(buffer, ev)
                if len(buffer) >= w.maxBatchSize {
                    flush()
                }

            case <-ticker.C:
                flush()
            }
        }
    }()
}

func (w *AuditWriter) Stop() {
    w.wg.Wait()
}

func (w *AuditWriter) Write(ev repository.AuditEvent) bool {
    select {
    case w.inputCh <- ev:
        return true
    default:
        // Backpressure signal: буфер переполнен.
        return false
    }
}
Enter fullscreen mode Exit fullscreen mode

Архитектурный смысл

Это уже ближе к production ingestion path:

  • есть bounded buffer;
  • есть backpressure;
  • есть controlled flush policy по размеру и времени;
  • ClickHouse не дергается на каждую запись;
  • можно встроить retry, DLQ, Kafka fallback, метрики flush_count, flush_latency, flush_errors.

Если у вас много независимых producer’ов и мелкие события, а клиентское batching неудобно, рассмотрите asynchronous inserts на стороне ClickHouse. Это официальный supported path для high-concurrency ingestion. Но тогда важнее мониторить очереди и delayed visibility данных. ([ClickHouse][9])


3.5. Запросы для аналитики

package queries

import (
    "context"
    "fmt"
    "time"

    clickhouse "github.com/ClickHouse/clickhouse-go/v2"
)

type EventAggRow struct {
    EventType string
    Count     uint64
}

func TopEventTypes(
    ctx context.Context,
    conn clickhouse.Conn,
    tenantID uint64,
    from, to time.Time,
    limit uint64,
) ([]EventAggRow, error) {
    query := `
        SELECT
            event_type,
            count() AS cnt
        FROM audit_events
        WHERE tenant_id = ?
          AND event_time >= ?
          AND event_time < ?
        GROUP BY event_type
        ORDER BY cnt DESC
        LIMIT ?
    `

    rows, err := conn.Query(ctx, query, tenantID, from.UTC(), to.UTC(), limit)
    if err != nil {
        return nil, fmt.Errorf("query top event types: %w", err)
    }
    defer rows.Close()

    result := make([]EventAggRow, 0, 32)
    for rows.Next() {
        var row EventAggRow
        if err := rows.Scan(&row.EventType, &row.Count); err != nil {
            return nil, fmt.Errorf("scan row: %w", err)
        }
        result = append(result, row)
    }

    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("rows err: %w", err)
    }

    return result, nil
}
Enter fullscreen mode Exit fullscreen mode

Best practices для query layer

  • всегда context.Context;
  • всегда ограничивайте временной диапазон;
  • фильтруйте по левому префиксу ORDER BY, если это возможно;
  • избегайте “wide scan by default” API-методов;
  • не делайте SELECT * в публичном query path;
  • декларируйте query contract на уровне репозитория/сервиса.

3.6. Предагрегация через materialized view

Если бизнес постоянно просит “график по часам за 90 дней по tenant/event_type”, не заставляйте ClickHouse считать всё из raw table на каждый запрос. Вынесите это в incremental materialized view: ClickHouse рекомендует materialized views как способ сместить стоимость вычислений на insert time и ускорить SELECT. ([ClickHouse][10])

CREATE TABLE audit_events_hourly
(
    tenant_id    UInt64,
    hour_bucket  DateTime('UTC'),
    event_type   LowCardinality(String),
    total_count  AggregateFunction(sum, UInt64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(hour_bucket)
ORDER BY (tenant_id, hour_bucket, event_type);

CREATE MATERIALIZED VIEW audit_events_hourly_mv
TO audit_events_hourly
AS
SELECT
    tenant_id,
    toStartOfHour(event_time) AS hour_bucket,
    event_type,
    sumState(toUInt64(1)) AS total_count
FROM audit_events
GROUP BY tenant_id, hour_bucket, event_type;
Enter fullscreen mode Exit fullscreen mode

Чтение:

SELECT
    tenant_id,
    hour_bucket,
    event_type,
    sumMerge(total_count) AS total
FROM audit_events_hourly
WHERE tenant_id = 42
  AND hour_bucket >= now() - INTERVAL 7 DAY
GROUP BY tenant_id, hour_bucket, event_type
ORDER BY hour_bucket;
Enter fullscreen mode Exit fullscreen mode

Когда это оправдано

  • repeated heavy aggregates;
  • SLA на latency важнее стоимости insert path;
  • стабильный набор аналитических срезов;
  • high-QPS dashboard/read API.

4. Антипаттерны


Антипаттерн 1. Использовать ClickHouse как OLTP-базу

Плохо

  • писать по одной записи на запрос;
  • часто делать UPDATE status = ...;
  • хранить “последнее состояние” как единственную запись;
  • рассчитывать на row-level transactional semantics как в PostgreSQL.

Почему плохо

ClickHouse оптимизирован под append-heavy аналитические нагрузки. Частые mutations тяжелые и официально не рекомендуются для high-volume таблиц. ([ClickHouse][2])

Лучше

  • append-only события;
  • state reconstruction на чтении;
  • ReplacingMergeTree/CollapsingMergeTree для отдельных кейсов corrections;
  • TTL и rollups вместо массовых update/delete.

Антипаттерн 2. Партиционировать по high-cardinality ключу

Плохо

PARTITION BY tenant_id
Enter fullscreen mode Exit fullscreen mode

или хуже:

PARTITION BY user_id
Enter fullscreen mode Exit fullscreen mode

Почему плохо

Слишком мелкие partitions ведут к explosion числа parts и ухудшают merge behavior. ClickHouse прямо рекомендует не партиционировать по client identifiers/names. ([ClickHouse][6])

Лучше

PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, ...)
Enter fullscreen mode Exit fullscreen mode

Антипаттерн 3. Выбирать ORDER BY “как получится”

Плохо

ORDER BY (request_id)
Enter fullscreen mode Exit fullscreen mode

если основные запросы — по tenant/date/type.

Почему плохо

Primary key и sort order определяют, сколько данных будет прочитано. Неправильный порядок ломает pruning и компрессию. ([ClickHouse][5])

Лучше

Проектировать ORDER BY от реальных read patterns, а не от формальной уникальности.


Антипаттерн 4. Мелкие inserts из Go без batching

Плохо

for _, ev := range events {
    _, _ = conn.Exec(ctx, "INSERT INTO audit_events ...", ...)
}
Enter fullscreen mode Exit fullscreen mode

Почему плохо

  • много network round-trips;
  • рост числа parts;
  • лишняя нагрузка на merges;
  • throughput быстро деградирует. ([ClickHouse][8])

Лучше

  • PrepareBatch и Send;
  • буфер на приложении;
  • или async_insert там, где batching на клиенте неудобен. ([ClickHouse][9])

Антипаттерн 5. database/sql по умолчанию для hot path

Плохо

Брать database/sql только потому, что “так привычнее”.

Почему плохо

Официальная документация прямо говорит: native ClickHouse API — лучший выбор по производительности и доступу к фичам; database/sql нужен в первую очередь для совместимости. ([ClickHouse][3])

Лучше

  • ingestion/query-heavy код — native API;
  • database/sql — только если есть реальная интеграционная причина.

Антипаттерн 6. Хранить всё как String или всё как JSON

Плохо

payload String
tenant_id String
actor_id String
event_time String
Enter fullscreen mode Exit fullscreen mode

Почему плохо

Вы теряете компрессию, predicate pushdown, дешевые агрегации и типовую оптимизацию. ClickHouse best practices отдельно акцентируют важность выбора data types. ([ClickHouse][1])

Лучше

  • типизировать hot-path поля;
  • JSON оставлять только для редко используемой или вариативной части payload.

5. Хороший и плохой подход: краткое сравнение

Задача Плохой подход Хороший подход
Ingestion INSERT по одной записи batching через PrepareBatch, либо async inserts
Модель данных нормализованная OLTP-схема с частыми update append-only events + rollups/materialized views
Partitioning по tenant_id / user_id по месяцу или дню для observability
Sorting key случайный surrogate key ORDER BY от реальных фильтров и диапазонов
Драйвер database/sql “потому что стандарт” native API для hot path
Агрегации считать из raw table на каждый запрос incremental materialized views / pre-aggregation
Удаление старых данных массовые DELETE TTL policies
Retention вручную кодом схема и lifecycle на стороне ClickHouse

6. Trade-offs: когда ClickHouse брать, а когда нет

Плюсы

  • очень высокая скорость аналитических запросов на больших объемах;
  • эффективное колоночное хранение и компрессия;
  • сильный ingestion throughput при правильной стратегии вставки;
  • удобен для time-series, событий и предагрегаций;
  • materialized views позволяют переносить вычисления на этап записи. ([ClickHouse][7])

Минусы

  • не замена OLTP-БД;
  • schema design критичен: ошибки в ORDER BY и partitioning дорого стоят;
  • updates/deletes тяжелые;
  • query model требует дисциплины;
  • часть команды будет по инерции применять неправильные паттерны из PostgreSQL/MySQL. ([ClickHouse][2])

Когда лучше отказаться

Не стоит выбирать ClickHouse как primary store, если у вас:

  • transactional workflow с сильной консистентностью и частыми row updates;
  • много point lookups по PK и почти нет аналитики;
  • критична OLTP-семантика, foreign keys и rich transactional behavior;
  • объем данных маленький и PostgreSQL закрывает задачу проще.

Практичное правило:

  • PostgreSQL — source of truth, transactional state;
  • ClickHouse — аналитический read store, event lake, observability backend, BI/aggregation layer.

7. TL;DR

  • ClickHouse в Go — это не просто “еще одна SQL-база”, а отдельная архитектурная модель: append-heavy ingestion + быстрые аналитические чтения.
  • Производительность определяется прежде всего схемой: ORDER BY, PARTITION BY, типами колонок и insert strategy. ([ClickHouse][1])
  • Для Go hot path обычно берите clickhouse-go native API, а не database/sql. ([ClickHouse][3])
  • Делайте batch inserts. Маленькие одиночные вставки — один из самых частых и дорогих антипаттернов. Для high-concurrency workloads рассмотрите asynchronous inserts. ([ClickHouse][8])
  • Не пытайтесь строить OLTP-паттерны поверх ClickHouse: частые UPDATE/DELETE здесь дорогие. ([ClickHouse][2])
  • Партиционирование — coarse-grained механизм. Обычно месяц, иногда день для observability. Не partition by tenant/user. ([ClickHouse][6])
  • Для повторяемых тяжелых агрегатов используйте materialized views и pre-aggregation. ([ClickHouse][10])

Source: dev.to

arrow_back Back to Tutorials