Ментальная модель: почему вообще колонки
Запомните одну фразу, и половина Parquet станет очевидной: на диске лежат не строки, а колонки. CSV пишет данные построчно — id,name,price первой записи, потом второй, потом третьей. Чтобы прочитать только колонку price, движок обязан пройти весь файл и в каждой строке перепрыгнуть через id и name. Parquet делает наоборот: сначала все значения id, потом все значения name, потом все price. Колонка лежит непрерывным блоком байтов.
Из этого «разворота» вырастают три суперспособности, ради которых формат и существует. Первая — проекция: нужна одна колонка из тридцати, читаешь примерно 1/30 файла, остальное даже не трогаешь. Вторая — компрессия: соседние значения одной колонки однотипны (все INT64, все из узкого диапазона дат), и универсальные кодеки сжимают их в разы лучше, чем разнородную строку CSV. Третья — пропуск данных через статистики, до которой мы дойдём в конце и ради которой стоит вся конструкция.
Но «развернуть» данные целиком на весь файл нельзя: тогда чтобы записать первую строку, надо дождаться последней. Поэтому Parquet режет таблицу на горизонтальные полосы —
Анатомия файла: три уровня вложенности
Физическая структура Parquet — это строгая иерархия из трёх уровней плюс служебный footer в самом конце. Двигаемся сверху вниз.
Row group — горизонтальный блок строк. Типичный размер целевой row group — 128 МБ или около миллиона строк (исторический дефолт — 128 МБ, у многих движков сейчас меньше). Это единица, которую один воркер может прочитать и обработать независимо от других: row groups — естественные сплиты для параллельного чтения в Spark, Trino, DuckDB.
Column chunk — внутри row group данные каждой колонки лежат отдельным куском. На N колонок в таблице приходится ровно N column chunks на каждую row group. Column chunk непрерывен на диске, и именно его адрес (offset + length) записан в метаданных, чтобы читатель мог сделать один seek и прочитать только нужную колонку.
Page — самый нижний уровень. Column chunk бьётся на страницы по ~1 МБ (несжатого размера). Page — это единица кодирования, компрессии и декодирования: кодек применяется к странице целиком, и при чтении страница распаковывается атомарно. Бывают data pages (значения), dictionary page (словарь в начале chunk-а, если колонка словарно-кодирована) и опциональные index pages.
| Уровень | Что это | Типичный размер | Единица чего |
|---|---|---|---|
| Row group | Горизонтальный срез строк | ~128 МБ / ~1M строк | Параллелизма + пропуска по статистике |
| Column chunk | Одна колонка в одной row group | зависит от данных | Проекции (читаем только нужные колонки) |
| Page | Кусок column chunk | ~1 МБ несжатого | Кодирования, компрессии, декодирования |
Ключевой момент: row group режет по строкам, column chunk режет по колонкам. Их пересечение — это «прямоугольник» (колонка X в полосе Y), и адрес каждого такого прямоугольника лежит в footer. Поэтому чтение «колонки price из row group 3» — это вычисленный заранее seek в конкретный offset, а не сканирование.
Footer: карта файла, которую читают первой
Parquet — self-describing формат: схема и вся навигация лежат внутри файла, во footer-е. Структура сериализована через Apache Thrift и записана в самом конце, перед 4-байтным хвостом length и магической сигнатурой PAR1.
Почему в конце, а не в начале? Потому что writer не знает финальные смещения и размеры chunk-ов, пока не дописал данные. Положив метаданные в хвост, формат остаётся однопроходным для записи и при этом полностью навигируемым для чтения.
Читатель действует так: открывает файл, делает seek в EOF - 8, читает длину footer-а и магию, затем одним range-запросом тянет сам footer. На object storage (S3, GCS) это критично — два маленьких GET вместо стягивания гигабайтов. Внутри footer лежит уже знакомая иерархия метаданных: FileMetaData → RowGroupMetaData → ColumnChunkMetaData.
+-----------------------------------+
| PAR1 | <- magic (4 байта)
+-----------------------------------+
| Row Group 0 |
| Column chunk: id (pages...) |
| Column chunk: ts (pages...) |
| Column chunk: price(pages...) |
+-----------------------------------+
| Row Group 1 ... |
+-----------------------------------+
| FileMetaData (Thrift) | <- схема + список row groups
| RowGroupMetaData[] | + offsets + статистики
| ColumnChunkMetaData[] |
| Statistics{min,max,nulls} |
+-----------------------------------+
| footer_length (4 байта, LE) | <- читается ПЕРВЫМ
| PAR1 | <- magic (4 байта)
+-----------------------------------+
В ColumnChunkMetaData хранятся file_offset, кодек (ZSTD, SNAPPY…), список применённых кодировок, сжатый и несжатый размеры, и — самое интересное — min, max, null_count.
Кодировки: PLAIN, dictionary, RLE, bit-packing
Прежде чем колонку сожмёт кодек вроде Zstd, Parquet применяет кодирование — структурную трансформацию, которая использует знание о типе и паттернах данных. Это отдельный уровень: сначала encoding, потом compression. Порядок такой: сырые значения → encoding → compression → диск.
PLAIN — без трансформации. INT64 пишется как little-endian 8 байт, строки — как длина плюс байты. Это fallback, когда хитрить не на чем.
Dictionary encoding (RLE_DICTIONARY) — главный приём для колонок низкой "RU", "US", "DE"). Сами данные заменяются на индексы в словарь: 0, 1, 0, 0, 2. Вместо повторяющихся строк по три байта — крошечные целые. Если словарь распухает выше порога, writer откатывается на PLAIN для этого chunk-а.
RLE (Run-Length Encoding) и bit-packing в Parquet работают гибридом и применяются как раз к потоку dictionary-индексов (а также к repetition/definition levels). Логика двойная. Когда значения идут длинными прогонами — 0,0,0,0,0 — RLE пишет пару (значение, длина_прогона): «0 повторяется 5 раз». Когда прогонов нет, но значения мелкие, включается bit-packing: индексам 0..7 хватает 3 бит, и восемь индексов пакуются в 3 байта вместо 8. Декодер на лету переключается между двумя режимами по флагу в заголовке прогона.
Для отсортированных числовых колонок (timestamps, монотонные id) есть DELTA_BINARY_PACKED: хранятся разности между соседями, а не абсолюты — дельты мелкие, bit-packing на них творит чудеса. Итог: колонка status из миллиона строк с пятью значениями ужимается с мегабайтов до килобайтов ещё до того, как её увидит Zstd.
Predicate pushdown: магия, ради которой всё затевалось
Теперь соберём всё вместе. Допустим, запрос: SELECT price FROM trades WHERE ts >= '2026-01-01'. Что делает движок, читая Parquet?
Шаг 1 — проекция. Из footer видно, что таблица имеет колонки id, ts, price, qty, venue, .... Запросу нужны price (вывод) и ts (фильтр). Движок читает только эти два column chunk-а в каждой row group. Остальные колонки физически не покидают диск — это экономия, недоступная CSV в принципе.
Шаг 2 — пропуск row groups по статистике. Это и есть WHERE вниз, к слою чтения файла. Движок сверяет предикат с min/max статистиками и отбрасывает целые row groups или страницы, не декодируя их.min/max для ts. Движок сверяет их с условием ts >= '2026-01-01':
# Псевдокод skip-логики на уровне row group
predicate_lo = date("2026-01-01")
for rg in file_metadata.row_groups:
stats = rg.column("ts").statistics
if stats.max < predicate_lo: # вся полоса левее границы
continue # пропускаем — ни одного seek в данные
read_and_filter(rg, columns=["ts", "price"])
Если у row group max(ts) = '2025-12-30', ни одна строка не пройдёт фильтр — и движок не читает эту полосу вообще, даже не разжимая ни одной страницы. На годовой таблице, разбитой по дате, запрос за январь честно трогает несколько процентов файла. Обратите внимание на дешевизну операции: сравнение max < predicate_lo — это арифметика над двумя числами из уже загруженного footer-а, без единого обращения к данным колонки. Тысячи row groups отсеиваются за микросекунды, и только выжившие порождают реальные range-чтения.
Шаг 3 — пропуск страниц. Современный Parquet хранит ещё более тонкие структуры — ColumnIndex и OffsetIndex — с min/max уже на уровне отдельных страниц. Тогда даже внутри прошедшей row group движок отбрасывает страницы, чьи диапазоны не пересекаются с предикатом, и делает точечные seek-и к выжившим. Дополняет картину опциональный Bloom-фильтр: для точечных равенств (venue = 'NYSE') он отвечает «точно нет» по chunk-у с высокой кардинальностью, где min/max бесполезны.
Важная оговорка: пропуск эффективен ровно настолько, насколько данные локальны. Если строки за январь равномерно размазаны по всем row groups, то min/max каждой полосы накрывают весь год, и пропускать нечего. Поэтому сортировка/кластеризация данных по часто-фильтруемой колонке перед записью — не косметика, а то, что включает predicate pushdown по-настоящему.
Размер row group: главная ручка, которую крутят неправильно
Размер row group — это компромисс, и оба конца плохи.
Слишком большие row groups (скажем, 512 МБ): footer-статистики min/max покрывают огромные диапазоны, гранулярность пропуска грубая, скипать почти нечего. Плюс для обработки одной полосы воркеру нужно больше памяти, а параллелизм падает — мало сплитов на много ядер.
Слишком маленькие row groups (1 МБ): footer раздувается до тысяч RowGroupMetaData, его парсинг становится дорогим, а на object storage каждое чтение колонки дробится на множество мелких range-запросов — латентность GET-ов съедает выигрыш. Толстый footer сам по себе превращается в узкое место.
Практический ориентир — десятки-сотни МБ на row group (классический дефолт 128 МБ), при этом размер согласуют с блоком файловой системы или с типичным размером range-чтения на S3. И помните: пропуск работает только в паре с layout. 128 МБ отсортированных по ts данных дают точные min/max и реальный skip; 128 МБ случайно перемешанных — те же байты, но pushdown мёртв.
Parquet против CSV: разница не в проценте, а в разах
| Аспект | CSV | Parquet |
|---|---|---|
| Раскладка | построчно (row-major) | поколоночно внутри row groups |
| Схема и типы | нет, всё текст | self-describing, типизирована во footer |
| Чтение 1 из 30 колонок | весь файл | ~1/30 файла (проекция) |
Пропуск по WHERE | невозможен | row group + page skip по min/max |
| Компрессия | gzip всего текста | encoding + кодек на колонку |
| Сжатие (типично) | 1x baseline | 5–15x меньше на диске |
CSV выигрывает ровно в двух нишах: человекочитаемость и потоковая дозапись строк. Для аналитики он проигрывает не на проценты, а на порядок: нет схемы, нет статистик, нет проекции, нет пропуска. Каждый SELECT по CSV — это full scan текстового парсинга.
Куда дальше
Вы только что увидели Parquet как карту с навигацией: footer — это оглавление, статистики — индекс, кодировки — сжатие смысла, а predicate pushdown — то, ради чего аналитические движки вообще выбрали колоночные форматы. Та же идея «метаданные отдельно, пропуск по статистике» масштабируется на ORC, на Arrow в памяти и на table-форматы вроде Iceberg и Delta поверх Parquet-файлов.
Это бесплатный разбор-тизер. Если хотите дойти до байтов — Dremel-кодирование вложенных структур через repetition/definition levels, BYTE_STREAM_SPLIT для float, Bloom-фильтры, схема ORC stripes и Arrow zero-copy — это премиум-курс Modern storage forms, где каждый формат разбирается с hex-viewer-ом и лабами. Введение в него открыто бесплатно. А обзорную карту всего трека инженерии данных смотрите на странице направления Data.
Готовы перестать гадать, почему один и тот же запрос на Parquet в десять раз быстрее — и научиться раскладывать данные так, чтобы pushdown реально срабатывал? Начните с бесплатного интро курса Modern storage forms.