Learning Platform
Урок 15.03 · 24 мин
Продвинутый
Column storeRow storeCompressionRLEDictionary encodingOLAPOLTP

В прошлом уроке мы получили vectorized execution на стороне CPU. Но что подаёт эти векторы в движок? Если данные на диске лежат row-by-row (как в Postgres heap), для построения вектора колонки amount нужно прочитать всю строку и выкинуть остальные колонки. Это убивает половину выгоды.

Чтобы vectorized engine работал на полную, нужно изменить и хранение: column store.

Row store vs column store

Идея: вместо «лежим N строк подряд, в каждой все колонки» — «лежим колонка целиком, потом следующая колонка».

Row layout vs Column layout: одни и те же данные на диске

Таблица из 5 строк и 4 колонок. В row layout строка лежит непрерывно. В column layout — колонка целиком, потом следующая.

Row store (Postgres heap)строка целиком в одной странице
[id=1, name=A, country=RU, amount=100]row 1
[id=2, name=B, country=DE, amount=200]row 2
[id=3, name=C, country=RU, amount=150]row 3
[id=4, name=D, country=US, amount=300]row 4
[id=5, name=E, country=RU, amount=120]row 5
Column store (Parquet / Arrow)колонка непрерывным куском
id[1, 2, 3, 4, 5]
name[A, B, C, D, E]
country[RU, DE, RU, US, RU]
amount[100, 200, 150, 300, 120]
следствиеrow: чтобы прочитать одну колонку, надо тронуть все страницы. column: читаешь только колонку. на широких таблицах разница 10-50x в IO.

Это физическая разница. Логически строка та же — (id, name, country, amount). Но восстановить её из column store надо вручную: взять id[0], name[0], country[0], amount[0].

Немного истории

Идея column store не нова: впервые предложена ещё в 1970-х (Bjorn Skog). Коммерческое воплощение — Sybase IQ (1996, первый mainstream column DB). Революцию сделала статья C-Store от Stonebraker et al. (2005), которая показала, что на современном hardware column store даёт 50-100x на аналитике. C-Store превратился в Vertica (HP), и вдохновил MonetDB, ClickHouse, Snowflake.

Сейчас (2026) ландшафт стабилен: все аналитические warehouse-движки — column store. Row store остался только в OLTP-нише: Postgres, MySQL, Oracle.

Где column выигрывает

1. Чтение только нужных колонок

Запрос SELECT sum(amount) FROM orders в row store читает все колонки на странице, потому что страница содержит целые строки. Если строка 500 байт, а amount — 4 байта, ты читаешь в 125 раз больше данных, чем нужно. Реальный disk bandwidth тратится в основном на ненужное.

В column store ты читаешь только файл колонки amount. На 100M строк × 4 байта = 400 MiB вместо 50 GiB.

2. Compression

В одной колонке значения гомогенны — одного типа, часто с малой кардинальностью или повторами. Это золотая жила для compression. Топовые техники:

  • RLE (Run-Length Encoding)
    : [RU, RU, RU, RU, DE, DE, DE][(RU, 4), (DE, 3)]. Идеально для отсортированных колонок и group by-keys.
  • Dictionary encoding
    : словарь уникальных значений + индексы. Колонка country с 200 уникальных стран хранится как индексы 0-199 (1 байт) вместо строк по 6-12 байт. Compression 6-12x только от этого.
  • Bitpacking: integer-значения хранятся в минимально необходимом числе бит. Если все значения помещаются в 10 бит — храним по 10 бит, а не по 32.
  • Delta encoding: для timestamp’ов хранится разница с предыдущим (часто 1 секунда → 1 байт вместо 8).
  • Frame-of-Reference: значения в окне относительно базы.

Эти техники складываются. На реальных аналитических датасетах общая компрессия 10-30x против uncompressed row store — норма. На time-series — до 100x.

3. Aggregation скорости

SIMD прекрасно работает с плотной непрерывной колонкой. sum(amount) на 100M int32 = одна горячая loop с AVX2-инструкциями, ~0.1 сек на ноутбуке.

В row store те же 100M значений лежат рассеянно по 50 GiB — даже после декомпрессии в память, SIMD не помогает: данные не упакованы. Vectorized engine физически не может построить vector из таких данных без копирования.

4. Vectorized scan на сжатой колонке

Многие column stores умеют выполнять часть операций прямо на сжатой колонке, без декомпрессии. Например, dictionary-encoded country сжат в индексы 0-199, и фильтр WHERE country = 'RU' превращается в:

  1. Look up ‘RU’ в словаре → получили индекс 17.
  2. SIMD-сравнение колонки индексов (1 байт каждый) с 17 — 16 значений за такт AVX-128.

Декомпрессии не было вообще. Это называется late decompression: данные остаются в сжатом виде максимально долго, и декомпрессия делается только на финальном этапе (если нужна оригинальная строка). На RLE-encoded данных это ещё выгоднее: sum() по [(100, 50), (200, 30), (150, 20)] — это 100*50 + 200*30 + 150*20, не нужно даже разворачивать run’ы.

5. Late materialization

При выполнении запроса можно как можно дольше работать только с индексами строк, не материализуя сами кортежи. Например:

SELECT amount FROM t WHERE country = 'RU' AND status = 'paid';
  1. Прочитать колонку country → SIMD-фильтр → selection vector (битмаска прошедших).
  2. Прочитать колонку status → SIMD-фильтр только на прошедших → уточнённый selection.
  3. Прочитать колонку amount только по прошедшим позициям.

В row store шаг 3 невозможен — данные неразделимы. В column store это естественная операция. На селективных фильтрах late materialization экономит 90%+ IO.

Где column проигрывает

Архитектура с trade-off’ом. Чем платишь:

1. Random row access дорог

SELECT * FROM t WHERE id = 42: в row store одно чтение, в column store — N чтений (по одному на каждую из N колонок), потом сборка строки. На широкой таблице (50+ колонок) point lookup в 10-50 раз медленнее.

Для OLTP это смерть. Поэтому никто не делает банковские транзакции на ClickHouse.

2. Updates / Deletes

Изменить одну строку в column store — это коснуться N файлов колонок. Большинство OLAP-движков либо запрещают UPDATE целиком, либо реализуют его через append + tombstone + последующее переписывание (MERGE/OPTIMIZE).

ClickHouse: ALTER TABLE ... UPDATE существует, но это асинхронная мутация, переписывающая части таблицы. Стоит дорого. Snowflake: переписывает целые micro-partitions при любом UPDATE.

3. Inserts по одной строке

Vectorized engines любят bulk insert. Один INSERT одной строки = создание новых блоков колонок размером в 1 значение, потом отдельный compaction step. Это работает, но дорого.

4. Транзакции

MVCC + column store + concurrent updates — сложный коктейль. Snowflake и BigQuery платят за это копированием micro-partitions. ClickHouse изначально не поддерживал классических транзакций; в 2023 добавил experimental.

Hybrid форматы: что между row и column

Чистые крайности встречаются реже, чем гибриды. Несколько важных форматов посередине:

  • PAX (Partition Attributes Across) — на каждой странице/блоке колонки лежат отдельно, но блоки одинакового размера, и k-й tuple каждой колонки на странице — это k-я строка. Используется в Hyper, ParquetV2, HBase. Даёт column-store преимущества внутри блока, плюс block-level row reconstruction.
  • Parquet (Apache) — стандарт для аналитического файлового формата. Внутри row group, внутри column chunk, внутри page. Pages сжимаются (Snappy, GZip, ZSTD), есть statistics на каждой page (min/max/null count) для page pruning. Read-only, но всё OLAP читает Parquet.
  • Apache ORC — аналог Parquet, чуть лучше compression и предикаты pushdown. Используется в Hive/Presto экосистеме.
  • Apache Arrow — in-memory columnar формат для interchange между процессами без сериализации. Использует тот же layout, что и Parquet, но в RAM. DuckDB, DataFusion, pandas-arrow интегрированы через Arrow.

В Postgres heap — ни одно из этого. Это всё OLAP-форматы, существуют рядом с PG (через FDW или внешние движки).

OLTP vs OLAP в одной картинке

Когда какое хранилище

Row store оптимизирован под точечные транзакции, column store — под массовые аналитические запросы. В реальности компании часто держат оба и реплицируют данные.

Row store (OLTP)Postgres, MySQL, Oracle
single-row read1 IO
single-row writeдёшево + WAL
full-table aggregationдорого (читай всё)
compressionслабая (heterogeneous data на странице)
Column store (OLAP)ClickHouse, DuckDB, Snowflake, Parquet
single-row readN IO (N = колонок)
single-row writeдорого (touch N файлов)
full-table aggregationидеально (только нужная колонка)
compression10-30x норма, до 100x на time-series
hybridHTAP — попытка совместить (TiDB, SAP HANA). На практике 2 копии данных под капотом + sync

Что это значит для Postgres

Postgres — row store. По всем правилам выше: оптимален под OLTP, слаб на full-table aggregation, не имеет инкрементальной decompression на странице (TOAST — это про большие значения, не про colocation).

Это архитектурное свойство, не bug. Полная замена heap на column store потребовала бы переписать половину Postgres. Но есть расширения, добавляющие column store рядом с row store — следующий урок.

Encoding-стратегии: какой выбирается автоматически

В коммерческих column stores выбор encoding’а — задача движка, не пользователя. На основе sample’а данных колонки движок решает, какой encoding выгоднее:

  • Low cardinality (≤ 256 уникальных значений) → dictionary + 1 байт на индекс.
  • Medium cardinality (256-65536) → dictionary + 2 байта.
  • Sorted integer/timestamp → delta + bitpacking.
  • Sorted with low cardinality → RLE.
  • High cardinality strings → frame-of-reference или просто LZ4/ZSTD compression на raw байтах.
  • Sparse колонки с null → отдельный null-bitmap + хранение только non-null значений.

Это часто несколько encoding’ов на одну колонку, выбираемых per-block. В Parquet это RLE_DICTIONARY, DELTA_BINARY_PACKED, BYTE_STREAM_SPLIT и десяток других. Compression на самих байтах (Snappy / ZSTD) — поверх encoding’а, на следующем уровне.

Сжатие на time-series

Time-series колонки (timestamp, метрики) — отдельная история. Здесь применяется Facebook’овский Gorilla compression (2015) и его варианты:

  • Timestamp: delta-of-delta (разница разностей). На равномерных интервалах это 00000000... — упаковывается в 1-2 бита на значение.
  • Value: XOR с предыдущим, кодирование значимых бит.

На реальных IoT-данных Gorilla даёт compression 10-50x относительно raw float64. Это то, что под капотом у InfluxDB, Prometheus VictoriaMetrics, TimescaleDB compress_chunk.

Смотрим IO-pattern руками

В Postgres можно увидеть, как row layout заставляет читать «много», когда нужна одна колонка:

Запрашиваем одну колонку из 100K строк. Buffers: shared hit показывает число прочитанных страниц. В row layout это число близко к pg_relation_size(orders)/8192 — мы читаем всю таблицу, чтобы получить одну колонку.

PostgreSQL

Сравни число Buffers: shared hit с числом страниц таблицы:

Размер таблицы. Сопоставь с buffers выше — это и есть 'мы прочитали всё, чтобы получить одну колонку'.

PostgreSQL

В column store на тех же данных читалась бы только колонка total_cents. На orders с 5 колонками — примерно в 5 раз меньше IO. На широких таблицах с 50+ колонками — в 30-50 раз меньше.

И теперь представим типичный аналитический запрос:

Filter + aggregation. В row store читаются ВСЕ страницы со всеми колонками — даже если в SELECT/WHERE используется 3 из 5. Column store читает 3 файла из 5, плюс ещё компрессию учитываем.

PostgreSQL

В column store на тех же данных прочитались бы: placed_at (8 байт × 100K = 800 KiB), status (dictionary-encoded, ~100 KiB), total_cents (4 байта × 100K = 400 KiB). Итого ~1.3 MiB вместо размера всей таблицы. На 100M строк масштабируется в десятки GB разницы.

Проверка знанийKnowledge check
У вас таблица events 500M строк, 30 колонок (включая user_agent, url, body — текстовые TEXT поля по 200-500 байт). Аналитики ежедневно запускают запросы вида: SELECT date_trunc('hour', occurred_at), event_type, count(*) FROM events WHERE occurred_at >= NOW() - INTERVAL '7 days' GROUP BY 1, 2. На Postgres это занимает 4 минуты. Какие свойства column store сделают этот же запрос на ClickHouse быстрее, и насколько?
ОтветAnswer
Свойства, играющие здесь главную роль: (1) Selective column read — Postgres читает все 30 колонок (heap-страница содержит строку целиком, средняя строка ~600 байт). ClickHouse читает только occurred_at и event_type — ~12 байт на строку × 500M ≈ 6 GiB вместо ~300 GiB. (2) Dictionary encoding на event_type (типично 10-50 уникальных значений) сжимает колонку в 10-50x. После dictionary занимает ~500 MiB. (3) Delta encoding на occurred_at (отсортированные timestamps): хранится разница с предыдущим. Сжатие 5-10x. Время колонки ~400 MiB. (4) Vectorized aggregation: SIMD group by по event_type — десятки ns на строку против сотен в volcano. Итого: IO падает с ~300 GiB до ~1 GiB (300x), CPU — в 30-50x благодаря vectorized. Реальный замер: 4 минуты Postgres → 2-5 секунд ClickHouse. Это типичный кейс — 50-100x ускорение для time-series аналитики с фильтром по времени и group by.

Чек-лист

  • Row store — строка лежит непрерывно. Column store — колонка лежит непрерывно. Логическая структура та же.
  • Column store читает только нужные колонки — для широких таблиц это огромный выигрыш по IO.
  • Compression-friendly layout: RLE, dictionary, bitpacking, delta. Норма 10-30x, на time-series до 100x.
  • SIMD-aggregation на плотной колонке = десятки ns на строку.
  • Late materialization: фильтруем по индексам, материализуем колонку только по выжившим — экономит 90% IO на селективных запросах.
  • Trade-off: точечный row lookup, single-row INSERT/UPDATE — дорого в column store.
  • OLTP → row store. OLAP → column store. HTAP пытается совместить, обычно через две копии.
  • Postgres — row store. Расширения column store обсуждаются в следующем уроке.
Column Chunks в Apache Parquet Кодеки сжатия в ClickHouse

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Запрос `SELECT sum(amount) FROM orders` на таблице с 50 колонками и 100M строк. Почему он на column store в 30-50 раз быстрее, чем на row store, даже если оба читают с одного и того же диска?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5