Compaction: стратегии укрупнения файлов через Spark и Iceberg

Философия разделения ответственности между быстрой записью и асинхронным уплотнением, механизм snapshot-изоляции в Iceberg, три стратегии rewrite_data_files (Bin-Pack, Sort, Z-Order) и проектирование автоматических пайплайнов compaction.

optimization

В предыдущем уроке мы диагностировали Small Files Disease и познакомились с базовыми приёмами борьбы с ней - coalesce(), maxPartitionBytes и ручной перезаписью партиций. Все эти инструменты работают, но у них есть общая проблема: они либо блокируют таблицу на время перезаписи, либо требуют тщательного ручного сопровождения. В production-системах, где данные читаются и пишутся непрерывно, это неприемлемо.

В этом уроке мы переходим на следующий уровень - промышленные стратегии Compaction (уплотнения данных). Разберём, почему правильная архитектура вообще не противостоит мелким файлам в момент записи, как Apache Iceberg делает compaction без блокировки читателей через snapshot-изоляцию, и как выбирать между тремя стратегиями уплотнения в зависимости от паттернов запросов.


1. Философия Compaction: зачем и когда он нужен

Почему мелкие файлы неизбежны

Streaming ingestion, CDC-пайплайны и частые инкрементальные batch-задачи неизбежно создают мелкие файлы. Это не ошибка проектирования - это физическое следствие архитектуры:

  • Kafka-топик с задержкой 10 секунд пишет данные каждые 10 секунд. Если за 10 секунд пришло 5 МБ данных - именно такой файл и запишется.
  • CDC-система реплицирует каждую транзакцию из PostgreSQL. Одна транзакция - несколько КБ данных. Миллион транзакций в день = миллион крошечных файлов.
  • Пайплайн SLA требует записать данные за последний час в течение 5 минут после его окончания. За 5 минут невозможно накопить 512 МБ данных - запишется что есть.

Попытка принудительно укрупнить файлы в момент записи через coalesce() перед каждым write - это компромисс, который ухудшает SLA записи ради SLA чтения. В системах с жёсткими требованиями к latency записи это неприемлемо.

Принцип разделения обязанностей: Hot Write + Cold Compact

Правильная архитектура разделяет два потока работы:

Hot Write (быстрая запись): пишем данные как можно быстрее, не беспокоясь о размере файлов. Стриминг работает с минимальной latency. SLA записи соблюдается.

Cold Compact (асинхронное уплотнение): отдельный фоновый процесс периодически забирает накопившиеся мелкие файлы и объединяет их в оптимальные крупные. Этот процесс не мешает ни записи, ни чтению - он работает изолированно.

Это и есть принцип асинхронного дворника: система не борется с мелкими файлами в момент их появления - она убирает за собой отдельным процессом, когда это удобно, не мешая основным потокам работы.

Анатомия «наивного» Compaction в ванильном Spark

Прежде чем разбирать современные подходы, важно понять, почему простое решение «прочитай таблицу и перезапиши» - это проблема в production:

# ❌ Наивный compaction: БЛОКИРУЕТ таблицу для читателей
df = spark.read.parquet("s3a://data/events/event_date=2026-01-15/")

# Во время этой операции:
# - Читатели получают либо старые данные (до overwrite) либо новые (после)
# - Если упадёт на середине - таблица в несогласованном состоянии
# - Нет атомарности: часть файлов удалена, часть ещё не записана
df.coalesce(4) \
  .write \
  .mode("overwrite") \
  .parquet("s3a://data/events/event_date=2026-01-15/")

# Что происходит в S3 при mode("overwrite"):
# 1. Spark удаляет все существующие файлы в папке
# 2. Spark пишет новые файлы
# Если шаг 2 упадёт на 50% - у вас пустая папка!
# Нет атомарности, нет изоляции, нет time-travel.

В production с такой схемой возникают три проблемы:

  1. Видимое окно несогласованности: пока старые файлы удалены, а новые ещё не записаны, таблица частично пуста.
  2. Нет изоляции транзакций: читатель, начавший запрос до compaction, может получить разные данные на разных шагах исполнения.
  3. Нет возможности откатиться: если compaction прошёл неправильно, исходные данные уже удалены.

Именно эти проблемы решают современные табличные форматы - прежде всего Apache Iceberg.


2. Революция Iceberg: архитектура compaction без блокировок

Концепция Snapshots: читатели не замечают compaction

Ключевая идея Iceberg - каждая операция записи создаёт новый Snapshot. Snapshot - это неизменяемое состояние таблицы в конкретный момент времени. Метаданные таблицы хранятся не в папке как в Hive, а в иерархии JSON/Avro-файлов в специальном каталоге metadata/.

Когда запущен Compaction Job:

  1. Spark читает старые мелкие файлы через Snapshot 123
  2. Объединяет их в крупные файлы и записывает новые file_big.parquet рядом со старыми
  3. Создаёт новый Manifest, где мелкие файлы помечены как DELETED, а крупные - как ADDED
  4. Атомарно переключает указатель metadata/current на новый Snapshot 789

Все читатели, начавшие работу до шага 4 - продолжают видеть Snapshot 123 и читать старые мелкие файлы. Все читатели, начавшие работу после шага 4 - видят Snapshot 789 и работают с крупными файлами. Ни один из них не заметил compaction. Нет блокировки, нет окна несогласованности.

Data Files vs Manifests: как Iceberg победил NameNode

В классическом Hive Driver вынужден делать Directory Listing - обходить всё дерево папок через S3 ListObjects. При миллионе файлов это сотни HTTP-запросов.

Iceberg хранит список файлов в Manifest-файлах - Avro-файлах, каждый из которых содержит информацию о тысячах data files: путь, размер, min/max статистика по каждой колонке, статус (ADDED/DELETED/EXISTING).

Классический Hive:
  S3 LIST s3://data/events/ → 1000 объектов
  S3 LIST s3://data/events/dt=2026-01-01/ → 1000 объектов
  ... 365 запросов × 1000 объектов = 365 000 файлов → медленно

Iceberg:
  Читаем metadata/current → snapshot-id
  Читаем manifest-list (один Avro-файл) → список Manifest
  Читаем нужные Manifests (Avro-файлы) → список data files с статистикой
  Применяем Partition Pruning и Column Statistics → читаем только нужные файлы
  Всё это - несколько GET запросов вместо 365+ LIST запросов

При compaction количество Manifest сокращается: вместо тысячи строк в Manifests (по одной на каждый мелкий файл) появляется несколько строк для крупных файлов. Следующий rewrite_data_files начнёт работать намного быстрее.

Жизненный цикл файлов в Iceberg после compaction

Старые файлы не удаляются сразу - это намеренно. Они необходимы для:

  • Time Travel: SELECT * FROM events TIMESTAMP AS OF '2026-01-15 10:00:00' даст данные из Snapshot 123, который всё ещё ссылается на старые файлы
  • Читателей, начавших работу до compaction: они должны дочитать данные из Snapshot 123

Только после expire_snapshots старые Snapshots помечаются как истёкшие, и только после remove_orphan_files или VACUUM файлы физически удаляются с диска. Это явное разделение ответственности: compaction меняет логику (какие файлы считаются активными), а expire+vacuum меняет физику (что хранится на диске).


3. Стратегии уплотнения в Apache Iceberg

Iceberg предоставляет три стратегии rewrite_data_files, каждая с разным соотношением «стоимость compaction vs выгода для последующих запросов».

Стратегия 1: Bin-Pack - самая быстрая

Механика: алгоритм «bin-packing» (упаковка в ящики) группирует мелкие файлы в пакеты суммарным размером до target-file-size-bytes. Файлы просто конкатенируются без пересортировки строк. Аналогия: аккуратно сложить вещи в чемодан, не раскладывая их по типам.

Когда использовать:

  • Когда нужна быстрая борьба с мелкими файлами с минимальными ресурсами
  • Когда запросы к таблице не используют точечные фильтры по некластеризованным колонкам
  • Как первый шаг перед более дорогим Sort/Z-Order

Что получаем: сокращение числа файлов → меньше Tasks при чтении → меньше overhead. Но порядок строк внутри файлов случайный - Data Skipping внутри файлов не улучшается.

# Bin-Pack: просто объединить мелкие файлы в крупные
result = spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'db.events',
        strategy => 'binpack',
        options => map(
            'target-file-size-bytes', '536870912',
            -- ↑ целевой размер файла: 512 МБ
            -- Iceberg будет пытаться создать файлы близкие к этому размеру

            'min-input-files', '5',
            -- ↑ минимальное число файлов в группе для объединения
            -- Если в партиции только 3 файла - не трогать (не стоит overhead)

            'min-file-size-bytes', '134217728',
            -- ↑ файлы меньше 128 МБ считаются "мелкими" и подлежат объединению
            -- Файлы больше этого порога пропускаются (уже оптимальные)

            'max-file-size-bytes', '644245094'
            -- ↑ максимальный размер создаваемого файла: 614 МБ
            -- Файлы больше этого порога тоже подлежат расщеплению (слишком большие)
        )
    )
""")
result.show()
# +---------------------------+---------------------------+-----------------------+
# |rewritten_data_files_count |added_data_files_count     |rewritten_bytes_count  |
# +---------------------------+---------------------------+-----------------------+
# |1500                       |3                          |5368709120             |
# +---------------------------+---------------------------+-----------------------+
# Интерпретация: 1500 мелких файлов объединены в 3 крупных файла по ~1.8 ГБ каждый.
# Общий объём данных не изменился: 5368709120 байт ≈ 5 ГБ.

Ключевые параметры Bin-Pack:

  • target-file-size-bytes: целевой размер выходного файла. Для S3/Lakehouse: 256–512 МБ. Для HDFS: 128–256 МБ.
  • min-input-files: если в группе файлов меньше этого числа - compaction не запускается (overhead не окупается).
  • min-file-size-bytes: файлы меньше этого размера считаются мелкими и включаются в compaction.
  • max-file-size-bytes: файлы больше этого размера расщепляются на части. Обычно target × 1.2.

Стратегия 2: Sort - балансированная

Механика: перед объединением строки сортируются по указанной колонке (или нескольким). После compaction строки внутри каждого файла лежат в предсказуемом порядке. Аналогия: разложить вещи в чемодан по типам, чтобы потом быстро найти нужное.

Почему сортировка ускоряет запросы? Parquet хранит min/max статистику для каждой колонки в каждом Row Group (блок из ~1М строк). Если данные отсортированы по user_id, то строки с user_id от 1 до 1000 лежат в первом Row Group, от 1001 до 2000 - во втором и т.д. При запросе WHERE user_id = 500 Spark прочитает только первый Row Group, пропустив все остальные. Это Data Skipping на уровне Row Group.

Когда использовать:

  • Когда большинство запросов фильтруют по одной колонке (например, WHERE user_id = ?)
  • Когда эта колонка имеет высокую кардинальность (миллионы уникальных значений)
  • Когда стоимость compaction окупается ускорением многих последующих запросов
# Sort по одной колонке
result_sort = spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'db.events',
        strategy => 'sort',
        sort_order => 'user_id ASC NULLS LAST',
        -- ↑ сортировать строки внутри файлов по user_id по возрастанию
        -- Null-значения в конце (NULLS LAST - удобнее для аналитики)
        options => map(
            'target-file-size-bytes', '536870912',
            'min-input-files', '5'
        )
    )
""")
result_sort.show()

# Sort по нескольким колонкам (составной ключ)
result_sort_multi = spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'db.events',
        strategy => 'sort',
        sort_order => 'event_date ASC, user_id ASC NULLS LAST',
        -- ↑ сначала по дате, внутри даты - по user_id
        -- Идеально для запросов: WHERE event_date = '2026-01-15' AND user_id = 500
        options => map(
            'target-file-size-bytes', '536870912',
            'min-input-files', '3'
        )
    )
""")

Стоимость Sort: сортировка требует полного Shuffle - все данные перераспределяются по Executor-ам по значению sort-ключа, затем каждый Executor сортирует свою долю. Это в 3–10 раз дороже Bin-Pack по времени и ресурсам. Sort compaction на 100 ГБ данных может занять 10–30 минут и потребовать столько же CPU, сколько средний аналитический запрос за день.

Правило окупаемости Sort: Sort стоит делать если сумма времени всех аналитических запросов, которые ускорятся благодаря сортировке, превышает время самого compaction. Если таблица читается 50 раз в день, а каждый запрос ускоряется с 10 минут до 30 секунд - Sort сэкономил 8 часов CPU за один compaction.

Стратегия 3: Z-Order - продвинутая многомерная кластеризация

Проблема Sort по одной колонке: если аналитики фильтруют по user_id, Sort по user_id помогает. Но если в половине запросов фильтр по event_type, а в другой половине - по country, сортировка только по user_id не поможет запросам по event_type.

Z-Order (Z-кривая, пространственно-заполняющая кривая) - это алгоритм, который обеспечивает многомерную близость: строки, близкие по нескольким измерениям одновременно, будут физически находиться рядом на диске. Строки с user_id=500 и country=RU будут в соседних Row Groups с строками user_id=501 и country=RU.

Z-Order работает следующим образом: для каждой строки вычисляется Z-value - число, которое перемежает биты нескольких колонок. Строки сортируются по Z-value. В результате строки, близкие по всем участвующим измерениям, физически лежат рядом.

Когда использовать Z-Order:

  • Когда аналитики фильтруют по разным комбинациям 2–4 колонок
  • Когда нет явного «главного» ключа фильтрации
  • Когда таблица большая и ресурсы для compaction достаточны
  • Типичный пример: события фильтруют то по user_id, то по country, то по event_type, то по комбинации нескольких
# Z-Order по нескольким колонкам
result_zorder = spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'db.events',
        strategy => 'sort',
        sort_order => 'zorder(user_id, country, event_type)',
        -- ↑ Z-Order: близость одновременно по user_id, country И event_type
        -- Iceberg использует zorder() как специальный синтаксис в sort_order
        options => map(
            'target-file-size-bytes', '536870912',
            'min-input-files', '5',
            'rewrite-job-order', 'files-desc'
            -- ↑ начинать с самых больших файлов (приоритет на группы с наибольшим объёмом)
        )
    )
""")
result_zorder.show()

Стоимость Z-Order: самая дорогая из трёх стратегий. Вычисление Z-значений для каждой строки + глобальная сортировка по Z-value + запись. Обычно в 5–15 раз дороже Bin-Pack. Запускать Z-Order имеет смысл не чаще раза в неделю для активно читаемых таблиц.

Сравнение трёх стратегий


4. Практика: настройка и запуск Compaction

Инициализация Spark с Iceberg

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder \
    .appName("Iceberg-Compaction-Deep-Dive") \
    .config("spark.sql.extensions",
            "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
    # ↑ подключаем Iceberg SQL-расширения для CALL-процедур

    .config("spark.sql.catalog.lakehouse",
            "org.apache.iceberg.spark.SparkCatalog") \
    # ↑ регистрируем каталог с именем "lakehouse"

    .config("spark.sql.catalog.lakehouse.type", "hadoop") \
    # ↑ тип хранилища: hadoop-совместимая файловая система
    # В production: "hive" (Hive Metastore) или "rest" (Iceberg REST Catalog)

    .config("spark.sql.catalog.lakehouse.warehouse", "/tmp/iceberg-warehouse") \
    # ↑ путь к warehouse (корень хранилища)
    # В production: s3a://bucket/warehouse/

    .config("spark.executor.memory", "4g") \
    .config("spark.driver.memory", "2g") \
    .getOrCreate()

Создание тестовых данных: симулируем Small Files Disease

import datetime

# ─── Шаг 1: создать Iceberg таблицу ──────────────────────────────────────────
spark.sql("CREATE DATABASE IF NOT EXISTS lakehouse.db")

spark.sql("""
    CREATE TABLE IF NOT EXISTS lakehouse.db.events (
        event_id    BIGINT,
        user_id     BIGINT,
        event_type  STRING,
        country     STRING,
        amount      DOUBLE,
        event_ts    TIMESTAMP
    )
    USING iceberg
    PARTITIONED BY (days(event_ts))
    TBLPROPERTIES (
        'write.target-file-size-bytes' = '134217728',
        'write.distribution-mode' = 'hash'
    )
""")

# ─── Шаг 2: симулируем streaming запись (много мелких батчей) ─────────────────
# Записываем 100 «микробатчей» по 10 000 строк каждый
# Имитируем то, что Structured Streaming делал бы каждые 10 секунд

COUNTRIES = ["RU", "US", "DE", "CN", "BR", "IN", "GB", "FR"]
EVENT_TYPES = ["click", "purchase", "view", "add_to_cart", "checkout"]

print("Записываем 100 микробатчей...")
for batch_id in range(100):
    micro_batch = (
        spark.range(batch_id * 10_000, (batch_id + 1) * 10_000)
        .withColumn("user_id",    (F.col("id") % 100_000).cast("long"))
        .withColumn("event_type", F.element_at(
            F.array([F.lit(e) for e in EVENT_TYPES]),
            (F.col("id") % 5 + 1).cast("int")
        ))
        .withColumn("country", F.element_at(
            F.array([F.lit(c) for c in COUNTRIES]),
            (F.col("id") % 8 + 1).cast("int")
        ))
        .withColumn("amount",    (F.rand(seed=batch_id) * 5000 + 1).cast("double"))
        .withColumn("event_ts",  F.from_unixtime(
            1_735_689_600 + (F.col("id") % (7 * 24 * 3600)).cast("long")
        ).cast("timestamp"))
        .withColumnRenamed("id", "event_id")
    )

    micro_batch.writeTo("lakehouse.db.events").append()

print("Готово. Таблица создана с 100 микробатчами.")

# ─── Шаг 3: проверить состояние таблицы до compaction ────────────────────────
files_before = spark.sql("""
    SELECT
        count(*) AS file_count,
        sum(file_size_in_bytes) / (1024*1024) AS total_mb,
        avg(file_size_in_bytes) / (1024*1024) AS avg_file_mb,
        min(file_size_in_bytes) / (1024*1024) AS min_file_mb,
        max(file_size_in_bytes) / (1024*1024) AS max_file_mb
    FROM lakehouse.db.events.files
""")
files_before.show()
# +----------+---------+-------------+-------------+-------------+
# |file_count|total_mb |avg_file_mb  |min_file_mb  |max_file_mb  |
# +----------+---------+-------------+-------------+-------------+
# |~700-900  |~180-220 |~0.2-0.3     |~0.05        |~1.5         |
# Примерно 800 файлов по 200-300 КБ - типичная Small Files Disease

Запуск Bin-Pack Compaction

# ─── Bin-Pack: быстрое уплотнение без сортировки ─────────────────────────────
print("Запускаем Bin-Pack compaction...")
binpack_result = spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'lakehouse.db.events',
        strategy => 'binpack',
        options => map(
            'target-file-size-bytes', '268435456',
            'min-input-files', '5',
            'min-file-size-bytes', '134217728',
            'max-file-size-bytes', '536870912'
        )
    )
""")
binpack_result.show(truncate=False)
# +---------------------------+---------------------------+-----------------------+--------+
# |rewritten_data_files_count |added_data_files_count     |rewritten_bytes_count  |...     |
# +---------------------------+---------------------------+-----------------------+--------+
# |780                        |4                          |189054976              |...     |
# Интерпретация: 780 файлов → 4 файла, 180 МБ данных без изменений

# Проверить состояние ПОСЛЕ compaction
files_after = spark.sql("""
    SELECT count(*) AS file_count,
           avg(file_size_in_bytes) / (1024*1024) AS avg_file_mb
    FROM lakehouse.db.events.files
""")
files_after.show()
# +----------+------------+
# |file_count|avg_file_mb |
# +----------+------------+
# |4         |45.0        |  ← 4 файла вместо 780!

Запуск Sort Compaction

# ─── Sort по user_id: ускорение запросов WHERE user_id = ? ───────────────────
print("Запускаем Sort compaction по user_id...")

# Сначала делаем новую запись с мелкими файлами (как будто продолжился стриминг)
for batch_id in range(100, 150):
    micro_batch = spark.range(batch_id * 10_000, (batch_id + 1) * 10_000) \
        .withColumn("user_id",   (F.col("id") % 100_000).cast("long")) \
        .withColumn("event_type", F.lit("click")) \
        .withColumn("country",   F.lit("RU")) \
        .withColumn("amount",    (F.rand() * 1000).cast("double")) \
        .withColumn("event_ts",  F.from_unixtime(
            1_735_689_600 + (F.col("id") % (7 * 24 * 3600)).cast("long")
        ).cast("timestamp")) \
        .withColumnRenamed("id", "event_id")
    micro_batch.writeTo("lakehouse.db.events").append()

sort_result = spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'lakehouse.db.events',
        strategy => 'sort',
        sort_order => 'user_id ASC NULLS LAST, event_ts ASC',
        options => map(
            'target-file-size-bytes', '268435456',
            'min-input-files', '3',
            'rewrite-job-order', 'bytes-asc'
            -- ↑ начинать с самых маленьких файлов (укрупняем приоритетно мелкое)
        )
    )
""")
sort_result.show(truncate=False)

# Проверить эффект: запрос с фильтром по user_id
spark.sql("""
    SELECT user_id, sum(amount) AS total
    FROM lakehouse.db.events
    WHERE user_id = 42
    GROUP BY user_id
""").explain(mode="formatted")
# В плане теперь должны быть минимальные numFiles и хорошие PartitionFilters

Запуск Z-Order Compaction

# ─── Z-Order по (user_id, country, event_type) ───────────────────────────────
print("Запускаем Z-Order compaction...")
zorder_result = spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'lakehouse.db.events',
        strategy => 'sort',
        sort_order => 'zorder(user_id, country, event_type)',
        -- ↑ Z-Order по трём измерениям: запросы по любой из этих колонок ускорятся
        options => map(
            'target-file-size-bytes', '536870912',
            'min-input-files', '5',
            'max-concurrent-file-group-rewrites', '5'
            -- ↑ максимально 5 групп файлов перезаписываются параллельно
            -- Больше → быстрее compaction, но больше нагрузка на кластер
        )
    )
""")
zorder_result.show(truncate=False)

Очистка: expire_snapshots и remove_orphan_files

После compaction старые мелкие файлы ещё физически хранятся на диске - они нужны для Time Travel и читателей, работавших со старым Snapshot. Только expire_snapshots позволяет их удалить.

# ─── Шаг 1: expire_snapshots - пометить старые снапшоты как истёкшие ─────────
print("Очищаем старые снапшоты...")
expire_result = spark.sql("""
    CALL lakehouse.system.expire_snapshots(
        table => 'lakehouse.db.events',
        older_than => TIMESTAMP '2026-01-22 00:00:00',
        -- ↑ удалить снапшоты старше этой даты
        -- На практике: CURRENT_TIMESTAMP - interval 7 days (держать 7 дней Time Travel)
        retain_last => 10
        -- ↑ всегда оставлять минимум 10 последних снапшотов
        -- Даже если они старше older_than - это защита от случайного удаления всего
    )
""")
expire_result.show()
# +-------------------+----------------------+-----------------------+
# |deleted_data_files |deleted_manifest_files|deleted_manifest_lists |
# +-------------------+----------------------+-----------------------+
# |780                |15                    |8                      |
# Физически удалено 780 старых мелких файлов!

# ─── Шаг 2: remove_orphan_files - удалить «осиротевшие» файлы ────────────────
# Orphan files - файлы на диске без ссылок в Manifest (например, от прерванной записи)
orphan_result = spark.sql("""
    CALL lakehouse.system.remove_orphan_files(
        table => 'lakehouse.db.events',
        older_than => TIMESTAMP '2026-01-22 00:00:00'
        -- ↑ только файлы старше этого времени
        -- Не удалять очень свежие - вдруг запись ещё идёт
    )
""")
orphan_result.show()

# ─── Шаг 3: rewrite_manifests - уплотнение самих Manifest-файлов ──────────────
# После тысячи операций записи накопились тысячи мелких Manifest-файлов
# Это тоже замедляет планирование запросов
manifest_result = spark.sql("""
    CALL lakehouse.system.rewrite_manifests(
        table => 'lakehouse.db.events'
    )
""")
manifest_result.show()
# +-------------------------+-----------------------+
# |rewritten_manifests_count|added_manifests_count  |
# +-------------------------+-----------------------+
# |142                      |4                      |
# 142 мелких Manifest → 4 крупных = быстрее планирование запросов

Почему важен порядок: сначала rewrite_data_files (создать новые файлы), потом expire_snapshots (пометить старые как удалённые), потом remove_orphan_files (физически удалить). Нарушение порядка может привести к удалению файлов, которые ещё нужны активным читателям.


5. Проектирование пайплайнов уплотнения

Паттерн «Асинхронный дворник»

В production compaction - это отдельная регулярная задача, запускаемая через оркестратор (Apache Airflow, Prefect, Dagster). Вот типичная архитектура:

# ─── Шаблон Compaction DAG для Apache Airflow ─────────────────────────────────
# (псевдокод - реальный Airflow DAG будет в курсе по оркестрации)

def run_compaction(table_name: str, partition_date: str, strategy: str = "binpack"):
    """
    Запустить compaction для конкретной партиции.
    Вызывается из Airflow с параметрами.
    """
    from pyspark.sql import SparkSession

    spark = SparkSession.builder \
        .appName(f"Compaction-{table_name}-{partition_date}") \
        .config("spark.sql.extensions",
                "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions") \
        .getOrCreate()

    if strategy == "binpack":
        sort_clause = ""
        strategy_val = "binpack"
    elif strategy == "sort":
        sort_clause = ", sort_order => 'user_id ASC NULLS LAST'"
        strategy_val = "sort"
    else:
        sort_clause = ", sort_order => 'zorder(user_id, country)'"
        strategy_val = "sort"

    # Compaction только за конкретную дату (не трогаем всю историю!)
    result = spark.sql(f"""
        CALL lakehouse.system.rewrite_data_files(
            table => 'lakehouse.db.{table_name}',
            strategy => '{strategy_val}',
            where => 'event_ts >= TIMESTAMP ''{partition_date} 00:00:00''
                      AND event_ts < TIMESTAMP ''{partition_date} 23:59:59''',
            {sort_clause}
            options => map(
                'target-file-size-bytes', '268435456',
                'min-input-files', '5'
            )
        )
    """)

    metrics = result.first()
    print(f"Compaction {table_name}/{partition_date}: "
          f"{metrics['rewritten_data_files_count']}{metrics['added_data_files_count']} файлов, "
          f"{metrics['rewritten_bytes_count'] / (1024**3):.2f} ГБ")

    # Expire старых снапшотов (7 дней retention)
    spark.sql(f"""
        CALL lakehouse.system.expire_snapshots(
            table => 'lakehouse.db.{table_name}',
            older_than => TIMESTAMP '{partition_date} 00:00:00',
            retain_last => 5
        )
    """)

    spark.stop()

Партиционный Compaction: не трогать исторический архив

Главное правило production-компакции: уплотняйте только свежие партиции. Исторические данные за 2 года уже оптимальны после предыдущих compaction - перезаписывать их снова бессмысленно и дорого.

# ─── Ежедневная стратегия: компактируем «вчера» ───────────────────────────────
from datetime import date, timedelta

def daily_compaction_job():
    """
    Ежедневный compaction:
    - Вчера: Bin-Pack (всегда, убираем мелкие файлы от вчерашнего стрима)
    - 7 дней назад: Sort (раз в неделю, оптимизируем для аналитики)
    - 30 дней назад: Z-Order (раз в месяц, максимальная оптимизация для BI)
    """
    today = date.today()

    # Bin-Pack вчерашней партиции
    yesterday = (today - timedelta(days=1)).isoformat()
    run_compaction("events", yesterday, strategy="binpack")

    # Sort неделю назад (по понедельникам)
    if today.weekday() == 0:  # понедельник
        week_ago = (today - timedelta(days=7)).isoformat()
        run_compaction("events", week_ago, strategy="sort")

    # Z-Order месяц назад (первого числа)
    if today.day == 1:
        month_ago = (today - timedelta(days=30)).isoformat()
        run_compaction("events", month_ago, strategy="zorder")

Проверка состояния таблицы через Iceberg метаданные

Iceberg предоставляет богатый набор системных таблиц для мониторинга состояния:

# ─── Мониторинг: сколько файлов и какого размера ─────────────────────────────
spark.sql("""
    SELECT
        partition.event_ts_day AS partition_date,
        count(*) AS file_count,
        sum(file_size_in_bytes) / (1024*1024) AS total_mb,
        avg(file_size_in_bytes) / (1024*1024) AS avg_file_mb,
        min(file_size_in_bytes) / (1024*1024) AS min_file_mb,
        max(file_size_in_bytes) / (1024*1024) AS max_file_mb
    FROM lakehouse.db.events.files
    GROUP BY 1
    HAVING count(*) > 10  -- показать только партиции с >10 файлами (кандидаты)
    ORDER BY file_count DESC
""").show(20, truncate=False)

# ─── Мониторинг: история снапшотов ──────────────────────────────────────────
spark.sql("""
    SELECT snapshot_id,
           committed_at,
           operation,
           summary['spark.app.id'] AS app_id,
           summary['added-data-files'] AS added_files,
           summary['deleted-data-files'] AS deleted_files
    FROM lakehouse.db.events.snapshots
    ORDER BY committed_at DESC
    LIMIT 20
""").show(truncate=False)

# ─── Time Travel: читать данные до compaction ─────────────────────────────────
# Можно убедиться, что данные не изменились
old_snapshot_id = 123456789  # ID снапшота до compaction (из таблицы snapshots)

before = spark.sql(f"""
    SELECT count(*), sum(amount)
    FROM lakehouse.db.events VERSION AS OF {old_snapshot_id}
""")

after = spark.sql("""
    SELECT count(*), sum(amount)
    FROM lakehouse.db.events
""")

before.show()  # должны совпасть с after
after.show()   # compaction не меняет данные, только физическую организацию

6. Правило окупаемости Compaction

Когда Compaction стоит делать

Compaction - это инвестиция: мы тратим CPU сейчас, чтобы сэкономить CPU на будущих запросах. Принцип окупаемости:

Стоит делать compaction ЕСЛИ:
  CPU(compaction) < N × (CPU(запрос без compaction) - CPU(запрос с compaction))

Где N - число запросов, которые будут сделаны до следующего compaction.

Пример:
  Bin-Pack compaction: 5 CPU-минут
  Скорость одного запроса: 10 мин → 2 мин после compaction (экономия 8 мин/запрос)
  Число запросов в день: 20
  Экономия в день: 20 × 8 = 160 CPU-минут
  Стоимость compaction: 5 CPU-минут
  ROI: 160/5 = 32x - однозначно стоит!

  Sort compaction: 60 CPU-минут
  Ускорение одного запроса: 0.5 мин (слабая аналитика, и так быстро)
  Число запросов в день: 5
  Экономия в день: 5 × 0.5 = 2.5 CPU-минуты
  Стоимость compaction: 60 CPU-минут
  ROI: 2.5/60 = 0.04x - НЕ стоит! Тратим больше чем экономим.

Когда Compaction может навредить

Слишком частый Compaction: если compaction-джоб запускается каждые 10 минут, а новые данные пишутся медленно, то большую часть времени compaction будет перезаписывать файлы, которые только что создал. Это «холостой» I/O.

Compaction во время пикового чтения: compaction создаёт нагрузку на кластер. Запускать его во время пикового времени аналитики (рабочий день) - плохая идея. Лучше - ночью или в выходные.

Z-Order на маленьких таблицах: стоимость Z-Order линейна от размера данных, но ускорение пропорционально числу Row Groups в файлах. На маленькой таблице (< 1 ГБ) Z-Order даст ничтожное ускорение при заметном overhead.


7. Best Practices и чек-лист

Матрица выбора стратегии

Критерий Bin-Pack Sort Z-Order
Главная цель Борьба с мелкими файлами Data Skipping по одной колонке Data Skipping по нескольким колонкам
Стоимость Низкая Средняя Высокая
Частота запуска Ежедневно Еженедельно Ежемесячно
Когда не использовать Никогда (всегда полезен) Запросы по разным колонкам Маленькие таблицы
Дополнительный эффект Нет Сортировка по join-ключу (Bucket Co-location) Близость для multi-col фильтров

Золотые правила Compaction

1. Компактируй свежие партиции, не трогай архив. Исторические данные за 2 года уже оптимальны. Запускай compaction только для партиций за последние 1–7 дней.

2. Начинай с Bin-Pack, добавляй Sort по необходимости. Bin-Pack решает 80% проблем. Sort нужен только когда конкретная колонка является основным фильтром в запросах.

3. expire_snapshots - обязательный шаг. Без него compaction не освобождает место: старые файлы живут вечно для Time Travel. Держи retention 7 дней (стандарт индустрии) и удаляй старше.

4. Мониторь метрики compaction. Результат rewrite_data_files показывает rewritten_data_files_count, added_data_files_count, rewritten_bytes_count. Если rewritten_data_files_count мало - compaction почти не работает (файлы уже оптимального размера или их мало).

5. Compaction должен окупаться. Считай ROI: стоимость compaction-джоба vs суммарная экономия на аналитических запросах. Если compaction занимает 2 часа, а таблицу читают 2 раза в день с ускорением по 5 минут - это не окупается.


Домашнее задание

Задание 1 - Воспроизвести Small Files Disease. Напишите цикл, который 200 раз записывает по 5 000 строк в одну Iceberg-таблицу через .writeTo().append(). Проверьте состояние через table.files - убедитесь, что получилось ~1000–1500 мелких файлов.

Задание 2 - Запустить три стратегии. Применить последовательно Bin-Pack, Sort по user_id и Z-Order по (user_id, country). После каждого шага зафиксировать метрики: число файлов, средний размер, время выполнения compaction. Построить сравнительную таблицу.

Задание 3 - Убедиться в неизменности данных. С помощью Time Travel (VERSION AS OF) прочитать данные из Snapshot до compaction и сравнить count(*) и sum(amount) с текущим состоянием. Убедиться что они одинаковы - compaction не меняет данные.

Задание 4 - Проектирование пайплайна. Спроектируйте compaction-пайплайн для следующего сценария: таблица событий получает 500 МБ данных в час через Structured Streaming. Укажите: какую стратегию применять, как часто, для каких партиций, как настроить expire_snapshots, и как обосновать ROI перед командой.


В следующем уроке разберём Data Layout - как организовать физическое расположение данных для максимальной эффективности: выбор колонок для кластеризации, взаимодействие между partitioning, bucketing и compaction, и как Modern Lakehouse форматы автоматически оптимизируют layout через Liquid Clustering и Hidden Partitioning.