Compaction: стратегии укрупнения файлов через Spark и Iceberg
Философия разделения ответственности между быстрой записью и асинхронным уплотнением, механизм snapshot-изоляции в Iceberg, три стратегии rewrite_data_files (Bin-Pack, Sort, Z-Order) и проектирование автоматических пайплайнов compaction.
В предыдущем уроке мы диагностировали 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 с такой схемой возникают три проблемы:
- Видимое окно несогласованности: пока старые файлы удалены, а новые ещё не записаны, таблица частично пуста.
- Нет изоляции транзакций: читатель, начавший запрос до compaction, может получить разные данные на разных шагах исполнения.
- Нет возможности откатиться: если compaction прошёл неправильно, исходные данные уже удалены.
Именно эти проблемы решают современные табличные форматы - прежде всего Apache Iceberg.
2. Революция Iceberg: архитектура compaction без блокировок¶
Концепция Snapshots: читатели не замечают compaction¶
Ключевая идея Iceberg - каждая операция записи создаёт новый Snapshot. Snapshot - это неизменяемое состояние таблицы в конкретный момент времени. Метаданные таблицы хранятся не в папке как в Hive, а в иерархии JSON/Avro-файлов в специальном каталоге metadata/.
Когда запущен Compaction Job:
- Spark читает старые мелкие файлы через Snapshot 123
- Объединяет их в крупные файлы и записывает новые
file_big.parquetрядом со старыми - Создаёт новый Manifest, где мелкие файлы помечены как
DELETED, а крупные - какADDED - Атомарно переключает указатель
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.