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-разработчика это важно по двум причинам:
- ClickHouse — не “PostgreSQL для аналитики”. Многие привычки из OLTP здесь вредны: частые
UPDATE, мелкиеINSERT, избыточныеJOIN, попытка строить нормализованную модель как в transactional DB. ClickHouse сам рекомендует избегать частых и крупных mutation-операций и проектировать append-only потоки данных там, где это возможно. ([ClickHouse][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;
Почему так
-
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
}
Что здесь важно
- Для 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
}
Почему это 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
}
}
Архитектурный смысл
Это уже ближе к 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
}
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;
Чтение:
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;
Когда это оправдано
- 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
или хуже:
PARTITION BY user_id
Почему плохо
Слишком мелкие partitions ведут к explosion числа parts и ухудшают merge behavior. ClickHouse прямо рекомендует не партиционировать по client identifiers/names. ([ClickHouse][6])
Лучше
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_date, ...)
Антипаттерн 3. Выбирать ORDER BY “как получится”
Плохо
ORDER BY (request_id)
если основные запросы — по 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 ...", ...)
}
Почему плохо
- много 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
Почему плохо
Вы теряете компрессию, 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-gonative 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])