Раздел 26 · Data Engineering

Колоночные базы и ClickHouse: почему агрегаты летают

middle-senior~80 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Колоночные базы и ClickHouse: почему агрегаты летают

Этот урок про другую базу. Не ту, которая хранит твоих пользователей, корзины и заказы. Не ту, к которой ты привык в виде Postgres или Mongo. А ту, которая отвечает на вопрос “сколько людей из Берлина потратили больше 100 евро за прошлую неделю” по миллиарду строк за 200 миллисекунд.

Цель не выучить SQL-диалект ClickHouse, его легко гуглить. Цель понять, как колоночная база достигает таких чисел и почему Postgres так не сможет, сколько индексов на него ни ставь. Когда поймёшь механику, ты сможешь говорить на собесе о трейдоффах, проектировать таблицы для аналитики и ловить себя за руку, когда тянешь ClickHouse туда, где он сделает только хуже.

Аудитория: ты уже работал с PostgreSQL и MongoDB как с боевыми хранилищами, знаешь что такое индекс, транзакция и EXPLAIN. Если этого нет, сначала надо понять Postgres, иначе сравнения ниже не лягут.

Откуда взялся ClickHouse

ClickHouse родился в Яндекс.Метрике в 2009 году. Алексей Миловидов с командой проверял гипотезу: можно ли строить аналитические отчёты в реальном времени прямо по сырым логам пользовательских действий, а не по предсчитанным кубам, как в хранилищах прошлого поколения. На доказательство ушло три года. В 2012 году ClickHouse поехал в прод Метрики, в 2016 году Яндекс выложил его в open-source, а в 2021 году появился отдельный стартап ClickHouse Inc. и облачная платформа ClickHouse Cloud.

Само название, это сшивка двух терминов. Clickstream, это последовательность переходов пользователя по сайту, ровно тот сигнал, который собирала Метрика. Data Warehouse, это хранилище для аналитики на больших объёмах. Получается, ClickHouse, это data warehouse, заточенный под clickstream-нагрузку: миллиарды событий, широкие таблицы, агрегации в реальном времени.

Эту историю полезно держать в голове, потому что архитектурные решения, которые мы разберём дальше (колоночное хранение, sparse index, MergeTree, materialized views), не появились абстрактно. Они выросли из конкретной задачи: за миллисекунды отвечать на вопрос “что происходило на сайте за последний час” по сырым событиям, без предварительной агрегации.

0. С чего всё начинается: OLTP против OLAP

Рабочих нагрузок на базу грубо две.

OLTP ( Online Transaction Processing ), это короткие транзакции, точечные read и write по primary key, миллисекундный SLA, ACID. Банковский перевод, корзина в магазине, обновление профиля. Шаблон запроса, SELECT * FROM users WHERE id = ? или UPDATE accounts SET balance = ... WHERE id = ?. Доступ к немногим строкам, всем колонкам.

OLAP ( Online Analytical Processing ), это аналитические запросы по широким таблицам, агрегации миллионов строк, latency секунды, ACID на каждый insert не нужен. DAU и MAU, выручка по регионам, retention cohort. Шаблон запроса, SELECT region, SUM(revenue) FROM events WHERE ts > ? GROUP BY region. Доступ к миллионам строк, нескольким колонкам.

Это ключевая дихотомия. Row-store (Postgres, MySQL, Mongo) оптимизирован под OLTP. Column-store (ClickHouse, Snowflake, Redshift, DuckDB) оптимизирован под OLAP. Универсальной БД, которая идеально хороша в обоих, не существует. Гибриды (SingleStore, Spanner) есть, но это всегда компромисс.

Если ты пробовал крутить аналитический дашборд поверх Postgres на 100 млн строк и упирался в десятки секунд на каждый refresh, ты столкнулся именно с тем, что row-store плохо подходит для этой задачи. Materialized view с cron-refresh каждые 5 минут это не решение, это симптом, что ты тащишь не тот молоток.

Сквозной пример

На весь урок берём одну таблицу:

events (
  user_id     UInt64,
  ts          DateTime,
  event_type  String,
  region      LowCardinality(String),
  revenue     Float64,
  ip          IPv4,
  user_agent  String,
  session_id  UUID,
  page_url    String,
  ... ещё 41 колонка ...
)
-- 50 колонок, 1 млрд строк, около 300 ГБ raw данных

И один запрос для дашборда:

SELECT region, SUM(revenue)
FROM events
WHERE ts >= '2026-04-01'
GROUP BY region

К этому запросу мы вернёмся 4 раза по ходу урока, каждый раз он будет работать быстрее, и ты будешь точно понимать, за счёт чего.

1. Row vs column: первый и главный сдвиг

Вернёмся к запросу. Ты просишь region и revenue, две колонки из пятидесяти. Что физически происходит на диске?

В row-store каждая строка лежит непрерывным блоком байтов:

[u_id|ts|type|region|revenue|ip|ua|sess|url|...] [u_id|ts|type|region|revenue|...]

Чтобы прочитать region и revenue, СУБД вынуждена прочитать все 50 колонок для каждой строки, потому что они физически перемешаны. Ты вытаскиваешь с диска в 17 раз больше байт, чем тебе реально нужно. Эти байты идут через шину, занимают page cache, тратят CPU на парсинг, и в конце 94% выкидывается.

В column-store данные хранятся по колонкам:

user_id:    [101, 102, 103, 104, ...]   -> один файл user_id.bin
ts:         [..., ..., ...]              -> один файл ts.bin
region:     ['EU', 'US', 'EU', 'EU', ...] -> один файл region.bin
revenue:    [12.50, 0.0, 8.30, 15.0, ...] -> один файл revenue.bin

Каждая колонка, отдельный файл на диске. Запрос на region и revenue читает ровно два файла. Файлы остальных 48 колонок не открываются вообще.

Это первый и фундаментальный выигрыш column-store. Дальше всё вторично, но оно усиливает первый эффект мультипликативно.

Row vs column layout: что физически читает диск
Layout
Запрос
прочитано с диска0B
из них полезных0B
оверхед0%

Поиграйся с переключателями. Обрати внимание, что для SELECT * оба layout читают одинаково, разницы нет (даже row быстрее, потому что меньше random I/O). А для аналитического запроса картина в десять раз отличается. Это и есть граница, по которой ClickHouse имеет смысл, а Postgres нет.

2. Компрессия как мультипликатор

Когда данные одной колонки лежат рядом, они похожи. Тот же тип, часто похожие значения, повторы, монотонные ID, ограниченный набор регионов. Это даёт радикально лучший compression ratio, чем в row-store, где рядом лежат значения разных типов.

Несколько техник, которые ClickHouse применяет.

2.1 Run-Length Encoding (RLE)

RLE: если колонка отсортирована или имеет длинные повторы, кодируешь как пары (значение, длина серии). Колонка region: ['EU', 'EU', 'EU', 'EU', 'US', 'US', 'US'] сжимается в [('EU', 4), ('US', 3)]. На отсортированных колонках с малой cardinality эффект драматический. На случайной перестановке тех же данных RLE почти не работает: каждый run длиной 1.

2.2 Dictionary encoding

Dictionary encoding: для колонок с низкой cardinality (регион, тип события, страна), строишь словарь {0: 'EU', 1: 'US', 2: 'APAC'}, в самой колонке хранишь маленькие integer-коды. Если у тебя строка длиной 4 байта и 1000 повторов, экономия в разы. Бонусом, фильтр WHERE region = 'EU' превращается в целочисленное сравнение WHERE region_code = 0, что для CPU кратно дешевле строкового.

В ClickHouse это явно через тип LowCardinality(String). Dictionary не зависит от sortedness данных, это отличает её от RLE.

2.3 Delta encoding

Delta encoding: для монотонно растущих колонок (timestamps, autoincrement ID) хранишь не значения, а разницы между соседями. [1704067200, 1704067201, 1704067203] становится [1704067200, 1, 2]. Маленькие числа отлично жмутся следующим уровнем (например, ZSTD).

В ClickHouse это CODEC(Delta, ZSTD), типичный кодек для timestamp колонок.

2.4 Bit-packing и FSST

Bit-packing: если значение умещается в N бит, упаковываешь N бит на запись, а не полный byte. is_premium boolean, 1 бит на запись вместо 8.

FSST (Fast Static Symbol Table): для длинных строк с высокой cardinality (URL, user agent), токенизация общих подстрок. Используется в DuckDB, в ClickHouse есть похожие подходы для определённых сценариев.

2.5 Поверх всего: LZ4 / ZSTD

Общие компрессоры (LZ4, ZSTD) ставятся поверх специализированных кодеков как финальный layer. LZ4 быстрее жмёт и распаковывает, ZSTD жмёт сильнее. Дефолт в ClickHouse, LZ4.

Помимо общих, ClickHouse поддерживает специализированные числовые кодеки: Gorilla и FPC для float (метрики, sensor data), T64 и DoubleDelta для целых, GCD для рядов с общим делителем. На правильно подобранных данных они дают ещё 1.5x-3x поверх ZSTD.

В синтаксисе таблицы это выглядит так:

CREATE TABLE events (
  user_id  UInt64                                CODEC(ZSTD(1)),
  ts       DateTime                              CODEC(Delta, ZSTD(1)),
  region   LowCardinality(String)                CODEC(LZ4),
  revenue  Float64                               CODEC(ZSTD(3))
)
ENGINE = MergeTree
ORDER BY (region, user_id, ts);

Compression ratio в проде, 10x до 50x, на сильно повторяющихся данных бывает 100x. Это не только диск, это ещё и кратное уменьшение I/O для тех же данных. Меньше байт прочитал, быстрее ответил.

Компрессия колонки: 4 кодека на одних и тех же данных

    Подвигай слайдеры. Заметь, как RLE катастрофически проигрывает Dictionary при низкой sortedness, и как Dictionary не зависит от порядка вообще. Delta+ZSTD выигрывает там, где данные “почти монотонные” (sortedness ближе к 100% и cardinality высокая). Это и есть ключ к выбору кодека для конкретной колонки.

    3. Vectorized execution и late materialization

    Хорошее хранение, это половина истории. Вторая половина, как мы это читаем и обрабатываем.

    3.1 Volcano model и почему она тормозит

    Классический row-engine построен на так называемой Volcano model: каждый оператор плана запроса (Scan, Filter, Aggregate, Project) имеет метод next(), который возвращает одну строку.

    top.next() -> aggregate.next() -> filter.next() -> scan.next() -> 1 row

    На миллиардах строк это адские накладные расходы. Виртуальные вызовы функций, branch mispredictions, cache misses, CPU pipeline всё время простаивает. Большая часть времени уходит не на полезную работу, а на оверхед фреймворка.

    3.2 Vectorized execution

    Колоночные движки (ClickHouse, DuckDB, Snowflake) обрабатывают данные батчами по 1024-65536 значений. Один вызов функции, обработка целого вектора. Что это даёт:

    • Резко падает overhead от вызовов функций. Один call на 8192 значения, а не 8192 call’а.
    • CPU инструкции SIMD (AVX2, AVX-512) применяются к векторам: одна инструкция складывает 8 или 16 чисел сразу.
    • Лучше использует L1/L2 cache процессора и pipeline.
    • Branch predictor работает эффективнее, потому что внутри вектора почти всегда одна и та же ветка.

    Именно поэтому ClickHouse написан на C++, а не на JVM. Не потому что C++ “быстрее в принципе”, а потому что вы хотите ручной контроль над выравниванием памяти и интринсиками SIMD. На JVM это сложнее (хотя проекты типа Apache Arrow и Velox показывают, что и там можно).

    3.3 Late materialization

    Запрос проходит через несколько операторов: scan, filter, aggregate, project, output.

    В early materialization (классика row-store) ты сразу собираешь полную row из колонок и таскаешь её через все операторы. Каждый оператор работает с rows.

    В late materialization (column-store) ты как можно дольше работаешь только с позициями (row IDs) и нужными для конкретного оператора колонками. Полную row собираешь в самом конце, когда надо вернуть результат пользователю.

    Пример: SELECT name FROM users WHERE age > 30 AND country = 'NL'.

    • Early: прочитал все колонки для всех строк, потом отфильтровал. Много I/O.
    • Late:
      1. Прочитай только age, найди bitmap позиций где age > 30.
      2. Прочитай только country для этих позиций, отфильтруй ещё раз.
      3. И только теперь прочитай name для финальных позиций.

    I/O сокращается ещё в разы. На широких таблицах с селективными фильтрами эффект огромный.

    Запомни: column-store быстрее не из одной причины, а из трёх сложенных. Меньше I/O за счёт чтения только нужных колонок. Лучше компрессия за счёт похожести данных. Vectorized SIMD execution на CPU. Если на собесе спросят “почему ClickHouse быстрее Postgres на аналитике”, сильный ответ это все три причины и понимание, что они мультипликативны.

    урок из курса

    Урок из курса «Data Engineering». Курс покупается один раз и остаётся навсегда: 500 ₽.

    Оплата пока не подключена: премиум и курсы открываются промокодом.