ClickHouse — решения, которые нельзя изменить без пересоздания таблицыClickHouse — decisions you can't change without recreating the table
`ORDER BY` в ClickHouse — не подсказка планировщику, как индекс в PostgreSQL, а физический порядок данных на диске. Ошибка здесь не чинится `ALTER` — только пересозданием таблицы и перезаливкой данных. Три решения, которые определяют схему: ключ сортировки/партиционирования, движок таблицы и то, что можно вычислить заранее через материализованные представления.`ORDER BY` in ClickHouse isn't a hint to the planner the way an index is in PostgreSQL — it's the physical order of data on disk. Getting it wrong isn't fixed with `ALTER`, only by recreating the table and reloading the data. Three decisions define a schema: the sort/partition key, the table engine, and what can be precomputed via materialized views.
ORDER BY определяет физический порядок строк внутри куска и, как следствие, какие фильтры используют разреженный индекс (см. архитектуру). Правило выбора: колонки с низкой кардинальностью и частым равенством в `WHERE` — первыми (например, `symbol` — символов сотни, а не миллионы), высококардинальные и часто используемые в диапазонных фильтрах — следом (`ts` — таймстемп почти уникален для каждой строки). Порядок колонок в `ORDER BY` работает как префикс составного индекса: фильтр по второй колонке без первой не использует индекс вообще.ORDER BY determines the physical row order inside a part and, as a result, which filters can use the sparse index (see architecture). Rule of thumb: low-cardinality columns with frequent equality filters go first (say, symbol — hundreds of values, not millions), high-cardinality columns often used in range filters follow (ts — a timestamp near-unique per row). Column order in ORDER BY works like a composite index prefix: a filter on the second column without the first doesn't use the index at all.
PARTITION BY — отдельный механизм, не заменяющий `ORDER BY`: он делит данные на крупные независимые блоки (обычно по месяцу — `toYYYYMM(ts)`), которыми удобно управлять целиком — быстро дропнуть старую партицию (`ALTER TABLE ... DROP PARTITION`, почти мгновенная метаданная-операция, в отличие от `DELETE` по условию) или перенести на холодное хранилище по TTL. Слишком мелкое партиционирование (по дню или даже по часу на высоконагруженной таблице) плодит те же проблемы с количеством кусков, что и частые мелкие вставки — партиций должны быть десятки-сотни, а не десятки тысяч.PARTITION BY is a separate mechanism, not a replacement for ORDER BY: it splits data into large independent chunks (typically by month — toYYYYMM(ts)) that are convenient to manage as a whole — quickly dropping an old partition (ALTER TABLE ... DROP PARTITION, an almost instant metadata operation, unlike a conditional DELETE) or moving it to cold storage via TTL. Partitioning too finely (by day or even by hour on a high-throughput table) creates the same too-many-parts problem as frequent small inserts — partitions should number in the tens to hundreds, not tens of thousands.
CREATE TABLE trades (
ts DateTime64(3),
symbol LowCardinality(String),
price Float64,
qty Float64
) ENGINE = MergeTree
PARTITION BY toYYYYMM(ts)
ORDER BY (symbol, ts); // фильтр "symbol = 'BTC' AND ts BETWEEN ..." использует индекс полностью// a "symbol = 'BTC' AND ts BETWEEN ..." filter uses the index fully
| движокengine | когда выбратьwhen to choose it |
|---|---|
| MergeTree | базовый вариант — данные только добавляются, дублей и переписывания строк не предполагается (append-only лог событий, тики).the base case — data is append-only, no duplicates or row rewrites expected (an append-only event log, ticks). |
| ReplacingMergeTree | нужна поздняя дедупликация по ключу `ORDER BY` — например, при повторной доставке событий из Kafka с at-least-once семантикой; дедуп происходит лениво при фоновом слиянии, не мгновенно (см. грабли).late deduplication by the ORDER BY key is needed — say, with at-least-once Kafka redelivery; dedup happens lazily on background merge, not instantly (see gotchas). |
| AggregatingMergeTree | предагрегация на вставке через `AggregateFunction`-колонки — например, поминутные OHLC-свечи из потока сделок: движок сам досчитывает частичные агрегаты при слиянии кусков.pre-aggregation on insert via AggregateFunction columns — say, per-minute OHLC candles built from a trade stream: the engine finishes combining partial aggregates during part merges. |
| SummingMergeTree | частный случай AggregatingMergeTree — просто суммирует числовые колонки при слиянии строк с одинаковым ключом; проще в использовании, но покрывает только сумму, не произвольные агрегаты.a special case of AggregatingMergeTree — just sums numeric columns when merging rows with a matching key; simpler to use, but covers only sums, not arbitrary aggregates. |
| CollapsingMergeTree / VersionedCollapsingMergeTree | изменяемые сущности без реального `UPDATE` — каждое изменение пишется как пара строк (`Sign = -1` отменяет предыдущую версию, `Sign = 1` вставляет новую); Versioned-вариант добавляет явную колонку версии для корректного схлопывания при вставках не по порядку.mutable entities without a real UPDATE — every change is written as a row pair (Sign = -1 cancels the previous version, Sign = 1 inserts the new one); the Versioned variant adds an explicit version column for correct collapsing under out-of-order inserts. |
Общая логика: ClickHouse не даёт мгновенного `UPDATE` по архитектурным причинам (см. архитектуру — куски неизменяемы), поэтому "изменяемость" данных моделируется на уровне выбора движка и семантики вставки, а не на уровне операции записи, как в OLTP.The common thread: ClickHouse doesn't offer an instant UPDATE for architectural reasons (see architecture — parts are immutable), so data "mutability" is modeled at the engine-choice and insert-semantics level, not at the write-operation level the way OLTP does it.
Материализованное представление в ClickHouse — это триггер на вставку: при каждом `INSERT` в исходную таблицу запрос представления прогоняется над новыми строками, а результат вставляется в отдельную таблицу-хранилище. Это не "закэшированный результат запроса", как в реляционных БД (там представление пересчитывается по расписанию или по требованию) — здесь агрегация происходит инкрементально, на лету, для каждой новой пачки данных.A materialized view in ClickHouse is an insert trigger: on every INSERT into the source table, the view's query runs over the new rows, and the result is inserted into a separate storage table. It isn't "a cached query result" the way it is in relational DBs (there a view recomputes on a schedule or on demand) — here the aggregation happens incrementally, on the fly, for every new batch of data.
Типичное применение — переложить агрегацию с момента чтения на момент записи: вместо того чтобы на каждый дашборд-запрос пересчитывать `sum(qty)` по миллиарду сырых сделок, материализованное представление на базе `AggregatingMergeTree` держит уже частично досчитанные суммы по минутам, и дашборд читает из него напрямую — на порядки меньше данных, на порядки быстрее ответ.A typical use is shifting aggregation from read-time to write-time: instead of recomputing sum(qty) over a billion raw trades on every dashboard query, a materialized view backed by AggregatingMergeTree holds already partially-combined per-minute sums, and the dashboard reads from it directly — orders of magnitude less data, orders of magnitude faster.
CREATE MATERIALIZED VIEW trades_1m_mv
TO trades_1m
AS SELECT
toStartOfMinute(ts) AS minute,
symbol,
argMinState(price, ts) AS open_state,
maxState(price) AS high_state,
sumState(qty) AS volume_state
FROM trades
GROUP BY minute, symbol;
Дубликаты схлопываются только при фоновом слиянии кусков — до этого момента `SELECT` без явного `FINAL` видит все версии строки одновременно. `FINAL` заставляет ClickHouse домержить куски на лету при запросе, что резко дороже обычного чтения. Правильный паттерн — либо мириться с временными дублями и дедуплицировать в самом запросе (`argMax` по колонке версии), либо использовать `FINAL` только там, где точность важнее скорости.Duplicates collapse only during background part merges — until then, a SELECT without an explicit FINAL sees every version of a row at once. FINAL forces ClickHouse to merge parts on the fly at query time, which is sharply more expensive than a normal read. The right pattern is either tolerating temporary duplicates and deduplicating within the query itself (argMax over a version column), or using FINAL only where correctness matters more than speed.
Колонка типа `AggregateFunction(sum, Float64)` хранит не число, а промежуточное состояние агрегатной функции — записывать в неё нужно через `sumState()`, а читать через `sumMerge()`, иначе получите ошибку типов или бессмысленный бинарный дамп состояния вместо значения. Это прямое следствие того, что материализованное представление досчитывает агрегат по частям при каждой новой вставке, а не хранит готовое число.A column of type AggregateFunction(sum, Float64) stores not a number but the aggregate function's intermediate state — you write to it via sumState() and read via sumMerge(); otherwise you get a type error or a meaningless binary state dump instead of a value. This is a direct consequence of the materialized view combining the aggregate incrementally on every new insert, rather than storing a finished number.
В отличие от вторичного индекса в реляционной БД, который можно добавить или удалить в любой момент, `ORDER BY` — это физический порядок хранения данных на диске. Изменить его "на лету" нельзя (`ALTER TABLE ... MODIFY ORDER BY` разрешает только расширить существующий ключ новыми колонками справа, не переставить и не убрать существующие) — при ошибке в выборе ключа единственный выход это создать новую таблицу с верным `ORDER BY` и перезалить в неё данные (`INSERT INTO ... SELECT FROM ...`).Unlike a secondary index in a relational DB, which can be added or dropped at any time, ORDER BY is the physical on-disk storage order. It can't be changed on the fly (ALTER TABLE ... MODIFY ORDER BY only allows extending the existing key with new columns on the right, not reordering or removing existing ones) — if the key choice was wrong, the only way out is creating a new table with the correct ORDER BY and reloading the data into it (INSERT INTO ... SELECT FROM ...).