Files
mini-lakehouse-lab/notebooks/08_maintenance_and_final_lab.ipynb
T
ddadmin bf3241124c fix(course): clarify history and fix snapshot expiration
Set an explicit expiration cutoff because retain_last alone preserves fresh snapshots under the default five-day age threshold. Add result checks, distinguish time travel from rollback, and explain schema metadata versions.

Validated changed cells with Spark 3.5.1 and Iceberg 1.10.0 in an isolated container: 10 to 2 snapshots, 1300 rows preserved, expired time travel rejected, repeat execution in UTC and America/New_York. Notebook validation, Python syntax, local links and diff checks pass.
2026-09-20 16:14:53 -04:00

38 KiB

Модуль 8. Базовое обслуживание таблиц и финальная практика

Цель: Научиться базовому обслуживанию Iceberg-таблиц (compaction и expire_snapshots) и закрепить все навыки курса в финальной сквозной практике.

Prerequisites: пройден Модуль 6, Модуль 7 рекомендуется.

Две части модуля:

  1. Базовое обслуживание: compaction (решение проблемы мелких файлов) и expire_snapshots (очистка истории).
  2. Финальная практика: сквозной end-to-end сценарий.

Зачем нужно обслуживание? Iceberg-таблица — не «чёрный ящик». После множества записей накапливаются мелкие файлы и старые snapshot-ы. Без обслуживания чтение замедляется (много мелких файлов), а хранилище растёт (старые данные не удаляются). Это знакомо из мира Greenplum: AppendOnly-таблицы тоже требуют VACUUM и REORGANIZE.

Greenplum AppendOnly Iceberg
Проблема Мёртвые строки после UPDATE/DELETE Мелкие файлы после множества INSERT
Компактификация ALTER TABLE ... REORGANIZE rewrite_data_files
Очистка устаревших версий Нет встроенной истории версий expire_snapshots
Очистка мёртвых строк/файлов VACUUM expire_snapshots (удаляет старые файлы)
Автоматизация в базовой конфигурации Ручной запуск Ручной запуск
В production Расписание через cron/Airflow Расписание через cron/Airflow

Секция 1: Spark-сессия, Trino-клиент и проверка таблиц

In [ ]:
import pandas as pd
from pyspark.sql import SparkSession
from trino.dbapi import connect
import trino
from IPython.display import display

spark = SparkSession.builder \
    .appName("module-08-maintenance-and-lab") \
    .getOrCreate()

spark.sparkContext.setLogLevel("ERROR")
print("SparkSession создана.")

SILVER_TABLE = "lakehouse.silver.nyc_taxi_yellow"
DEMO_TABLE = "lakehouse.default.taxi_maintenance_demo"
RAW_PATH = "s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet"

Если ячейки выше упали с ImportError: trino — нужно пересобрать образ Jupyter (в терминале: docker compose build jupyter && docker compose up -d).

In [ ]:
# Проверяем, что silver-таблица существует (Модуль 5 должен быть пройден)
assert spark.catalog.tableExists(SILVER_TABLE), f"Таблица {SILVER_TABLE} не найдена. Пройдите Модуль 5."
assert spark.table(SILVER_TABLE).count() > 0, f"Таблица {SILVER_TABLE} пуста."
print(f"Таблица {SILVER_TABLE} готова к работе.")

# Проверяем, что raw-данные доступны (Модуль 3)
try:
    spark.read.parquet(RAW_PATH).limit(1).collect()
    print(f"Raw-данные доступны по пути: {RAW_PATH}")
except Exception as e:
    raise AssertionError(f"Raw-данные не найдены по пути {RAW_PATH}. Вернись в Модуль 3 и выполни загрузку данных в MinIO.") from e

In [ ]:
# Helper для выполнения запросов к Trino
def trino_query(sql: str) -> pd.DataFrame:
    """Выполняет SQL-запрос в Trino и возвращает результат как Pandas DataFrame."""
    conn = connect(
        host="trino",
        port=8080,
        user="jupyter",
        catalog="lakehouse"
    )
    cur = conn.cursor()
    try:
        cur.execute(sql)
        rows = cur.fetchall()
        columns = [desc[0] for desc in cur.description]
        return pd.DataFrame(rows, columns=columns)
    except trino.exceptions.ProgrammingError as e:
        if "No nodes available to run query" in str(e):
            print("Ошибка: Trino ещё не готов. Подождите пару минут после старта контейнеров.")
            raise
        # Запросы типа CREATE/DROP не возвращают строк
        return pd.DataFrame([{"status": "Success"}])
    finally:
        conn.close()

print("Trino helper функция готова.")

Silver на месте, raw-данные доступны, Trino готов. В первой части модуля создадим демо-таблицу и научимся её обслуживать. Во второй — финальная практика.

Секция 2: Проблема мелких файлов

Каждый INSERT INTO в Iceberg создаёт новые data files. Если вставлять данные мелкими порциями (частые микробатчи, ручные INSERT-ы, инкрементальные загрузки), таблица накапливает множество мелких файлов. Это замедляет чтение: движку приходится открывать и читать каждый файл отдельно.

Создадим демо-таблицу и сымитируем эту проблему: один изначальный CTAS (500 строк) и 8 маленьких INSERT INTO (по 100 строк). Перед созданием удалим одноразовую таблицу через DROP TABLE ... PURGE, чтобы при повторном запуске история снова начиналась с одного snapshot-а. (Внимание: 8 INSERT-ов могут занять 1-2 минуты)

In [ ]:
# Демо-таблица одноразовая: удаляем данные и старую snapshot-историю,
# чтобы повторный запуск дал те же 9 snapshot-ов.
spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE")

# Создание демо-таблицы (создаст 1 файл и 1 snapshot)
spark.sql(f"""
    CREATE TABLE {DEMO_TABLE}
    USING iceberg AS
    SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,
           passenger_count, trip_distance, fare_amount, total_amount,
           pickup_borough, pickup_zone
    FROM {SILVER_TABLE}
    LIMIT 500
""")
print(f"Демо-таблица создана: {spark.table(DEMO_TABLE).count()} строк.")

# 8 мелких INSERT INTO (создадут 8 новых мелких файлов)
print("Запуск 8 INSERT-ов (может занять 1-2 минуты)...")
for i in range(8):
    spark.sql(f"""
        INSERT INTO {DEMO_TABLE}
        SELECT VendorID, tpep_pickup_datetime, tpep_dropoff_datetime,
               passenger_count, trip_distance, fare_amount, total_amount,
               pickup_borough, pickup_zone
        FROM {SILVER_TABLE}
        LIMIT 100
    """)
    print(f"INSERT {i+1}/8 выполнен.")

print(f"Итого строк: {spark.table(DEMO_TABLE).count()}")

In [ ]:
# Файловая статистика через metadata-таблицу files
files_df = spark.sql(f"""
    SELECT file_path, file_format, record_count, file_size_in_bytes
    FROM {DEMO_TABLE}.files
""")
print(f"Количество data files: {files_df.count()}")
files_df.show(truncate=40)

In [ ]:
# Snapshot-история
spark.sql(f"SELECT snapshot_id, committed_at, operation, summary FROM {DEMO_TABLE}.snapshots").show(truncate=False)

Результат: 9 data files, 9 snapshot-ов. Каждый INSERT создал отдельный маленький файл. В production-сценарии после недель инкрементальных загрузок таблица может накопить сотни и тысячи мелких файлов.

Для сравнения посмотрим на файловую статистику нашей основной silver-таблицы, которая была создана одним большим CTAS.

In [ ]:
silver_files = spark.sql(f"SELECT file_path, record_count, file_size_in_bytes FROM {SILVER_TABLE}.files")
print(f"Silver: {silver_files.count()} data file(s)")
silver_files.show(truncate=40)

silver имеет мало крупных файлов — проблемы мелких файлов нет. Compaction нужен не всегда, а после множества мелких записей. Далее мы исправим проблему на демо-таблице.

Секция 3: Compaction — rewrite_data_files

rewrite_data_files объединяет мелкие data files в меньшее количество крупных. Аналогия: дефрагментация диска. Или ALTER TABLE ... REORGANIZE в Greenplum.

In [ ]:
# Сохраним статистику ДО compaction
files_before = spark.sql(f"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files").first()
print(f"До compaction: {files_before['file_count']} файлов, {files_before['total_bytes']} байт")

In [ ]:
# Выполняем compaction
spark.sql(f"CALL lakehouse.system.rewrite_data_files(table => 'default.taxi_maintenance_demo')")
print("Compaction выполнен.")

In [ ]:
# Статистика ПОСЛЕ compaction
files_after = spark.sql(f"SELECT count(*) AS file_count, sum(file_size_in_bytes) AS total_bytes FROM {DEMO_TABLE}.files").first()
print(f"После compaction: {files_after['file_count']} файлов, {files_after['total_bytes']} байт")
print(f"Было {files_before['file_count']} файлов → стало {files_after['file_count']} файлов")

# Проверим, что данные на месте
print(f"Строк в таблице: {spark.table(DEMO_TABLE).count()}")

Данные не изменились — только реорганизованы файлы (чтение стало быстрее). Compaction создал новый snapshot с новыми, более крупными файлами.

In [ ]:
# Посмотрим snapshot-историю: появился новый snapshot
spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots").show(truncate=False)

Но старые мелкие файлы всё ещё лежат в MinIO: они нужны для time travel к старым snapshot-ам (например, чтобы мы могли запросить данные ДО compaction или ДО какого-то INSERT-а). Чтобы освободить место в хранилище, нужен expire_snapshots.

Секция 4: expire_snapshots — очистка истории

expire_snapshots удаляет старые snapshot-ы из метаданных и файлы, которые больше не нужны ни одному сохранённому snapshot-у. Очистка выполняется при вызове процедуры.

Два параметра определяют, какие snapshot-ы будут удалены:

  • older_than — граница времени: удалить можно snapshot-ы, созданные раньше неё. Без явной границы при стандартных настройках удаляются только snapshot-ы старше 5 дней.
  • retain_last — сколько последних snapshot-ов в цепочке предков текущего состояния сохранить независимо от возраста. Это минимум, а не требование оставить ровно столько.

Поэтому один retain_last => 2 не удалит snapshot-ы, которые мы только что создали. Для демо возьмём текущее время Spark-сессии как older_than и сохраним два последних snapshot-а.

Важный компромисс: time travel к удалённым snapshot-ам станет невозможен. Текущее время как границу используем только для одноразовой демо-таблицы; в production срок хранения выбирают с учётом требований к истории и восстановлению.

In [ ]:
# Snapshot-история ДО expire
snapshots_before = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots")
snapshot_count_before = snapshots_before.count()
print(f"Snapshot-ов до expire: {snapshot_count_before}")
snapshots_before.show(truncate=False)

# Сохраняем ID самого старого snapshot для проверки time travel после expire
first_snapshot_id = snapshots_before.orderBy("committed_at").first()["snapshot_id"]
print(f"Самый старый snapshot: {first_snapshot_id}")
row_count_before_expire = spark.table(DEMO_TABLE).count()

In [ ]:
# Получаем границу времени в часовом поясе Spark-сессии
cutoff = spark.sql("""
    SELECT date_format(current_timestamp(), 'yyyy-MM-dd HH:mm:ss.SSSSSS') AS cutoff
""").first()["cutoff"]

# Удаляем snapshot-ы до cutoff, сохраняя два последних
spark.sql(f"""
    CALL lakehouse.system.expire_snapshots(
        table => 'default.taxi_maintenance_demo',
        older_than => TIMESTAMP '{cutoff}',
        retain_last => 2
    )
""")
print("expire_snapshots выполнен.")

In [ ]:
# Snapshot-история ПОСЛЕ expire
snapshots_after = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots")
remaining_snapshot_ids = [row["snapshot_id"] for row in snapshots_after.collect()]
print(f"Snapshot-ов: {snapshot_count_before} → {len(remaining_snapshot_ids)}")
snapshots_after.show(truncate=False)
assert len(remaining_snapshot_ids) == 2, "Ожидалось ровно 2 snapshot-а после очистки."
assert first_snapshot_id not in remaining_snapshot_ids, "Самый старый snapshot не удалён."

# Очистка истории не меняет данные в текущем состоянии
row_count_after_expire = spark.table(DEMO_TABLE).count()
assert row_count_after_expire == row_count_before_expire, "Число строк изменилось после очистки."
print(f"Данные на месте: {row_count_after_expire} строк.")

Осталось всего 2 snapshot-а. Попробуем прочитать данные из удалённого (самого первого) snapshot-а:

In [ ]:
try:
    spark.sql(f"""
        SELECT count(*) FROM {DEMO_TABLE}
        VERSION AS OF {first_snapshot_id}
    """).show()
except Exception as e:
    print(f"Ожидаемая ошибка: {e}")
    print("Snapshot удалён — time travel невозможен.")
else:
    raise AssertionError("Time travel сработал: старый snapshot не был удалён.")

Вывод: expire нужно запускать обдуманно. Слишком агрессивно (потеря страховки для rollback), слишком редко (рост метаданных и стоимости хранения).

В нашем демо сохранены snapshot после compaction и его предок до compaction. Предку ещё нужны старые мелкие файлы, поэтому expire_snapshots их не удаляет. Удаление snapshot-ов не обязательно освобождает data files: важны ссылки из оставшихся состояний.

remove_orphan_files решает другую задачу: удаляет файлы, на которые вообще нет ссылок в метаданных таблицы, например остатки неудачной записи. Для файлов, нужных сохранённым snapshot-ам, она не подходит.

Секция 5: Порядок обслуживания и типичные ошибки

Правильный порядок обслуживания Iceberg-таблицы:

Шаг Операция Что делает Что будет, если пропустить
1 rewrite_data_files Объединяет мелкие файлы в крупные Чтение остаётся медленным
2 expire_snapshots Удаляет старые snapshot-ы Метаданные и хранилище растут

Если выполнить expire до compaction, мелкие файлы останутся: на них ссылается текущий snapshot. После compaction они будут нужны его предку. Удалить их сможет следующий вызов expire_snapshots, когда этот предок попадёт под условия очистки. До этого файлы не являются «сиротами».

Типичные ошибки:

  1. Никогда не запускать compaction: таблица обрастает тысячами мелких файлов, чтение деградирует в десятки раз.
  2. Удалить исправный snapshot после ошибочной записи: если older_than и retain_last допускают его удаление, после expire откатиться к нему уже не выйдет. Правило: перед expire убедись, что текущее состояние корректно.
  3. Обслуживание на каждый INSERT: compaction — тяжёлая операция. В production его делают по расписанию (например, 1 раз в сутки ночью).

Секция 6: Параллель с Greenplum AppendOnly

Развернутое сравнение для студентов с опытом Greenplum:

Аспект Greenplum AppendOnly Iceberg
Как накапливается «мусор» UPDATE/DELETE оставляют мёртвые строки в сегментах Множество INSERT создают мелкие data files
Влияние на чтение Сканирование мёртвых строк замедляет запросы Открытие множества мелких файлов замедляет запросы
Компактификация ALTER TABLE ... REORGANIZE rewrite_data_files (объединение файлов)
Очистка VACUUM (удаление мёртвых строк) expire_snapshots (удаление старых snapshot-ов)
Автоматизация autovacuum для heap-таблиц, ручной VACUUM для AO Ручной запуск, автоматизация через Airflow/cron
Просмотр состояния pg_stat_all_tables, gp_toolkit.gp_bloat_diag table.files, table.snapshots
Time travel после очистки Невозможен Невозможен для удалённых snapshot-ов

Ключевое сходство: ни одна система не обслуживает себя сама. И там, и там это задача инженера. Ключевое отличие: Iceberg даёт встроенную историю (snapshot-ы) и контролируемую очистку (retain_last), чего нет в классическом DWH.

Секция 7: Самостоятельное задание — обслуживание

Задача 1 (анализ silver). Посмотри файловую статистику silver-таблицы (lakehouse.silver.nyc_taxi_yellow): количество data files, средний размер файла, количество snapshot-ов. Сравни с демо-таблицей до compaction. Ответь: нужна ли silver compaction? При каких условиях потребовалась бы?

Задача 2 (практика).

  • Создай таблицу lakehouse.default.taxi_compaction_practice через CTAS (500 строк из silver).
  • Выполни 5 INSERT INTO по 200 строк.
  • Посмотри файловую статистику ДО.
  • Выполни compaction, затем получи свежий cutoff по образцу Секции 4 и вызови expire_snapshots с older_than => TIMESTAMP '{cutoff}' и retain_last => 1.
  • Проверь результат ПОСЛЕ: остался 1 snapshot, число строк не изменилось.
  • Удали таблицу (DROP TABLE).
In [ ]:
# Ваш код: файловая статистика silver (файлы, размеры, snapshot-ы)

Ваш ответ на Задачу 1: нужна ли compaction для silver? При каких условиях потребовалась бы? ...

In [ ]:
# Ваш код: создание taxi_compaction_practice + 5 INSERT

In [ ]:
# Ваш код: файловая статистика до compaction

In [ ]:
# Ваш код: compaction + expire_snapshots

In [ ]:
# Ваш код: файловая статистика после + DROP TABLE

Секция 8: Удаление демо-таблицы

Демо-таблица для обслуживания больше не нужна.

In [ ]:
spark.sql(f"DROP TABLE IF EXISTS {DEMO_TABLE} PURGE")
print("Демо-таблица и её учебные данные удалены.")

Секция 9: Финальная практика — сквозной end-to-end сценарий

Финальная практика курса. Самостоятельно пройди полный цикл Lakehouse-пайплайна: от raw-данных до обслуживания таблицы. Все навыки из Модулей 1–8 в одном сценарии.

Работаем в отдельном namespace lakehouse.final, чтобы не затрагивать основные таблицы.

Сценарий (8 шагов):

Шаг 1 (namespace). Создай namespace lakehouse.final.

Шаг 2 (bronze). Прочитай raw parquet из MinIO (s3a://lakehouse/raw/nyc_taxi/yellow_tripdata_*.parquet, LIMIT 5000 строк) и создай таблицу lakehouse.final.trips_bronze через CTAS. Это bronze: данные «как есть».

Шаг 3 (silver). Создай lakehouse.final.trips_silver из bronze с тремя трансформациями:

  • Фильтр: fare_amount >= 0
  • Нормализация: passenger_count → INT с обработкой NULL (coalesce + cast)
  • Обогащение: вычисляемая колонка trip_duration_minutes (разница между dropoff и pickup в минутах)

Шаг 4 (Trino). Прочитай lakehouse.final.trips_silver из Trino. Сравни count с результатом из Spark.

Шаг 5 (schema evolution). Добавь колонку processed_at TIMESTAMP к trips_silver через ALTER TABLE ADD COLUMNS. Проверь из Trino (DESCRIBE), что колонка видна.

Шаг 6 (snapshot-ы). Выполни 3 INSERT INTO trips_silver (например, по 300 строк из trips_bronze). Каждый INSERT должен включать трансформации из шага 3, плюс current_timestamp() AS processed_at. Подсказка: если не указать processed_at, Spark выдаст ошибку несовпадения числа колонок; альтернативный вариант — использовать явный список target-колонок в INSERT INTO trips_silver (col1, col2, ...). Посмотри файловую статистику и snapshot-ы.

Шаг 7 (обслуживание). Выполни compaction (rewrite_data_files). После него получи свежий cutoff по образцу Секции 4 и вызови expire_snapshots с older_than => TIMESTAMP '{cutoff}' и retain_last => 1. Проверь, что файлов стало меньше, snapshot-ов остался 1, а число строк не изменилось.

Шаг 8 (cleanup). Удали trips_silver, trips_bronze и namespace lakehouse.final.

In [ ]:
# Шаг 1: CREATE NAMESPACE

In [ ]:
# Шаг 2: прочитай raw и создай trips_bronze (LIMIT 5000)

In [ ]:
# Шаг 2: проверка trips_bronze (count, printSchema)

In [ ]:
# Шаг 3: создай trips_silver с трансформациями

In [ ]:
# Шаг 3: проверка trips_silver (count, проверка фильтра)

DBeaver: SELECT count(*) FROM lakehouse.final.trips_silver;

In [ ]:
# Шаг 4: чтение trips_silver из Trino

In [ ]:
# Шаг 5: ALTER TABLE ADD COLUMNS

DBeaver: DESCRIBE lakehouse.final.trips_silver;

In [ ]:
# Шаг 5: проверка из Trino (DESCRIBE)

In [ ]:
# Шаг 6: 3 INSERT INTO trips_silver

In [ ]:
# Шаг 6: файловая статистика и snapshot-ы

In [ ]:
# Шаг 7: compaction + expire_snapshots

In [ ]:
# Шаг 7: проверка результата

In [ ]:
# Шаг 8: cleanup (DROP TABLE, DROP NAMESPACE)

Секция 10: Финальный checkpoint

Часть A — Обслуживание таблиц (Модуль 8):

  1. Что такое проблема мелких файлов и когда она возникает?
  2. Что делает rewrite_data_files? Удаляет ли он старые файлы?
  3. Что делает expire_snapshots? Какой компромисс он создаёт?
  4. В каком порядке нужно выполнять compaction и expire_snapshots? Почему?
  5. Как обслуживание Iceberg-таблиц соотносится с VACUUM/REORGANIZE в Greenplum?

Часть B — Весь курс (итоговые вопросы):

  1. Назови три роли в архитектуре Lakehouse и какие компоненты стенда их выполняют.
  2. Почему Spark может записать данные, а Trino прочитать ту же таблицу без копирования?
  3. Чем raw отличается от bronze? Чем bronze отличается от silver?
  4. Что такое snapshot в Iceberg? Чем он отличается от бэкапа в PostgreSQL?
  5. Назови три безопасные и три опасные операции с Iceberg-таблицей.
  6. Зачем нужны compaction и expire_snapshots в production-сценарии?

Секция 11: Завершение

Итог модуля:

  • Compaction (rewrite_data_files) — решает проблему мелких файлов, не меняя данные (чтение становится быстрее).
  • expire_snapshots — очищает старые snapshot-ы (сокращая размер метаданных и хранилища), но лишает возможности time travel.
  • Порядок: compaction → expire_snapshots.
  • Обслуживание Iceberg похоже на обслуживание Greenplum AppendOnly: обе системы требуют периодической заботы.

Итог курса. Что ты теперь умеешь:

  1. Поднимать и диагностировать локальный Lakehouse-стенд (Модуль 1).
  2. Объяснять роли storage, catalog, compute (Модуль 2).
  3. Загружать raw-данные и проверять схему (Модуль 3).
  4. Создавать и заполнять Iceberg-таблицы (Модуль 4).
  5. Строить воспроизводимый поток raw → bronze → silver (Модуль 5).
  6. Читать одну таблицу из Spark и Trino (Модуль 6).
  7. Безопасно изменять таблицы: schema evolution, time travel, rollback (Модуль 7).
  8. Обслуживать таблицы: compaction, expire_snapshots (Модуль 8).

Это базовый набор навыков для безопасной работы в Lakehouse-стенде. Дальше — production: партиционирование, MERGE, streaming, Airflow, governance. Но фундамент заложен.

Курс завершен! Поздравляем! 🎉

In [ ]:
spark.stop()