ClickHouse из Go и Java: MergeTree, вставки и запросы

Как устроено семейство MergeTree (ORDER BY, PRIMARY KEY, партиционирование), почему в ClickHouse нельзя вставлять по одной строке и что дают async inserts, и как всё это выглядит из клиентов clickhouse-go и clickhouse-java — нативный протокол против HTTP

Первое, обо что спотыкаются на ClickHouse из приложения, — вставка. Код, который отлично работал с PostgreSQL (INSERT на каждое событие), на ClickHouse превращается в лавину крошечных partов и деградацию фоновых слияний. Причина — не в клиентской библиотеке, а в самом устройстве MergeTree: движок любит большие батчи и разреженный индекс, а не построчную работу. Разберём, как таблица устроена внутри — гранулы, первичный индекс, партиции — и как с ней правильно говорить из Go и Java.

Это вторая статья серии «ClickHouse и аналитические БД» (первая — «Когда нужен OLAP», где разобрано, что тот же построчный анти-паттерн — точечный UPDATE/DELETE — обходится ClickHouse в сотни раз дороже, чем PostgreSQL). Дальше в серии — сравнение драйверов с полноценным бенчмарком и эксплуатация MergeTree: parts, фоновые merges, mutations.

Все числа ниже — из живых стендов clickhouse/go/mergetree и clickhouse/java/mergetree в публичном репозитории digital-cookbook: один и тот же детерминированный датасет, одна и та же DDL, вставка тем же батчем и построчно, запрос с фильтром и без.

MergeTree: разреженный первичный индекс и пропуск гранул, батчевая вставка против построчного анти-паттерна

В статье

Семейство MergeTree

MergeTree — не одна таблица, а семейство движков хранения, у которых общий фундамент: данные пишутся в отдельные неизменяемые куски (parts), а фоновый процесс периодически сливает их в более крупные (merge). Каждая вставка (batch-INSERT) создаёт новый part; после слияния старые части удаляются, а данные физически лежат в новых, более крупных. Именно этот механизм и определяет всё остальное поведение движка — от того, почему вставка по одной строке плоха, до того, почему точечный UPDATE дорог (об этом — в статье про эксплуатацию, где разобраны system.merges и system.mutations вживую).

Базовый MergeTree просто хранит данные отсортированными по ключу. Остальные варианты семейства добавляют поведение поверх той же механики parts/merge:

  • ReplacingMergeTree — при слиянии оставляет одну строку из дубликатов с одинаковым ключом сортировки (дедупликация происходит асинхронно, во время merge, а не при вставке — если запрос выполнить до слияния, дубликаты ещё видны);
  • SummingMergeTree / AggregatingMergeTree — при слиянии схлопывают строки с одинаковым ключом, суммируя или агрегируя числовые колонки; это движок под материализованные представления с инкрементальной агрегацией, детально — в статье про материализованные представления;
  • CollapsingMergeTree — пара строк с sign=+1/-1 схлопывается в ноль при merge, способ выразить «изменение» через append-only лог;
  • ReplicatedMergeTree — любой из вариантов выше плюс репликация через ClickHouse Keeper; кластерная топология и координация — тема отдельной статьи про шардирование и репликацию.

В этой статье вся демонстрация — на обычном MergeTree: variants семейства меняют, что происходит при merge, но не меняют физику вставки и первичного индекса, а именно она отвечает на вопрос «почему одна архитектура вставки работает, а другая — нет».

ORDER BY, PRIMARY KEY и разреженный индекс

DDL, на которой построены все числа ниже — общая для Go- и Java-стендов:

CREATE TABLE demo.mergetree_events
(
    event_time  DateTime,
    user_id     UInt64,
    event_type  LowCardinality(String),
    url         String,
    duration_ms UInt32,
    country     LowCardinality(String),
    revenue     Decimal(10,2)
)
ENGINE = MergeTree
ORDER BY (event_time, user_id)
PARTITION BY toYYYYMM(event_time)
SETTINGS index_granularity = 8192

ORDER BY (event_time, user_id) — не подсказка планировщику, а физика хранения: строки внутри каждого part реально лежат на диске отсортированными по этому кортежу. Если PRIMARY KEY не указан отдельно, он берётся как есть из ORDER BY (может быть и более коротким префиксом — так экономят память под сам индекс, если для пропуска гранул достаточно не всех колонок сортировки).

Первичный индекс MergeTree — разреженный, и в этом принципиальное отличие от B-tree в PostgreSQL, где запись есть почти на каждую строку (или диапазон близких значений). ClickHouse делит отсортированные данные на гранулы — блоки по index_granularity строк (по умолчанию и в этом стенде — 8192), и хранит в индексе только по одной засечке (mark) на гранулу: значение ORDER BY в её первой строке. Индекс на 5 000 000 строк при таких настройках — это не пять миллионов записей, а несколько сотен: по одной на каждые 8192 строки.

Дальше при выполнении запроса с условием на event_time (первую колонку ORDER BY) ClickHouse делает бинарный поиск по этим засечкам, находит диапазон гранул, которые точно могут содержать нужные строки, и читает с диска только их — остальные гранулы физически не трогает. Это называется пропуском гранул (granule skipping), и вот как это выглядит на живых данных:

flowchart TB subgraph DATA["Part на диске: строки отсортированы по ORDER BY (event_time, user_id)"] direction LR G1["гранула 1
8192 строки
event_time с 00:00"] G2["гранула 2
8192 строки"] G3["...
гранула N-1"] G20["гранула 20
15 июня, 00:00"] G21["гранула 21
15 июня, продолжение"] G39["гранула 39
15 июня, конец суток"] G40["гранула 40
16 июня"] G204["...
гранула 204"] end IDX["Первичный индекс:
засечка (mark) на каждую гранулу —
значение event_time первой строки гранулы"] IDX -.засечка.-> G1 IDX -.засечка.-> G2 IDX -.засечка.-> G20 IDX -.засечка.-> G21 IDX -.засечка.-> G40 IDX -.засечка.-> G204 Q["WHERE event_time >= '2026-06-15 00:00'
AND event_time < '2026-06-16 00:00'"] Q -->|бинарный поиск по засечкам| IDX IDX -->|попадают в диапазон дат| READ["читает: гранулы 20..39
(20 гранул × 8192 = 163840 строк)"] IDX -->|вне диапазона, физически не читаются| SKIP["пропускает: гранулы 1..19, 40..204
(184 гранулы из 204)"] style DATA fill:#f9f3e3,stroke:#8b7355 style IDX fill:#f4d9c6,stroke:#c67a4a style READ fill:#c9e4c5,stroke:#5b8a5e style SKIP fill:#e8e2d5,stroke:#8b7355,stroke-dasharray: 4 3

flowchart TB
  subgraph DATA["Part на диске: строки отсортированы по ORDER BY (event_time, user_id)"]
    direction LR
    G1["гранула 1
8192 строки
event_time с 00:00"] G2["гранула 2
8192 строки"] G3["...
гранула N-1"] G20["гранула 20
15 июня, 00:00"] G21["гранула 21
15 июня, продолжение"] G39["гранула 39
15 июня, конец суток"] G40["гранула 40
16 июня"] G204["...
гранула 204"] end IDX["Первичный индекс:
засечка (mark) на каждую гранулу —
значение event_time первой строки гранулы"] IDX -.засечка.-> G1 IDX -.засечка.-> G2 IDX -.засечка.-> G20 IDX -.засечка.-> G21 IDX -.засечка.-> G40 IDX -.засечка.-> G204 Q["WHERE event_time >= '2026-06-15 00:00'
AND event_time < '2026-06-16 00:00'"] Q -->|бинарный поиск по засечкам| IDX IDX -->|попадают в диапазон дат| READ["читает: гранулы 20..39
(20 гранул × 8192 = 163840 строк)"] IDX -->|вне диапазона, физически не читаются| SKIP["пропускает: гранулы 1..19, 40..204
(184 гранулы из 204)"] style DATA fill:#f9f3e3,stroke:#8b7355 style IDX fill:#f4d9c6,stroke:#c67a4a style READ fill:#c9e4c5,stroke:#5b8a5e style SKIP fill:#e8e2d5,stroke:#8b7355,stroke-dasharray: 4 3
Разреженный первичный индекс: засечка на гранулу, бинарный поиск по засечкам определяет, какие гранулы читать, а какие пропустить

Датасет стенда — 5 000 000 строк, 90-дневное окно event_time. Запрос с фильтром на одни сутки внутри этого окна против запроса без фильтра, оба через EXPLAIN indexes = 1 и реальный read_rows из system.query_log (а не просто время выполнения — важно, сколько ClickHouse физически прочитал с диска):

-- запрос с фильтром на одни сутки внутри 90-дневного окна
SELECT count(), sum(revenue)
FROM demo.mergetree_events
WHERE event_time >= '2026-06-15 00:00:00' AND event_time < '2026-06-16 00:00:00'

-- контрольный запрос без фильтра — полный скан
SELECT count(), sum(revenue)
FROM demo.mergetree_events

EXPLAIN indexes=1 на первом запросе показывает Parts: 8/8, Granules: 20/204 — из восьми затронутых parts бинарный поиск по индексу отобрал 20 гранул из 204 в них. Это ровно соответствует прочитанным строкам: read_rows = 163840, то есть 20 × 8192, — при том что фильтр отобрал count() = 55297 строк реального результата, а всего в таблице 5 000 000. Прочитано 163840 из 5 000 000 строк (3,28%) — остальные 96,72% физически не покидали диск, потому что их гранулы гарантированно не попадают в диапазон дат. Контрольный запрос без WHERE читает все 5 000 000 строк — read_rows = 100%, пропускать нечего, засечки указывают только начало и конец диапазона.

Ключевой практический вывод: пропуск гранул работает только по колонкам из ORDER BY, начиная с первой (или по их префиксу) — так же, как составной B-tree индекс в PostgreSQL бесполезен для фильтра по второй колонке без первой. Фильтр по user_id (второй колонке ORDER BY) без фильтра по event_time пропуска почти не даст: строки с конкретным user_id рассеяны по всем гранулам, потому что физический порядок задаёт event_time. Выбор ORDER BY — это не техническая деталь, а решение о том, какие запросы будут дешёвыми, а какие — нет; для точечного поиска по произвольной колонке, не входящей в этот префикс, MergeTree — не тот инструмент (это же в статье «Когда нужен OLAP» показано на PK-запросе, который читает 12,5% таблицы вместо Index Scan).

Кроме первичного индекса ClickHouse умеет строить skip-индексы (minmax, set, bloom_filter и другие) на произвольных колонках вне ORDER BY — они не заменяют разреженный первичный индекс, а дополнительно отсекают гранулы по условиям на других колонках; в этой статье не разбираются, но стоит держать в уме как следующий шаг оптимизации, если фильтр регулярно идёт не по префиксу ORDER BY.

Партиционирование

PARTITION BY toYYYYMM(event_time) делит таблицу на независимые наборы parts по месяцу события — партиции физически изолированы друг от друга: у каждой свой набор parts на диске, слияния идут только внутри партиции, part никогда не объединяется с part соседнего месяца. Это важно не путать с шардированием — партиционирование работает внутри одного сервера и одной таблицы, а шардирование распределяет данные между серверами (шардирование и Distributed-таблицы — тема статьи про распределённый ClickHouse).

Партиционирование даёт две вещи. Во-первых, отсечение партиций (partition pruning) — если запрос фильтрует по event_time в границах одного-двух месяцев, ClickHouse даже не открывает parts остальных партиций, это происходит на уровне выше гранул, ещё до бинарного поиска по индексу внутри part. Во-вторых, дешёвый DROP PARTITION для ретеншна: удаление данных за конкретный месяц — это удаление файлов партиции целиком, без построчного DELETE и без мутации, которая переписывает parts (мутации и их реальная цена — тема статьи про эксплуатацию).

Обратная сторона: партиционирование по слишком высококардинальному ключу (например, по дню вместо месяца на данных с низким темпом роста, или тем более по user_id) даёт множество мелких партиций, в каждой из которых — свой независимый набор parts. Слияния идут внутри партиции, поэтому мелкие партиции означают больше мелких parts суммарно по таблице — тот же эффект «too many parts», что и от построчной вставки, только на уровне партиционирования, а не вставки. Практическое правило: партиционировать по времени с шагом, при котором в партиции за разумный интервал (день/неделя загрузки) набирается не единицы parts, а сотни тысяч — миллионы строк.

Почему нельзя вставлять по одной строке

Каждый INSERT, независимо от того, сколько строк он содержит, создаёт минимум один part (на затронутую партицию). Тысяча отдельных INSERT с одной строкой — это тысяча отдельных parts, и фоновым merge приходится сливать их постепенно, тратя I/O и CPU на то, что можно было не создавать вовсе. При достаточном потоке одиночных вставок число активных parts растёт быстрее, чем фон успевает их сливать, — сервер начинает притормаживать вставки (too many parts в логе) как защиту от деградации чтений.

Стенд воспроизводит это в чистом виде — apples-to-apples сравнение на одинаковом N=2000 строк, фоновые слияния специально остановлены (SYSTEM STOP MERGES) на время построчной вставки, чтобы зафиксировать состояние parts до того, как фон его сгладит:

// rowByRowLoad — отдельный INSERT ... VALUES на каждую строку, синхронно.
// Каждый такой INSERT в MergeTree создаёт свой part.
insertSQL := fmt.Sprintf("INSERT INTO demo.%s %s VALUES (?, ?, ?, ?, ?, ?, ?)", table, insertColumns)
for {
    rw, ok := cr.next()
    if !ok {
        break
    }
    if err := conn.Exec(ctx, insertSQL, rw.eventTime, rw.userID, rw.eventType,
        rw.url, rw.durationMs, rw.country, rw.revenue); err != nil {
        return rows, time.Since(start), err
    }
    rows++
}

// batchLoad — один PrepareBatch на batchSize строк, один Send.
insertSQL = fmt.Sprintf("INSERT INTO demo.%s %s", table, insertColumns)
batch, _ := conn.PrepareBatch(ctx, insertSQL)
for /* строки чанка */ {
    batch.Append(rw.eventTime, rw.userID, rw.eventType, rw.url, rw.durationMs, rw.country, rw.revenue)
}
batch.Send()

Результат на N=2000:

  • построчно: 2000 строк за 2m1.052132363s (16,5 rows/s), 2000 активных parts — по одному на вставку, ровно как и ожидается;
  • батч (один PrepareBatch/Send на все 2000 строк): 2000 строк за 12,46602 мс (160436,1 rows/s), 3 активных parts — по одному на затронутую партицию (toYYYYMM(event_time)), не на строку и не на вставку.

Батч быстрее построчной вставки того же объёма в 9710,6 раза. Разница в parts (2000 против 3) — не побочный эффект, а первопричина разницы в скорости: построчная вставка тратит время на создание, фиксацию и последующее слияние двух тысяч отдельных кусков данных, батч создаёт три part и на этом заканчивает работу. Практический вывод простой и без нюансов: приложение должно копить строки на своей стороне (в памяти, в очереди) и отправлять их в ClickHouse пачками по тысячи–десятки тысяч строк за один вызов, а не по одной на событие.

Async inserts: буферизация на сервере

Батчинг на стороне приложения — не всегда возможен. Если события в таблицу шлют независимо десятки или сотни клиентов (IoT-устройства, множество мелких сервисов, edge-агенты), собрать их в общий батч до отправки в ClickHouse — отдельная инфраструктурная задача. Для этого случая у ClickHouse есть async inserts (async_insert = 1): сервер сам принимает мелкие вставки и буферизует их в памяти, а на диск сбрасывает уже накопленный буфер одним куском — по достижении async_insert_max_data_size либо по истечении async_insert_busy_timeout_ms, что наступит раньше.

settings := clickhouse.Settings{
    "async_insert":                 1,
    "wait_for_async_insert":        wait, // 1 — ждать флаша, 0 — не ждать
    "async_insert_busy_timeout_ms": busyTimeoutMs,
}
sql := syntheticInsertSQL(table)

for i := 0; i < total; i++ {
    go func(userID int) {
        qctx := clickhouse.Context(ctx, clickhouse.WithSettings(settings))
        conn.Exec(qctx, sql, uint64(userID))
    }(i)
}

На стенде — 2000 конкурентных (50 горутин) однострочных INSERT с async_insert=1, wait_for_async_insert=1, async_insert_busy_timeout_ms=200 заняли 47,07088914 с и создали 4 активных part — вместо потенциальных 2000, если бы каждая вставка попадала в таблицу напрямую. Сервер сам скоалесцировал сотни независимых конкурентных вставок в считаные куски данных.

wait_for_async_insert — это компромисс подтверждения против задержки, а не косметическая настройка. При wait_for_async_insert=1 клиент получает ответ только после того, как буфер реально сброшен на диск — вставка гарантированно видна следующему запросу. При wait_for_async_insert=0 сервер подтверждает приём в буфер немедленно, не дожидаясь флаша, — и это создаёт окно частичной видимости: на стенде 500 последовательных INSERT с wait_for_async_insert=0 и async_insert_busy_timeout_ms=5000 заняли 322,7131 мс, но сразу после отправки в таблице видно только 80 из 500 строк — часть уже попала в предыдущий флаш, часть ещё лежит в серверном буфере. Только после ожидания 6,5 с (больше, чем busy_timeout_ms=5000) в таблице видно все 500 из 500 — это eventual consistency в буквальном виде: данные подтверждены, но не сразу видимы.

Async inserts — не замена батчингу на клиенте, а инструмент для случая, когда батчинг на клиенте невозможен или неудобен; там, где приложение и так может копить батч само (как в примере выше с построчной вставкой), батч дешевле и предсказуемее по задержке видимости.

Go: clickhouse-go v2

clickhouse-go v2 говорит с сервером по нативному бинарному протоколу ClickHouse — тому же, что использует clickhouse-client. Формат колоночный уже на уровне протокола: сервер отдаёт и принимает данные блоками по колонкам, а не построчными пакетами, как в HTTP/JDBC. Именно поэтому для загрузки больших объёмов, показанной выше, использовался этот путь — PrepareBatch собирает батч в памяти клиента, Append добавляет строки, Send отправляет их одним запросом:

ch, err := clickhouse.Open(&clickhouse.Options{
    Addr:        []string{chAddr}, // native-порт 9000, не HTTP 8123
    Auth:        clickhouse.Auth{Database: "demo", Username: "default"},
    DialTimeout: 10 * time.Second,
})

rows, dur, err := batchLoad(ctx, ch, "mergetree_events", csvPath, /*batchSize=*/ 100_000, /*limit=*/ 0)
// [load] mergetree_events: 5000000 rows in 10.45s (478592 rows/s),
// batch=100000, 24 active parts сразу после загрузки

5 000 000 строк батчами по 100 000 загрузились за 10,45 с — 478 592 rows/s, с 24 активными parts сразу после загрузки (batchLoad шлёт 100k строк за вызов, но затрагивает несколько партиций по мере продвижения event_time через 90-дневное окно, отсюда parts больше, чем число вызовов Send). Для чтения тот же коннектор отдаёт обычный QueryRow/Query с маппингом в Go-типы; в примере с гранулами выше runWithReadRows берёт агрегат через QueryRow и отдельно читает read_rows из system.query_log — обычный SQL-путь, ничего специфичного для батчинга.

Java: JDBC против client-v2

У ClickHouse для Java два принципиально разных клиентских пути, и разница между ними — не про удобство API, а про транспорт. clickhouse-jdbc даёт стандартный java.sql.Connection/PreparedStatement и вписывается в любой JDBC-based стек (HikariCP, ORM), но под капотом всё равно ходит через HTTP-интерфейс построчно, добавляя протокольные накладные расходы на каждую строку батча. client-v2 (com.clickhouse.client.api.Client) — низкоуровневый клиент без JDBC-обёртки: он умеет отправлять данные целым потоком в одном HTTP-запросе, без построчного протокола поверх соединения.

// JDBC: построчный addBatch, executeBatch отправляет накопленный батч
String sql = "INSERT INTO demo." + table
        + " (event_time, user_id, event_type, url, duration_ms, country, revenue) VALUES (?, ?, ?, ?, ?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    for (CsvRow r : rows) {
        ps.setTimestamp(1, Timestamp.valueOf(r.eventTime()));
        ps.setLong(2, r.userId());
        ps.setString(3, r.eventType());
        ps.setString(4, r.url());
        ps.setInt(5, r.durationMs());
        ps.setString(6, r.country());
        ps.setBigDecimal(7, new BigDecimal(r.revenue()));
        ps.addBatch();
    }
    ps.executeBatch();
}

// client-v2: весь CSV одним потоком в одном insert()-вызове
try (Client client = new Client.Builder()
        .addEndpoint(httpUrl).setUsername("default").setPassword("").setDefaultDatabase("demo")
        .build()) {
    try (InputStream in = new ByteArrayInputStream(csvBytes);
         InsertResponse resp = client.insert(table, in, ClickHouseFormat.CSVWithNames, new InsertSettings()).get()) {
        // resp.getWrittenRows()
    }
}

На 100 000 строк (зеркало Go-батча, тот же датасет):

  • JDBC (addBatch/executeBatch): 100 000 строк за 0,788 с — 126 827 rows/s, 3 активных parts;
  • client-v2 (CSV-поток, один вызов insert): 100 000 строк за 0,139 с — 717 644 rows/s, тоже 3 активных parts.

client-v2 быстрее JDBC в этом сценарии примерно в 5,7 раза (717 644 против 126 827 rows/s) на одном и том же объёме и результате — то же соотношение по сути, что батч против построчной вставки в Go-стенде: построчный протокол (в данном случае JDBC поверх HTTP, где addBatch собирает батч на клиенте, но сериализация внутри драйвера всё равно построчная) дороже потокового. Число parts у обоих путей одинаковое (3) — оба в итоге делают один эффективный batch-insert на сервере, разница целиком в накладных расходах клиентского протокола, а не в том, что происходит на стороне ClickHouse. Гранульный сценарий на этом же датасете (окно фильтра расширено до 7 суток — против одних суток в Go-стенде; таблица меньше по строкам, поэтому одна гранула на 8192 строки покрывает более широкий интервал времени, при том же 90-дневном окне генерации данных) даёт тот же паттерн пропуска, что и в Go: count() = 7970, read_rows = 16384 из 100 000 (16,38%) — EXPLAIN indexes=1 отбирает всего 2 гранулы по 8192 строки.

Ниже — сводка обоих сравнений, построчная вставка против батча и JDBC против client-v2, на одной шкале порядков:

Throughput вставки (лог. шкала, rows/s), характерный прогонGo: построчная vs батч (N=2000)1010²10³10⁴10⁵16,5построчно160436батчбатч быстрее в 9710,6×Java: JDBC vs client-v2 (100000 строк)10²10³10⁴10⁵10⁶126827JDBC717644client-v2client-v2 быстрее в 5,7×

Важная оговорка про эти числа: и абсолютные значения, и кратности здесь host-зависимы — это результат одного характерного прогона, а не константа технологий. Повторный прогон того же стенда на другой машине дал батч быстрее построчной вставки примерно в 1810× (вместо 9710,6×) и client-v2 быстрее JDBC примерно в 3,65× (вместо 5,7×): направление и порядок вывода те же, множители заметно другие. Отношение плывёт, потому что стороны сравнения упираются в разные ресурсы и масштабируются по-разному — число ядер, скорость диска, версии сервера и драйверов, фоновая нагрузка. Переносить в свои оценки стоит паттерн, а не цифру: построчная вставка в MergeTree проигрывает батчу на порядки, а построчный клиентский протокол проигрывает потоковому в разы — и оба разрыва достаточно велики, чтобы решение не зависело от того, получится у вас 5× или 3,65×.

Оба контраста — не про «какая-то из технологий медленная», а про один и тот же принцип на разных уровнях: чем меньше протокольных накладных расходов на строку и чем крупнее единица передачи данных серверу, тем ближе throughput к физическому пределу диска и сети. Для JDBC-стека, где client-v2 неудобен архитектурно (нужна интеграция с пулом соединений, ORM, транзакционным кодом вокруг JDBC), у clickhouse-jdbc в 0.9.0 всё ещё остаётся вариант — минимизировать число executeBatch() за счёт увеличения размера батча, а не отказ от JDBC целиком; подробное сравнение всех доступных путей — нативных клиентов, database/sql-обёрток, HTTP и JDBC на одном датасете — в следующей статье про сравнение драйверов.

Версии в стендах: ClickHouse 26.6.1.1193, clickhouse-go v2.47.0, clickhouse-jdbc/client-v2 0.9.0.

Обсуждение в Telegram

Присоединиться →

Комментарии