Третья статья серии «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-драйвера в сценарии стенда была другая единица передачи данных, чем у остальных пяти, — об этом подробно ниже, в разделе про бенчмарк вставки.
В статье
- ch-go: минимальный слой поверх нативного протокола
- clickhouse-go v2: native API против database/sql
- Java: client-v2 против clickhouse-jdbc
- HTTP-интерфейс: когда оправдан голый POST
- Бенчмарк вставки: шесть путей, один датасет
- Латентность запроса и байт-идентичность результата
- Python и Rust: обзорно
- Карта выбора: задача → драйвер
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 API — PrepareBatch/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, а транзакцией: Begin → Prepare → 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 с)
Читать это ранжирование стоит с оговоркой из предыдущего раздела на первом месте: 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, мониторинг и бэкапы в статье про эксплуатацию и тюнинг.
ручные 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 26.6.1.1193, clickhouse-go v2.47.0, ch-go v0.73.0, clickhouse-jdbc/client-v2 0.9.0.
Комментарии