Apache Iceberg. Упражнение: Обслуживание таблиц
Практика очистки и оптимизации
Содержание
Обзор
Вы создавали таблицы, меняли схемы, партицировали данные, путешествовали во времени по снапшотам и использовали теги и ветки. Всё прекрасно работает в разработке. Но что происходит после шести месяцев в продакшене?
Вот реальность: streaming‑задания, которые пишут каждую минуту, создают тысячи крошечных файлов. Каждая операция UPDATE и DELETE создаёт новые снапшоты. Старые файлы данных накапливаются, даже когда на них больше не ссылаются. Оставленные без контроля, ваши таблицы превращаются в беспорядок из мелких файлов, которые убивают производительность запросов и тратят хранилище.
Iceberg предоставляет несколько операций обслуживания, чтобы поддерживать таблицы здоровыми:
- Сжатие (compaction): объединение мелких файлов в более крупные
- Удаление старых снапшотов (snapshot expiration): удаление старых снапшотов, которые вам больше не нужны
- Очистка осиротевших файлов (orphan file cleanup): удаление файлов данных, на которые не ссылается ни один снапшот
В этом упражнении вы намеренно создадите фрагментированную таблицу, увидите влияние на производительность, а затем очистите её.
Цели обучения
К концу упражнения вы сможете:
- Определять фрагментацию файлов с помощью метатаблиц
- Понимать delete‑файлы и как Iceberg обрабатывает DELETE
- Сжать мелкие файлы и мержить delete‑файлы с помощью
optimize - Удалять старые снапшоты с помощью
expire_snapshots - Удалять осиротевшие файлы с помощью
remove_orphan_files - Понимать, когда и зачем запускать каждую операцию обслуживания
Предварительные требования
- Завершённое упражнение 5 (или запущенное окружение)
- Около 25 минут
Шаг 1: Подготовка окружения
Запустите окружение, если оно ещё не запущено:
docker compose up -d
Запустите Spark SQL с дополнительными параметрами для доступа к данным напрямую из S3‑совместимой файловой системы (MinIO):
docker compose exec -it spark-iceberg spark-sql \ --packages org.apache.hadoop:hadoop-aws:3.3.4 \ --conf "spark.hadoop.hive.cli.print.header=true" \ --conf "spark.hadoop.fs.s3a.endpoint=http://minio:9000" \ --conf "spark.hadoop.fs.s3a.path.style.access=true" \ --conf "spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem" \ --conf "spark.hadoop.fs.s3.impl=org.apache.hadoop.fs.s3a.S3AFileSystem" \ --conf "spark.hadoop.fs.s3a.connection.ssl.enabled=false"
Создайте свежую таблицу:
CREATE NAMESPACE IF NOT EXISTS demo.ecommerce;USE demo.ecommerce; DROP TABLE IF EXISTS events; CREATE TABLE events ( event_id BIGINT, user_id BIGINT, event_type STRING, event_time TIMESTAMP, properties STRING)USING icebergPARTITIONED BY (days(event_time))TBLPROPERTIES ( 'format-version'='2', 'write.format.default'='parquet', 'write.update.mode'='copy-on-write', 'write.delete.mode'='merge-on-read');
Шаг 2: Создайте фрагментацию файлов
В продакшене фрагментация часто возникает из‑за streaming‑ингestion — маленькие батчи приходят часто. Давайте смоделируем это:
-- Батч 1INSERT INTO events VALUES (1, 101, 'page_view', CAST('2025-01-15 10:00:00' AS TIMESTAMP), '{"page": "/home"}'), (2, 102, 'page_view', CAST('2025-01-15 10:01:00' AS TIMESTAMP), '{"page": "/products"}'); -- Батч 2INSERT INTO events VALUES (3, 101, 'click', CAST('2025-01-15 10:02:00' AS TIMESTAMP), '{"button": "add_to_cart"}'), (4, 103, 'page_view', CAST('2025-01-15 10:03:00' AS TIMESTAMP), '{"page": "/checkout"}'); -- Батч 3INSERT INTO events VALUES (5, 102, 'purchase', CAST('2025-01-15 10:05:00' AS TIMESTAMP), '{"amount": 99.99}'), (6, 101, 'page_view', CAST('2025-01-15 10:06:00' AS TIMESTAMP), '{"page": "/confirmation"}'); -- Батч 4INSERT INTO events VALUES (7, 104, 'page_view', CAST('2025-01-15 10:10:00' AS TIMESTAMP), '{"page": "/home"}'), (8, 104, 'click', CAST('2025-01-15 10:11:00' AS TIMESTAMP), '{"button": "signup"}'); -- Батч 5INSERT INTO events VALUES (9, 104, 'signup', CAST('2025-01-15 10:12:00' AS TIMESTAMP), '{"method": "email"}'), (10, 105, 'page_view', CAST('2025-01-15 10:15:00' AS TIMESTAMP), '{"page": "/products"}');
Это 10 строк, разбросанных по 5 INSERT‑запросам. Каждый INSERT создаёт новый файл данных.
Шаг 3: Изучите фрагментацию
Давайте посмотрим на ущерб:
SELECT file_path, record_count, file_size_in_bytesFROM demo.ecommerce.events.files;
| file_path | record_count | file_size_in_bytes |
|---|---|---|
| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-14-0ed83218-4644-467b-ac1f-2a5c795af668-0-00001.parquet | 2 | 1701 |
| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-11-94d36656-d34e-4037-833d-db6cecae604d-0-00001.parquet | 2 | 1724 |
| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-8-dac044bc-60c7-48a7-860c-66925593c6ba-0-00001.parquet | 2 | 1713 |
| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-5-ccf96623-2110-47ec-88fb-ae34b72bc4a6-0-00001.parquet | 2 | 1718 |
| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-2-d9eb5c8c-b968-4b38-ac39-1c0024f294fe-0-00001.parquet | 2 | 1749 |
Вы должны увидеть 5 файлов, каждый содержит только 2 записи. В реальной системе с миллионами строк у вас могут быть тысячи файлов с несколькими сотнями записей каждый. Это проблема, потому что:
- Накладные расходы на запросы: каждый файл требует поиска метаданных и операций ввода‑вывода
- Неэффективность хранилища: мелкие файлы плохо сжимаются и имеют высокие накладные расходы
- Время планирования: планировщик запросов должен обрабатывать метаданные каждого файла
Проверьте также снапшоты:
SELECT snapshot_id, committed_at, operation, summary['added-data-files'] AS files_added, summary['total-records'] AS total_recordsFROM demo.ecommerce.events.snapshotsORDER BY committed_at;
Вы должны увидеть что‑то вроде:
| snapshot_id | committed_at | operation | files_added | total_records |
|---|---|---|---|---|
| 8784372252740323981 | 2026-01-08 16:57:39.894 | append | 1 | 2 |
| 6087226536736430910 | 2026-01-08 16:57:40.06 | append | 1 | 4 |
| 6213127806288664064 | 2026-01-08 16:57:40.219 | append | 1 | 6 |
| 3242568885727246813 | 2026-01-08 16:57:40.419 | append | 1 | 8 |
| 2436576844827260458 | 2026-01-08 16:57:40.603 | append | 1 | 10 |
Пять снапшотов, по одному на INSERT.
Шаг 4: Создайте историю снапшотов с обновлениями
Теперь выполним несколько UPDATE. В режиме copy‑on‑write каждый UPDATE читает файл, содержащий целевые строки, записывает новый файл с изменениями и создаёт новый снапшот, указывающий на новый файл:
-- Исправляем опечатку в типе событияUPDATE eventsSET event_type = 'page_view'WHERE event_id = 3; -- Обновляем свойстваUPDATE eventsSET properties = '{"page": "/home", "referrer": "google"}'WHERE event_id = 1; -- Помечаем событие как обработанноеUPDATE eventsSET properties = '{"amount": 99.99, "processed": true}'WHERE event_id = 5;
Проверьте историю снапшотов:
SELECT snapshot_id, committed_at, operation, summary['total-data-files'] AS data_filesFROM demo.ecommerce.events.snapshotsORDER BY committed_at;
| snapshot_id | committed_at | operation | data_files |
|---|---|---|---|
| 5727511788255165259 | 2026-06-21 09:13:13.718 | append | 1 |
| 5392048263238804020 | 2026-06-21 09:13:13.989 | append | 2 |
| 3854506803823744005 | 2026-06-21 09:13:14.177 | append | 3 |
| 3720983344166769272 | 2026-06-21 09:13:14.357 | append | 4 |
| 918012346593915260 | 2026-06-21 09:13:14.603 | append | 5 |
| 7072160981690913109 | 2026-06-21 09:15:04.141 | overwrite | 5 |
| 8023586188817369023 | 2026-06-21 09:15:04.499 | overwrite | 5 |
| 4692699911196965107 | 2026-06-21 09:15:04.841 | overwrite | 5 |
Вы увидите 3 новых снапшота (по одному на UPDATE), всего 8 снапшотов. Каждая операция UPDATE заменила файл данных обновлённой версией. Текущий снапшот всё ещё показывает ~5 файлов данных, но старые версии этих файлов теперь осиротевшие (ссылаются только на предыдущие снапшоты).
Шаг 5: Удаление файлов и позиционные удаления
Теперь посмотрим, что происходит при удалении строк. Iceberg использует positional delete files для отслеживания удалённых строк без перезаписи файлов данных:
-- Удаляем некоторые событияDELETE FROM eventsWHERE event_id IN (7, 8); -- Удаляем ещё одно событиеDELETE FROM eventsWHERE event_id = 10;
Теперь изучите файлы и увидите появление delete‑файлов:
SELECT content, file_path, record_count, file_size_in_bytesFROM demo.ecommerce.events.filesORDER BY content, file_path;
| content | file_path | record_count | file_size_in_bytes |
|---|---|---|---|
| 0 | s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-11-94d36656-d34e-4037-833d-db6cecae604d-0-00001.parquet | 2 | 1724 |
| 0 | s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-14-0ed83218-4644-467b-ac1f-2a5c795af668-0-00001.parquet | 2 | 1701 |
| 0 | s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-23-b1d56e10-aae5-4f6e-baa8-dbbac67cdb9e-0-00001.parquet | 2 | 1823 |
| 0 | s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-27-a5f7b204-795c-42e2-af4f-9ad9992cead1-0-00001.parquet | 2 | 1859 |
| 0 | s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-31-6e017c2e-2943-4542-9618-4b7159ce2af1-0-00001.parquet | 2 | 1815 |
| 1 | s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-38-70c50e32-7034-468d-a4af-73343d58bb46-00001-deletes.parquet | 2 | 1631 |
| 1 | s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-40-6226306e-25f7-4be9-991b-007f0b577cf2-00001-deletes.parquet | 1 | 1582 |
Посмотрите на колонку content:
0= Data file (содержит фактические строки)1= Positional delete file (помечает строки как удалённые)
Вы должны увидеть новые файлы с content = 1. Это delete‑файлы, которые записывают, какие позиции (номера строк) в файлах данных были удалены.
Ключевое понимание: В режиме merge‑on‑read Iceberg не перезаписывает файлы данных сразу при удалении строк. Вместо этого он создаёт маленькие delete‑файлы, которые движки запросов должны читать и применять. Это эффективно для мелких удалений, но со временем накапливает накладные расходы.
Проверьте, сколько у нас delete‑файлов:
SELECT content, CASE content WHEN 0 THEN 'DATA' WHEN 1 THEN 'POSITION_DELETES' WHEN 2 THEN 'EQUALITY_DELETES' END AS file_type, COUNT(*) AS file_count, SUM(record_count) AS total_recordsFROM demo.ecommerce.events.filesGROUP BY contentORDER BY content;
Вы должны получить:
| content | file_type | file_count | total_records |
|---|---|---|---|
| 0 | DATA | 5 | 10 |
| 1 | POSITION_DELETES | 2 | 3 |
Шаг 6: Сжатие файлов (compaction)
Время убираться. Нам нужно сжать файлы данных И применить delete‑файлы. Ключ — использовать правильные опции:
CALL system.rewrite_data_files( table => 'demo.ecommerce.events', options => map( 'rewrite-all', 'true' ));
Опция 'rewrite-all' => 'true' принудительно перезаписывает все файлы данных независимо от размера.
Вот результат, который вы должны увидеть:
| rewritten_data_files_count | added_data_files_count | rewritten_bytes_count | failed_data_files_count |
|---|---|---|---|
| 5 | 1 | 8922 | 0 |
Процедура rewrite_data_files применяет позиционные удаления, физически удаляя 3 удалённые строки.
Давайте проверим, что у нас после сжатия:
SELECT content, file_path, record_count, file_size_in_bytesFROM demo.ecommerce.events.files;
content| file_path| record_count| file_size_in_bytes 0| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-51-0dd6c489-8b1f-40cd-a7dc-5407ced7608b-0-00001.parquet| 7| 2032 1| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-40-6226306e-25f7-4be9-991b-007f0b577cf2-00001-deletes.parquet| 1| 1582 1| s3://warehouse/ecommerce/events/data/event_time_day=2025-01-15/00000-38-70c50e32-7034-468d-a4af-73343d58bb46-00001-deletes.parquet| 2| 1631
Вы должны увидеть:
- 1 файл данных с 7 записями (сжатый файл с применёнными удалениями)
- 2 delete‑файла всё ещё видны (но они теперь осиротевшие)
Подождите, delete‑файлы всё ещё там? Да! Но обратите внимание: файл данных содержит только 7 строк. Удаления были физически применены во время сжатия. Delete‑файлы, которые вы видите, теперь осиротевшие — они ссылаются на старые файлы данных, которые были заменены. Не волнуйтесь, мы очистим их на следующих шагах.
Проверьте данные:
SELECT * FROM events ORDER BY event_id;
Вы должны увидеть 7 строк (10 исходных − 3 удалённых). Осиротевшие delete‑файлы не влияют на запросы — это просто остатки метаданных, которые скоро будут очищены.
Шаг 7: Поймите накопление снапшотов
Сжатие помогло с файлами, но посмотрите на снапшоты:
SELECT snapshot_id, committed_at, operationFROM demo.ecommerce.events.snapshotsORDER BY committed_at;
Теперь у вас много снапшотов: 5 INSERT (append), 3 UPDATE (overwrite), 2 DELETE (overwrite) и 1 сжатие (replace). В продакшене после месяцев операций у вас могут быть тысячи.
Помните: каждый снапшот не даёт удалять старые файлы данных (для time travel), но занимает место в метаданных и замедляет операции с метаданными.
Шаг 8: Удаление старых снапшотов
Если вам не нужно путешествовать во времени к каждому состоянию, вы можете удалить старые снапшоты. Сначала проверьте метку времени последнего снапшота:
SELECT MAX(committed_at) FROM demo.ecommerce.events.snapshots;
Затем удалите все старые снапшоты, сохраняя только самый свежий:
CALL system.expire_snapshots( table => 'demo.ecommerce.events', older_than => TIMESTAMP '2026-01-09 12:00:00', -- замените на максимальную метку времени из запроса выше retain_last => 1 -- Всегда сохранять как минимум столько снапшотов.);
Параметр older_than говорит Iceberg удалить снапшоты, закоммиченные до этой метки времени, а retain_last гарантирует, что вы всегда сохраните как минимум 1 снапшот (даже если он старше метки). В продакшене вы бы вычисляли метку времени динамически (например, текущая дата минус 7 дней), чтобы сохранить неделю истории.
Эта процедура должна вернуть:
| deleted_data_files_count | deleted_position_delete_files_count | deleted_equality_delete_files_count | deleted_manifest_files_count | deleted_manifest_lists_count | deleted_statistics_files_count |
|---|---|---|---|---|---|
| 8 | 0 | 0 | 11 | 10 | 0 |
Проверьте, что осталось:
SELECT snapshot_id, committed_at, operationFROM demo.ecommerce.events.snapshotsORDER BY committed_at;
Остался только самый свежий снапшот. Старые ушли.
Предупреждение: Как только снапшоты удалены, вы больше не можете путешествовать во времени к ним. Параметр retain_last защищает недавние снапшоты.
Шаг 9: Удаление осиротевших файлов
Удаление снапшотов удаляет метаданные старых снапшотов, но фактические файлы данных, на которые они ссылались, всё ещё на диске. Теперь они осиротевшие — файлы, на которые не ссылается ни один снапшот.
Давайте проверим на осиротевшие файлы, сравнив файлы в хранилище с файлами в метаданных:
-- Файлы, на которые в данный момент ссылается таблицаSELECT COUNT(*) AS referenced_filesFROM demo.ecommerce.events.files;
| referenced_files |
|---|
| 3 |
Осиротевшие файлы всё ещё в MinIO, занимая место. Вы можете проверить это в другом терминале, выполнив:
docker compose exec minio mc ls /data/warehouse/ecommerce/events/data/event_time_day=2025-01-15
На моей машине я получил:
[2026-06-21 09:17:42 UTC] 4.0KiB 00000-38-70c50e32-7034-468d-a4af-73343d58bb46-00001-deletes.parquet/[2026-06-21 09:17:43 UTC] 4.0KiB 00000-40-6226306e-25f7-4be9-991b-007f0b577cf2-00001-deletes.parquet/[2026-06-21 09:20:28 UTC] 4.0KiB 00000-51-0dd6c489-8b1f-40cd-a7dc-5407ced7608b-0-00001.parquet/
Чтобы очистить их, мы бы обычно использовали:
CALL system.remove_orphan_files( table => 'demo.ecommerce.events', older_than => TIMESTAMP 'xxxx-xx-xx 00:00:00' -- должно быть как минимум 24 часа назад);
Теперь Iceberg по умолчанию осторожен. Процедура очистки требует минимальный 24‑часовой период ожидания перед фактическим удалением файлов. Это мера безопасности, чтобы убедиться, что мы случайно не выбросим данные, которые всё ещё могут читаться длительным запросом.
Если вы действительно хотите увидеть удаление файлов и не хотите ждать до завтра, есть обходной путь с использованием Action API напрямую, но это немного выходит за рамки этого упражнения (это включает запуск spark‑shell вместо spark‑sql с параметрами в начале упражнения).
А сейчас давайте сосредоточимся на тех двух висячих delete‑файлах, которые всё ещё болтаются в наших метаданных. Даже несмотря на то, что данные, на которые они указывали, исчезли после сжатия и удаления снапшотов, метаданные ещё этого не знают.
Чтобы привести всё в порядок, нам нужно перезаписать позиционные delete‑файлы:
CALL system.rewrite_position_delete_files( table => 'demo.ecommerce.events', options => map('rewrite-all', 'true'));
Опция 'rewrite-all' => 'true' заставляет процедуру обработать все delete‑файлы, включая висячие. Она должна вернуть:
| rewritten_delete_files_count | added_delete_files_count | rewritten_bytes_count | added_bytes_count |
|---|---|---|---|
| 2 | 0 | 3217 | 0 |
2 висячих delete‑файла были консолидированы в ничто, поскольку они не ссылаются на существующие данные, эффективно удаляя их из метаданных.
Шаг 10: Проверка очистки
Теперь давайте проверим, что всё чисто:
-- Должно быть 7 записейSELECT COUNT(*) AS total_recordsFROM demo.ecommerce.events; -- Должен быть 1 файл данных (больше нет delete‑файлов!)SELECT content, CASE content WHEN 0 THEN 'DATA' WHEN 1 THEN 'POSITION_DELETES' WHEN 2 THEN 'EQUALITY_DELETES' END AS file_type, COUNT(*) AS file_countFROM demo.ecommerce.events.filesGROUP BY contentORDER BY content; -- Должно быть 2 снапшота типа "replace"SELECT COUNT(*) AS snapshot_countFROM demo.ecommerce.events.snapshots;
| snapshot_count |
|---|
| 2 |
Отлично! Вы успешно завершили workflow обслуживания:
- Сжали 5 мелких файлов данных в 1 и применили удаления (осталось 7 строк)
- Удалили 11 старых снапшотов, сохранив только самый свежий
- Удалили 9 осиротевших файлов данных из хранилища
- Очистили 2 висячих delete‑файла из метаданных
Таблица теперь компактна и чиста!
Шаг 11: Очистка
Выйдите из Spark SQL:
exit;
Если вы закончили:
docker compose down -v
Что вы узнали
Вы увидели полную последовательность обслуживания:
- Сжатие файлов данных (применение удалений)
- Удаление старых снапшотов
- Удаление осиротевших файлов
- Перезапись позиционных delete‑файлов
«Обслуживание таблиц Iceberg — это как уборка в data lake: если делать регулярно, вода остаётся чистой, а запросы — быстрыми. Если не делать — получается болото, где тонут даже самые оптимистичные запросы.»