Files
mini-lakehouse-lab/notebooks/08_maintenance_and_final_lab.ipynb
T
ddadmin 4bea2af9d2 feat(module-08): реализован модуль по обслуживанию таблиц и финальная практика
- Зачем:
  - обучение студентов базовому обслуживанию Iceberg-таблиц (compaction, expire_snapshots) и закрепление навыков всего курса.
- Что:
  - создан notebooks/08_maintenance_and_final_lab.ipynb с обзором проблемы мелких файлов и её решения.
  - добавлена финальная сквозная практика (raw -> bronze -> silver -> Trino -> schema evolution -> обслуживание) в namespace lakehouse.final.
  - в финальную практику включены педагогические подсказки и конкретные задания (trip_duration_minutes).
  - статус модуля 8 в планах обновлен до Ready for validation.
- Проверка:
  - ручная проверка выполнения ячеек в ноутбуке и синтаксиса stored procedures Iceberg.
2026-03-08 00:33:40 +03:00

34 KiB
Raw Blame History

Модуль 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 строк). (Внимание: 8 INSERT-ов могут занять 1-2 минуты)

In [ ]:
# Создание демо-таблицы (создаст 1 файл)
spark.sql(f"""
    CREATE OR REPLACE 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-ы из метаданных. Важный компромисс: после expire_snapshots time travel к удалённым snapshot-ам невозможен. Это необратимо. В production нужно решить: сколько истории хранить? Обычно 10–30 дней. Для демо оставим только 2 последних snapshot-а.

In [ ]:
# Snapshot-история ДО expire
snapshots_before = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots")
print(f"Snapshot-ов до expire: {snapshots_before.count()}")
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}")

In [ ]:
# Выполняем expire_snapshots (оставляем только 2 последних)
spark.sql(f"""
    CALL lakehouse.system.expire_snapshots(
        table => 'default.taxi_maintenance_demo',
        retain_last => 2
    )
""")
print("expire_snapshots выполнен.")

In [ ]:
# Snapshot-история ПОСЛЕ expire
snapshots_after = spark.sql(f"SELECT snapshot_id, committed_at, operation FROM {DEMO_TABLE}.snapshots")
print(f"Snapshot-ов после expire: {snapshots_after.count()}")
snapshots_after.show(truncate=False)

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

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

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

Примечание: физическое удаление файлов из MinIO зависит от конфигурации. Иногда требуется дополнительно запускать процедуру remove_orphan_files, которая физически чистит файлы без привязки к активным snapshot-ам.

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

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

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

Если сделать наоборот: expire удалит snapshot-ы, но мелкие файлы останутся (так как на них всё ещё ссылается текущий snapshot). Compaction затем создаст новые крупные файлы, а старые мелкие станут "orphan" (сиротами) и не удалятся автоматически.

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

  1. Никогда не запускать compaction: таблица обрастает тысячами мелких файлов, чтение деградирует в десятки раз.
  2. expire_snapshots сразу после ошибки (с retain_last => 1): если записали плохие данные и запустили expire, откатиться назад (rollback) уже не выйдет. Правило: перед 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 и expire_snapshots (retain_last => 1).
  • Проверь результат ПОСЛЕ.
  • Удали таблицу (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}")
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), затем expire_snapshots (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()