Колоночные форматы: Parquet, ORC, Arrow
В уроке 01 мы разобрали, как ClickHouse хранит данные у себя. Этот урок про другую сторону данных: что лежит в файлах, когда базы данных нет, а есть просто папка в S3, GCS или HDFS. Аналитика на petabyte-scale без открытия серверной БД, это про Parquet и ORC. Обмен между Python, Java, C++ без сериализации, это про Arrow.
Аудитория: ты понимаешь, чем колоночное хранение отличается от строкового (если нет, урок 01 обязателен), писал хоть раз CSV-pipeline и упирался в стоимость I/O или скорость чтения. Урок не учит pyspark или duckdb с нуля; учит понимать, какие байты лежат в файле и почему движок умеет читать их так быстро.
0. Откуда взялись колоночные файлы
CSV родился в 1972 году вместе с табуляторами; JSON, в 2001 году в JavaScript. Оба формата строковые: одна запись лежит подряд, поля разделены запятыми или фигурными скобками. Это удобно человеку и плохо машине, которая читает миллиард строк ради двух колонок.
В 2009 году Twitter собирал терабайты пользовательских кликов и не мог их быстро агрегировать. Параллельно Cloudera (Hadoop-вендор) пыталась дать Impala равные шансы с Vertica и Teradata. Из этой задачи родились два проекта: Trevni от Doug Cutting (автор Hadoop) и Impala columnar storage от Cloudera. В 2013 году они слились в один формат под Apache Foundation, это Parquet.
Параллельно Hortonworks (вторая Hadoop-компания) и Facebook делали то же самое для Hive. В 2013 году они выпустили ORC (Optimized Row Columnar), оптимизированный под Hive ACID, predicate pushdown и compression. Так получилось, что у каждой Hadoop-сборки оказался свой колоночный формат: Cloudera + Twitter поставили на Parquet, Hortonworks + Facebook, на ORC.
В 2016 году Wes McKinney (автор pandas) и Jacques Nadeau (один из создателей Drill) запустили Apache Arrow. Идея проще и радикальнее: Parquet и ORC, это on-disk формат (читаем с диска и десериализуем); Arrow, это in-memory формат, чтобы один и тот же байтовый layout работал в pandas, Spark, DuckDB, Snowflake-driver без копирования. Это позволило построить mosaic экосистему, где каждый компонент специализирован, а связки делаются zero-copy.
Сегодня (2026 год) ландшафт устаканился:
- Parquet, де-факто стандарт on-disk для analytics. Iceberg, Delta Lake, Spark, Trino, DuckDB, ClickHouse, BigQuery, Snowflake, всё умеет читать Parquet. Это тот файл, который ты увидишь в S3, если попадёшь в чужой data lake.
- ORC, всё ещё силён в Hadoop-стеке (Hive, Tez, Trino). Лучшие индексы, плотнее интеграция с ACID. За пределами Hadoop встречается реже.
- Arrow, in-memory стандарт. Не существует “файла Arrow на диске”, есть Arrow IPC (для inter-process exchange) и Arrow Flight (для network exchange). DuckDB, Polars, Velox, Snowflake-driver, всё внутри Arrow.
Дальше по уроку: разбираем Parquet поэтапно, сравниваем с ORC, потом Arrow. Avro упоминаем как row-based компанию (зачем существует и где применяется), но детально не разбираем, это другая задача.
1. Parquet: физическая структура
Parquet, это бинарный файл с заголовком, телом и футером. Заголовок и футер дают магические байты PAR1. Между ними, последовательность row groups; футер содержит метаданные, схему и индекс, где лежит каждая колонка.
1.1 Иерархия
Сверху вниз:
File
├── Row Group 1 (типично 128 MB)
│ ├── Column Chunk: user_id
│ │ ├── Page Header
│ │ ├── Page 1 (Data Page) (типично 1 MB)
│ │ ├── Page 2 (Data Page)
│ │ └── Page N (Dictionary Page, optional)
│ ├── Column Chunk: ts
│ ├── Column Chunk: country
│ └── Column Chunk: revenue
├── Row Group 2
└── Row Group 3
File Footer (Thrift-encoded metadata: schema, row groups, statistics)
Что важно:
- Row group, горизонтальный срез таблицы. Каждый row group, это законченный мини-файл со своими данными по всем колонкам и своим футером в общем footer-е. Можно прочитать один row group, не трогая остальные.
- Column chunk, вертикальный срез внутри row group, по одной колонке. Все байты одной колонки в одном row group лежат подряд.
- Page, минимальная единица сжатия и кодирования. Внутри column chunk лежит несколько pages, каждая со своим page header и независимо декодируемым body.
- Footer, центральный индекс файла. В нём схема, список row groups, для каждого, смещение на диске и статистики (min/max/null_count) по каждой колонке. Чтобы открыть Parquet, читатель сначала читает футер (последние ~10 KB), потом по нему понимает, какие куски нужно подтянуть.
Это значит, что Parquet можно читать сверху вниз через range-reads из S3. Не нужно скачивать весь файл, чтобы понять, что в нём; HTTP-range-запросом подтягиваем последние N байт, парсим футер, потом range-запросом подтягиваем нужные column chunks. На этом построена скорость DuckDB и Trino над Parquet в S3.
1.2 Размер row group
Размер row group, это главный tuning-параметр. Слишком мелкие, теряется выгода predicate pushdown и метаданные едят больше байт, чем сами данные. Слишком крупные, не получается читать параллельно (read-один-row-group, это один thread).
Правило: row group около 128 MB после сжатия. Это эмпирическое значение из Spark, оно же стало негласным дефолтом. Если у тебя ML-feature store с миллионами файлов, можно опуститься до 64 MB; если у тебя single-file warehouse в DuckDB, можно подняться до 512 MB.
import pyarrow.parquet as pq
# row group по умолчанию контролируется через row_group_size (в строках)
# или через write_batch_size + чтения целевого размера в байтах
pq.write_table(
table,
'events.parquet',
row_group_size=2_000_000, # ~128 MB при средней строке 64 байт
use_dictionary=True,
compression='zstd',
compression_level=3,
)
row_group_size в pyarrow указывается в строках, не в байтах. Эмпирически считаем: средняя строка после сжатия ~64 B, целевые 128 MB, получаем 2 миллиона строк на row group.
1.3 Один файл vs много файлов
Parquet самодостаточен: один файл, всё внутри. Но в data lake обычно лежит много мелких Parquet-ов, организованных как partitioned dataset:
s3://lake/events/
├── dt=2026-05-01/
│ ├── part-00000.parquet (256 MB)
│ ├── part-00001.parquet
│ └── part-00002.parquet
├── dt=2026-05-02/
│ ├── part-00000.parquet
│ └── part-00001.parquet
Папки с dt=... называются Hive-style partitions: движок видит WHERE dt = '2026-05-01' и читает только одну подпапку. Это самый дешёвый predicate pushdown, отсекает целые группы файлов до того, как открыт хоть один.
Внутри одной партиции лежит несколько файлов потому, что писатели работают параллельно: каждый Spark executor пишет свой part. Размер файла, тоже tuning: типично 256-1024 MB (один файл, несколько row groups). Слишком много мелких файлов, “small files problem”, это убивает производительность listObjects в S3 и забивает metadata cache.
2. Encodings: что лежит внутри page
Page, это минимальная единица кодирования. Кодек применяется к page целиком: сначала колонка кодируется специфичным encoding-ом (RLE, DICTIONARY, DELTA), потом result сжимается стандартным compressor-ом (Snappy, ZSTD, LZ4). Это два разных слоя.
2.1 PLAIN encoding
Дефолт: PLAIN кладёт значения подряд. INT32 берёт 4 байта на строку, DOUBLE, восемь байт, STRING, four-byte length plus UTF-8 bytes. Хорошо работает на случайных, не повторяющихся значениях. Плохо, когда значения повторяются.
2.2 DICTIONARY encoding
Главная экономия Parquet. Сначала собирается словарь уникальных значений (отдельная Dictionary Page), потом данные хранятся как индексы в словаре. Если в колонке country всего 50 стран на 10 миллионов строк, словарь занимает ~50 строк, а каждая запись, 1 байт индекса вместо 8 байт name. Сжатие до 8x на индексах + словарь сжимается отдельно.
Pyarrow включает dictionary encoding автоматически, пока словарь не превысит 1 MB. После порога переключается на PLAIN. Это разумно: на колонке с миллионом уникальных URL словарь сам станет огромным.
Контролируется флагом use_dictionary=True/False. Лучше оставить True и доверять автотюну; если знаешь, что на конкретной колонке кардинальность высокая, можно отключить точечно.
2.3 RLE / Bit-packing
RLE, run-length encoding, “значение и длина прогона”. Применяется к dictionary-индексам и к колонкам с низкой кардинальностью после сортировки. На колонке is_active BOOLEAN после сортировки [true, true, true, true, false, false] становится [(true, 4), (false, 2)], экономия колоссальная.
Bit-packing идёт в паре: если в RLE длина прогона = 1 (значит, прогона нет), значения паковались в N бит (логом от размера словаря). Колонка с словарём из 50 значений требует 6 бит на значение, а не 8. На больших колонках это экономит десятки процентов.
2.4 DELTA_BINARY_PACKED
Лучший encoding для отсортированных integer-колонок (timestamps, ID, sequences). Хранит первое значение и потом дельты к предыдущему. Дельты обычно мелкие, упаковываются в 1-4 бита. На отсортированной колонке ts (UNIX timestamp в секундах) DELTA даёт сжатие 10-30x по сравнению с PLAIN.
Pyarrow выбирает DELTA автоматически для INT32/INT64-колонок, если monotonic detector видит сортировку. Если ты явно знаешь, что колонка сортирована (потому что писал её через ORDER BY), убедись, что pyarrow это видит. Для timestamps это критично.
2.5 BYTE_STREAM_SPLIT (для float)
Float-числа плохо сжимаются стандартным compressor-ом, потому что хвост мантиссы выглядит как шум. BYTE_STREAM_SPLIT разбивает float на 4 байтовых потока (для FLOAT) или 8 (для DOUBLE), потом каждый поток сжимается ZSTD отдельно. Знаковый и экспоненциальный байты сжимаются хорошо, мантисса остаётся шумной, но overall выигрыш 1.5-3x на типичной numeric-колонке.
Поддержка появилась в Parquet 2.8 (2020); pyarrow включает по флагу column_encoding={'col': 'BYTE_STREAM_SPLIT'}. Используй для floats, когда размер файла важен.
2.6 Compression поверх encoding
После encoding идёт стандартный compressor:
- Snappy, дефолт исторически. Быстрый, но сжимает слабо. Имеет смысл, если ты упёрся в CPU декомпрессии.
- ZSTD-3, новый дефолт по факту. Сжимает в 1.5-2 раза лучше Snappy при сравнимом CPU. Если выбираешь сегодня, бери ZSTD.
- ZSTD-9 / ZSTD-15, сжатие сильнее, запись медленнее. Подходит для холодных архивов, читаемых редко.
- LZ4_RAW, конкурент Snappy на скорости, чуть сильнее. Поддерживается современными библиотеками, но не везде.
- GZIP, легаси. Сжимает хорошо, декомпрессия медленная. Не используй в новых проектах.
- BROTLI, нишевый, плохо поддержан в spark-стеке.
Главный совет: ZSTD-3 для новых файлов. Snappy оставь только для очень горячих read-heavy сценариев, где CPU декомпрессии становится узким местом.
урок из курса
Урок из курса «Data Engineering». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.