Skip to content
Learning Platform

Один и тот же запрос, разница в сто раз

Возьмём таблицу на миллиард строк событий и спросим у неё одно число: сумму выручки за последний месяц. На PostgreSQL такой запрос на честном железе может выполняться десятки секунд или минуты. На ClickHouse тот же SELECT sum(revenue) по тем же данным вернётся за доли секунды. Разница не в том, что один движок «лучше написан», а в том, что они спроектированы под принципиально разные паттерны доступа к данным.

PostgreSQL — это OLTP-система: её работа — быстро находить, вставлять и менять отдельные строки внутри транзакций. ClickHouse — это OLAP-система: её работа — прогонять агрегаты по миллиардам строк, трогая лишь несколько столбцов. Это разные миры, и почти вся разница в скорости вытекает из четырёх инженерных решений: колоночное хранение, движок MergeTree, разреженный первичный индекс и векторизованное выполнение. Разберём каждое до уровня того, что реально лежит на диске и происходит в CPU.

Это бесплатный самостоятельный разбор: всё объясняется здесь с нуля. Он пересекается с одним из модулей нашего платного курса ClickHouse, но читать его можно отдельно.

Строки против столбцов: что лежит на диске

Начнём с фундамента — физического расположения данных. PostgreSQL хранит данные построчно (row-store). Это значит, что все поля одной строки лежат на диске рядом, в одной странице размером 8 КБ. Строка (id, user_id, event_date, revenue, country, device) записывается единым непрерывным куском, затем сразу следующая строка, и так далее.

Для OLTP это идеально. Когда приложению нужно «отдай мне заказ номер 4815» целиком, движок читает одну страницу и получает все поля строки за одно обращение к диску. Точечные чтения и обновления отдельных записей — родная стихия row-store.

ClickHouse хранит данные поколоночно (column-store). Каждый столбец лежит в собственном файле: все значения revenue подряд в одном файле, все значения country подряд в другом, все event_date в третьем. Строка как непрерывная сущность на диске не существует — она «собирается» только при необходимости из значений, лежащих на одинаковых позициях в разных файлах.

Из этой разницы вытекают два мощных следствия, ради которых вся аналитика и переезжает на колоночные движки.

Первое — чтение только нужных столбцов. Запрос SELECT sum(revenue) WHERE event_date >= '2026-05-01' трогает ровно два столбца из, скажем, тридцати. Row-store вынужден прочитать с диска все тридцать столбцов всех строк, потому что они физически переплетены: чтобы добраться до revenue, движок поднимает всю страницу целиком. Column-store читает только файлы revenue и event_date — остальные двадцать восемь столбцов на диске даже не трогаются. Если строка широкая, это сразу даёт выигрыш в разы по объёму ввода-вывода.

Второе — сжатие однотипных данных. В колоночном файле подряд лежат значения одного типа и часто близкие по смыслу: страны из короткого справочника, монотонно растущие таймстампы, повторяющиеся коды устройств. Такие данные сжимаются в разы лучше, чем перемешанные поля строки. Меньше байт на диске означает меньше работы по чтению и распаковке — а ввод-вывод обычно и есть узкое место аналитики. ClickHouse применяет кодек: общего назначения (LZ4, ZSTD) и специализированные под паттерн данных (Delta и DoubleDelta для растущих последовательностей, Gorilla для медленно меняющихся чисел с плавающей точкой).

Сведём контраст в таблицу.

СвойствоRow-store (PostgreSQL)Column-store (ClickHouse)
Что лежит рядом на дискевсе поля одной строкивсе значения одного столбца
Чтение sum по 1 столбцу из 30читает все 30 столбцовчитает 1 столбец
Степень сжатияумеренная (поля разнотипны)высокая (данные однотипны)
Точечный SELECT * WHERE id = Xбыстрый (одна страница)дорогой (сборка из N файлов)
Вставка одной строкидешёваядорогая (правится N файлов)
UPDATE/DELETE строкидешёвый, на местедорогой (переписывание частей)
Целевая нагрузкаOLTP, точечные операцииOLAP, агрегаты по диапазонам

Колоночное хранение — не «улучшенный» формат, а другой инструмент: он выигрывает ровно на той нагрузке, где row-store проигрывает, и наоборот.

MergeTree: данные пишутся отсортированными частями

Колоночное хранение объясняет, почему ClickHouse читает мало. Движок MergeTree объясняет, как эти данные организованы, чтобы читать ещё меньше.

Когда вы вставляете пачку строк, ClickHouse не дописывает их в общий файл и не правит дерево индекса. Вместо этого он создаёт новую part — отдельную папку на диске. Внутри неё строки отсортированы по ключу ORDER BY таблицы и разложены по колоночным файлам (.bin на каждый столбец). Каждая такая часть неизменяема: после записи её содержимое уже никогда не меняется, только читается или целиком заменяется при слиянии.

Это объясняет, почему ClickHouse любит большие батчи на вставку и плохо переносит поток одиночных INSERT. Каждый INSERT рождает отдельную часть со своими файлами и метаданными. Тысячи мелких частей — это тысячи папок, которые движок вынужден открывать и сшивать при каждом запросе. Поэтому правило: вставлять крупными пачками (десятки и сотни тысяч строк), а не по строке.

Накопление частей решается фоновыми merge. Специальный пул потоков берёт несколько отсортированных частей и сливает их в одну большую — тоже отсортированную. Поскольку входные части уже упорядочены по одному ключу, слияние — это дешёвый k-путевой merge, как в сортировке слиянием: идём по входам параллельно и выписываем результат по возрастанию ключа. Отсюда и название MergeTree: фоновое слияние отсортированных частей — это сердце движка.

-- Типичная таблица MergeTree
CREATE TABLE events (
    event_date  Date,
    user_id     UInt64,
    revenue     Decimal(12, 2),
    country     LowCardinality(String)
)
ENGINE = MergeTree
ORDER BY (event_date, user_id)   -- ключ сортировки = первичный ключ
SETTINGS index_granularity = 8192;

Важно: ORDER BY здесь — это не «как отсортировать вывод», а физический порядок данных на диске и одновременно первичный ключ. Он определяет всё дальнейшее ускорение, поэтому порядок столбцов в нём — главное архитектурное решение при проектировании таблицы.

Разреженный индекс: одна засечка на гранулу, а не B-tree

Здесь ClickHouse расходится с PostgreSQL сильнее всего. B-tree-индекс Postgres хранит запись на каждую строку: чтобы это дерево позволяло мгновенно найти любую отдельную строку. За это платят памятью и стоимостью поддержки дерева при каждой записи — нормальная цена для OLTP.

ClickHouse не индексирует каждую строку. Внутри части строки логически нарезаны на гранула — блоки примерно по 8192 строки (index_granularity). Гранула — это минимальная единица чтения: нельзя прочитать половину гранулы или одну строку без чтения всей гранулы целиком.

Первичный индекс (primary.idx) хранит всего одну запись на гранулу — значение ключа ORDER BY для первой строки каждой гранулы. На миллиард строк это не миллиард записей, а около ста двадцати тысяч — индекс настолько мал, что целиком помещается в память. Это разреженный индекс: он не даёт адрес конкретной строки, он даёт диапазон гранул, в котором эта строка может лежать.

Рядом лежат marks — файл засечек (.mrk): для каждой гранулы он хранит байтовое смещение её начала в сжатом файле и смещение внутри распакованного блока. Связка проста: индекс по значению ключа находит номер гранулы, засечка по номеру гранулы даёт точное место в .bin-файле, откуда читать.

Как запрос отбирает гранулы

Теперь видно, как всё складывается. Возьмём запрос с условием по первому столбцу ключа:

SELECT sum(revenue)
FROM events
WHERE event_date BETWEEN '2026-05-01' AND '2026-05-07';

Поскольку данные внутри части физически отсортированы по (event_date, user_id), значения event_date в primary.idx монотонно не убывают. ClickHouse делает по индексу бинарный поиск и находит диапазон гранул, чьи границы пересекаются с искомым интервалом дат. Допустим, в части миллион строк (около 122 гранул), а под условие попадает 9 гранул. Движок прочитает только эти 9 гранул столбцов event_date и revenue — порядка 74 тысяч строк двух столбцов вместо миллиарда строк тридцати столбцов. Это называется отсечением гранул (granule pruning), и именно оно превращает полное сканирование в точечное чтение.

Ключевой нюанс: первичный индекс работает только по префиксу ORDER BY. Условие по event_date (первый столбец ключа) отсекает гранулы прекрасно. Условие по country, которого в ключе нет, индексу ничего не говорит — придётся читать все гранулы и фильтровать. Поэтому в ORDER BY первым ставят столбец, по которому чаще всего фильтруют диапазоном.

Для условий по не-ключевым столбцам существуют skip-индекс: вторичные skip-индексы. Они не находят строки — они хранят компактную сводку на блок из нескольких гранул (минимум-максимум значения, набор уникальных значений, Bloom-фильтр) и позволяют движку доказать, что в этом блоке искомого значения точно нет, и пропустить его не читая. Например, minmax-индекс по revenue пропустит блоки, чей диапазон выручки не пересекается с фильтром. Skip-индекс может только разрешить пропуск — он никогда не заставляет читать лишнее.

Сравним с тем, как тот же запрос идёт в PostgreSQL: либо seq scan по всем строкам со всеми столбцами, либо обход B-tree с произвольными прыжками по диску за каждой подходящей строкой. ClickHouse же читает несколько подряд идущих гранул двух столбцов последовательным чтением с диска — и этого достаточно.

Векторизованное выполнение: блоки и SIMD

Мало прочитать мало данных — надо ещё быстро их обработать. Здесь работает четвёртое решение. Классические row-store-движки выполняют запрос по строке за раз: для каждой строки вызывается цепочка функций (так называемая модель Volcano с вызовом next() на строку). Накладные расходы на интерпретацию плана и вызовы функций здесь сопоставимы с самой полезной работой.

ClickHouse обрабатывает данные векторизация: единица работы — не строка, а блок, то есть кусок столбца в несколько тысяч значений, лежащих в памяти непрерывным массивом. Функция вроде sum вызывается один раз на весь блок и в тугом цикле проходит по плотному массиву чисел. Накладные расходы на вызов размазываются по тысячам значений, а сам цикл по непрерывной памяти идеально ложится на кэш процессора и на SIMD-инструкции — когда CPU за один такт складывает сразу несколько соседних чисел.

# Грубая модель различия (псевдокод)

# Row-at-a-time: накладные расходы на каждую строку
total = 0
for row in scan_rows():          # вызов на каждую строку
    total += row.get("revenue")  # разбор строки, доступ к полю

# Векторизованно: одна операция на блок-столбец
total = 0
for block in scan_blocks():           # блок = плотный массив значений
    total += simd_sum(block.revenue)  # тугой цикл, дружелюбный к SIMD

Колоночное хранение и векторизация усиливают друг друга. Именно потому, что столбец на диске лежит непрерывным массивом одного типа, его можно загрузить в память таким же плотным массивом и скормить SIMD без перетасовки. В row-store, где поля переплетены, такой плотный массив пришлось бы сначала собирать.

Цена за скорость: точечные update и delete

У любого инженерного выбора есть оборотная сторона, и у ClickHouse она прямо вытекает из всего сказанного. Части неизменяемы. Это значит, что привычного «обнови одну строку на месте», как в PostgreSQL, здесь физически нет.

UPDATE и DELETE в ClickHouse реализованы как мутация — асинхронные ALTER TABLE ... UPDATE/DELETE. Чтобы изменить даже одну строку, движок не правит её на месте: он находит все части, которые её содержат, и переписывает эти части целиком в новые версии с учётом изменения. Поменять одно значение в большой части означает прочитать и записать заново все её столбцы. Это тяжёлая фоновая операция, а не дешёвая транзакция.

Отсюда практический вывод. ClickHouse великолепен для данных, которые пишутся один раз и потом только читаются и агрегируются: логи, события, метрики, факты. Он плохо подходит как хранилище изменчивого состояния, где строки постоянно точечно правятся. Когда обновления всё же нужны, в ClickHouse идут не через UPDATE, а через специальные движки семейства MergeTree (ReplacingMergeTree, CollapsingMergeTree), где «обновление» выражается как дозапись новой версии строки, а старую при фоновом слиянии схлопывает сам движок. То есть даже изменения здесь укладываются в модель «пиши новые отсортированные части, остальное доделает merge».

Когда что выбирать

Сведём всё в одну ясную мысль. ClickHouse быстрее PostgreSQL на аналитике не потому, что он «новее» или «оптимизированнее», а потому что под аналитическую нагрузку он:

  1. читает только нужные столбцы — колоночное хранение;
  2. хорошо сжимает однотипные данные — меньше ввода-вывода;
  3. читает только нужные гранулы — разреженный индекс по ORDER BY плюс skip-индексы;
  4. быстро считает — векторизация блоками с опорой на SIMD.

Ровно те же решения делают его плохим выбором для OLTP: точечные чтения собирают строку из многих файлов, а точечные изменения переписывают части целиком. PostgreSQL остаётся правильным инструментом для транзакций и изменчивого состояния. Это не конкуренты, а инструменты под разные классы задач — и зрелая дата-архитектура обычно использует оба.

Если хочется пощупать это руками — посмотреть на реальные части на диске, разложить запрос по гранулам и засечкам, поиграть с кодеками и движками семейства MergeTree — это ровно то, чем мы занимаемся на платном курсе ClickHouse: вводные уроки бесплатны, так что начать разбираться можно прямо сейчас. Сам курс — часть бесплатного направления Data Engineering, где те же internals-до-железа разбираются и по соседним инструментам экосистемы.

Ещё в направлении · Data Engineering

Все материалы направления →