В прошлом уроке мы получили vectorized execution на стороне CPU. Но что подаёт эти векторы в движок? Если данные на диске лежат row-by-row (как в Postgres heap), для построения вектора колонки amount нужно прочитать всю строку и выкинуть остальные колонки. Это убивает половину выгоды.
Чтобы vectorized engine работал на полную, нужно изменить и хранение: column store.
Row store vs column store
Идея: вместо «лежим N строк подряд, в каждой все колонки» — «лежим колонка целиком, потом следующая колонка».
Таблица из 5 строк и 4 колонок. В row layout строка лежит непрерывно. В column layout — колонка целиком, потом следующая.
Это физическая разница. Логически строка та же — (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' превращается в:
- Look up ‘RU’ в словаре → получили индекс 17.
- 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';
- Прочитать колонку
country→ SIMD-фильтр → selection vector (битмаска прошедших). - Прочитать колонку
status→ SIMD-фильтр только на прошедших → уточнённый selection. - Прочитать колонку
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 — под массовые аналитические запросы. В реальности компании часто держат оба и реплицируют данные.
Что это значит для 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 — мы читаем всю таблицу, чтобы получить одну колонку.
Сравни число Buffers: shared hit с числом страниц таблицы:
Размер таблицы. Сопоставь с buffers выше — это и есть 'мы прочитали всё, чтобы получить одну колонку'.
В column store на тех же данных читалась бы только колонка total_cents. На orders с 5 колонками — примерно в 5 раз меньше IO. На широких таблицах с 50+ колонками — в 30-50 раз меньше.
И теперь представим типичный аналитический запрос:
Filter + aggregation. В row store читаются ВСЕ страницы со всеми колонками — даже если в SELECT/WHERE используется 3 из 5. Column store читает 3 файла из 5, плюс ещё компрессию учитываем.
В column store на тех же данных прочитались бы: placed_at (8 байт × 100K = 800 KiB), status (dictionary-encoded, ~100 KiB), total_cents (4 байта × 100K = 400 KiB). Итого ~1.3 MiB вместо размера всей таблицы. На 100M строк масштабируется в десятки GB разницы.
Чек-лист
- 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 обсуждаются в следующем уроке.