Раздел 26 · Data Engineering

Колоночные форматы: Parquet, ORC, Arrow

middle~60 мин

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

Перед этим уроком

Колоночные форматы: 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 ₽.

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