Data engineering
Parquet: почему аналитика хранит колонки, а не строки
Ночной Spark-джоб пишет таблицу events в S3 как gzip CSV. Простой SELECT user_id, country FROM events WHERE day = '2024-02-01' читает 1.9 ГБ, распаковывает каждый байт, парсит каждую колонку, выбрасывает 38 из 40 колонок и работает 90 секунд. Аналитик перезапускает его сорок раз, настраивая дашборд. Те же данные в Parquet с snappy: 220 МБ на диске, движок читает два column chunk и row groups одного дня, и запрос возвращается меньше чем за две секунды. В данных не изменилось ничего — только формат файла.
Колонки на диске, а не строки
CSV и JSON хранят данные строка за строкой: все поля строки 1, потом все поля строки 2. Чтобы прочитать одну колонку, ты всё равно проходишь каждый байт каждой строки, парсишь разделители и выбрасываешь 95% распарсенного. Нет статистик, нет типов — "2024" может быть строкой или числом, и читатель не узнает без парсинга.
Parquet переворачивает раскладку. Внутри каждого файла данные разбиты на row groups (горизонтальный срез строк, целевой размер по умолчанию ~128 МБ), а внутри row group каждая колонка хранится как непрерывный column chunk, сам разбитый на pages (единица кодирования и сжатия, ~1 МБ по умолчанию). Поскольку все значения одной колонки лежат вместе, следуют две вещи, невозможные в CSV. Первая — column pruning: запрос, которому нужны user_id и country, читает только эти два column chunk и никогда не касается остальных 38. Вторая — кодирование становится жёстко эффективным: миллион строк country — это в основном одни и те же несколько строк, а колонка похожих значений сжимается куда лучше, чем строка из смешанных типов.
Скорость живёт в футере
Каждый Parquet-файл заканчивается футером: схема плюс метаданные по каждой row group и каждой колонке — и, что критично, min/max/null-count статистики для каждой колонки в каждой row group. Это самая важная структура для производительности запросов.
Когда ты выполняешь WHERE day = '2024-02-01', движок сначала читает футер, смотрит min и max колонки day каждой row group и пропускает любую row group, диапазон которой не может содержать значение, не читая ни байта данных. Это predicate pushdown плюс row-group skipping. На годе дневных данных, отсортированных по дню, такой фильтр касается примерно 1 из 365 row groups. Статистики на уровне pages (и опциональные page indexes) толкают ту же идею на уровень глубже, пропуская pages внутри уцелевшей row group. CSV не умеет ничего из этого — без статистик единственный способ узнать, подходит ли строка, — прочитать и распарсить её.
Почему это работает
Пропуск работает только если данные разложены так, что диапазоны min/max колонки фильтра узкие и непересекающиеся. Сортируй или партиционируй по колонкам, по которым фильтруешь чаще всего. Если значения day случайно разбросаны по row groups, [min, max] каждой row group охватывает весь год, ни одну row group нельзя пропустить, и ты заплатил за статистики, которые ничего не дают. Статистики необходимы, но не достаточны — окупает их кластеризация.
Кодирование и сжатие: два отдельных выигрыша
Parquet сжимает данные в двух наложенных слоях, и сеньоры держат их в голове раздельно. Кодирование учитывает тип и lossless по построению: dictionary encoding заменяет повторяющиеся значения короткими целыми кодами (идеально для строк низкой кардинальности вроде country, status), run-length encoding (RLE) схлопывает прогоны одинаковых значений, bit-packing использует ровно столько бит, сколько нужно диапазону значений, а delta encoding хранит разности для отсортированных целых и таймстемпов. Сжатие (snappy, zstd, gzip) затем прогоняет универсальный кодек поверх уже закодированных байтов.
Числа — вот почему формат победил. CSV на 1.9 ГБ превращается примерно в 1.2 ГБ сырого несжатого Parquet (одно кодирование), а со сжатием обычно в 9–15% от исходного размера CSV. snappy — частый дефолт: быстрый, дешёвый по CPU, ~8x. zstd даёт примерно на 15–20% меньше файлы, чем snappy, при потере чтения меньше 1%, поэтому он лучше для холодных или архивных данных; gzip сжимает ещё плотнее, но заметно медленнее на чтении.
| Свойство | CSV / JSON | Parquet |
|---|---|---|
| Раскладка | Строковая | Колоночная (row group → column chunk → page) |
| Читать только часть колонок | Нет — полный парсинг | Да — column pruning |
| Пропуск строк по фильтру | Нет статистик → читаешь всё | min/max в футере → пропуск row groups + pages |
| Схема / типы | Нет — угадываются при чтении | Встроены, типизированы, самоописывающие |
| Размер vs CSV | 1x (база) | ~0.09–0.15x в сжатии |
Где это кусает в проде
Parquet не свободен от граблей, и каждые из них вылезают на масштабе, а не в демо.
Первая — проблема мелких файлов. Каждый Parquet-файл несёт свой футер и стоит одного I/O, чтобы открыть и прочитать. Стриминговый джоб, пишущий файл по 4 КБ каждые несколько секунд, производит миллионы крошечных файлов, и теперь самая медленная часть каждого запроса — это их листинг: рекурсивно перечислять миллионы объектов в S3 печально медленно и дорого, а планировщику нужно прочитать каждый футер, прежде чем строить план. Фикс — компакция: периодически переписывать рой в файлы диапазона 128 МБ – 1 ГБ.
Вторая — размер row group. Слишком большой — теряешь гранулярность для пропуска и нужна огромная память, чтобы записать одну группу; слишком маленький — раздувается оверхед метаданных, и ты читаешь больше футеров, чем данных. Третья — разбухание словаря: dictionary encoding великолепен для колонок низкой кардинальности, но на колонке высокой кардинальности (UUID, свободный текст) словарь перерастает свой бюджет размера page (~1 МБ), Parquet откатывается к plain encoding, и ты заплатил за построение словаря, который ничего не дал, — иногда получая больший файл. Четвёртая — эволюция схемы: добавление, переименование или перестановка колонок по тысячам файлов оставляет читателей примирять несовпадающие схемы, а переименованное-и-переиспользованное имя колонки может молча смешать два смысла.
Табличные форматы: Parquet плюс мозг
Сырой Parquet — это просто файлы в директории; у него нет понятия транзакции, снимка или «текущего набора файлов». Именно этот пробел заполняют Iceberg, Delta Lake и Hudi. Они держат данные в Parquet, но добавляют сверху слой метаданных/манифестов, который отслеживает, какие файлы принадлежат таблице прямо сейчас, давая ACID-коммиты, time travel, безопасную эволюцию схемы (переименование колонки становится metadata-only операцией, а не переписыванием данных) и manifest-level pruning — движок сверяется с per-file статистиками манифеста, чтобы исключить целые файлы, ещё не открывая ни одного футера. Когда говорят «data lakehouse», имеют в виду именно этот слой: Parquet для хранения, табличный формат для корректности и планирования.
Kafka-консьюмер пишет события кликов в S3 как Parquet, сбрасывая крошечный файл каждые 10 секунд. Дашборды над этой таблицей стали мучительно медленными. Выбери фикс.
Запрос WHERE day = '2024-02-01' на годе данных. Что позволяет Parquet читать куда меньше, чем CSV?
Ты включаешь dictionary encoding на колонке случайных UUID. Какой вероятный исход?
Расставь физическую вложенность, снаружи внутрь:
- 1 Файл (заканчивается футером со схемой + статистиками на каждую row group)
- 2 Row group (горизонтальный срез строк, цель ~128 МБ)
- 3 Column chunk (все значения одной колонки внутри этой row group, непрерывно)
- 4 Page (единица кодирования + сжатия, ~1 МБ)
- 5 Закодированные + сжатые значения (dictionary / RLE / delta, затем snappy/zstd)
- 01Объясни коллеге, от и до, почему отфильтрованный запрос на Parquet читает куда меньше, чем тот же запрос на CSV.
- 02Что такое проблема мелких файлов, почему она калечит планирование запросов и как помогают табличные форматы?
Parquet хранит данные колонка за колонкой, а не строка за строкой, что делает возможными две вещи, недоступные CSV и JSON: column pruning, где запрос читает только нужные column chunk, и row-group skipping, где движок использует min/max/null-count статистики в футере файла, чтобы пропустить целые row groups (и pages), которые не могут удовлетворить фильтр, — predicate pushdown. Внутри раскладка вложена: файл → row group (~128 МБ) → column chunk → page (~1 МБ), и каждый page сначала кодируется (dictionary, RLE, bit-packing, delta), а затем сжимается (snappy для горячих данных, zstd для ~15–20% меньших холодных файлов), приземляясь обычно на 9–15% от исходного размера CSV. Сеньорские режимы отказа — проблема мелких файлов (миллионы крошечных файлов делают листинг и чтение футеров доминантой планирования — фиксится компакцией), неверный размер row group, разбухание словаря на колонках высокой кардинальности (откат к plain encoding, иногда больший файл) и грязная эволюция схемы по многим файлам. Пропуск окупается только когда данные кластеризованы по колонкам фильтра, поэтому сортируй и партиционируй осознанно. А поскольку у сырого Parquet нет транзакций и снимков, табличные форматы — Iceberg, Delta Lake, Hudi — оборачивают его слоем манифестов ради ACID, time travel, безопасной эволюции схемы и manifest-level pruning, и именно это превращает кучу Parquet-файлов в таблицу lakehouse.