Обслуживание таблиц: OPTIMIZE, expire_snapshots, remove_orphan_files
Из уроков 3 и 4 мы вынесли две вещи. Файлы данных Iceberg неизменяемы — каждый INSERT создаёт новые файлы. И снапшоты неизменяемы — они накапливаются в истории. Оба свойства дают атомарность и time travel, но имеют цену: таблица со временем «деградирует» — обрастает мелкими файлами и старыми снапшотами. Без обслуживания запросы замедляются, а счёт за object storage растёт. Iceberg-таблица — не «настроил и забыл», а живой объект, требующий регулярного ухода. Этот урок разбирает три ключевые процедуры обслуживания и то, какую конкретную деградацию каждая лечит.
Почему таблица деградирует
Две независимые проблемы накапливаются параллельно.
Проблема мелких файлов. Стриминговая загрузка или частые мелкие INSERT создают много маленьких Parquet-файлов — вспомните: каждый INSERT пишет новый файл, дописать в существующий нельзя. Через месяц у партиции вместо десяти файлов по 128 МБ могут быть тысячи файлов по 200 КБ. Это бьёт по чтению напрямую. Координатор генерирует сплит на файл (или его часть) — тысячи мелких файлов означают тысячи мелких сплитов, и накладные расходы на планирование и открытие файлов начинают доминировать над полезной работой. Каждый Parquet-файл — это ещё и footer с метаданными, который надо прочитать; для крошечного файла footer может быть сопоставим с данными. Колоночное сжатие на мелких файлах тоже хуже.
Проблема накопления метаданных. Каждое изменение создаёт снапшот, а старые снапшоты по умолчанию не удаляются. Их файлы данных, манифесты и manifest list остаются в object storage. Через год активной таблицы история — это десятки тысяч снапшотов и петабайты файлов, которые уже никем не читаются, но за хранение которых вы платите. Растёт и сам metadata-файл, в котором перечислены все снапшоты.
Все процедуры обслуживания вызываются через ALTER TABLE ... EXECUTE <процедура>(параметры).
OPTIMIZE: компакция мелких файлов
OPTIMIZE решает проблему мелких файлов. Процедура читает мелкие файлы данных и переписывает их содержимое в меньшее число крупных файлов. Логических изменений в данных нет — те же строки, но упакованные эффективно. Как любое изменение, компакция создаёт новый снапшот.
-- Скомпактить всю таблицу: мелкие файлы переписываются в крупные
ALTER TABLE iceberg.sales.orders EXECUTE optimize;
Ключевой параметр — file_size_threshold (по умолчанию 100MB). Процедура трогает только файлы меньше порога; файлы, уже достигшие хорошего размера, она не переписывает зря. Это важно: компакция — дорогая операция, она читает и пишет данные, и переписывать уже крупные файлы было бы пустой тратой I/O.
-- Компактить только файлы меньше 256 МБ
ALTER TABLE iceberg.sales.orders
EXECUTE optimize(file_size_threshold => '256MB');
На больших таблицах компактить всё сразу нерационально — обычно деградирует только свежая партиция, в которую активно льются данные. OPTIMIZE принимает WHERE для ограничения области по партициям:
-- Компактить только вчерашнюю партицию, в которую шла загрузка
ALTER TABLE iceberg.sales.orders
EXECUTE optimize
WHERE order_date = DATE '2026-05-19';
Эффект виден через metadata-таблицу $files — до и после компакции:
-- До OPTIMIZE: много мелких файлов от стриминга
SELECT count(*) AS files, sum(record_count) AS rows,
avg(file_size_in_bytes) AS avg_bytes
FROM iceberg.sales."orders$files";
-- files | rows | avg_bytes
-- -------+---------+-----------
-- 1843 | 2950000 | 21400
-- После ALTER TABLE EXECUTE optimize: те же строки, мало крупных файлов
-- files | rows | avg_bytes
-- -------+---------+------------
-- 24 | 2950000 | 122600000
Строк столько же (2 950 000), а файлов — 24 вместо 1843. Следующий SELECT сгенерирует 24 сплита вместо 1843.
Родственная процедура — optimize_manifests. OPTIMIZE компактит файлы данных; optimize_manifests перезаписывает сами манифесты, кластеризуя записи по партиционирующим столбцам. Это ускоряет уже не чтение данных, а планирование запроса: координатору проще отбрасывать манифесты на этапе чтения manifest list. Нужна на таблицах с очень большим числом манифестов.
expire_snapshots: удаление старой истории
OPTIMIZE создал 24 новых файла. Но 1843 старых файла никуда не делись — на них всё ещё ссылаются прошлые снапшоты, а значит, time travel к ним по-прежнему работает. Пока эти снапшоты живы, старые файлы нельзя удалить: это сломало бы time travel. Удаление старых файлов данных — задача expire_snapshots.
Процедура удаляет снапшоты старше заданного возраста, а вместе с ними — файлы данных, манифесты и manifest list, на которые ссылались только эти снапшоты (и больше никто).
-- Удалить снапшоты старше срока retention по умолчанию
ALTER TABLE iceberg.sales.orders EXECUTE expire_snapshots;
-- Явно задать порог: удалить всё старше 3 дней
ALTER TABLE iceberg.sales.orders
EXECUTE expire_snapshots(retention_threshold => '3d');
Здесь — критически важная защита. У expire_snapshots есть минимальный retention (по умолчанию 7 дней): процедура не даст удалить совсем свежие снапшоты, даже если вы попросите. Причина — параллельные операции и недавние записи могут ещё на них опираться, а слишком агрессивный expire мог бы оторвать данные у идущего запроса. Порог регулируется свойством таблицы, но осознанно понижать его ниже разумного не стоит.
Вторая защита: снапшот, на который указывает ветка или тег, не удаляется независимо от возраста. Это та самая причина, по которой в уроке 4 тег назывался способом «защитить» состояние от обслуживания. Голова любой ветки (включая main) и любой тег всегда переживают expire.
Порядок имеет значение. Сначала OPTIMIZE (создаёт новые файлы, старые становятся не нужны новым снапшотам), затем expire_snapshots (удаляет старые снапшоты и осиротевшие вместе с ними старые файлы). Если сделать только OPTIMIZE без последующего expire — счёт за storage не уменьшится, потому что старые файлы держатся прошлыми снапшотами. OPTIMIZE без expire ускоряет чтение, но не освобождает место.
remove_orphan_files: уборка мусора вне метаданных
Третья процедура чинит особую ситуацию — orphan-файлы, файлы-сироты. Это файлы, физически лежащие в директории таблицы в object storage, но не упомянутые ни в одном снапшоте — то есть невидимые для метаданных Iceberg вообще.
Откуда они берутся? Вспомните урок 2: запись в Iceberg — это оптимистичная конкуренция. Запрос сначала пишет Parquet-файлы в object storage, и только потом пытается атомарно закоммитить снапшот. Если коммит проиграл гонку или процесс упал между записью файлов и коммитом — Parquet-файлы остались, а снапшот не создан. Эти файлы не принадлежат ни одному снапшоту: их не удалит expire_snapshots, потому что expire ходит по снапшотам, а сироты вне снапшотов. Они просто лежат и занимают место.
-- Удалить файлы в директории таблицы, не связанные ни с одним снапшотом
ALTER TABLE iceberg.sales.orders
EXECUTE remove_orphan_files(retention_threshold => '7d');
remove_orphan_files сравнивает фактическое содержимое директории таблицы со списком файлов из всех снапшотов и удаляет лишнее. У неё тоже есть минимальный retention — и он критичен по особой причине. Сирота и недокоммиченный файл идущей прямо сейчас записи выглядят одинаково: и тот и другой — Parquet в директории без снапшота. Retention-порог защищает свежие файлы: процедура удаляет только то, что лежит без снапшота дольше порога, и не трогает файлы активной записи. Понижать этот порог особенно опасно — можно удалить данные коммита, который ещё не успел завершиться.
Регламент обслуживания
На практике три процедуры ставят в расписание (cron, Airflow), типичный регламент:
| Процедура | Что лечит | Частота | На что смотреть |
|---|---|---|---|
| OPTIMIZE с WHERE по свежим партициям | Мелкие файлы | Ежедневно/ежечасно для активных партиций | Число и средний размер файлов в $files |
| expire_snapshots | Накопленную историю снапшотов | Ежедневно или еженедельно | Число строк в $snapshots, объём storage |
| remove_orphan_files | Файлы-сироты от сбоев записи | Реже, еженедельно | Расхождение размера директории и суммы $files |
Базовый принцип: OPTIMIZE адресно по горячим партициям, затем expire_snapshots, и периодически — remove_orphan_files. Без обслуживания даже корректно спроектированная Iceberg-таблица через несколько месяцев станет медленной и дорогой.
Как обслуживание связано с записью данных
Полезно понимать, что потребность в обслуживании напрямую зависит от того, как в таблицу пишут данные, — и инженер может уменьшить деградацию ещё на этапе записи.
Главный фактор — размер порции записи. Один INSERT на 10 миллионов строк создаст несколько крупных файлов хорошего размера — такую таблицу почти не надо компактить. Десять тысяч INSERT по тысяче строк создадут десять тысяч мелких файлов — и потребуют агрессивного OPTIMIZE. Поэтому первое правило здоровой Iceberg-таблицы: писать большими порциями. Если источник по своей природе потоковый и порции мелкие, OPTIMIZE по свежим партициям должен идти часто, фактически как часть пайплайна загрузки.
Второй фактор — DML с удалением строк. DELETE, UPDATE и MERGE в Iceberg v2 не переписывают файлы данных, а создают delete-файлы (о версиях формата — в уроке 7). Это быстро, но delete-файлы накапливаются: чем их больше, тем дороже чтение, потому что движок применяет их поверх данных. OPTIMIZE решает и эту проблему — при компакции он схлопывает данные вместе с delete-файлами в чистые файлы. Поэтому таблица, в которую часто пишут MERGE (например, upsert из CDC-потока), нуждается в OPTIMIZE не только из-за мелких файлов, но и чтобы убирать накопленные delete-файлы.
Вывод: обслуживание и запись — две стороны одного процесса. Чем «грубее» пишут в таблицу (мелкими порциями, частым DML), тем интенсивнее должно быть обслуживание. Корректно спроектированный пайплайн закладывает OPTIMIZE и expire_snapshots в расписание с самого начала, а не вспоминает о них, когда таблица уже деградировала.
Попробуй сам
В песочнице создайте iceberg.sales.orders и сымитируйте стриминговую загрузку: выполните 30-50 отдельных INSERT по 1-3 строки — так вы получите много мелких файлов. Снимите метрику: SELECT count(*), avg(file_size_in_bytes) FROM "orders$files". Выполните ALTER TABLE EXECUTE optimize и снимите метрику снова — зафиксируйте, как изменилось число и размер файлов. Теперь главное упражнение на понимание: посмотрите orders$snapshots после OPTIMIZE и объясните, почему общее число файлов в object storage пока не уменьшилось, хотя «полезных» файлов стало мало. Затем выполните expire_snapshots с малым (но не нарушающим минимальный retention) порогом и проследите по $snapshots, сколько снапшотов осталось. Письменно ответьте: в каком порядке нужно запускать три процедуры обслуживания и что произойдёт, если делать только OPTIMIZE и никогда не делать expire_snapshots.