ClickHouse-драйверы: когда какой (ch-go, clickhouse-go, JDBC, HTTP)

Драйвер ClickHouse — половина производительности: низкоуровневый колоночный ch-go против эргономичного clickhouse-go (native и database/sql), clickhouse-java client-v2 против clickhouse-jdbc, и HTTP-интерфейс. Живой бенчмарк вставки и запроса, когда какой оправдан, карта выбора

Третья статья серии «ClickHouse и аналитические БД» (предыдущая — «MergeTree из Go и Java», где решён вопрос «как вставлять» — батчами, а не по одной строке). Здесь вопрос другой: каким именно клиентом. У ClickHouse для Go два официальных драйвера с разной философией — низкоуровневый колоночный ch-go и эргономичный clickhouse-go (native batch API и database/sql), для Java — client-v2 (native-клиент нового поколения) против clickhouse-jdbc, и поверх всего этого — сквозной HTTP-интерфейс, для которого вообще не нужна специализированная библиотека. Разница между путями — не про удобство API, а про то, сколько протокольных накладных расходов ложится на каждую вставленную строку и куда утекает эта разница в throughput.

Все числа ниже — из живого стенда clickhouse/drivers в публичном репозитории digital-cookbook: единый сценарий на 1 000 000 строк одного и того же CSV, каждый из шести драйверов (четыре в Go, два в Java) вставляет свою копию датасета в отдельную таблицу — чтобы драйверы не соревновались за один и тот же набор parts, — а затем один и тот же аналитический SELECT выполняется по всем шести таблицам. Результат сверяется не «на глаз», а побайтово: контрольной суммой CRC32 с одного административного соединения над всеми таблицами разом.

Один нюанс размечен заранее, потому что он важен для честного чтения бенчмарка: у HTTP-драйвера в сценарии стенда была другая единица передачи данных, чем у остальных пяти, — об этом подробно ниже, в разделе про бенчмарк вставки.

Бенчмарк драйверов ClickHouse: ch-go, clickhouse-go, JDBC, HTTP — разный throughput, байт-идентичный результат

В статье

ch-go: минимальный слой поверх нативного протокола

ch-go — официальный низкоуровневый Go-клиент ClickHouse, тот самый пакет, поверх которого фактически построен clickhouse-go/v2. У него нет database/sql-обёртки, нет автоматического маппинга Go-структур в колонки — каждая колонка результирующей таблицы заполняется вручную через proto.Col*-типы, а батч отправляется одним вызовом client.Do с явным proto.Input:

var (
    eventTime  proto.ColDateTime
    userID     proto.ColUInt64
    eventType  = new(proto.ColStr).LowCardinality()
    urlCol     proto.ColStr
    durationMs proto.ColUInt32
    country    = new(proto.ColStr).LowCardinality()
    revenue    proto.ColDecimal64
)
for n < batchSize {
    rw, ok := cr.next()
    eventTime.Append(rw.eventTime)
    userID.Append(rw.userID)
    eventType.Append(rw.eventType)
    urlCol.Append(rw.url)
    durationMs.Append(rw.durationMs)
    country.Append(rw.country)
    revenue.Append(proto.Decimal64(rw.revenueCents)) // Decimal(10,2) как целые "центы"
    n++
}
input := proto.Input{
    {Name: "event_time", Data: &eventTime},
    {Name: "user_id", Data: &userID},
    {Name: "event_type", Data: eventType},
    {Name: "url", Data: &urlCol},
    {Name: "duration_ms", Data: &durationMs},
    {Name: "country", Data: country},
    {Name: "revenue", Data: proto.Alias(&revenue, "Decimal(10, 2)")},
}
client.Do(ctx, ch.Query{Body: insertSQL, Input: input})

Две детали здесь неслучайны. Во-первых, Decimal(10,2) на сервере физически хранится как Decimal64 — сырое значение колонки это целое число «центов» (revenue * 10^scale), без плавающей точки; клиент обязан закодировать это сам, а не переложить на драйвер. Во-вторых, LowCardinality(String) требует явной обёртки .LowCardinality() — без неё колонка не соберётся в нужный протокольный формат. Это и есть цена «тонкого» клиента: всё, что более высокоуровневые драйверы делают за вас (маппинг типов, обёртки), здесь пишется руками — и если промахнуться, ошибка вылезает не на этапе компиляции, а на этапе декодирования ответа сервера.

Живая находка стенда — ровно про это: SELECT через ch-go падал при чтении GROUP BY по колонке LowCardinality(String), потому что тип не «снимается» автоматически через toString() — ClickHouse возвращает результат агрегата по LowCardinality-ключу в той же обёртке, даже если клиент её не запрашивал явно. Исправление — декодировать колонку результата как LowCardinality(String) напрямую, а не как обычный ColStr. Урок для ручного пути: типовая модель протокола ClickHouse просачивается в клиентский код чуть глубже, чем ожидаешь, и любое расхождение — забота вызывающей стороны, а не библиотеки.

Ценой этой ручной работы ch-go получает минимальный оверхед на строку среди всех Go-клиентов серии — что и подтверждает бенчмарк ниже.

clickhouse-go v2: native API против database/sql

clickhouse-go/v2 — тот же нативный бинарный протокол (порт 9000), что и у ch-go, но с высокоуровневым интерфейсом сверху. У него два пути обращения к серверу, и они не равнозначны по накладным расходам.

Native batch APIPrepareBatch/Append/Send, тот же приём, что уже разбирался в статье про MergeTree: собрать батч в памяти клиента и отправить одним вызовом.

batch, err := conn.PrepareBatch(ctx, insertSQL)
for {
    rw, ok := cr.next()
    if !ok {
        break
    }
    batch.Append(rw.eventTime, rw.userID, rw.eventType, rw.url, rw.durationMs, rw.country, rw.revenue)
    inBatch++
    if inBatch >= batchSize {
        batch.Send()
        batch, err = conn.PrepareBatch(ctx, insertSQL) // новый батч на следующий чанк
        inBatch = 0
    }
}

database/sql — тот же коннектор clickhouse-go/v2 регистрирует себя как обычный драйвер database/sql, поэтому вся вставка проходит через стандартный sql.DB. Батч здесь реализуется не отдельным API, а транзакцией: BeginPrepare → N раз Exec (строки копятся в клиентском буфере драйвера) → Commit, который отправляет один физический INSERT-блок на сервер, а не N отдельных round-trip’ов:

tx, _ := db.BeginTx(ctx, nil)
stmt, _ := tx.PrepareContext(ctx, insertSQL)
n := 0
for n < batchSize {
    rw, ok := cr.next()
    if !ok {
        break
    }
    stmt.ExecContext(ctx, rw.eventTime, rw.userID, rw.eventType, rw.url, rw.durationMs, rw.country, rw.revenue)
    n++
}
stmt.Close()
tx.Commit() // один физический INSERT-блок на сервер, а не N round-trip'ов

Разница в throughput между двумя путями на живом прогоне (см. раздел про бенчмарк ниже) — около 6-7% в пользу native API, а не разы: database/sql не притворяется, будто он бесплатный, но и не платит по-крупному. Это делает его разумным выбором там, где важнее вписаться в существующий стек — пул соединений sql.DB, инструментацию через database/sql-совместимые обвязки, миграции, sqlx поверх того же интерфейса, — чем выжать последние проценты пропускной способности. Пул соединений здесь — та же идея, что разбирается в статьях о конкурентности в Go: sql.DB сам управляет набором соединений и их переиспользованием, приложению не нужно писать это руками.

Java: client-v2 против clickhouse-jdbc

У Java-клиентов ClickHouse принципиально другой транспорт, чем у Go-семейства выше: оба ходят через HTTP-интерфейс (порт 8123), не через нативный бинарный протокол. Разница между ними — не в порте, а в том, как данные упаковываются в этот HTTP.

clickhouse-jdbc даёт стандартный java.sql.Connection/PreparedStatement и вписывается в любой JDBC-based стек (HikariCP, ORM, Spring Data), но addBatch() сериализует строки по одной внутри драйвера — построчный протокол поверх HTTP-соединения, даже если приложение вызывает executeBatch() один раз на тысячи строк:

PreparedStatement ps = conn.prepareStatement(sql);
while ((line = br.readLine()) != null) {
    CsvRow r = CsvRow.parse(line);
    ps.setTimestamp(1, Timestamp.valueOf(r.eventTime()));
    ps.setLong(2, r.userId());
    // ... остальные колонки
    ps.addBatch();
    if (++inBatch >= batchSize) {
        ps.executeBatch();
        ps.close();
        ps = conn.prepareStatement(sql); // новый Statement на каждый чанк — см. врезку ниже
        inBatch = 0;
    }
}

client-v2 (com.clickhouse.client.api.Client) — низкоуровневый клиент нового поколения без JDBC-обёртки: он отправляет данные целым CSV-потоком в одном HTTP-запросе на чанк, без построчной сериализации:

try (Client client = new Client.Builder()
        .addEndpoint(httpUrl).setUsername("default").setPassword("").setDefaultDatabase("demo")
        .build()) {
    try (InputStream in = new ByteArrayInputStream(csvBytes); // header + batchSize строк
         InsertResponse resp = client.insert(table, in, ClickHouseFormat.CSVWithNames, settings).get()) {
        total += resp.getWrittenRows();
    }
}

На M=1 000 000 строк (тот же датасет, батч 100 000) client-v2 даёт 270 621 rows/s против 129 829 rows/s у clickhouse-jdbc — быстрее примерно в 2,1 раза (на зеркальном стенде из статьи про MergeTree, где объём меньше — 100 000 строк, — тот же эффект был выражен сильнее, разрыв в 5,7 раза; абсолютное соотношение зависит от объёма и размера чанка, но направление и механизм — построчный протокол дороже потокового — одни и те же).

Живая находка здесь стоит отдельного упоминания, потому что она — не про производительность, а про корректность: в clickhouse-jdbc 0.9.0 метод PreparedStatement.clearBatch() не очищает внутренний буфер между вызовами executeBatch() на одном и том же Statement. Второй executeBatch() на «очищенном» батче пересылал на сервер не только новый чанк, а все ранее отправленные строки заново — на десяти чанках по 100 000 это дало 100000×(1+2+…+10) = 5 500 000 строк в system.parts вместо ожидаемого 1 000 000. Обход — создавать новый PreparedStatement на каждый чанк вместо переиспользования одного и того же (тот же приём, что и в database/sql-драйвере Go выше: свежий Prepare на транзакцию). После исправления system.parts совпадает с ожидаемым количеством строк — это и подтверждает ассерт стенда на каждом прогоне.

Тот же архитектурный компромисс — собственный протокол клиента поверх общего транспорта против совместимости со стандартным интерфейсом экосистемы — регулярно всплывает и у других JVM-клиентов для потоковых систем; например, у клиентов Kafka на JVM он тоже проявляется как выбор между нативным протоколом брокера и универсальными обёртками поверх него.

HTTP-интерфейс: когда оправдан голый POST

Ниже любого клиента у ClickHouse есть HTTP-интерфейс, для которого вообще не нужна ни одна ClickHouse-специфичная библиотека — работает curl, работает любой HTTP-клиент любого языка, работает через прокси и балансировщик без специальной поддержки протокола на их стороне. Стенд проверяет это буквально: сырой net/http, без единого импорта из экосистемы ClickHouse:

q := fmt.Sprintf("INSERT INTO demo.%s FORMAT CSVWithNames", table)
u := strings.TrimRight(baseURL, "/") + "/?query=" + url.QueryEscape(q)

req, _ := http.NewRequestWithContext(ctx, http.MethodPost, u, f) // f — весь CSV-файл целиком
req.Header.Set("Content-Type", "text/csv")
resp, err := http.DefaultClient.Do(req)

CSV-файл целиком стримится в тело POST-запроса, а FORMAT CSVWithNames в самом SQL говорит серверу разобрать его самостоятельно — никакого клиентского кодирования, никакого батчинга на стороне приложения. Это реальный, часто оправданный путь: скрипт эксплуатации, вебхук, который перекладывает данные из одной системы в ClickHouse без добавления зависимости под конкретный язык, прокладка через балансировщик или API-шлюз, который умеет только HTTP, редкие или разовые загрузки, где нет смысла тянуть SDK ради одного запроса. Цена — отсутствие батч-протокола и бинарного колоночного кодирования, которые есть у нативных клиентов: каждая колонка на сервере разбирается из текстового CSV, а не принимается уже в бинарном виде.

Здесь стоит честная оговорка, важная для следующего раздела: в сценарии стенда HTTP-драйвер отправлял один POST на весь файл — миллион строк одним запросом, — тогда как остальные пять драйверов вставляли тот же миллион десятью батчами по 100 000 каждый. Это два разных, оба по-своему валидных способа использовать транспорт, но не «яблоки к яблокам» в буквальном смысле — и high throughput HTTP-варианта в бенчмарке ниже нужно читать именно в этом контексте, не как «HTTP быстрее по своей природе, чем нативный протокол».

Бенчмарк вставки: шесть путей, один датасет

Полный сценарий: M = 1 000 000 строк одного детерминированного CSV, каждый из шести драйверов вставляет их в свою таблицу (та же DDL MergeTree ORDER BY (event_time, user_id) PARTITION BY toYYYYMM(event_time), что и в статье про MergeTree), пять драйверов — батчами по 100 000 строк за вызов, HTTP-драйвер — одним запросом на весь файл (оговорка выше). Числа — характерный прогон на одном хосте, важен порядок и относительное соотношение, а не абсолютная цифра:

  • raw HTTP (один POST на весь файл) — 901 925 rows/s (1,109 с)
  • ch-go (ручные proto.Col*) — 541 300 rows/s (1,847 с)
  • clickhouse-go native (PrepareBatch) — 458 855 rows/s (2,179 с)
  • clickhouse-go database/sql (tx + Exec) — 430 047 rows/s (2,325 с)
  • client-v2 (Java, CSV-поток) — 270 621 rows/s (3,695 с)
  • clickhouse-jdbc (Java, addBatch) — 129 829 rows/s (7,702 с)
Throughput вставки, rows/s (характерный прогон, M=1 000 000 строк)0200k400k600k800kraw HTTP *901925ch-go541300clickhouse-go native458855clickhouse-go database/sql430047client-v2 (Java)270621clickhouse-jdbc129829* один POST на весь файл — не батчами по 100 000, как у остальных пяти; см. оговорку в тексте

Читать это ранжирование стоит с оговоркой из предыдущего раздела на первом месте: HTTP-строка — не доказательство, что «HTTP быстрее нативного протокола по своей природе», это цифра для конкретного способа использования (весь файл одним запросом). Если бы HTTP-драйвер отправлял те же десять чанков по 100 000, что и остальные, добавился бы оверхед на установление и обработку девяти дополнительных HTTP-запросов — и итоговое число почти наверняка оказалось бы ближе к соседям по списку, а не на первом месте. Честное сравнение «протокол против протокола» на равном числе round-trip’ов — это как раз пятёрка ch-go/clickhouse-go native/clickhouse-go database/sql/client-v2/clickhouse-jdbc, где батч-размер и число вызовов одинаковы у всех.

Внутри этой пятёрки порядок логичен и совпадает с уровнем абстракции: ch-go (ручное кодирование колонок) быстрее clickhouse-go native (готовый, но всё ещё нативно-протокольный API) быстрее clickhouse-go database/sql (та же нативная реализация плюс слой транзакции и стандартного интерфейса) — Go-тройка идёт по нативному бинарному протоколу и обгоняет Java-пару, которая идёт по HTTP: client-v2 (потоковый CSV) быстрее clickhouse-jdbc (построчная сериализация внутри addBatch).

Латентность запроса и байт-идентичность результата

В отличие от вставки, замер SELECT — честно апельсины к апельсинам: все шесть драйверов выполняют один и тот же запрос без разницы в чанковании или числе round-trip’ов.

SELECT country, count() AS c, toInt64(sum(revenue) * 100) AS cents
FROM demo.%s
GROUP BY country
ORDER BY country

Латентность (GROUP BY country по 1 000 000 строк → 20 групп):

Драйвер Латентность
raw HTTP 6,9 мс
clickhouse-go native 10,8 мс
ch-go 12,5 мс
client-v2 (Java) 15 мс
clickhouse-jdbc 19 мс
clickhouse-go database/sql 34,1 мс

Порядок здесь заметно отличается от вставки, и это по-своему показательно: HTTP на маленьком, дешёвом по вычислению агрегате быстрее всех — накладные расходы одного round-trip’а на разбор текстового ответа малы по сравнению с самим вычислением, и здесь сравнение действительно честное (один запрос у всех). clickhouse-go database/sql неожиданно оказывается медленнее всех остальных нативных путей, хотя на вставке отставал от native API всего на 6-7%. Профилировать причину в стенде мы не стали, но правдоподобное объяснение — дополнительный слой database/sql на чтении: rows.Scan с рефлексией по указателям поверх и без того нативного протокола, которого нет ни у native-API того же драйвера, ни у ch-go.

Ключевой вывод бенчмарка — не про скорость, а про корректность: toInt64(sum(revenue) * 100) в запросе фиксирует результирующий тип (Int64-«центы»), одинаковый для всех шести способов декодирования — от ручных proto.Col* у ch-go до ResultSet.getLong() у JDBC. Финальная сверка через одно административное соединение над всеми шестью таблицами дала:

groups=20, totalRows=1000000, totalCents=1344564577, checksum=0da6fca6

одинаково на всех шести таблицах, где чексумма — CRC32-IEEE канонической строки "country|c|cents\n" по всем группам в порядке ORDER BY country (тот же полином, что и java.util.zip.CRC32, поэтому Go- и Java-чексуммы сравнимы побайтово). Независимо от того, каким путём данные попали в таблицу и каким путём их прочитали — вручную через proto.Col*, через database/sql, через JDBC построчно или через HTTP текстом, — ClickHouse отдаёт один и тот же результат до последнего бита. Драйвер меняет цену операции, но не её смысл.

Python и Rust: обзорно

Стенд не прогонял Python и Rust — сознательное решение по объёму (шесть путей на Go и Java уже покрывают весь спектр «низкий уровень / высокий уровень / нативный протокол / HTTP»), но для полноты картины стоит знать, что там есть:

Драйвер Язык Транспорт Профиль
clickhouse-connect Python HTTP (нативный протокол — экспериментально) официальный клиент, интеграция с Pandas/Arrow «из коробки», типичный выбор для ETL-скриптов и аналитики в Jupyter
clickhouse-rs Rust нативный бинарный протокол асинхронный (tokio), типизированные строки через derive-макросы, экосистемная параллель sqlx в мире Rust

Числа по ним в этой серии не измерялись — это честно обзорная таблица по документации и экосистеме, а не результат прогона.

Карта выбора: задача → драйвер

Задача Драйвер
Максимальный throughput вставки из Go, готовы кодировать колонки вручную ch-go
Эргономичная вставка/чтение из Go без ORM, но не голый протокол clickhouse-go v2 native (PrepareBatch)
Go-сервис уже построен вокруг database/sql (пул, миграции, инструментация, sqlx) clickhouse-go v2 через database/sql
Java-сервис, максимальный throughput, JDBC не обязателен архитектурно clickhouse-java client-v2
Java-сервис на JDBC-стеке (HikariCP, ORM, Spring Data) clickhouse-jdbc (крупные батчи, свежий PreparedStatement на чанк)
Прокси, балансировщик, скрипт эксплуатации, разовая или редкая загрузка/выборка без зависимостей HTTP-интерфейс напрямую
ETL-скрипт, аналитика в Jupyter, интеграция с Pandas/Arrow clickhouse-connect (Python)
Rust-сервис на асинхронном рантайме clickhouse-rs

Общее правило по обе стороны языковой границы одно и то же: чем меньше протокольных накладных расходов на строку и чем крупнее единица передачи данных серверу, тем ближе throughput к физическому пределу диска и сети — но платить за это приходится ручной работой (ch-go) или отказом от привычной экосистемы (JDBC/ORM). Дальше в серии — эксплуатация MergeTree вживую: system.merges, system.mutations, мониторинг и бэкапы в статье про эксплуатацию и тюнинг.

flowchart TB ROOT["Клиент приложения"] subgraph NATIVEBRANCH["Нативный бинарный протокол, порт 9000"] direction TB CHGO["ch-go
ручные proto.Col*, минимум абстракции"] CGONATIVE["clickhouse-go v2 — native API
PrepareBatch / Append / Send"] CGOSQL["clickhouse-go v2 — database/sql
sql.DB, транзакция, пул"] CGONATIVE -->|"тот же коннектор,
сверху обёртка database/sql"| CGOSQL end subgraph HTTPBRANCH["HTTP-интерфейс, порт 8123"] direction TB RAWHTTP["raw HTTP POST
net/http, CSV в теле запроса"] JCLIENT["clickhouse-java client-v2
Client.insert, CSV-поток чанками"] JDBCN["clickhouse-jdbc
PreparedStatement addBatch/executeBatch"] end ROOT --> CHGO ROOT --> CGONATIVE ROOT --> RAWHTTP ROOT --> JCLIENT JCLIENT -.->|"тот же порт,
но построчный протокол внутри"| JDBCN style NATIVEBRANCH fill:#f9f3e3,stroke:#8b7355 style HTTPBRANCH fill:#f4d9c6,stroke:#c67a4a

flowchart TB
  ROOT["Клиент приложения"]

  subgraph NATIVEBRANCH["Нативный бинарный протокол, порт 9000"]
    direction TB
    CHGO["ch-go
ручные proto.Col*, минимум абстракции"] CGONATIVE["clickhouse-go v2 — native API
PrepareBatch / Append / Send"] CGOSQL["clickhouse-go v2 — database/sql
sql.DB, транзакция, пул"] CGONATIVE -->|"тот же коннектор,
сверху обёртка database/sql"| CGOSQL end subgraph HTTPBRANCH["HTTP-интерфейс, порт 8123"] direction TB RAWHTTP["raw HTTP POST
net/http, CSV в теле запроса"] JCLIENT["clickhouse-java client-v2
Client.insert, CSV-поток чанками"] JDBCN["clickhouse-jdbc
PreparedStatement addBatch/executeBatch"] end ROOT --> CHGO ROOT --> CGONATIVE ROOT --> RAWHTTP ROOT --> JCLIENT JCLIENT -.->|"тот же порт,
но построчный протокол внутри"| JDBCN style NATIVEBRANCH fill:#f9f3e3,stroke:#8b7355 style HTTPBRANCH fill:#f4d9c6,stroke:#c67a4a
Родословная драйверов ClickHouse: два транспорта (нативный бинарный протокол на 9000, HTTP на 8123) и разные уровни абстракции внутри каждого

Версии в прогоне: ClickHouse 26.6.1.1193, clickhouse-go v2.47.0, ch-go v0.73.0, clickhouse-jdbc/client-v2 0.9.0.

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

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

Комментарии