Если выбрать одну тему, где чаще всего ломают ClickHouse, — это вставка. Модель хранения (иммутабельные куски + фоновые слияния) наказывает за частые мелкие записи жёстче, чем любая OLTP-база.
Почему мелкие вставки — это яд
Каждый INSERT создаёт отдельный кусок данных на диске. Дальше фоновые потоки должны эти куски слить. Если вставок много и они мелкие:
- куски плодятся быстрее, чем успевают сливаться;
- слияния съедают CPU и диск, конкурируя с запросами;
- растёт нагрузка на Keeper (каждый кусок реплицируемой таблицы — это записи в координации);
- в какой-то момент срабатывает защита и вы получаете
Too many parts— вставки начинают падать.
Вставка по одной строке из приложения — самый быстрый способ довести кластер до этого состояния.
Правильный размер батча
Базовое правило: вставляйте большими пачками и редко. Ориентир — от десятков тысяч строк за вставку; на потоках обычно целятся в диапазон 10 000–100 000+ строк или несколько десятков мегабайт, с частотой порядка одной вставки в секунду на таблицу, а не сотен.
Второе правило — не более одной вставки в секунду на партицию в среднем. Если данные распределяются по многим партициям сразу, каждая вставка создаёт кусок в каждой затронутой партиции — вставка «размазанная» по 30 партициям создаёт 30 кусков вместо одного.
Отсюда следствие: сортируйте данные перед вставкой так, чтобы одна пачка попадала в одну-две партиции.
async_insert: батчинг на стороне сервера
Собирать батчи в приложении не всегда удобно — особенно когда пишут много независимых источников. Для этого есть асинхронная вставка: клиент отправляет мелкие запросы, а ClickHouse сам буферизует их в памяти и записывает одним куском, когда накопится достаточно данных или истечёт таймаут.
SET async_insert = 1;
SET wait_for_async_insert = 1;Ключевые настройки и их значения по умолчанию:
| Настройка | По умолчанию | Смысл |
|---|---|---|
async_insert | 0 | Включает асинхронный режим |
async_insert_max_data_size | ~100 МиБ | Размер буфера, при котором сбрасывать |
async_insert_busy_timeout_ms | 200 мс (в облаке 1000) | Сброс по времени |
async_insert_max_query_number | 450 | Сброс по числу накопленных запросов |
wait_for_async_insert | 1 | Ждать ли реальной записи на диск |
Про wait_for_async_insert стоит сказать отдельно. Значение 1 (по умолчанию) означает: клиент получает подтверждение только после того, как данные реально записаны. Значение 0 — это «отправил и забыл»: ответ приходит сразу, латентность минимальная, но гарантии записи нет — ошибки всплывут только при сбросе буфера, а данные в буфере при падении узла теряются.
Для продовых пайплайнов рекомендуется async_insert=1 вместе с wait_for_async_insert=1. Отключать ожидание стоит только там, где потеря отдельных записей допустима осознанно.
Альтернатива из прошлого — движок
Buffer. Он живёт в памяти узла и теряет данные при перезапуске, поэтому для новых решений предпочтителенasync_insert.
Куда вставлять в шардированном кластере
Два варианта, и у каждого своя цена:
В Distributed-таблицу. Просто: пишем в одну точку, ClickHouse сам раскладывает по шардам согласно ключу шардирования. Но по умолчанию узел складывает данные во временную очередь на диске и пересылает асинхронно — появляется промежуточное звено и дополнительная задержка. Обязательно включайте internal_replication = true в конфигурации кластера, иначе Distributed попытается писать во все реплики сам, вместо того чтобы отдать репликацию движку ReplicatedMergeTree (получите двойную работу и риск расхождений).
Напрямую в локальные таблицы шардов. Клиент сам выбирает шард (round-robin или по ключу) и пишет в ReplicatedMergeTree. Меньше звеньев, предсказуемее. Это предпочтительный способ для высоконагруженных пайплайнов, но требует, чтобы логика распределения жила в клиенте или в балансировщике.
Дедупликация
У реплицируемых таблиц есть встроенная защита от повторной вставки: ClickHouse запоминает хеши последних вставленных блоков в Keeper, и повторная вставка идентичного блока игнорируется. Это спасает при ретраях после сетевых ошибок.
Важные оговорки:
- дедупликация работает по блоку целиком, а не по строкам: изменился хоть один байт — блок считается новым;
- окно ограничено (по умолчанию порядка сотни последних блоков на партицию);
- это не замена уникальному ключу. Логическая дедупликация по бизнес-ключу — это
ReplacingMergeTree(и помните из части 5, что схлопывание там происходит при слиянии, а не мгновенно).
Полезно задавать insert_deduplication_token — тогда вы явно управляете идентичностью вставки, не завися от побайтового совпадения.
Потоки из Kafka
Три рабочих подхода:
1. Движок Kafka внутри ClickHouse. Таблица-консьюмер + материализованное представление, перекладывающее данные в MergeTree:
CREATE TABLE events_queue (raw String)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'kafka:9092',
kafka_topic_list = 'events',
kafka_group_name = 'clickhouse',
kafka_format = 'JSONAsString';
CREATE MATERIALIZED VIEW events_mv TO events AS
SELECT
JSONExtractString(raw, 'event') AS event_type,
toDateTime(JSONExtractUInt(raw, 'ts')) AS ts,
JSONExtractUInt(raw, 'user_id') AS user_id
FROM events_queue;Просто и без внешних компонентов, но потребление живёт внутри БД: проблемы с парсингом и отставанием придётся диагностировать через системные таблицы, а масштабирование потребления ограничено узлами кластера.
2. Внешний потребитель (свой сервис, Vector, Kafka Connect) — больше контроля над батчингом, ретраями и обработкой «плохих» сообщений. Для сложных пайплайнов обычно предпочтительнее.
3. ClickPipes/managed-интеграции — если используете облачное предложение.
Общее правило для любого варианта: батчить на стороне потребителя, а не полагаться на то, что «ClickHouse быстрый».
За чем следить
Три запроса, которые стоит держать под рукой (и вынести в алерты):
-- Не подбираемся ли к пределу по числу кусков
SELECT database, table, count() AS parts
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY parts DESC
LIMIT 10;
-- Не растёт ли очередь репликации
SELECT database, table, queue_size, absolute_delay
FROM system.replicas
WHERE queue_size > 0;
-- Что происходит с асинхронными вставками
SELECT * FROM system.asynchronous_inserts;Дальше: Часть 7. Ресурсы и память: как не получить OOMKilled.