Learning Platform
Глоссарий Troubleshooting
Урок 13.02 · 40 мин
Начальный
ParquetColumnarpyarrowCompressionPredicate PushdownPartitioning

Apache Parquet: columnar формат для аналитики

Avro оптимизирован под транзакционную работу: одна запись — одно сообщение, append-only поток. Когда нужно делать SQL-аналитику над миллионами строк (SELECT avg(price) FROM events WHERE date = '2026-05-14'), row-формат проигрывает: чтобы вычислить среднее по одной колонке, движок читает все остальные.

Apache Parquet — columnar бинарный формат, оптимизированный под аналитику. Спроектирован Twitter и Cloudera в 2013 году, основан на работе Google Dremel. В 2026 — стандарт для data lake-ов (S3, ADLS, GCS), нативно поддерживается Spark, ClickHouse, Snowflake, Iceberg, Delta Lake, DuckDB, DataFusion.

Бинарные форматы: Avro, Parquet, ORC в Python

Зачем колоночное хранение

В row-формате на диске последовательно лежат полные записи:

[ id=1 | name='Alice' | age=30 | city='Moscow' | salary=100000 ]
[ id=2 | name='Bob'   | age=25 | city='Kazan'  | salary=80000  ]
[ id=3 | name='Carol' | age=35 | city='Moscow' | salary=120000 ]

Запрос SELECT avg(salary) FROM users читает все 5 колонок, чтобы добраться до salary. На таблице из 50 колонок — 98% I/O впустую.

В columnar-формате значения одной колонки лежат рядом:

column id:     [1, 2, 3]
column name:   ['Alice', 'Bob', 'Carol']
column age:    [30, 25, 35]
column city:   ['Moscow', 'Kazan', 'Moscow']
column salary: [100000, 80000, 120000]

Запрос на средний salary читает только последнюю колонку — 2% от объёма данных. При типичной аналитике 10-100x ускорение.

Бонус: значения одной колонки часто похожи. Все salary — целые числа в узком диапазоне, города часто повторяются. Это делает

сжатие в 5-20 раз лучше
, чем у JSON или CSV.

Структура файла

Parquet-файл — не поток записей, а строгая трёхуровневая иерархия: row groups -> column chunks -> pages, плюс metadata в footer-е.

Иерархия Parquet: file -> row group -> column chunk -> page
Parquet FileОдин физический файл на диске или в S3. Размер обычно 100 МБ -- 1 ГБ.
Row Group 0 (~128 MB)Горизонтальный срез таблицы. Содержит ~миллион строк. Минимальная единица параллельного чтения.
каждый column chunk = одна колонка для строк этого row group
Column Chunk: колонка 'salary'Все значения salary для строк этого row group, последовательно на диске. Несколько pages внутри.
page -- единица сжатия и I/O
Page (~1 MB)Минимальный блок сжатия. Содержит header + repetition levels + definition levels + values
File FooterThrift-сериализованные FileMetaData: schema, расположение всех row groups и column chunks, statistics для каждого chunk-а.

Row group (~128 MB по умолчанию) — это горизонтальный «слой» строк, единица параллельного чтения. На кластере из 10 нод можно поделить файл из 100 row groups между нодами по 10 каждой.

Column chunk — все значения одной колонки в одном row group. Это первое, что читает движок при projection: «нужны только salary и city из 50 колонок» -> читаем 2 column chunks из 50, остальные пропускаем.

Page (~1 MB) — минимальная единица сжатия и кодирования. Внутри page значения кодируются и сжимаются.

Footer хранит всю метаинформацию: schema, расположение каждого row group, statistics (min/max/null_count) для каждого column chunk. Movie читает footer первым делом.

Encoding: умные представления значений

Parquet применяет несколько слоёв оптимизации до сжатия:

  • Plain — значения как есть, в нативных байтах. Baseline.
  • Dictionary — для колонок с low cardinality (статус, страна). Уникальные значения кладутся в dictionary, в основной части — индексы. ['ACTIVE', 'INACTIVE', 'ACTIVE', ...] -> dictionary ['ACTIVE', 'INACTIVE'] + indices [0, 1, 0, ...].
  • RLE (Run-Length Encoding) — повторы одного значения кодируются как пары (count, value).
  • Delta encoding — для отсортированных или почти отсортированных чисел/timestamps записывается разница с предыдущим значением. Если интервал данных — каждая секунда, дельты = 1, и они отлично сжимаются.
  • Bit packing — для маленьких чисел (например, индексов dictionary размером 256) используется ровно столько бит, сколько нужно.

Эти кодировки применяются автоматически. Pyarrow выбирает оптимальную стратегию в зависимости от данных в page.

Compression: ещё один слой

После encoding к страницам применяется обычный compression-кодек:

  • snappy — стандарт по умолчанию. Быстро, средний compression ratio.
  • gzip — медленнее, лучше сжимает.
  • zstd — современный, лучшее соотношение скорость/размер. В 2026 рекомендуется по умолчанию для новых pipeline-ов.
  • lz4 — самое быстрое сжатие/распаковка, ratio хуже.
  • brotli — высокое сжатие, сравнимо с gzip, чуть быстрее.

Двухступенчатое (encoding + compression) даёт реальную выгоду: на типичных аналитических данных Parquet занимает 5-15% от исходного CSV.

Pyarrow в Python

Каноническая Python-библиотека — pyarrow (binding Apache Arrow). Простейший пример записи DataFrame:

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

df = pd.DataFrame({
    "id": range(1_000_000),
    "city": ["Moscow", "Kazan", "Sochi"] * 333_334,
    "ts": pd.date_range("2026-01-01", periods=1_000_000, freq="1s"),
    "value": np.random.uniform(0, 100, 1_000_000),
})

# Через Pandas
df.to_parquet("data.parquet", engine="pyarrow", compression="zstd")

# Через pyarrow напрямую -- больше контроля
table = pa.Table.from_pandas(df)
pq.write_table(
    table,
    "data.parquet",
    compression="zstd",
    use_dictionary=True,
    row_group_size=500_000,  # сколько строк в одном row group
)

# Чтение всего файла
table = pq.read_table("data.parquet")
df = table.to_pandas()

# Чтение только нужных колонок (column pruning)
table = pq.read_table("data.parquet", columns=["city", "value"])

# Чтение с фильтрами (predicate pushdown)
table = pq.read_table(
    "data.parquet",
    columns=["city", "value"],
    filters=[("city", "=", "Moscow")],
)

Параметр filters — критически важный. Без него движок читает все row groups; с ним использует statistics в footer-е, чтобы пропустить row groups, где Moscow точно отсутствует.

Predicate pushdown

В footer для каждого column chunk Parquet хранит статистики:

column 'city' in row group 0:
  min = 'Anapa'
  max = 'Kazan'
  null_count = 0

column 'city' in row group 1:
  min = 'Krasnodar'
  max = 'Novosibirsk'
  null_count = 12

Запрос WHERE city = 'Moscow' обрабатывается так:

  1. Прочитать footer.
  2. Для каждого row group проверить statistics column ‘city’: попадает ли ‘Moscow’ в [min, max]?
  3. Row group 0: max='Kazan', что меньше 'Moscow' -> SKIP.
  4. Row group 1: 'Krasnodar' <= 'Moscow' <= 'Novosibirsk' -> READ.

Это и есть predicate pushdown — фильтр опускается до уровня I/O, мы вообще не читаем нерелевантные row groups. На отсортированных данных эффект максимальный.

TIP

Сортируйте данные по часто фильтруемой колонке перед записью в Parquet. Это даёт min/max statistics высокой селективности и многократно ускоряет аналитические запросы. ClickHouse, Iceberg и Delta Lake специально поддерживают сортированную запись.

Дополнительные механизмы pruning-а:

  • Column index (Parquet 2.0+) — page-level statistics. Можно пропускать целые pages внутри column chunk.
  • Bloom filter — для high-cardinality колонок (user_id, transaction_id). Позволяет с большой вероятностью сказать «этого значения точно нет в row group».
  • Dictionary filtering — если запрос на одно значение, и оно отсутствует в dictionary этого chunk-а, можно пропустить.

Partitioning по Hive-style

В data lake-ах файлы обычно разбиты по директориям, имитирующим колонки:

events/
  year=2026/
    month=04/
      day=01/
        part-00000.parquet
      day=02/
        part-00000.parquet
    month=05/
      day=14/
        part-00000.parquet
        part-00001.parquet

Это

Hive-style partitioning
. Запрос WHERE year = 2026 AND month = 5 обрабатывается через partition pruning: движок не читает футеры файлов из других месяцев — он даже не открывает эти файлы.

# Запись с партиционированием
pq.write_to_dataset(
    table,
    root_path="s3://lake/events",
    partition_cols=["year", "month", "day"],
)

# Чтение с фильтром по партиции
import pyarrow.dataset as ds
dataset = ds.dataset("s3://lake/events", format="parquet", partitioning="hive")
filtered = dataset.to_table(
    columns=["user_id", "value"],
    filter=(ds.field("year") == 2026) & (ds.field("month") == 5),
)

Хорошее партиционирование = ключи запросов. Плохое — слишком мелкие партиции (один файл на каждый user_id) или партиция по непопулярному фильтру.

Parquet vs Avro vs CSV

СценарийCSVAvroParquet
ОбъёмБазовая (1x)0.3x0.05-0.15x
SELECT * FROM huge_tableМедленноСреднеБыстро (с column pruning)
SELECT col1 FROM huge_tableОчень медленноОчень медленноОчень быстро
INSERT 1 записьТривиальноТривиальноНе подходит (нужен row group)
Streaming Kafka messageПодходитИдеальноНе подходит
Schema evolutionНикакойХорошаяОграниченная
Human-readableДаНетНет
Параллельное чтениеПо размеруПо sync markerПо row groups (идеально)

Parquet почти всегда выбирают для аналитического хранения, Avro — для streaming и transport, CSV — только для interop с не-DE инструментами (Excel, ad-hoc).

Где встретится у DE

  • S3 Data Lake — стандартный формат. Iceberg, Delta Lake, Hudi — все построены на Parquet под капотом.
  • Snowflake — внешние таблицы через Parquet, COPY INTO Parquet.
  • ClickHouse — функция s3('https://.../*.parquet') для прямого SQL.
  • DuckDBSELECT * FROM 'data.parquet' без импорта.
  • Spark — нативный формат: spark.read.parquet(path).
  • dbt — материализации в Parquet через адаптеры (Spark, Trino).
  • Pandas / Polars — стандартный формат сериализации в data engineering pipeline-ах.

Junior DE в первый месяц напишет десятки df.to_parquet() и pd.read_parquet() — это базовый рабочий инструмент.

Проверка знанийKnowledge check
Команда хранит логи приложения в S3 как один большой Parquet-файл (10 ГБ, 200 миллионов строк, 30 колонок). Запросы 'покажи last_login для пользователя user_id=42 за последние 7 дней' тормозят. На какие три направления оптимизации стоит посмотреть и почему?
ОтветAnswer
Три ключевых оптимизации, отражающие три уровня pruning в Parquet. (1) Партиционирование по date -- разбить данные на структуру 'logs/year=2026/month=05/day=14/part-N.parquet'. Это даёт partition pruning: запрос за 7 дней откроет только 7 директорий, остальные не будут трогаться вообще. На 10 GB файле без партиций движок обязан хотя бы прочитать footer, а с партициями -- даже этого не нужно. Эффект 50-100x на больших таблицах. (2) Сортировка по user_id внутри каждой партиции -- это даёт высокую селективность min/max statistics для row groups. Если данные отсортированы, user_id=42 окажется в одном-двух row groups из сотен; predicate pushdown пропустит остальные. Если сортировка невозможна (сложный паттерн доступа) -- добавить bloom filter на user_id, который тоже позволяет пропускать row groups. (3) Column pruning -- конкретно запрос на last_login требует только 2 колонки из 30: user_id (для фильтра) и last_login (для результата). Убедиться, что в коде указано columns=['user_id','last_login'] и pyarrow читает только эти column chunks. Без этого движок читает все 30 колонок впустую. Дополнительно: если файл одиночный -- разбить на несколько файлов по 200-500 MB для параллельного чтения; если используется compression=gzip -- переключить на zstd для лучшей скорости распаковки.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Запрос 'SELECT avg(salary) FROM users' над таблицей с 50 колонками. Почему Parquet выполнит его в 10-50 раз быстрее, чем CSV?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 3