Один файл, два способа лежать
Представь таблицу с миллионом заказов и 30 колонками: order_id, customer_id, created_at, amount, currency, ещё 25 полей. Как эти данные положить на диск?
Вариант первый, естественный: пишем строку за строкой. Сначала весь первый заказ (все 30 значений подряд), потом второй, потом третий. Это row-based (построчное) хранение. Так работают CSV, JSON, Avro, OLTP-базы данных типа Postgres и MySQL.
Вариант второй, странный: пишем колонку за колонкой. Сначала все миллион значений order_id, потом все миллион значений customer_id, потом все created_at. Это columnar (колоночное) хранение. Так работают Parquet, ORC, ClickHouse, BigQuery, Snowflake (внутри).
Разница кажется косметической, но она определяет всю архитектуру современной аналитики.
Одни и те же данные, два разных способа размещения на диске
Почему это важно: типичные аналитические запросы
Посмотрим на реальный запрос:
SELECT customer_id, SUM(amount)
FROM orders
WHERE created_at >= '2026-05-01'
GROUP BY customer_id;
В row-based хранилище: чтобы посчитать сумму по клиентам, движок читает каждую строку целиком — все 30 колонок, хотя нам нужны только 3 (customer_id, amount, created_at). Если в таблице 100 GB данных, прочитать придётся все 100 GB.
В columnar: движок открывает только три файла-колонки и читает их. Если три колонки занимают 10 GB, читаются именно эти 10 GB. В 10 раз меньше I/O.
Это первое и главное преимущество колоночного хранения для аналитики: projection pushdown — читаем только нужные колонки.
Projection pushdown: читать только нужное
Термин происходит из проекции реляционной алгебры (SELECT col1, col2 FROM ...). “Pushdown” — потому что оптимизатор запроса “толкает” эту операцию вниз, ближе к storage layer, ещё до чтения данных.
В row-based проекция применяется только после чтения строк — мы прочитали всё, выбросили лишние колонки в памяти. В columnar — проекция применяется на уровне I/O: лишние колонки даже не открываются с диска.
import pyarrow.parquet as pq
# Читаем все колонки — медленно
df_full = pq.read_table("orders.parquet")
# Читаем только две — в 10-15 раз быстрее на широких таблицах
df_proj = pq.read_table("orders.parquet", columns=["customer_id", "amount"])
Predicate pushdown: пропускать ненужное
Второй фундаментальный механизм — predicate pushdown. Это про условие WHERE.
В Parquet/ORC данные хранятся блоками (row groups). Для каждого блока сохраняются statistics: min/max значение каждой колонки, count of nulls, иногда bloom filter. Когда движок видит WHERE created_at >= '2026-05-01', он проверяет stats каждого блока:
- Блок A: min=2026-01-01, max=2026-02-28 -> весь блок не подходит -> пропускаем, не читаем
- Блок B: min=2026-03-01, max=2026-04-30 -> не подходит -> пропускаем
- Блок C: min=2026-05-01, max=2026-05-17 -> подходит -> читаем
На исторической таблице с данными за 5 лет запрос за последний месяц сканирует ~2% данных вместо 100%.
Запрос WHERE date >= '2026-05-01' пропускает блоки, не подходящие по statistics
Компрессия: колонки сжимаются лучше
Это третий бесплатный бонус columnar. В одной колонке значения однородны: все currency — это USD, EUR, RUB и десяток других. Все country — короткий набор кодов. Все status — pending, paid, cancelled.
Однородные данные сжимаются великолепно:
- Dictionary encoding: вместо строк хранится словарь
{0:USD, 1:EUR, 2:RUB}+ массив индексов[0, 0, 1, 0, 2, 0, ...]. Колонка из миллионаUSDвесит как байт + dictionary. - Run-length encoding (RLE): если значение повторяется, хранится пара
(значение, количество).[USD, USD, USD, USD, EUR, EUR]->[(USD,4), (EUR,2)]. - Delta encoding для сортированных чисел: хранятся разности, не сами значения.
- Поверх — стандартные алгоритмы (Snappy, ZSTD, GZIP).
В результате типичная аналитическая таблица в Parquet занимает в 5-20 раз меньше места, чем в CSV. На больших объёмах это означает реальные деньги: вместо 1 PB в S3 — 100 TB, разница в стоимости $20K/мес.
В row-based такая компрессия невозможна: рядом лежат 1001, 42, 150.00, USD — разные типы, разные распределения, словарь и RLE не работают.
Цена columnar: точечные операции
За всё надо платить. Колоночное хранение проигрывает в трёх сценариях:
1. Чтение одной строки. Запрос SELECT * FROM orders WHERE order_id = 1001 в row-based найдёт одну страницу с этой строкой и прочитает её. В columnar придётся открыть 30 файлов-колонок и из каждого достать одно значение. Дорого.
2. Запись отдельных строк (INSERT/UPDATE). Добавить одну строку в row-based — append в конец файла. В columnar — нужно дописать 30 разных колонок, перестроить statistics. На один INSERT — тяжёлая операция. Поэтому Parquet/ORC обычно пишутся батчами: накопили 100K строк, записали как row group.
3. Транзакционность. Изменить одну строку — практически невозможно без переписывания целого блока. Поэтому OLTP базы (Postgres, MySQL) — row-based, а OLAP (ClickHouse, BigQuery) — columnar.
Когда что выбирать
Простое правило: много колонок, мало читаемых за раз, агрегации, исторические данные -> columnar. Много мелких операций, точечные UPDATE/DELETE, транзакции -> row-based.
| Сценарий | Row | Columnar |
|---|---|---|
| Web app, INSERT/UPDATE per request | Да | Нет |
| Аналитический отчёт, агрегация миллиардов строк | Нет | Да |
| Логи (append-only, фильтр по полям) | Иногда | Да |
| Точечное чтение по PK | Да | Нет |
| Сканирование 90% данных | Не разница | Не разница |
В реальности современные системы делают гибрид: HTAP-базы хранят данные в обоих представлениях, real-time идёт в row-store, потом мигрирует в columnar. Подробности в уроке 5.04 (HTAP) и в курсе clickhouse-course.
Внутри columnar: Parquet и ORC
Когда говорят “колоночный формат” в data engineering — обычно имеют в виду Parquet (от Apache, чаще в Spark/Databricks/Snowflake) или ORC (от Apache Hive). Это два самых распространённых формата в data lake. Снаружи оба колоночные, внутри отличаются деталями row groups, encoding-ов, индексов. Разбираем оба отдельно в уроках 4.03 и 4.04.
ClickHouse, BigQuery и другие OLAP-движки используют свои собственные колоночные форматы внутри, недоступные снаружи. Но публикуют экспорт в Parquet — это de facto lingua franca data lake.
Реальный замер
Возьми любую публичную таблицу: например, NYC Taxi (1.6 млрд поездок). В CSV это ~250 GB. В Parquet — ~30 GB (8x компрессия). Запрос “сумма выручки за май 2026 в Манхэттене”:
- CSV: full scan 250 GB, парсинг каждой строки -> 15-25 минут на одной машине
- Parquet: predicate pushdown отбрасывает 99% блоков, projection отбрасывает 95% колонок -> читается ~50 MB -> 2-3 секунды
Это разница не в проценты, а в три порядка. Углубление в Parquet — следующий урок.
Попробуй сам
- Скачай любую открытую таблицу (NYC Taxi, например —
https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page). Сохрани в CSV и Parquet. Сравни размеры на диске. - В DuckDB выполни запрос
SELECT passenger_count, AVG(fare_amount) FROM taxi GROUP BY passenger_countсначала на CSV, потом на Parquet. Замерь время. Объясни разницу. - Открой Parquet-файл через
pyarrow.parquet.ParquetFile(path).metadata. Посмотри, как разбит на row groups, какие statistics хранятся. - Прикинь оценочную экономию места: у тебя таблица 10 TB в CSV. Перевели в Parquet с ZSTD. Сколько займёт? Сколько ты сэкономишь на S3 ($23/TB/мес)?