ClickHouse прощает многое — один сервер тянет миллиарды строк, — но у него свой набор эксплуатационных сложностей, не похожих ни на PostgreSQL, ни на Kafka. Разберём, что настраивают до прода и на что смотрят после.
Писать можно в любую реплику: вставка складывает данные в новый кусок и добавляет в журнал Keeper запись «забери такой-то кусок». Остальные реплики держат свою очередь по этому журналу и качают куски друг у друга напрямую — через Keeper идут только метаданные. Длина ещё не применённой очереди и есть отставание реплики.
Репликация: зачем и как устроена
Если ClickHouse стоит в одном экземпляре и сервер упадёт — данные недоступны. Дублировать их на несколько машин помогает репликация.
В ClickHouse репликация — на уровне таблицы, не сервера. Реплицируются только те таблицы, чей движок принадлежит семейству Replicated*MergeTree:
CREATE TABLE order_events ON CLUSTER main
(
event_time DateTime,
event_type LowCardinality(String),
order_id UUID,
amount Decimal(18, 2)
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/order_events', '{replica}')
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time);
Координатор репликации — ClickHouse Keeper (встроенная замена ZooKeeper; для новых установок — только Keeper). Keeper хранит метаданные: какие куски данных существуют, очередь репликации, какая реплика назначает слияния. Сами данные ходят между репликами напрямую.
Важные свойства:
- Репликация асинхронная и мультимастерная — писать можно в любую реплику, остальные подтянут. Чтение с другой реплики сразу после записи может не увидеть данные — это нормально для аналитических сценариев, но не строить на этом строгую согласованность.
- Вставка одних и тех же данных дважды не задвоит строки: для Replicated-таблиц включена дедупликация по хешу вставленного блока. Окно у неё двойное: последние
replicated_deduplication_windowблоков (по умолчанию 10 000) и не дольшеreplicated_deduplication_window_seconds(в свежих версиях час; раньше стояла неделя). Что выйдет за любую из двух границ — приедет как новые данные.
Когда «прочитать свою запись» всё-таки нужно, есть три штатных способа, и выбирают между ними по цене. insert_quorum заставляет вставку дождаться подтверждения от заданного числа реплик — запись становится медленнее, зато после ответа данные точно есть не на одной машине. Парная ей select_sequential_consistency на стороне чтения не даёт реплике отвечать, пока она не догнала кворумные вставки. А SYSTEM SYNC REPLICA table_name — грубее и проще: команда просто блокируется, пока реплика не разберёт свою очередь. Первые две ставят в настройках профиля, третью обычно зовут руками или из теста.
Отдельно стоит знать, как эта конструкция ломается. Keeper для реплицированных таблиц — не вспомогательный сервис, а условие работы: если он недоступен, таблицы переходят в режим только для чтения. Запросы идут, отчёты строятся, а любая вставка отвечает ошибкой — и так до тех пор, пока Keeper не поднимется. Поэтому его выносят на отдельные машины и следят за ним так же внимательно, как за самим ClickHouse.
Отставание реплики — это длина её очереди по общему журналу, ровно то, что показывает system.replication_queue. Механика умещается в два десятка строк:
живой пример
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class KeeperLogDemo {
record Entry(String type, String part) {}
public static void main(String[] args) {
List<Entry> log = List.of(
new Entry("GET_PART", "202406_1"),
new Entry("GET_PART", "202406_2"),
new Entry("GET_PART", "202406_3"),
new Entry("DROP_TABLE", "-"));
Set<String> replica = new LinkedHashSet<>();
int applied = 0;
for (Entry entry : log) {
switch (entry.type()) {
case "GET_PART" -> replica.add(entry.part());
case "DROP_TABLE" -> replica.clear();
}
applied++;
System.out.println(entry.type() + " " + entry.part()
+ " -> куски реплики " + replica
+ ", отставание " + (log.size() - applied));
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"fmt"
"strings"
)
type entry struct{ kind, part string }
func main() {
log := []entry{{"GET_PART", "202406_1"}, {"GET_PART", "202406_2"}, {"GET_PART", "202406_3"}, {"DROP_TABLE", "-"}}
var replica []string
applied := 0
for _, e := range log {
switch e.kind {
case "GET_PART":
replica = append(replica, e.part)
case "DROP_TABLE":
replica = replica[:0]
}
applied++
fmt.Printf("%s %s -> куски реплики [%s], отставание %d\n", e.kind, e.part, strings.Join(replica, ", "), len(log)-applied)
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const log = [
{ type: 'GET_PART', part: '202406_1' },
{ type: 'GET_PART', part: '202406_2' },
{ type: 'GET_PART', part: '202406_3' },
{ type: 'DROP_TABLE', part: '-' },
];
const replica = new Set();
let applied = 0;
for (const entry of log) {
if (entry.type === 'GET_PART') replica.add(entry.part);
else if (entry.type === 'DROP_TABLE') replica.clear();
applied++;
console.log(`${entry.type} ${entry.part} -> куски реплики [${[...replica].join(', ')}], отставание ${log.length - applied}`);
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
log = [("GET_PART", "202406_1"), ("GET_PART", "202406_2"), ("GET_PART", "202406_3"), ("DROP_TABLE", "-")]
replica: list[str] = []
applied = 0
for kind, part in log:
if kind == "GET_PART":
replica.append(part)
elif kind == "DROP_TABLE":
replica.clear()
applied += 1
print(f"{kind} {part} -> куски реплики [{', '.join(replica)}], отставание {len(log) - applied}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Последняя запись — обратная сторона устройства: DROP TABLE для реплики такая же запись журнала, как загрузка куска.
Минимальная продуктивная топология: 2 реплики + 3 ноды Keeper (Keeper можно совмещать с ClickHouse-нодами на небольших кластерах). Одна нода без репликации допустима, если данные можно перезалить из Kafka или PostgreSQL; если ClickHouse — единственное место хранения событий, одна нода не подходит.
Шардинг: позже, чем кажется
Типичная ошибка — шардировать сразу. Один сервер ClickHouse обрабатывает терабайты; пока вертикальный рост (диск, память) и правильная схема справляются — шардинг не нужен. Он нужен, когда данные физически не помещаются на одной машине или когда один запрос упирается в CPU одного сервера.
Механика: данные разрезаются по шардам, поверх — таблица-маршрутизатор:
ENGINE = Distributed(cluster, db, local_table, sharding_key)
Запрос в Distributed разлетается по шардам и собирается на инициаторе. Вставка — либо через Distributed (он раскидает по ключу), либо напрямую в локальные таблицы шардов (надёжнее под нагрузкой, меньше промежуточной буферизации).
Запрос приходит в таблицу Distributed, разлетается по локальным таблицам шардов, а частичные ответы собираются обратно на принявшем узле.
Выбор sharding_key: равномерность важнее смысла. cityHash64(order_id) распределит ровно; шардинг «по региону» даст горячий шард на крупном регионе.
Что при этом ломается — важнее, чем как это включается.
Соединения между шардами. Обычный JOIN выполняется на каждом узле локально: узел соединяет свою часть левой таблицы со своей частью правой. Если правая таблица разложена по другому ключу, часть пар просто не встретится — результат получится неверным, и молча. Чтобы этого не было, есть GLOBAL JOIN и GLOBAL IN: правая сторона считается один раз на узле-инициаторе и рассылается остальным. Работает правильно, стоит сетевого обмена и памяти на всех узлах, поэтому большие таблицы так не соединяют — справочники держат словарями, реплицированными на все узлы.
Группировка с высокой кардинальностью. GROUP BY по колонке с миллионами значений означает, что каждый узел посчитает свои частичные состояния и пришлёт их инициатору, а тот объединит — то есть по сети едет объём, пропорциональный числу групп. На узких разрезах это незаметно, на «по идентификатору пользователя» — это гигабайты и упор в память инициатора.
Ключ шардирования нельзя поменять. Он определяет, на каком узле окажется строка; смена ключа означает перераскладку всех данных. Поэтому его выбирают так, чтобы данные распределялись равномерно (хеш от идентификатора, а не поле с перекосом) и чтобы частые запросы попадали в один шард.
Место под слияния и OPTIMIZE
Слияние кусков — это чтение старых и запись нового: пока оно идёт, на диске лежат обе версии. Значит, свободного места нужно как минимум столько, сколько занимают самые крупные сливаемые куски, а с запасом — порядка объёма самой большой партиции. Отсюда и порог «меньше 20 % свободно — тревога»: на заполненном диске слияния перестают запускаться, число кусков растёт, чтение замедляется, а исправить это без добавления места нельзя.
OPTIMIZE TABLE … FINAL принудительно сливает все куски партиции в один. Это полезно в тестах (получить предсказуемый результат от движка, сворачивающего версии) и иногда как разовая операция обслуживания. В проде его не ставят в расписание: он перечитывает и перезаписывает всю партицию, то есть читает и пишет десятки гигабайт, вытесняя из кэша всё остальное. Правильный ответ на «дубли мешают» — читать с FINAL в запросе или через argMax, а не переписывать таблицу по ночам.
Пользователи, профили и квоты
Доступ и ограничения настраиваются в конфигурации сервера (или командами создания пользователей) и состоят из трёх частей.
Пользователь — кто подключается: пароль, разрешённые адреса, привязанный профиль и квота, права на базы и таблицы (GRANT SELECT ON analytics.* TO reporting).
Профиль настроек — ограничения на запросы этого пользователя: max_execution_time (сколько секунд запросу жить), max_memory_usage (сколько памяти он может занять), max_rows_to_read, readonly. Это главный инструмент против «аналитик запустил запрос и положил сервер»: отчётному пользователю дают жёсткие лимиты, конвейеру записи — свои.
Квота — ограничение на период: сколько запросов, строк и времени пользователь может потратить за час или сутки. Ею закрывают сценарий «скрипт в цикле», который лимитами на один запрос не ловится.
Минимальный рабочий набор — три роли: запись (конвейер, права только на вставку в свои таблицы), чтение отчётов (жёсткий профиль, только SELECT), администрирование (миграции и обслуживание). Общий пользователь default без пароля в проде закрывают первым делом.
Что мониторить в цифрах
Системные таблицы полезны с порогами.
Число кусков на партицию (system.parts): десятки — норма, сотни — слияния не успевают. Дальше вступают два порога: на parts_to_delay_insert (по умолчанию тысяча) сервер начинает искусственно притормаживать вставки, на parts_to_throw_insert (три тысячи) — отклоняет их с ошибкой. Именно второй и выглядит как «вставки перестали работать».
Мутации (system.mutations): одновременно идущих должно быть единицы. Мутация, висящая часами, — это либо огромная таблица, либо ошибка в запросе (тогда она висит вечно и её надо снимать KILL MUTATION). Растущая очередь означает, что мутации ставят быстрее, чем они выполняются, — обычно это признак того, что изменения данных пытаются делать так же, как в обычной базе.
Отставание материализованных представлений меряют не отдельной метрикой, а сравнением: максимальная метка времени в исходной таблице против максимальной в целевой. Разница в минуты — норма при пакетной вставке, разница в часы — представление сломалось или отстаёт.
Очередь репликации (system.replication_queue): нули или единицы — норма; растущая очередь означает, что реплика не успевает или отключена. Отдельно смотрят system.replicas на признак «только для чтения» — реплика, потерявшая связь с координатором, переходит в этот режим и перестаёт принимать запись.
TTL: когда старые данные дорого хранить
Аналитические данные стареют, и хранить сырые события вечно накладно. TTL решает это декларативно:
ALTER TABLE order_events
MODIFY TTL event_time + INTERVAL 12 MONTH DELETE;
Варианты политики:
DELETE— удалить старые строки (фоном, при слиянии кусков).TO DISK 'cold'/TO VOLUME 'cold'— многоуровневое хранение: свежие месяцы на быстрых дисках, старые — на дешёвых или S3. Это то, что в других системах называют горячий/тёплый/холодный уровень.GROUP BY ... SET— понижение точности: старше года хранить не события, а суточные агрегаты.
Типовая связка: сырые события с TTL 12 месяцев + материализованное представление с агрегатами навсегда. Дашборды живут на агрегатах, сырьё для точечного анализа есть за последний год, диск под контролем.
Свежие партиции лежат на быстром диске, по первому правилу TTL переезжают на холодный, по второму удаляются совсем.
Удаление целыми партициями — самый дешёвый способ чистки:
ALTER TABLE order_events DROP PARTITION 202401;
Это ещё один аргумент за месячные партиции.
Бэкапы
Реплика — не бэкап: DROP TABLE реплицируется так же исправно, как INSERT. Для настоящего резервного копирования есть встроенный механизм:
BACKUP TABLE analytics.order_events
TO S3('https://s3.../backups/order_events/2026-06', '...', '...');
RESTORE TABLE analytics.order_events
FROM S3('https://s3.../backups/order_events/2026-06', '...', '...');
Бэкапы умеют быть инкрементальными: куски данных неизменяемы, поэтому копировать можно только новые. Само по себе это не включается — в команде BACKUP нужно явно указать, поверх какой предыдущей копии делается текущая (SETTINGS base_backup = ...), иначе каждый раз выйдет полная копия. Целиться стоит в S3.
Вторая линия защиты — перезалить данные из источника: пока Kafka хранит события, а PostgreSQL историю, ClickHouse восстановим пересборкой. Она исчезает, как только срок хранения в Kafka короче глубины данных в ClickHouse.
Мониторинг через system-таблицы
ClickHouse рассказывает о себе SQL-запросами — вся диагностика в схеме system:
| Таблица | Что смотреть | Порог для алерта |
|---|---|---|
system.parts | Число активных кусков на таблицу-партицию | приближение к parts_to_throw_insert (по умолчанию 3000) — вставка скоро встанет |
system.merges | Текущие слияния, прогресс | Слияния не успевают за вставкой |
system.mutations | Очередь ALTER UPDATE/DELETE | is_done = 0 несколько часов |
system.replication_queue | Отставание реплик | Очередь растёт и не разгребается |
system.query_log | Кто, что и сколько читал | Запросы с чтением > N ГБ или > N секунд |
system.disks | Свободное место | < 20% — слияниям нужен запас под копию кусков |
Экспорт метрик в Prometheus встроен — секция <prometheus> в конфиге сервера, дальше стандартные дашборды и алерты. Стартовый набор алертов: слишком много кусков (TOO_MANY_PARTS), отставание репликации, диск, доля упавших запросов, длительность фоновых изменений.
Типичные проблемы и их причины
TOO_MANY_PARTS — вставка отвергнута. Причина почти всегда в пишущем сервисе: мелкие частые INSERT-ы, слишком дробное партиционирование или каскад материализованных представлений, размножающий вставки. Решается группировкой вставок и пересмотром схемы, а не подкруткой лимита. Если собирать пачки на стороне приложения неудобно, есть третий штатный ответ — асинхронная вставка (async_insert): сервер сам копит мелкие запросы в буфер и сливает их одним куском. Подробности и её цену разбирает статья про интеграцию.
OOM на тяжёлом GROUP BY. Агрегация по высококардинальной колонке не влезла в max_memory_usage. Быстрое средство — max_bytes_before_external_group_by (выгрузка на диск: медленнее, но запрос доживёт); системное решение — предагрегация через материализованные представления.
Реплики разошлись. Смотреть в system.replication_queue и логи Keeper: чаще всего причина — сеть до Keeper или заполненный диск.
Дальше есть две команды, и подряд их не выполняют — они для разных бед:
-- реплика жива, но запуталась в очереди: перечитать состояние из Keeper заново
SYSTEM RESTART REPLICA table_name;
-- в Keeper пропали метаданные таблицы: восстановить их по кускам, лежащим на диске
SYSTEM RESTORE REPLICA table_name;
Первая — лёгкая, ей и начинают. Вторая нужна в узком случае, когда данные на диске есть, а Keeper о таблице больше не знает; в обычной ситуации она просто не выполнится. Так что сначала разбираются, что именно потерялось, и только потом выбирают команду. Недостающие куски дольёт репликация самостоятельно.
Изменения (мутации) стоят. Очередь ALTER ... DELETE (например, удаление данных по требованию регуляторов) блокируется нехваткой диска или ошибкой в одной мутации. Зависшую мутацию можно отменить через KILL MUTATION, после чего разбирать причину.
Запрос-убийца. SELECT * за два года без фильтра. Защита — профили пользователей: max_execution_time, max_memory_usage, max_rows_to_read для read-only пользователей, квоты на частоту. Эти ограничения ставят до инцидента.
Версии и обновление
ClickHouse выпускается ежемесячно; для продуктивных систем — LTS-релизы (два в год, год поддержки). Обновление кластера — пореплично, без остановки: реплика выводится, обновляется, догоняет очередь репликации, затем следующая. Перед крупным обновлением — прогон рабочих запросов на тестовом окружении: оптимизатор и поведение настроек между LTS-версиями меняются заметнее, чем в PostgreSQL.
Глубже: пределы в цифрах: куски, партиции и память запросарасширенное
Статья объясняет, как ClickHouse устроен, а вот числа, на которых устройство ломается, и что с ними делать.
Куски. Каждая вставка создаёт кусок, слияния их укрупняют. Пока в одной партиции активных кусков больше parts_to_delay_insert (1000), вставки искусственно замедляются, а при parts_to_throw_insert (3000) отвергаются с TOO_MANY_PARTS. Здоровое число это десятки кусков на партицию; ориентир на вставку один-два запроса в секунду на таблицу и блоки от десятков тысяч строк. Смотреть: SELECT partition, count() FROM system.parts WHERE active GROUP BY partition.
Партиции. Одна вставка не может задеть больше max_partitions_per_insert_block (100) партиций, иначе ошибка TOO_MANY_PARTS с другим текстом; это ловит партиционирование по дню при загрузке данных за год. И общее число партиций в таблице держат в пределах сотен, потому что каждая это отдельный набор кусков и файлов, а SELECT по многим партициям открывает их все. Партиционирование по месяцу для событий и по дню только там, где день реально удаляют.
Память. Один запрос может взять до max_memory_usage (10 ГБ по умолчанию), и GROUP BY по колонке с миллионами значений в него не вмещается: Memory limit (for query) exceeded. Лечат max_bytes_before_external_group_by, который переводит группировку на диск, или переписыванием запроса. max_execution_time по умолчанию не задан, и запрос-мамонт из отчёта живёт часами; на пользователе для отчётов ставят десятки секунд. На весь сервер работает max_server_memory_usage, обычно 90 % памяти машины, а под слияния и кэш индексов остаётся остальное, поэтому «дать ClickHouse всю память» не работает.
Колонки и строки. Тысяча колонок в таблице это уже тысяча файлов на кусок и медленные слияния; широкие таблицы делят или переводят часть колонок в Map. Вставочный блок по умолчанию до max_insert_block_size (около миллиона строк), и один INSERT на десять миллионов строк ClickHouse сам разрежет на десять кусков.
Мутации. ALTER TABLE ... DELETE и UPDATE переписывают затронутые куски целиком и выполняются в фоне; их очередь видна в system.mutations, и сотня мутаций подряд забьёт диск и слияния. Лёгкое удаление, DELETE FROM, помечает строки и переписывает позже, но всё равно не для построчных правок, для которых ClickHouse не предназначен.
Коротко
- Репликация — на уровне таблицы, движок
ReplicatedMergeTree; координирует Keeper, минимальная топология — 2 реплики + 3 ноды Keeper. - Отставание реплики — длина её очереди по журналу Keeper; куски реплики качают друг у друга напрямую.
- Шардинг нужен позже, чем кажется — один сервер тянет терабайты; добавляйте, когда вертикальный рост исчерпан.
- TTL управляет жизнью данных: удаление, перемещение на холодный диск или понижение точности.
- Реплика — не бэкап;
DROP TABLEреплицируется. Бэкап —BACKUP TO S3. - Вся диагностика — SQL через
system.*таблицы, экспорт метрик в Prometheus встроен; самая частая авария,TOO_MANY_PARTS, лечится группировкой вставок, а не подкруткой лимита. Рядом смотрят очередь мутаций (одновременно должны идти единицы), очередь репликации (около нуля) и отставание представлений как разницу меток времени. - Пределы: 1000 кусков на партицию замедляют вставку, 3000 останавливают; не больше 100 партиций в одной вставке и сотни в таблице;
max_memory_usage10 ГБ на запрос иmax_execution_timeдля отчётов; мутации в очередиsystem.mutations. - Шардирование ломает соединения (нужен
GLOBAL JOIN/GLOBAL IN), дорожает группировку с высокой кардинальностью и требует ключа, который потом не поменять. - Слиянию нужно свободное место под обе версии кусков, поэтому заполненный диск останавливает слияния;
OPTIMIZE … FINAL— разовая операция, а не расписание. - Доступ настраивают тремя частями: пользователь с правами, профиль с
max_execution_timeиmax_memory_usage, квота на период — иначе один отчёт кладёт сервер.
Что почитать дальше
- Fundamentals — куски и слияния, из которых растёт половина этого раздела.
- Моделирование и запросы — материализованные представления, на которых держатся агрегаты из раздела про TTL.
- Интеграция с ClickHouse — конвейер записи, который эти алерты охраняют.
- Elasticsearch: эксплуатация — схожие темы (тиеринг, снэпшоты, размер кластера) в соседнем хранилище.