ClickHouse — устройство движкаClickHouse — engine internals

Архитектура движкаEngine architecture

Всё, что ClickHouse умеет, вытекает из одного решения: хранить данные по колонкам, а не по строкам, и организовывать их в неизменяемые отсортированные куски. MergeTree — не "движок таблиц" в смысле MySQL (InnoDB vs MyISAM), а сама суть того, как ClickHouse физически владеет данными.Everything ClickHouse can do follows from one decision: store data by column, not by row, and organize it into immutable sorted parts. MergeTree isn't a "table engine" in the MySQL sense (InnoDB vs MyISAM) — it's the very essence of how ClickHouse physically owns its data.

частый вопросcommon interview question

Колоночное хранение и сжатиеColumnar storage and compression

В строковой БД (PostgreSQL, MySQL) все колонки одной строки физически лежат рядом на диске — хорошо для чтения "всей записи целиком" (типичный OLTP), плохо для агрегации по одной колонке из миллиарда строк: диск читает и лишние колонки тоже. ClickHouse хранит каждую колонку в отдельном файле — запрос `SELECT avg(price) FROM trades` читает с диска только колонку `price`, игнорируя остальные, даже если в таблице их полсотни.In a row-store (PostgreSQL, MySQL), all columns of one row sit physically together on disk — good for reading "the whole record" (typical OLTP), bad for aggregating one column across a billion rows: the disk reads the unneeded columns too. ClickHouse stores each column in a separate file — a query like SELECT avg(price) FROM trades reads only the price column off disk, ignoring the rest even if the table has fifty of them.

Однотипные значения, лежащие подряд (все цены, все таймстемпы), сжимаются намного эффективнее вперемешку с другими типами — отсюда типичное сжатие на порядок и больше по сравнению со строковым хранением. Поверх общего сжатия (LZ4 по умолчанию — быстро; ZSTD — выше степень сжатия ценой CPU) ClickHouse даёт кодеки под конкретный характер данных: Delta хорошо сжимает монотонно растущие значения (таймстемпы, автоинкрементные ID), DoubleDelta — для почти равномерных временных рядов, Gorilla — для медленно меняющихся float-метрик. Кодек и общий компрессор комбинируются: сначала кодек уменьшает энтропию, затем компрессор жмёт результат.Same-typed values sitting next to each other (all prices, all timestamps) compress far more effectively than when interleaved with other types — hence the typical order-of-magnitude-plus compression versus row storage. On top of the general compressor (LZ4 by default — fast; ZSTD — a higher ratio at a CPU cost), ClickHouse offers codecs tuned to specific data shapes: Delta compresses monotonically increasing values well (timestamps, auto-increment IDs), DoubleDelta suits near-regular time series, Gorilla suits slowly-changing float metrics. The codec and the general compressor combine: the codec reduces entropy first, then the compressor squeezes the result.

CREATE TABLE trades (
    ts       DateTime64(3) CODEC(DoubleDelta, ZSTD),
    price    Float64 CODEC(Gorilla, LZ4),
    symbol   LowCardinality(String)
) ENGINE = MergeTree
ORDER BY (symbol, ts);

Куски данных и фоновые слиянияData parts and background merges

Каждая вставка (`INSERT`) создаёт новый неизменяемый "кусок" (part) — набор файлов на диске, отсортированный по ключу `ORDER BY`. Данные внутри куска никогда не изменяются на месте: обновления и удаления в ClickHouse — это фоновая перезапись затронутых кусков, а не point-модификация строки, как в OLTP-движках. Отсюда системное ограничение: `UPDATE`/`DELETE` в ClickHouse — тяжёлые асинхронные операции (`ALTER TABLE ... UPDATE`), а не то, на что можно полагаться в горячем пути.Every insert creates a new immutable "part" — a set of files on disk sorted by the ORDER BY key. Data inside a part is never modified in place: updates and deletes in ClickHouse are a background rewrite of the affected parts, not an in-place row modification like in OLTP engines. Hence a systemic limit: UPDATE/DELETE in ClickHouse (ALTER TABLE ... UPDATE) are heavy asynchronous operations, not something to rely on in a hot path.

Фоновый процесс слияния (merge) периодически объединяет мелкие куски в более крупные — уменьшает их количество (важно: слишком много мелких кусков перегружает файловую систему и замедляет запросы, см. грабли ниже) и, в зависимости от движка таблицы (`ReplacingMergeTree`, `AggregatingMergeTree`, `SummingMergeTree`), попутно схлопывает дубликаты или досчитывает агрегаты. Слияние — именно фоновый и асинхронный процесс: сразу после вставки дубликаты или неагрегированные строки могут какое-то время сосуществовать, и это нормально для OLAP-нагрузки, но неожиданно для тех, кто ждёт немедленной консистентности как в OLTP.A background merge process periodically combines small parts into larger ones — reducing their count (important: too many small parts overloads the filesystem and slows queries, see the gotchas below) and, depending on the table engine (ReplacingMergeTree, AggregatingMergeTree, SummingMergeTree), along the way collapses duplicates or finishes computing aggregates. The merge is specifically a background, asynchronous process: right after an insert, duplicates or unaggregated rows can coexist for a while, which is normal for OLAP workloads but surprising to anyone expecting immediate consistency the way OLTP provides it.

Разреженный первичный индексThe sparse primary index

ClickHouse не строит B-Tree по первичному ключу, как реляционные БД — вместо этого он индексирует не каждую строку, а каждую N-ю (по умолчанию `index_granularity = 8192`), храня в памяти лишь эти "вехи". Поиск по ключу находит не точную строку, а диапазон гранул, в котором она может быть, после чего движок читает и фильтрует эти гранулы целиком.ClickHouse doesn't build a B-Tree on the primary key like relational DBs — instead it indexes not every row but every Nth one (index_granularity = 8192 by default), keeping only these "milestones" in memory. A key lookup finds not the exact row but the range of granules it might be in, and the engine then reads and filters those granules in full.

Компромисс осознанный: точечное чтение одной строки по ключу в ClickHouse на порядки медленнее, чем в PostgreSQL с B-Tree индексом (нужно прочитать целую гранулу — тысячи строк — а не одну запись), но зато первичный индекс занимает пренебрежимо мало памяти даже для триллионов строк, и его не нужно перестраивать при вставке новых данных — он просто нарастает вместе с новыми кусками. Это прямое следствие оптимизации под сканирование диапазонов и агрегацию, а не под точечный поиск.The trade-off is deliberate: a point read of a single row by key in ClickHouse is orders of magnitude slower than in PostgreSQL with a B-Tree index (a whole granule — thousands of rows — must be read, not one record), but in exchange the primary index takes negligible memory even for trillions of rows, and never needs rebuilding on insert — it simply grows alongside new parts. This is a direct consequence of optimizing for range scans and aggregation, not point lookups.

Репликация и ClickHouse KeeperReplication and ClickHouse Keeper

ReplicatedMergeTree добавляет к обычному `MergeTree` синхронизацию метаданных о кусках между репликами через координирующий сервис — исторически ZooKeeper, сейчас — встроенный ClickHouse Keeper (совместимый по протоколу, но не требующий отдельного JVM-процесса рядом с БД). Реплицируются не сырые данные построчно, а факты о кусках ("реплика X создала кусок Y") — сами данные каждая реплика затем скачивает независимо, что делает схему устойчивой к временной недоступности отдельных реплик.ReplicatedMergeTree adds to plain MergeTree the synchronization of part metadata between replicas via a coordinating service — historically ZooKeeper, now the built-in ClickHouse Keeper (protocol-compatible but not requiring a separate JVM process alongside the DB). What's replicated isn't raw row data but facts about parts ("replica X created part Y") — each replica then downloads the actual data independently, which makes the scheme resilient to individual replicas being temporarily unavailable.

Шардирование — отдельная от репликации ось масштабирования: `Distributed`-таблица не хранит данные сама, а маршрутизирует запросы к нижележащим шардам (обычным `MergeTree`/`ReplicatedMergeTree`-таблицам на разных узлах) и объединяет результат. Шардирование масштабирует и запись, и объём данных горизонтально; репликация внутри каждого шарда даёт отказоустойчивость. Это те же две ортогональные оси, что в key-value кластеризации (см. связанные темы), только применённые к колоночному хранению.Sharding is a scaling axis separate from replication: a Distributed table doesn't store data itself but routes queries to underlying shards (regular MergeTree/ReplicatedMergeTree tables on different nodes) and merges the result. Sharding scales both writes and data volume horizontally; replication within each shard provides fault tolerance. These are the same two orthogonal axes as key-value clustering (see related topics), just applied to columnar storage.

ГраблиGotchas

Слишком много мелких кусков — "Too many parts"Too many small parts — "Too many parts"

Каждый `INSERT` создаёт новый кусок независимо от его размера — частые мелкие вставки (по одной строке, как в типичном OLTP-паттерне) плодят тысячи кусков быстрее, чем фоновое слияние успевает их объединять. ClickHouse защищается сам, начиная тормозить, а затем отклоняя вставки ошибкой `Too many parts`, — это не баг, а сигнал о неправильном паттерне записи. Решение — батчинг: собирать строки в буфер и вставлять пачками в тысячи-десятки тысяч строк за раз (см. эксплуатацию).Every INSERT creates a new part regardless of its size — frequent small inserts (one row at a time, a typical OLTP pattern) spawn thousands of parts faster than the background merge can consolidate them. ClickHouse defends itself by first slowing down and then rejecting inserts with a Too many parts error — not a bug, but a signal of the wrong write pattern. The fix is batching: buffer rows and insert in batches of thousands to tens of thousands of rows at a time (see operations).

Первичный ключ ≠ уникальностьPrimary key ≠ uniqueness

В отличие от реляционных БД, `ORDER BY`/первичный ключ в ClickHouse не накладывает ограничение уникальности — дубликаты по ключу спокойно существуют, если явно не выбран движок вроде `ReplacingMergeTree`, да и тот схлопывает дубли лениво в фоновом слиянии, а не мгновенно на вставке. Рассчитывать на "ключ гарантирует одну строку" без понимания этого — источник тонких багов в отчётности.Unlike relational DBs, ORDER BY/the primary key in ClickHouse doesn't enforce a uniqueness constraint — duplicates on the key coexist just fine unless an engine like ReplacingMergeTree is explicitly chosen, and even that collapses duplicates lazily during background merges, not instantly on insert. Assuming "the key guarantees one row" without understanding this is a source of subtle reporting bugs.

Point-чтение по не-ключевой колонке — full scan гранулA point read on a non-key column is a full granule scan

Разреженный индекс ускоряет фильтрацию только по колонкам, входящим в `ORDER BY` (и только по их префиксу слева направо, как в составном B-Tree индексе). Фильтр по любой другой колонке заставляет движок читать и фильтровать все гранулы целиком — на таблице в миллиарды строк это может означать чтение терабайт данных ради одной строки. Проектирование `ORDER BY` под реальные паттерны фильтрации — не опциональная оптимизация, а обязательное условие приемлемой производительности.The sparse index only speeds up filtering on columns that are part of ORDER BY (and only on their left-to-right prefix, as with a composite B-Tree index). A filter on any other column forces the engine to read and filter every granule in full — on a billion-row table that can mean reading terabytes of data for a single row. Designing ORDER BY around real filtering patterns isn't an optional optimization, it's a precondition for acceptable performance.