Apache Iceberg. Обслуживание таблиц
Профилактика вместо пожаров
Содержание
Обслуживание таблиц имеет значение.
Пожалуй, обслуживание — тема, которую мало кто любит. Оно кажется скучным, пока что-нибудь не сломается, и тогда все внезапно начинают проявлять к нему пристальный интерес, верно? Это как замена масла в автомобиле. Можно пропустить одну замену, но если не делать этого вовсе, двигатель заклинит, и придётся менять его целиком. Так что до такого доводить не хочется. Эти практики нужно держать в арсенале.
Вот к чему приводит игнорирование обслуживания:
- Iceberg создаёт новый файл для каждой записи. Вы уже это знаете. Вы гоняете CDC-пайплайн несколько месяцев — и получаете сотни тысяч файлов размером около мегабайта. Крошечные файлики везде. Для каждого нужны метаданные. Запросы, которые раньше планировались за 50 миллисекунд, теперь тратят пять секунд просто на то, чтобы понять, какие файлы сканировать. Это не время вычислений, это чистые накладные расходы на работу с метаданными.
- Старые снапшоты накапливаются бесконечно. Версии месячной и годовой давности, которые никто никогда не запрашивает, продолжают потреблять место в хранилище и стоить денег.
- Осиротевшие файлы (orphan files) — это ещё хуже. Тонкая проблема. Это файлы из неудачных коммитов, незавершённых записей, удалённых веток — откуда бы они ни взялись. На них не ссылается ни один снапшот. Они просто лежат в вашем blob-хранилище, тратят ваши деньги и не приносят никакой пользы.
- И наконец, файлы удалений (delete files). Каждое обновление и удаление создаёт эти мелкие position delete files. Они накапливаются, и при использовании merge-on-read запросам приходится мёржить их прямо во время чтения. Производительность падает с высокой до уровня «алло, оно вообще работает?».
В Iceberg есть пять операций обслуживания, каждая решает свою задачу. Пройдёмся по каждой.
1. Удаление старых снапшотов (expire snapshots)
Первая операция — expire_snapshots. По умолчанию Iceberg хранит каждый снапшот вечно. Это отлично для time travel, но ужасно для счёта за хранилище. Операция expire_snapshots позволяет задать политику хранения, чтобы очищать старые версии таблицы.
Вы передаёте ей временную метку и минимальное количество снапшотов, которые нужно сохранить — и старые снапшоты удаляются.
CALL system.expire_snapshots( table => 'sales', older_than => TIMESTAMP '2025-12-01 00:00:00', retain_last => 2);
graph LR
%% Узлы-круги
B(("`<s>Snapshot 1</s><br>2025-11-20`")) -->C(("`<s>Snapshot 1</s><br>2025-11-24`")) -->D(("`<s>Snapshot 1</s><br>2025-11-26`")) -->E(("`Snapshot 4<br>2025-11-28`")) --> A(("`Snapshot 5<br>2025-12-10`"))Например, этот вызов expire_snapshots означает: «Удали снапшоты, созданные до 1 декабря, но в любом случае сохрани как минимум два последних». Снапшоты 1, 2 и 3 удаляются, так как они старше 1 декабря. Снапшот 4 тоже до 1 декабря, но он выживает благодаря параметру retain_last_two. Если появится снапшот 6, снапшот 4 удалится, потому что у нас уже есть 5 и 6 — минимум в два снапшота сохранён.
2. Очистка метаданных (clean up metadata)
Вторая операция — удаление старых файлов метаданных. При каждом изменении таблицы Iceberg (insert, update, delete) создаётся новый JSON-файл метаданных. Одна таблица, 100 коммитов — 100 файлов метаданных просто валяются без дела. Это быстро превращается в свалку, особенно при стриминговых задачах, которые коммитятся каждые несколько секунд или минут. Набегают тысячи JSON-файлов, захламляющих слой метаданных.
ALTER TABLE sales SET TBLPROPERTIES ( 'write.metadata.delete-after-commit.enabled' = 'true', 'write.metadata.previous-versions-max' = '5');
Решение простое. Установите write.metadata.delete-after-commit.enabled в true в свойствах таблицы. Теперь при создании нового файла метаданных Iceberg будет автоматически удалять самый старый, оставляя только N последних версий, что регулируется параметром write.metadata.previous-versions-max.
По сути, это скорее профилактика, чем лечение. Вы не разгребаете завалы, а настраиваете таблицу на автоматическую уборку за собой.
3. Удаление осиротевших файлов (delete orphan files)
Что такое осиротевшие файлы? Это manifest- и data-файлы, на которые больше не ссылается ни один снапшот. Они просто лежат в blob-хранилище, занимают место и увеличивают счёт.
При запуске expire_snapshots есть опция удалить осиротевшие файлы заодно. Не рекомендую так делать. Вместо этого используйте remove_orphan_files. Это выделенная операция, которая работает параллельно. Она сканирует хранилище гораздо быстрее, чем последовательная очистка во время expire snapshots.
CALL system.remove_orphan_files( table => 'sales', older_than => TIMESTAMP '2025-11-15 00:00:00', dry_run => false);
И задумайтесь о самом «сканировании хранилища» для поиска сирот: вам придётся рекурсивно обойти весь бакет, составить исчерпывающий список всех файлов и понять, на какие из них есть ссылки (referenced). Задача нетривиальная, верно? Это ресурсоёмкая операция, поэтому стоит делать отдельный вызов remove_orphan_files.
И время здесь имеет значение. Iceberg пишет снизу вверх. Сначала в хранилище уходят data-файлы, затем манифесты, и только потом обновляются метаданные снапшота, чтобы на них сослаться. Если удалять осиротевшие файлы слишком агрессивно, можно удалить файлы из записи, которая ещё не завершилась. Технически в этот момент они уже осиротевшие, верно? Снапшота, который на них ссылается, ещё нет. Файлы данных уже есть, но снапшот ещё не закоммичен. Если вы их удалите, запись упадёт.
Именно поэтому параметр older_than служит буфером безопасности. Установите его на несколько дней назад. Ничего страшного, если немного мусора полежит. Это не разорит вас. Эти файлы либо принадлежат завершённому снапшоту, либо действительно являются сиротами от неудачной записи.
Всегда сначала запускайте это с dry_run. Вы, наверное, и так это знаете, но вам действительно нужно посмотреть список того, что операция собирается удалить, и проверить его. Часто бывает, что список оказывается намного больше ожидаемого или содержит файлы, которые удалять нельзя. По странным путям S3 и именам файлов это не всегда очевидно, но вы должны убедиться, что примерный порядок удаляемых файлов совпадает с ожиданиями. Dry run даст вам уверенность перед реальным запуском.
Награда за труды — на практике можно вернуть 30–40% затрат на хранилище, просто запустив эту операцию на таблицах, где осиротевшие файлы копились месяцами. Это вполне реальные деньги. Делать это определённо стоит.
4. Сжатие (compaction) — перезапись файлов данных
Четвёртая операция — rewrite_data_files, оно же сжатие. Мелкие файлы, как мы знаем, убивают производительность. Они раздувают метаданные. Каждый файл — это вызов S3 API, который тратит время, а за вызовы API нужно платить.
Сжатие объединяет мелкие файлы в файлы оптимального размера. Большинство движков, которые это делают (опять же, зависит от вендора и реализации), работают параллельно. Так что всё происходит максимально быстро даже на больших таблицах. К счастью, blob-хранилища отлично поддерживают высокую параллельность. Это реальная оптимизация, которая заметно улучшает ситуацию.
CALL system.rewrite_data_files( table => 'sales', options => map ( 'target-file-size-bytes', '536870912' -- 512 MB 'min-file-size-bytes', '134217728' -- 128 MB ));
В этом примере мы хотим получать файлы по полгигабайта (512 МБ — наша цель) и перезаписываем только файлы меньше 128 МБ. Настраивайте под свою нагрузку. Ранее мы говорили, что золотая середина — где-то от 100 МБ до 1 ГБ. Придётся немного поэкспериментировать, но это хорошая отправная точка.
Запускайте это регулярно на таблицах с частыми мелкими записями: стриминговый ingest, почасовые батч-задачи, CDC, нагрузки с множеством upsert'ов — и ваши запросы скажут вам спасибо.
Помните таблицу продаж на 5 миллионов строк из предыдущего модуля, где мы делали странный запрос со всеми модификациями? Мы запустили на ней сжатие, нацелившись на файлы по 10 МБ (возможно, искусственно маленькие) с минимальным порогом в 1 МБ. Результат: шесть файлов данных объединились в три. Цифры искусственно маленькие, но для тестовых данных подходят. Исходные файлы были около 4,5 МБ каждый. После сжатия два новых файла оказались чуть меньше 10 МБ, третий — около 7 МБ. Ещё есть запас на новые данные, и мы продолжим запускать сжатие.
5. Перезапись манифестов (rewrite manifests)
Манифест-файлы отслеживают, какие файлы данных принадлежат какому снапшоту. Считайте их индексом для ваших data-файлов. Если вы делаете частые стриминговые коммиты, легко набрать тысячу крошечных манифестов. Движку запросов придётся открывать каждый из них, чтобы выяснить, в каких файлах реально лежат нужные данные. Это тысяча открытий файлов ещё до того, как запрос начнёт выполняться. Заканчивается это плохо.
Обратная крайность не лучше. Например, один гигантский манифест на 50 000 записей. Парсинг этой махины займёт вечность, даже если запросу нужны данные только из одной партиции.
CALL system.rewrite_manifests('sales')
Операция rewrite_manifests решает эту проблему, консолидируя манифесты до оптимального размера. Просто передайте ей имя таблицы — никаких дополнительных параметров настраивать не нужно, Iceberg сам знает оптимальный размер манифеста. Запускайте это после тяжёлых операций со снапшотами или перезаписей файлов. Планирование запросов заметно ускорится, особенно на партиционированных таблицах.
Автоматизация обслуживания
Суть в том, что обслуживание работает только если вы его реально делаете. Так что эти операции лучше автоматизировать.
# Ежедневное обслуживаниеdef iceberg_maintenance (table_name, day_of_week): # Каждую ночь сжатие свежих данных rewrite_data_files( table_name, where="sale_date > current_date - 7" ) # Понедельник: удаление старых снэпшотов if day_of_week == 'Monday': expire_snapshots(table_name, older_than_days=30) # Вторник: удаление бесхозных файлов if day_of_week == 'Tuesday': remove_orphan_files (table_name, older_than_days=3) # Воскресенье: оптимизация манифестов if day_of_week == 'Sunday': rewrite_manifests (table_name)
Вот простая стратегия (мы внезапно перешли от SQL к Python API, но вы можете написать скрипт и запускать его по расписанию):
- Понедельник вечером: expire snapshots старше 30 дней.
- Вторник вечером: remove orphan files с трёхдневным буфером, чтобы случайно не удалить файлы, которые ещё пишутся. Три дня — это, пожалуй, более чем щедрый запас. Надеюсь, сама операция займёт всего пару минут, но безопасность превыше всего: не будем торопиться выгонять сирот на улицу.
- Воскресенье вечером: rewrite manifests, чтобы планирование запросов оставалось быстрым и точным.
Используйте любой удобный оркестратор. Я упомянул cron, потому что я олдскул, но Airflow, Dagster или что-то подобное тоже отлично подойдут. Главное — это регулярность. Просто делайте это. Регулярная небольшая уборка всегда лучше, чем квартальные пожарные учения.
Быстрые советы для продакшена
- Всегда сначала тестируйте с
dry_run. Я уже говорил это, но без шуток: вам не нужны сюрпризы. Если вы проходите этот курс, вы наверняка это знаете и, скорее всего, можете рассказать пару историй о том, почему это важно. - Устанавливайте grace period не менее трёх дней для удаления осиротевших файлов. Это учтёт действительно долго работающие задания и ретраи, не убивая in-flight записи.
- Фокусируйте сжатие на партициях, которые реально запрашиваются. Нет смысла оптимизировать данные, которые никто не читает или читает крайне редко. Они гораздо менее чувствительны к производительности, и нет смысла тратить вычислительные ресурсы на их оптимизацию.
- Отслеживайте метрики. У вас есть все эти замечательные метатаблицы — используйте их. Количество файлов, время выполнения запросов, стоимость хранилища. Иначе вы просто гадаете, помогает ли обслуживание.
И да, обслуживание таблиц не гламурно. За это не дают медалей, но именно это отличает data lake от data swamp (болота данных). Так что настройте эти операции, автоматизируйте их и подкручивайте параметры исходя из того, что видите в продакшене.
Зайдите и проверьте свои таблицы прямо сейчас. Гарантирую, им не помешает немного заботы.
«Обслуживание таблиц Iceberg — это как замена масла в автомобиле: скучно, пока двигатель не заклинит. Но если делать это регулярно, ваш data lake будет работать как швейцарские часы, а не как болото, где тонут запросы.»