Learning Platform
Глоссарий Troubleshooting
Урок 10.02 · 30 мин
Продвинутый
Block SizeCompression TuningBenchmarkingZstd DictionarySpark CompressionDuckDB CompressionPolars Compression

Компрессия на практике: тюнинг и измерение

В предыдущем уроке мы разобрали внутренности LZ77, Huffman и ANS — теперь понимаем почему Zstd сжимает лучше Snappy. Но знание алгоритмов — только половина. Вторая половина — как настроить компрессию для ваших данных: размер блока, выбор алгоритма per-column, методология измерения, и Zstd dictionary training.

Размер блока: ratio vs random access

Компрессия применяется к блокам (page в Parquet, chunk в ORC). Размер блока — фундаментальный trade-off:

  • Больше блок → больше контекст для LZ77 → больше повторов → лучше compression ratio
  • Меньше блок → меньше данных для декомпрессии при random access → быстрее point queries
Размер блока vs compression ratio: кривая trade-off
Размер блокаРазмер единицы компрессии (Parquet page, ORC chunk)
Zstd ratioКоэффициент сжатия Zstd-3 на типичных аналитических данных
Decompress для 1 строкиСколько данных нужно распаковать, чтобы прочитать одну строку
Full scan speedСкорость sequential scan всего файла
Типичное использованиеГде используется этот размер блока
8 KBОчень маленький блок. Мало контекста для LZ77 — окно Zstd не заполняется.
~2.0xСлабое сжатие: 8 KB < типичное окно Zstd-3 (256 KB). LZ77 находит мало matches в маленьком блоке. ANS таблицы занимают заметную долю блока (overhead).
8 KB (мгновенно)Для одной строки распаковать 8 KB ≈ 4 μs на Zstd. Практически бесплатно.
Замедление ~20%Много маленьких блоков = много header overhead + много вызовов decompress. Каждый вызов имеет fixed cost (ANS table decode). Full scan медленнее.
OLTP, key-valueБазы с point lookups: PostgreSQL (8 KB pages), RocksDB, Cassandra.
64 KBУмеренный блок. Достаточный контекст для LZ77.
~3.0xХорошее сжатие: окно Zstd-3 (256 KB) частично заполнено. LZ77 находит повторы в пределах блока.
64 KB (быстро)Распаковать 64 KB ≈ 32 μs. Для single-row read = допустимый overhead.
НормальноБаланс: header overhead низкий, decompress calls средние.
DuckDB segmentsDuckDB использует ~64-128 KB compressed segments. Баланс scan + point access.
1 MB (Parquet default)Стандартный размер Parquet page. Хороший баланс для аналитических запросов.
~3.5xСильное сжатие: полный контекст для Zstd-3. LZ77 окно заполнено. ANS overhead минимален относительно размера блока.
1 MB (заметно)Распаковать 1 MB ≈ 500 μs. Для point query: 0.5 ms только на decompress одной страницы. При predicate pushdown: нужно декомпрессировать даже для проверки min/max.
ОптимальноЛучшая throughput для full scan: мало calls, хороший ratio. Стандарт индустрии.
Parquet defaultparquet.page.size = 1 MB (PyArrow, Spark). Оптимально для OLAP: scan-heavy workloads.
8 MBБольшой блок. Максимизация compression ratio.
~3.8xПочти максимальное сжатие: Zstd-3 с окном 256 KB полностью утилизирован. Дальнейшее увеличение блока даёт diminishing returns.
8 MB (дорого)Распаковать 8 MB ≈ 4 ms. Для point query: 4 ms только на decompress. Если запрос затрагивает 10 страниц = 40 ms. Predicate pushdown менее эффективен (гранулярность min/max = 8 MB).
Diminishing returnsRatio улучшился с 3.5x до 3.8x (+8%), а point query стал в 8x дороже. Обычно не оправдано.
ArchivalДля write-once/read-never архивов: максимизировать сжатие, не заботиться о random access.
TIP

Правило большого пальца: Parquet page size = 1 MB — правильный выбор для 90% случаев. Увеличивать до 4–8 MB имеет смысл только для archival (read-never) данных. Уменьшать до 64–256 KB — только если point queries доминируют (тогда, возможно, Parquet — не лучший формат).

Зависимость от данных

Кривая “block size → ratio” не универсальна. Она зависит от entropy данных после encoding:

Block size эффект: высокая vs низкая энтропия
Тип данныхХарактеристика данных после encoding
64 KB → 1 MB (прирост ratio)Сколько выигрывает сжатие от увеличения блока с 64 KB до 1 MB
ОбъяснениеПочему прирост такой
Integers после Delta encodingДельты маленькие, значения повторяются. Уже encoding делает данные повторяемыми.
+5–10%Минимальный прирост: LZ77 уже находит matches в 64 KB. Больший блок мало что добавляет — данные и так повторяемые.
Encoding уже сделал работу. LZ77 находит matches быстро.Delta encoding → маленькие числа → короткие bit-packed patterns → LZ77 быстро ловит повторы даже в маленьком окне.
String колонки без dictionaryВысокая кардинальность, строки переменной длины. Нет encoding — чистые UTF-8 байты.
+30–50%Значительный прирост: больший блок = больше текста = больше повторяющихся подстрок (URLs, email domains, JSON keys).
LZ77 нужен большой контекст для поиска повторяющихся подстрок.String данные: паттерны повторяются на большом расстоянии (domain names каждые 1000 строк). Маленький блок не ловит эти повторы.
Смешанные колонки (типичный случай)Mix из int, string, timestamp колонок с разным encoding
+15–25%Среднее значение. Для некоторых колонок прирост 5%, для других 50%. В среднем — 15-25%.
Зависит от доли string колонок без dictionary.Если 80% колонок — integers с encoding, блок 64 KB достаточен. Если 80% — string без dict, нужен 1 MB+.

Column-level выбор компрессии

Разные колонки одного файла могут использовать разные алгоритмы компрессии. На уровне файла задаётся default, на уровне колонки — override:

Column-level compression: API трёх движков
Spark: parquet compressionSpark задаёт compression на уровне файла (все колонки одинаково). Per-column override через Hadoop ParquetOutputFormat невозможен без custom writer. Spark 3.x: snappy (default до 3.2), zstd (default с 3.2+).
DuckDB: PRAGMA + COPYDuckDB задаёт compression через COPY TO statement. PRAGMA force_compression — для DuckDB internal storage (не Parquet). Для Parquet: COPY ... (FORMAT PARQUET, CODEC 'zstd').
Polars: write_parquet / sink_parquetPolars задаёт compression через параметр write_parquet(). Per-column compression невозможен напрямую — только file-level. Для per-column: use_pyarrow=True + pyarrow.parquet.write_table с column_encoding.
PyArrow: per-column compressionPyArrow — единственный из популярных движков, позволяющий разную компрессию per-column через column_encoding + compression dict. Для maximum control — используйте PyArrow напрямую.
NOTE

Spark и Polars не поддерживают per-column compression — только file-level. Для per-column control используйте PyArrow напрямую: pq.write_table() принимает dict с compression per column name. Это полезно для файлов со смешанными колонками: ZSTD для больших строк, LZ4 для уже хорошо закодированных integers.

Методология бенчмаркинга

Типичная ошибка: измерить compression ratio, объявить “Zstd лучше” и остановиться. Правильный бенчмарк включает три метрики и учитывает пять pitfalls:

Три метрики правильного бенчмарка компрессии
1. Compression RatioОтношение original_size / compressed_size. Чем больше — тем лучше. Но ratio зависит от ДАННЫХ, не только от алгоритма: dictionary-encoded integers дают ratio 10x с любым алгоритмом, raw strings — 2-4x. Всегда тестируйте на ВАШИХ данных.
2. Compress Speed (MB/s)Скорость записи = сколько MB/s сырых данных алгоритм обрабатывает при сжатии. Важно для write-heavy workloads: Kafka producers, Spark writers, ETL pipelines. Если compress speed < network/disk throughput — компрессия становится bottleneck.
3. Decompress Speed (MB/s)Скорость чтения = сколько MB/s сырых данных алгоритм восстанавливает при распаковке. Критично для read-heavy OLAP: каждый query = decompress. Если decompress speed < scan speed — компрессия замедляет queries. Обычно decompress >> compress для LZ-based алгоритмов.

Пять pitfalls бенчмаркинга

5 ошибок измерения компрессии
PitfallРаспространённая ошибка при бенчмаркинге компрессии
Почему неправильноЧто это искажает
Правильный подходКак делать правильно
1. Горячий кеш ОСПервое чтение: данные с диска (медленно). Второе чтение: данные в page cache ОС (быстро). Если не очистить кеш — decompress speed завышен, потому что I/O = 0.
decompress кажется быстрее, чем естьНа горячем кеше: decompress 1 GB = 200 ms (данные уже в RAM). На холодном: decompress 1 GB = 800 ms (500 ms read + 300 ms decompress). Разница 4x.
Первый прогон — warmup (отбросить). Или: sync && echo 3 > /proc/sys/vm/drop_cachesLinux: drop page cache перед каждым замером. macOS: purge. Или: 3 прогона, median. Первый прогон всегда отбрасывается (cold cache penalty).
2. Data skewТестируем на одном dataset (TPC-H lineitem), объявляем Zstd лучшим. Но: TPC-H = uniform distribution. Real data = zipf/skewed. Compression ratio может отличаться в 2x.
Результаты не переносятся на ваши данныеCompression ratio на TPC-H: Zstd 3.5x. На real web logs: Zstd 5.2x. На random UUIDs: Zstd 1.1x. Один бенчмарк ничего не говорит о ваших данных.
Бенчмаркить на ВАШИХ данных. Минимум 3 типа колонок: int, string low-card, string high-card.Берём sample из production. Тестируем per-column type: integers (sorted, random), strings (low-card, high-card), timestamps. Общий ratio = weighted average.
3. Column type biasИзмеряем ratio на файле с 90% integer колонок (хорошо сжимаются с любым алгоритмом). Различие между Snappy/Zstd = 5%. На файле с 90% string = 40%.
Разница между алгоритмами минимальна на integersInteger колонки после encoding: entropy ~2 бит/value. Компрессия добавляет 10-20% сверху. String колонки без dict: entropy ~6 бит/byte. Компрессия добавляет 50-70%. Если тестировать на integers — все алгоритмы выглядят одинаково.
Тестировать per-column type отдельно. Общий ratio = weighted по column sizes.Разделить бенчмарк: integers отдельно, strings отдельно. Сводный ratio: (int_ratio × int_size + str_ratio × str_size) / total_size.
4. Encoding + compression conflationСравниваем 'Parquet Zstd' vs 'Parquet Snappy', но Parquet сначала encoding (dictionary, delta), потом compression. Если encoding = dictionary, данные уже сжаты в 10x. Compression сверху = ещё 2-3x. Итого: 20-30x. Без encoding: 3-5x.
Кажется, что compression делает основную работуТипичное заблуждение: 'Zstd даёт 20x сжатие'. На самом деле: encoding = 8x, Zstd сверху = 2.5x, итого 20x. Заслуга — encoding, не compression.
Измерить encoding-only (compression=none) и compression-only (encoding=plain) отдельно.Два бенчмарка: (1) encoding + no compression (показать вклад encoding). (2) no encoding + compression (показать вклад compression). (3) both (финальный результат). Разделение атрибуции.
5. Decompress throughput vs query latencyDecompress throughput: 1 GB/s. Звучит быстро. Но query latency: decompress 1 MB page = 1 ms. Если query читает 100 pages (после predicate pushdown) = 100 ms. На 10 concurrent queries = 1s CPU time.
Высокая throughput маскирует latency impactDecompress throughput — bulk metric. Для OLAP query: metric = pages × per-page decompress time × concurrency. На 64-core server с 100 concurrent queries: decompress = 30% CPU.
Измерить per-page decompress latency (P50, P99). Умножить на pages-per-query × concurrency.Latency бенчмарк: декомпрессировать 1000 случайных pages, записать P50/P99. Multiply: pages_per_query × decompress_p99 = worst-case query decompress overhead.

Шаблон бенчмарка

Чек-лист правильного бенчмарка компрессии
  1. Данные: production sample ≥ 1 GB, разные column types
Подготовка: sample из production data. Минимум 1 GB. Разнообразные column types. Если нет production данных — генерировать реалистичные с правильным distribution (не uniform random).
  1. Алгоритмы: Snappy, LZ4, Zstd-1, Zstd-3, Zstd-9
Алгоритмы: тестировать минимум 3 (Snappy, LZ4, Zstd-3). Для Zstd: тестировать уровни 1, 3, 9, 19. Для LZ4: standard + LZ4HC-9 (если доступен).
  1. 3 метрики × 5 прогонов × drop caches
Измерения: compression ratio + compress speed + decompress speed. Каждое: 5 прогонов, median. Первый прогон — warmup (отбросить). Drop page cache перед cold-cache тестом.
  1. Отделить encoding от compression per-column-type
Разделение: encoding-only (compression=none) + compression-only (encoding=plain) + both. Для каждого column type отдельно.
  1. Результат: best algorithm per column type per workload
Результат: таблица [algorithm × metric × column_type]. Решение: не 'лучший алгоритм', а 'лучший алгоритм для column type X при workload Y'.

Zstd Dictionary Training

Стандартная компрессия работает плохо на маленьких данных (< 4 KB): LZ77 не находит повторов в коротких блоках, ANS таблицы — значительный overhead. Решение: обучить словарь на sample данных и использовать его при компрессии.

Zstd Dictionary: обучение и использование

Проблема: короткие записи (< 4 KB) → ratio ~1.2x

Проблема: короткие записи (JSON events 200–500 bytes, log lines 100–300 bytes, Kafka messages 50–2000 bytes). Стандартный Zstd: ratio ~1.2x (почти нет сжатия). Причина: LZ77 окно пустое, ANS overhead > savings.
Решение: обучить dictionary
Шаг 1: собрать training samplesСобрать 1000–10000 типичных записей в отдельные файлы (каждый файл = одна запись). Или: один файл с записями, разделёнными newline. Samples должны быть репрезентативны — то же distribution, что в production.
Шаг 2: обучить словарьzstd --train: анализирует samples, находит общие паттерны (повторяющиеся ключи JSON, общие prefixes, частые подстроки). Строит словарь ~100 KB — содержит частые patterns. Словарь = 'предзаполненное окно LZ77' + предобученные ANS таблицы.
Шаг 3: компрессия со словарёмПри компрессии: словарь загружается в компрессор. LZ77 начинает с pre-filled окном (содержимое словаря). ANS таблицы — предобученные. Результат: первые же байты записи находят matches в словаре. Ratio: 1.2x → 3–5x на коротких данных.

Результат: ratio 1.2x → 3–5x на записях < 4 KB

Результат: ratio на коротких записях вырастает с ~1.2x до 3–5x. Compress/decompress speed не меняется (словарь = предзаполненное состояние, не дополнительная работа). Ограничение: один словарь per data type. Нельзя сжимать JSON и CSV одним словарём.

Когда dictionary полезен

Zstd Dictionary: когда использовать
СценарийТип данных / workload
Без словаряStandard Zstd-3
Со словарёмZstd-3 + trained dictionary
Kafka messages (200–500 B)JSON events в Kafka. Повторяющаяся schema (ключи), переменные values. Типичный размер 200–500 байт.
~1.1x (почти нет сжатия)200 байт: LZ77 не находит повторов (окно пустое). ANS header ~30 байт. Net: 200 → 180 байт. Ratio: 1.1x.
~3.5x (JSON keys в словаре)Словарь содержит все повторяющиеся ключи JSON. LZ77 сразу находит matches. 200 → 57 байт. Ratio: 3.5x.
Log lines (100–300 B)Structured log lines: timestamp, level, message. Короткие, повторяющийся формат.
~1.2xЧуть лучше: timestamps и log levels дают некоторые patterns. Но 100 байт — слишком мало для LZ77.
~4.0x (формат в словаре)Формат лога, timestamp patterns, level names — всё в словаре. Каждая строка ≈ 3-5 matches из словаря. 100 → 25 байт.
Parquet pages (1 MB)Стандартные Parquet pages. Больше 4 KB — dictionary бесполезен.
~3.5x1 MB — достаточный контекст для LZ77. Стандартный Zstd работает оптимально.
~3.5x (нет улучшения)На больших блоках (>16 KB) dictionary не добавляет ничего: LZ77 и так находит все patterns в самих данных. Overhead словаря = wasted space.
WARNING

Dictionary ≠ silver bullet. Он помогает только на данных < 4–16 KB. На больших блоках (Parquet pages, ORC stripes) — бесполезен. И: словарь нужно хранить вместе с данными или в отдельном registry. Если словарь потерян — данные не распакуются. Версионирование словарей — обязательно.

Compression ratio vs query latency: кривая trade-off

Финальный практический вопрос: как compression level влияет на end-to-end query latency? Сжатие экономит I/O (меньше данных с диска), но тратит CPU (decompress). Существует оптимальная точка:

Zstd level vs end-to-end query time
Zstd LevelУровень компрессии
File Size (1 GB raw)Размер сжатого файла из 1 GB сырых данных
I/O Time (200 MB/s disk)Время чтения с диска (200 MB/s sequential read)
Decompress TimeВремя декомпрессии на 1 CPU core
Total (I/O + CPU)Суммарное время = I/O + decompress (упрощённо, без overlap)
noneБез компрессии: raw данные
1000 MB1 GB файл
5.0s1000 MB / 200 MB/s = 5.0 секунд
0sНет компрессии — нет CPU cost
5.0sBaseline: 5 секунд на чтение
1 (fast)Минимальная компрессия
350 MB (2.9x)2.9x сжатие
1.75s350 MB / 200 = 1.75s. Экономия: 3.25s I/O.
0.7s1 GB / 1.4 GB/s (Zstd-1 decompress speed) ≈ 0.7s
2.45s +Лучше baseline на 51%. I/O экономия > CPU cost.
3 (default)Стандартный уровень
285 MB (3.5x)3.5x сжатие
1.43s285 / 200 = 1.43s
0.9s1 GB / 1.1 GB/s ≈ 0.9s. Чуть медленнее decompress.
2.33s + (optimal)Оптимальная точка: минимальный total. I/O gain от 2.9x→3.5x ещё перевешивает CPU cost.
9Высокая компрессия
240 MB (4.2x)4.2x сжатие
1.20s240 / 200 = 1.20s
1.1s1 GB / 0.9 GB/s ≈ 1.1s. Decompress медленнее из-за сложных ANS таблиц.
2.30s ≈Примерно равно level 3. I/O gain минимален (3.5x→4.2x = -45 MB), CPU cost выше. Diminishing returns.
19Ultra компрессия
210 MB (4.8x)4.8x сжатие
1.05s210 / 200 = 1.05s
1.3s1 GB / 0.8 GB/s ≈ 1.3s. Сложные matches + большие ANS таблицы.
2.35s −Хуже level 3! CPU cost вырос на 0.4s, I/O экономия — только 0.38s. Не оправдано для queries.
TIP

Оптимальный уровень для queries = тот, где дополнительная I/O экономия ≈ дополнительному CPU cost. На типичных SSD/HDD (200–500 MB/s) это Zstd-3 (default). На NVMe (3+ GB/s) — Zstd-1 или LZ4: диск быстрее CPU, компрессия сверху минимальна. На S3 (~100 MB/s): Zstd-9 — медленный network оправдывает сильное сжатие.

Ключевые выводы

  1. Размер блока = ratio vs random access. Parquet 1 MB — правильный default. Увеличивать только для archival, уменьшать — для point lookups.
  2. Column-level compression: Spark/Polars — только file-level. PyArrow — per-column dict. Используйте PyArrow для mixed workloads.
  3. Бенчмаркинг: три метрики (ratio, compress speed, decompress speed) × пять pitfalls (cache, skew, column bias, encoding conflation, throughput ≠ latency).
  4. Zstd dictionary: обучить словарь на samples → ratio на коротких записях (< 4 KB) вырастает с ~1.1x до 3–5x. Бесполезен для больших блоков (> 16 KB).
  5. Compression level vs query latency: optimal point = Zstd-3 на SSD, Zstd-1/LZ4 на NVMe, Zstd-9 на S3. После optimal point — diminishing returns (CPU cost > I/O gain).
  6. Encoding снижает ценность compression: на хорошо закодированных данных разница между алгоритмами минимальна — encoding уже сделал основную работу.
Compression codecs в ClickHouse — детальный разбор

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Spark: spark.sql.parquet.compression.codec = 'zstd'. DuckDB: PRAGMA force_compression = 'zstd'. Polars: .write_parquet(compression='zstd', compression_level=3). В чём ключевое различие API?

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

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

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

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