Partition Evolution: изменение схемы партиционирования без перезаписи данных

Механика ALTER TABLE ADD/DROP/REPLACE PARTITION FIELD, неперезаписываемость field-id, сосуществование нескольких partition-spec в одной таблице, Split-Query Planning при смешанных манифестах и инженерные ограничения безопасной эволюции партиционирования.

lakehouse storage

Эффект масштаба: почему идеальная схема партиционирования портится со временем

Четвёртый урок модуля показал, как выбрать partition transform под конкретный паттерн нагрузки - и закончился важной оговоркой: выбор делается на основе текущего понимания паттерна запросов и текущего объёма данных. Проблема в том, что ни одно из этих двух условий не остаётся постоянным дольше, чем несколько кварталов в любой растущей системе.

Рассмотрим типичный, не выдуманный сценарий. Таблица logs создаётся с партиционированием days(event_ts), потому что на старте проекта в неё пишется около 50 ГБ в сутки - это даёт несколько файлов оптимального размера (128-512 МБ) на партицию, как обсуждалось в предыдущих уроках. Через год трафик вырастает в 20 раз - 1 ТБ в сутки. Партиция за один день теперь содержит тысячи файлов, манифесты для одного дня раздуваются, а пиковая нагрузка записи (вся суточная partition активно дописывается в течение 24 часов) создаёт постоянную конкуренцию commit'ов между параллельными writer'ами, разобранную во втором уроке модуля. Правильным решением полгода назад было бы партиционирование по hours(event_ts) - но таблица уже существует, уже содержит петабайты исторических данных, и уже обслуживает десятки production-пайплайнов.

Кошмар в стиле Hive

В классическом Hive-подходе у этой проблемы есть только один путь решения - полная миграция. Поскольку партиционирование физически закодировано в путях файлов (event_date=2026-06-20/), изменение granularity означает создание новой таблицы с новым layout и перезапись всего исторического датасета в новую структуру директорий.

Стоимость этой миграции - не только вычислительная (переписать петабайты), но и организационная: каждый downstream-пайплайн, каждый Airflow DAG, каждый BI-дашборд, ссылающийся на старую таблицу, должен быть скоординированно переключён на новую. Для крупной организации с десятками команд-потребителей данных это часто означает проект на несколько недель, а не разовую команду.

Главный тезис урока

Физическая структура таблицы должна иметь возможность адаптироваться к росту данных и изменению паттернов запросов плавно - без остановки системы, без полной перезаписи истории и без необходимости синхронно менять код всех потребителей. Apache Iceberg реализует это через механизм Partition Evolution: партиционная спецификация версионируется точно так же, как версионируется схема колонок (разобранная в первом уроке модуля), и изменение этой спецификации - лёгкая метаданная операция, а не операция над терабайтами файлов.


Partition Spec как версионируемый объект

Второй и четвёртый уроки модуля уже показали, что metadata.json хранит partition-specs как массив, а не единственное значение, и что каждое поле спецификации идентифицируется field-id, начинающимся с 1000. Этот раздел разворачивает именно эту часть структуры - не как статический факт, а как механизм, специально спроектированный для версионирования во времени.

{
  "default-spec-id": 1,
  "partition-specs": [
    {
      "spec-id": 0,
      "fields": [
        {"name": "event_ts_day", "transform": "day", "source-id": 4, "field-id": 1000}
      ]
    },
    {
      "spec-id": 1,
      "fields": [
        {"name": "event_ts_hour", "transform": "hour", "source-id": 4, "field-id": 1001}
      ]
    }
  ]
}

Этот пример - результат одной операции эволюции: таблица начинала со spec-id: 0 (партиционирование по дням), а затем перешла на spec-id: 1 (партиционирование по часам). Обратите внимание на ключевую деталь, отличающую этот массив от массива schemas, разобранного в первом уроке: обе спецификации остаются в массиве одновременно, и default-spec-id указывает только на то, какая из них используется для новых записей - старые данные продолжают существовать со ссылкой на spec-id: 0, и Iceberg никогда не переинтерпретирует их под новую формулу.

Четыре операции эволюции: ADD, DROP, REPLACE, RENAME

Iceberg предоставляет ровно четыре SQL-команды для изменения партиционной спецификации, и каждая создаёт новую запись в массиве partition-specs с инкрементированным spec-id, не трогая ни одну из предыдущих.

-- ADD: добавляет новое партиционное поле к ТЕКУЩЕЙ спецификации.
-- Новые данные партиционируются И по старому, И по новому полю одновременно.
ALTER TABLE lakehouse.analytics.logs
ADD PARTITION FIELD hours(event_ts);

-- DROP: убирает поле из спецификации для НОВЫХ данных.
-- Старые файлы, записанные с этим полем, не меняются и не теряют
-- свою партиционную информацию в манифестах.
ALTER TABLE lakehouse.analytics.logs
DROP PARTITION FIELD days(event_ts);

-- REPLACE: атомарная замена одного transform на другой ДЛЯ ТОЙ ЖЕ
-- исходной колонки - семантически эквивалент DROP + ADD за один шаг,
-- но создаёт только ОДНУ новую версию spec вместо двух промежуточных.
ALTER TABLE lakehouse.analytics.logs
REPLACE PARTITION FIELD days(event_ts) WITH hours(event_ts);

-- RENAME: меняет только человекочитаемое ИМЯ партиционного поля
-- (используемое в DESCRIBE TABLE EXTENDED и путях файлов) -
-- НЕ создаёт новый spec-id, потому что не меняет ни transform,
-- ни source-id, ни field-id - это чисто косметическая операция.
ALTER TABLE lakehouse.analytics.logs
RENAME PARTITION FIELD event_ts_day TO day_bucket;

Принципиальная разница между RENAME и остальными тремя командами заслуживает отдельного комментария: RENAME не создаёт новую версию partition spec, потому что не меняет ничего, влияющего на физическое распределение данных или на алгоритм Predicate Projection из прошлого урока - меняется только отображаемое имя поля. ADD, DROP и REPLACE, напротив, всегда создают новую запись в partition-specs, потому что они меняют формулу, по которой вычисляется партиция для будущих записей.

Почему field-id никогда не переиспользуется

Это - самая важная деталь безопасности всего механизма, и она заслуживает отдельного разбора с конкретным примером того, что пошло бы не так без этого правила.

Если бы field-id переиспользовался, у движка не было бы способа отличить «значение партиции 7, вычисленное по формуле bucket(16, user_id)» от «значение партиции 7, вычисленное по совершенно другой формуле bucket(32, user_id)», если бы оба поля случайно получили один и тот же field-id в разные моменты истории таблицы. Поскольку field-id однозначно фиксирует, по какой именно формуле вычислено число в манифесте (через ссылку partition_spec_id записи манифеста на конкретную версию spec, где этот field-id определён), любое переиспользование сделало бы Predicate Projection (алгоритм из прошлого урока) потенциально неправильным - предикат может быть спроецирован через формулу bucket(32, ...), в то время как реальные исторические данные физически вычислены через bucket(16, ...), и сравнение даст неверный, не консервативный результат (то есть может ошибочно отбросить файл, который реально содержит совпадения - именно та ошибка, которую алгоритм пруннинга обязан исключать категорически, как было подчёркнуто в третьем уроке модуля).

Сводная таблица последствий каждой операции

Перед тем как переходить к внутреннему механизму DROP, полезно свести четыре команды эволюции в одну сравнительную таблицу - конкретный референс, к которому можно вернуться при проектировании реальной миграции партиционирования, не пересматривая весь теоретический материал заново.

Операция Новый spec-id Новый field-id Затрагивает старые файлы Затрагивает снапшот
ADD PARTITION FIELD Да Да (для нового поля) Нет Нет
DROP PARTITION FIELD Да Нет (поле просто исключается/маркируется void) Нет Нет
REPLACE PARTITION FIELD ... WITH ... Да Да (для нового поля; старое не переиспользуется) Нет Нет
RENAME PARTITION FIELD Нет Нет Нет Нет

Единственная строка, не создающая новую версию spec - RENAME, и это прямое следствие того, что переименование не меняет ничего из того, что определяет физическое распределение данных (source-id, transform, field-id) - меняется только отображаемое имя, используемое в DESCRIBE TABLE EXTENDED и в путях файлов для будущих записей. Все три остальные операции одинаковы в одном важном отношении: ни одна из них не затрагивает ни один существующий файл данных и не создаёт новый снапшот - это операции исключительно над структурой метаданных, а не над состоянием данных.

Внутренний механизм DROP: void-маркер вместо физического удаления позиции

Отдельная деталь реализации, объясняющая, почему DROP PARTITION FIELD иногда выглядит как «замена», а не «удаление» при внимательном изучении внутренней структуры новой спецификации: для таблиц, где порядок полей в partition struct физически значим (унаследовано от более ранних версий формата), Iceberg может представить удалённое поле специальным void-transform - трансформом, который всегда возвращает null для любого входного значения - вместо того, чтобы буквально убрать позицию из структуры. Это сохраняет позиционную совместимость структуры partition tuple между версиями spec, избегая необходимости менять Avro-схему data_file.partition для каждой версии. С точки зрения пользователя и Predicate Projection это не меняет видимого поведения - void-поле просто никогда не участвует ни в записи новых данных (потому что новые записи используют актуальный default-spec-id, где этого поля либо нет, либо оно заменено), ни в pruning (потому что у него нет осмысленного диапазона значений) - но знание этого механизма полезно при отладке, если в дампе метаданных встречается поле с transform void.


Анатомия: что происходит в момент ALTER TABLE ADD/DROP PARTITION FIELD

Эта операция - не операция над данными, а commit метаданных, использующий ровно тот же протокол атомарного переключения указателя, что был детально разобран во втором уроке модуля для обычных операций записи.

Решающая деталь этой диаграммы, на которую стоит обратить отдельное внимание: current-snapshot-id не меняется в результате ALTER TABLE ... PARTITION FIELD. Это означает, что данная операция, в отличие от INSERT/UPDATE/MERGE, не создаёт новый снапшот - она меняет только описание структуры таблицы (партиционную и, в общем случае, схему колонок при ALTER TABLE ... ADD COLUMN), но не состояние данных. Любой запрос, выполненный сразу после ALTER TABLE ADD PARTITION FIELD и до первой новой записи, увидит абсолютно тот же набор файлов, что и до этой команды - просто следующая операция записи будет использовать уже новую спецификацию.

# Подтверждение: ALTER TABLE PARTITION FIELD не создаёт снапшот
snapshots_before = spark.sql(
    "SELECT count(*) AS cnt FROM lakehouse.analytics.logs.snapshots"
).collect()[0]["cnt"]

spark.sql("""
    ALTER TABLE lakehouse.analytics.logs
    ADD PARTITION FIELD hours(event_ts)
""")

snapshots_after = spark.sql(
    "SELECT count(*) AS cnt FROM lakehouse.analytics.logs.snapshots"
).collect()[0]["cnt"]

print(f"Снапшотов до: {snapshots_before}, после ALTER: {snapshots_after}")
# Снапшотов до: 12, после ALTER: 12  -  ЧИСЛО НЕ ИЗМЕНИЛОСЬ

Сосуществование нескольких спецификаций в манифестах

Естественный технический вопрос: если одна таблица содержит файлы, написанные с разными partition spec, как именно это отражается на уровне manifest list и manifest files, разобранных во втором и третьем уроках модуля?

Ответ заключён в поле partition_spec_id, которое было упомянуто (но не развёрнуто подробно) при описании структуры записи manifest list в третьем уроке. Это поле фиксирует: один конкретный manifest file целиком относится к одной конкретной версии spec - то есть Iceberg не смешивает записи, вычисленные по разным spec, в одном физическом manifest-файле. Когда происходит запись новых данных после ALTER TABLE ADD PARTITION FIELD, новый манифест создаётся отдельно от старых, и помечается ссылкой на новый spec-id.

Это разделение - не временное неудобство переходного периода, которое потом «срастается» само собой. Манифесты со старым partition_spec_id остаются такими навсегда, если не подвергнутся явной maintenance-операции (rewrite_manifests/rewrite_data_files, разобранным в третьем уроке и более подробно - в уроке про maintenance). Это значит, что таблица, прошедшая через несколько эволюций партиционирования за свою историю, в самом общем случае содержит манифесты нескольких разных spec-id одновременно, и это - нормальное, ожидаемое долгосрочное состояние, а не аномалия, требующая немедленного исправления.


Анатомия Query Planning со смешанными спецификациями: Split-Query Planning

Это центральный механизм урока. Третий урок модуля показал алгоритм Scan Planning как единый процесс пруннинга манифестов и файлов. Четвёртый урок показал Predicate Projection как способ спроецировать предикат пользователя в домен значений конкретного transform. Теперь нужно объединить эти два механизма для случая, когда разные манифесты в одном снапшоте используют разные transform для одного и того же исходного предиката.

Принцип: проекция выполняется для каждого манифеста отдельно, относительно ЕГО собственного spec

Когда Driver на Этапе 2 алгоритма Scan Planning (третий урок) итерирует записи manifest list, он не применяет одну глобальную проекцию предиката ко всем манифестам - он смотрит на partition_spec_id каждой конкретной записи манифеста и применяет проекцию именно той формулы трансформа, которая определена в соответствующей версии partition-specs.

Эта диаграмма - буквальная реализация фразы «split-query planning» из плана урока: один предикат пользователя расщепляется на N независимых проекций - по одной на каждую версию spec, встреченную среди манифестов текущего снапшота - и каждая проекция сравнивается со статистикой только тех манифестов, что относятся к соответствующему spec. Ни на одном этапе аналитик не видит этого расщепления: результат всех проекций просто объединяется в общий список файлов, который передаётся executor'ам, и финальный DataFrame не содержит ни единого признака того, что часть строк физически лежала в днёвных партициях, а часть - в часовых.

Особый случай: предикат на поле, существующем только в одной из спецификаций

Что происходит, если запрос фильтрует по полю, которое было добавлено через ADD PARTITION FIELD и существует только в новой спецификации, а в старой спецификации такого поля не было вообще (а не просто заменено другим transform той же колонки)? Например, после ALTER TABLE logs ADD PARTITION FIELD bucket(16, user_id) (добавление, а не замена) старые манифесты (spec-id=0) физически не содержат значения бакета ни для одного файла - в их partition_field_summary просто нет соответствующей записи.

# Концептуальная иллюстрация логики при отсутствии поля в старом spec
def project_for_manifest(predicate, manifest_spec_id, partition_specs):
    spec = partition_specs[manifest_spec_id]
    field = spec.find_field_by_source_id(predicate.column_id)

    if field is None:
        # Поле появилось позже - в ЭТОЙ версии spec его не существует.
        # КОНСЕРВАТИВНО считаем "может содержать совпадения" -
        # та же гарантия безопасности, что и при отсутствующей статистике
        # колонки (third lesson), просто теперь причина - отсутствие
        # самого партиционного поля, а не отсутствие сбора статистики
        return "ROWS_MIGHT_MATCH"

    projected = field.transform.project(predicate)
    return evaluate_against_manifest_bounds(projected, manifest_spec_id)

Для манифестов старого spec, не содержащих поля bucket(16, user_id) вообще, движок не может выполнить manifest-level pruning по этому измерению - он консервативно оставляет такие манифесты для File-level Pruning (Этап 3), который продолжает работать по обычной поколоночной статистике независимо от партиционирования. Это снова прямая иллюстрация принципа консервативности из третьего урока: отсутствие информации (в данном случае - отсутствие самого партиционного измерения для старых файлов) никогда не приводит к ошибочному отбрасыванию файлов, только к менее эффективному, но всё ещё корректному пруннингу.

Стоимость Split-Query Planning относительно числа исторических spec

Стоит явно оценить, во сколько обходится Split-Query Planning по сравнению со сценарием, где вся таблица имела бы единственный, никогда не менявшийся spec. Каждая дополнительная версия spec, манифесты которой пересекаются с диапазоном конкретного запроса, добавляет одну дополнительную независимую проекцию предиката - операцию, по стоимости сопоставимую с вычислением границ transform(X) для одной формулы, то есть тривиально дешёвую в CPU-время по сравнению с самим чтением манифестов (Этап 2 алгоритма Scan Planning).

def estimate_split_planning_overhead(num_specs_in_range: int, base_planning_ms: float) -> float:
    """
    Грубая оценка дополнительных накладных расходов Split-Query Planning.
    Каждая дополнительная версия spec добавляет фиксированный,
    небольшой CPU-overhead на проекцию - НЕ дополнительные сетевые
    запросы (манифесты читаются один раз независимо от числа spec).
    """
    per_spec_projection_overhead_ms = 0.05  # вычисление границ - тривиально
    return base_planning_ms + num_specs_in_range * per_spec_projection_overhead_ms


for specs_count in [1, 3, 10]:
    total = estimate_split_planning_overhead(specs_count, base_planning_ms=150)
    print(f"Spec в диапазоне запроса: {specs_count} -> planning: {total:.2f} мс")

# Spec в диапазоне запроса: 1  -> planning: 150.05 мс
# Spec в диапазоне запроса: 3  -> planning: 150.15 мс
# Spec в диапазоне запроса: 10 -> planning: 150.50 мс

Этот расчёт подтверждает интуицию из раздела про инженерные нюансы: даже десяток исторических версий spec в диапазоне одного запроса добавляет накладные расходы на уровне сотых долей миллисекунды - то есть Split-Query Planning сам по себе практически никогда не является узким местом производительности. Замедление запросов с широким историческим охватом, если оно наблюдается на практике, почти всегда объясняется не стоимостью самой проекции, а тем, что старые spec обычно соответствуют менее эффективному партиционированию (например, months() вместо days()) - то есть проблема в granularity исторических данных, а не в механизме Split-Query Planning как таковом.


Практический демо-блок: эволюция партиционирования на лету

Переходим к практике. Конфигурация SparkSession идентична прошлым урокам модуля.

Кейс 1: создание таблицы и заполнение историческими данными

spark.sql("""
    CREATE TABLE lakehouse.analytics.logs (
        log_id BIGINT, service STRING, level STRING, event_ts TIMESTAMP
    )
    USING iceberg
    PARTITIONED BY (days(event_ts))
    TBLPROPERTIES ('format-version' = '2')
""")

# Записываем "исторические" данные за прошлый месяц - 30 дней,
# равномерно распределённые, имитирующие старую низкую интенсивность трафика
import random
from datetime import datetime, timedelta
from pyspark.sql import functions as F

base_date = datetime(2026, 5, 1)
historical_rows = []
for day_offset in range(30):
    current_date = base_date + timedelta(days=day_offset)
    for _ in range(500):
        historical_rows.append((
            random.randint(1, 10_000_000),
            random.choice(["api", "worker", "scheduler"]),
            random.choice(["INFO", "WARN", "ERROR"]),
            current_date + timedelta(seconds=random.randint(0, 86400)),
        ))

historical_df = spark.createDataFrame(
    historical_rows,
    schema="log_id BIGINT, service STRING, level STRING, event_ts TIMESTAMP",
)
historical_df.writeTo("lakehouse.analytics.logs").append()

Проверяем физическую структуру в MinIO напрямую через boto3 (тот же подход, что использовался во втором уроке модуля для ручной инспекции метаданных):

import boto3

s3 = boto3.client(
    "s3", endpoint_url="http://minio:9000",
    aws_access_key_id="minioadmin", aws_secret_access_key="minioadmin",
)

objects = s3.list_objects_v2(
    Bucket="lakehouse", Prefix="warehouse/analytics/logs/data/", Delimiter="/"
)
for prefix in objects.get("CommonPrefixes", []):
    print(prefix["Prefix"])

# warehouse/analytics/logs/data/event_ts_day=2026-05-01/
# warehouse/analytics/logs/data/event_ts_day=2026-05-02/
# ... (30 директорий, по одной на каждый день)
# warehouse/analytics/logs/data/event_ts_day=2026-05-30/

Кейс 2: эволюция схемы партиционирования

Трафик вырос, и партиционирование по дням стало слишком грубым. Переходим на часы:

spark.sql("""
    ALTER TABLE lakehouse.analytics.logs
    ADD PARTITION FIELD hours(event_ts)
""")
spark.sql("""
    ALTER TABLE lakehouse.analytics.logs
    DROP PARTITION FIELD days(event_ts)
""")

# Эквивалентно одной командой - REPLACE атомарно меняет formula
# для ОДНОЙ И ТОЙ ЖЕ исходной колонки за один commit метаданных:
#
# spark.sql("""
#     ALTER TABLE lakehouse.analytics.logs
#     REPLACE PARTITION FIELD days(event_ts) WITH hours(event_ts)
# """)

# Записываем "новые" данные за текущий день - высокая интенсивность,
# имитирующая возросший трафик, из-за которого и понадобилась эволюция
today = datetime(2026, 6, 20)
new_rows = []
for hour in range(24):
    for _ in range(2000):  # в 4 раза больше строк в час, чем было в день раньше
        new_rows.append((
            random.randint(1, 10_000_000),
            random.choice(["api", "worker", "scheduler"]),
            random.choice(["INFO", "WARN", "ERROR"]),
            today + timedelta(hours=hour, seconds=random.randint(0, 3600)),
        ))

new_df = spark.createDataFrame(
    new_rows, schema="log_id BIGINT, service STRING, level STRING, event_ts TIMESTAMP"
)
new_df.writeTo("lakehouse.analytics.logs").append()

Заходим в MinIO снова и видим именно то, что было обещано теоретической частью - физическое доказательство сосуществования двух структур:

objects = s3.list_objects_v2(
    Bucket="lakehouse", Prefix="warehouse/analytics/logs/data/", Delimiter="/"
)
for prefix in objects.get("CommonPrefixes", []):
    print(prefix["Prefix"])

# warehouse/analytics/logs/data/event_ts_day=2026-05-01/   <- СТАРЫЕ, нетронуты
# warehouse/analytics/logs/data/event_ts_day=2026-05-02/
# ... (все 30 старых директорий по дням остались на месте)
# warehouse/analytics/logs/data/event_ts_day=2026-05-30/
# warehouse/analytics/logs/data/event_ts_hour=2026-06-20-00/   <- НОВЫЕ, по часам
# warehouse/analytics/logs/data/event_ts_hour=2026-06-20-01/
# ... (24 новые директории по часам)
# warehouse/analytics/logs/data/event_ts_hour=2026-06-20-23/

Ни один файл, ни один байт в директориях event_ts_day=* не был тронут операцией эволюции - они просто продолжают существовать рядом с новыми директориями event_ts_hour=*, и обе структуры одновременно являются частью одной и той же логической таблицы lakehouse.analytics.logs.

Кейс 3: проверка прозрачности чтения и инспекция метаданных

spark.sql("DESCRIBE TABLE EXTENDED lakehouse.analytics.logs").show(truncate=False)
# # Partitioning
# Part 0: days(event_ts)     <- неактивная историческая спецификация
# Part 1: hours(event_ts)    <- АКТУАЛЬНАЯ спецификация для новых записей

spark.sql("""
    SELECT spec_id, count(*) AS manifest_count
    FROM lakehouse.analytics.logs.manifests
    GROUP BY spec_id
""").show()
# spec_id | manifest_count
#       0 |              4    <- манифесты со старыми, дневными файлами
#       1 |              2    <- манифесты с новыми, часовыми файлами

Теперь пишем единый запрос, покрывающий и старый, и новый период - именно тот «шок-контент» из плана урока, демонстрирующий полную прозрачность для аналитика:

unified_query = spark.sql("""
    SELECT service, count(*) AS error_count
    FROM lakehouse.analytics.logs
    WHERE event_ts >= '2026-05-15' AND event_ts < '2026-06-21'
      AND level = 'ERROR'
    GROUP BY service
""")
unified_query.show()
unified_query.explain(mode="formatted")
== Physical Plan ==
* HashAggregate (group by service)
+- * Filter (event_ts >= ... AND event_ts < ... AND level = 'ERROR')
   +- * BatchScan lakehouse.analytics.logs
      PushedFilters: [event_ts >= 2026-05-15, event_ts < 2026-06-21, level = ERROR]
      ScanStatistics:
          Total Manifests: 6,  Manifests skipped: 1   (один старый месяц без пересечения)
          Manifests scanned: 5  (часть spec_id=0 ЗА период + ВСЕ spec_id=1)
          Data Files scanned: 38

Этот план - прямое доказательство Split-Query Planning из теоретической части: запрос охватывает диапазон, пересекающийся и со старыми дневными манифестами (часть мая), и с новыми часовыми манифестами (20 июня), и Iceberg успешно выполнил manifest-level pruning для обеих групп манифестов независимо, объединив результат в единый список файлов - аналитик не написал ни единого слова про days() или hours() в своём SQL и получил единый, корректный DataFrame.

Кейс 4: руками подтверждаем неперезаписываемость field-id

Последний практический кейс - прямая экспериментальная проверка утверждения из теоретической части про field-id: выполняем DROP, затем ADD поля с той же формулой transform, и смотрим, получает ли новое поле тот же field-id, читая metadata.json напрямую через boto3 (тот же метод ручной инспекции, что использовался во втором уроке модуля).

import json

def get_current_metadata(spark, s3_client, bucket: str, table_path: str) -> dict:
    objects = s3_client.list_objects_v2(
        Bucket=bucket, Prefix=f"{table_path}/metadata/"
    )
    metadata_files = [
        obj for obj in objects["Contents"] if obj["Key"].endswith(".metadata.json")
    ]
    latest_key = max(metadata_files, key=lambda o: o["LastModified"])["Key"]
    obj = s3_client.get_object(Bucket=bucket, Key=latest_key)
    return json.loads(obj["Body"].read())


# Шаг 1: смотрим текущие partition-specs ДО эксперимента
meta_before = get_current_metadata(spark, s3, "lakehouse", "warehouse/analytics/logs")
for spec in meta_before["partition-specs"]:
    print(f"spec-id={spec['spec-id']}: {spec['fields']}")

# spec-id=0: [{'name': 'event_ts_day', 'transform': 'day', 'field-id': 1000}]
# spec-id=1: [{'name': 'event_ts_hour', 'transform': 'hour', 'field-id': 1001}]

# Шаг 2: удаляем hours(event_ts), затем добавляем его обратно
spark.sql("ALTER TABLE lakehouse.analytics.logs DROP PARTITION FIELD hours(event_ts)")
spark.sql("ALTER TABLE lakehouse.analytics.logs ADD PARTITION FIELD hours(event_ts)")

# Шаг 3: смотрим partition-specs ПОСЛЕ эксперимента
meta_after = get_current_metadata(spark, s3, "lakehouse", "warehouse/analytics/logs")
for spec in meta_after["partition-specs"]:
    print(f"spec-id={spec['spec-id']}: {spec['fields']}")

# spec-id=0: [{'name': 'event_ts_day', 'transform': 'day', 'field-id': 1000}]
# spec-id=1: [{'name': 'event_ts_hour', 'transform': 'hour', 'field-id': 1001}]   <- после DROP
# spec-id=2: [{'name': 'event_ts_hour', 'transform': 'hour', 'field-id': 1002}]   <- НОВЫЙ field-id!

Результат подтверждает теоретическое утверждение буквально: несмотря на то, что новое поле event_ts_hour использует ту же формулу hour(event_ts), что и удалённое, оно получает новый field-id=1002, а не переиспользует освободившийся field-id=1001. Это - прямое физическое доказательство правила безопасности, разобранного в теоретической части: каждое поле, когда-либо существовавшее в истории spec таблицы, навсегда занимает уникальный field-id, даже если оно идентично по формуле какому-то более раннему полю.


Инженерные нюансы: что безопасно менять, а что нет

Partition Evolution - мощный, но не безграничный инструмент. Стоит явно зафиксировать границы безопасного использования, отдельно от того, что технически возможно.

Безопасные операции

Добавление нового измерения (ADD PARTITION FIELD) - всегда безопасно: старые манифесты просто не содержат этого измерения и пруннинг по нему деградирует до консервативного «может содержать совпадения», но никакая корректность не нарушается.

Изменение granularity монотонного трансформа (REPLACE PARTITION FIELD days(...) WITH hours(...)) - безопасно, и именно этот сценарий разобран в практическом блоке урока. Оба transform монотонны (четвёртый урок модуля), поэтому Predicate Projection продолжает работать эффективно для диапазонных предикатов в обеих исторических эрах.

Удаление избыточного измерения (DROP PARTITION FIELD) - безопасно для корректности, хотя и снижает эффективность pruning для будущих запросов по этому измерению (новые файлы просто не будут иметь этой статистики).

Операции, требующие осторожности

Изменение числа бакетов в bucket-трансформе (bucket(16, ...)bucket(32, ...)) технически реализуется через REPLACE PARTITION FIELD, но заслуживает отдельного предупреждения: это не «уточнение» существующего бакетирования, а полностью другая функция распределения. Значение bucket(16, X) и bucket(32, X) для одного и того же X в общем случае дают совершенно разные числа (хэш считается тот же, но остаток от деления берётся по другому модулю) - то есть строка, физически лежавшая в файле бакета 7 под старой схемой, не имеет никакого предсказуемого отношения к файлам бакета 7 под новой схемой. Для equality-pruning (главного применения bucket(), разобранного в прошлом уроке) это означает, что движок обязан выполнять отдельную проекцию для каждой исторической версии bucket-схемы, что увеличивает число независимых ветвей Split-Query Planning пропорционально числу исторических версий bucket-трансформа, накопленных за время жизни таблицы.

Слишком частая эволюция - технически каждая операция ALTER TABLE ... PARTITION FIELD дешева (это метаданная-операция без снапшота, как было показано в теоретической части), но накопление десятков версий spec за историю таблицы увеличивает число независимых веток Split-Query Planning при каждом запросе, охватывающем длинный исторический период. Практическое правило: рассматривать эволюцию партиционирования как редкое архитектурное решение (раз в несколько месяцев-кварталов при существенном изменении паттерна нагрузки), а не как инструмент тонкой ежедневной настройки.

Изменения, требующие нормализации через rewrite. Если бизнес-требование - унифицировать всю историю под одну актуальную спецификацию (например, для упрощения дальнейшей поддержки или потому что разница в эффективности pruning между эрами стала ощутимой), единственный путь - явная maintenance-операция:

# rewrite_data_files может принудительно переписать историю
# под ТЕКУЩУЮ актуальную спецификацию партиционирования -
# это ЕДИНСТВЕННЫЙ способ "обновить" старые файлы до нового spec,
# и именно поэтому он называется rewrite (переписывание), а не migrate
spark.sql("""
    CALL lakehouse.system.rewrite_data_files(
        table => 'analytics.logs',
        where => 'event_ts < TIMESTAMP \\'2026-06-01\\''
    )
""")

После этой операции старые майские данные физически переписываются в новые файлы, организованные по актуальному default-spec-id (часовому партиционированию) - но это осознанное, потенциально дорогое (по объёму I/O) решение, принимаемое отдельно от самой операции эволюции, а не автоматическое следствие ALTER TABLE. Детальный разбор экономики этого решения (когда оно оправдано, а когда «пусть полежит со старым spec») - тема урока про Table Maintenance.

Взаимодействие с write.distribution-mode при смене spec

Четвёртый урок модуля показал, что write.distribution-mode = 'hash' заставляет Spark выполнять shuffle строк по значению партиции перед записью, чтобы гарантировать clustered write и избежать fanout-проблемы. Когда происходит эволюция партиционирования, важно понимать: хэш для shuffle вычисляется относительно актуального default-spec-id на момент конкретной операции записи, а не относительно какого-либо фиксированного на момент создания таблицы spec. Это означает, что переход, например, с days() на days() + bucket(32, user_id) автоматически меняет и логику распределения нагрузки shuffle при следующей записи - без необходимости менять конфигурацию distribution-mode отдельно.

# distribution-mode остаётся 'hash' без изменений,
# но РЕАЛЬНОЕ разбиение, которое использует Spark при shuffle,
# меняется автоматически вместе со spec
spark.sql("""
    ALTER TABLE lakehouse.analytics.logs
    ADD PARTITION FIELD bucket(32, service)
""")

# Эта запись уже использует НОВУЮ spec (days + bucket(32, service))
# для вычисления того, как сгруппировать строки перед записью -
# никакой отдельной настройки distribution-mode не требуется
new_batch_df.writeTo("lakehouse.analytics.logs").append()

Это - удобное следствие архитектуры, но оно же создаёт повод для путаницы при отладке: если после эволюции партиционирования вдруг изменилось число файлов на batch при том же объёме данных и той же конфигурации distribution-mode, причина - не в самом distribution-mode, а в том, что число уникальных комбинаций партиционных значений (а значит, и число файлов, на которые распределяются строки при clustered write) изменилось вместе со spec.

Совместная эволюция схемы колонок и партиционирования

Первый урок модуля показал ID-based Schema Evolution (безопасное добавление/удаление/переименование колонок через числовые ID), а этот урок показал ID-based Partition Evolution через тот же принцип (field-id, никогда не переиспользуемый). Эти два механизма не просто архитектурно похожи - они напрямую связаны через поле source-id партиционного поля, ссылающееся на ID колонки схемы (третий и четвёртый уроки модуля). Стоит явно разобрать, что происходит, когда обе эволюции происходят одновременно или последовательно над одной и той же колонкой.

Эта диаграмма показывает важное, но не самоочевидное следствие: переименование колонки event_timestamp -> event_ts (операция Schema Evolution из первого урока модуля) не требует никакой дополнительной операции Partition Evolution, хотя partition spec формально определён через days(event_timestamp). Причина - partition spec ссылается на колонку через source-id=4, а не через текстовое имя, и переименование колонки меняет только отображаемое имя для этого же самого id=4 - связь между spec и колонкой остаётся целой автоматически, без какого-либо явного действия со стороны инженера.

Обратная ситуация - что если нужно удалить колонку, которая используется как source-id для активного партиционного поля? Iceberg явно запрещает эту операцию для защиты целостности:

# Попытка удалить колонку, на которую ссылается АКТИВНОЕ
# партиционное поле - завершится ошибкой валидации
try:
    spark.sql("ALTER TABLE lakehouse.analytics.logs DROP COLUMN event_ts")
except Exception as exc:
    print(f"Ошибка: {exc}")
    # Cannot drop column that is used as a source field
    # in the table's current partition spec: event_ts

# Правильная последовательность действий:
# 1. Сначала DROP PARTITION FIELD для всех полей, ссылающихся на эту колонку
spark.sql("ALTER TABLE lakehouse.analytics.logs DROP PARTITION FIELD hours(event_ts)")
spark.sql("ALTER TABLE lakehouse.analytics.logs DROP PARTITION FIELD days(event_ts)")
# 2. Теперь DROP COLUMN проходит успешно
spark.sql("ALTER TABLE lakehouse.analytics.logs DROP COLUMN event_ts")

Этот защитный механизм - прямое следствие того, что без активного source-id Predicate Projection не смогла бы определить, как вычислять значение партиции для будущих записей - Iceberg сознательно предпочитает явную ошибку валидации в момент ALTER TABLE, а не молчаливое создание несогласованного состояния метаданных.

Тонкий случай: расширение типа колонки, используемой в bucket()

Отдельная, менее очевидная ситуация возникает при безопасном расширении типа колонки (intlong, разобранном в первом уроке модуля как одна из четырёх гарантированно безопасных операций Schema Evolution), если эта колонка одновременно служит источником для bucket(N, col). Формула хэширования bucket() (четвёртый урок модуля) вычисляется от байтового представления значения, и это представление физически отличается для int (4 байта) и long (8 байт) - то есть после расширения типа новые записи с тем же числовым значением, что и старые, в принципе могут хэшироваться в байтовое представление, отличное от того, что использовалось для старых записей с тем же числом.

Важно подчеркнуть: это не проблема корректности чтения исторических данных - старые файлы хранят своё значение партиции, зафиксированное в манифесте на момент записи (как и для любой другой эволюции, разобранной в этом уроке), и пруннинг для них продолжает работать корректно относительно той версии типа и формулы, что использовалась тогда. Эффект проявляется только в одном специфическом сценарии: если для equality-предиката user_id = 12345 нужно искать совпадения и среди старых (int), и среди новых (bigint) файлов одновременно - Split-Query Planning обязан вычислить проекцию bucket(N, 12345) дважды, используя байтовое представление, соответствующее типу колонки на момент существования каждой конкретной версии spec, а не один раз глобально. Это усложнение полностью скрыто от пользователя и обрабатывается автоматически, но стоит знать о нём при отладке неожиданных результатов pruning после одновременного изменения и схемы, и партиционирования одной и той же колонки.

Мультидвижковая согласованность: эволюция, выполненная из Spark, видна везде

Первый урок модуля подчёркивал, что открытость спецификации Iceberg позволяет разным вычислительным движкам (Spark, Trino, Flink, ClickHouse) читать и писать одну и ту же таблицу через общий каталог. Partition Evolution - хороший конкретный тест этого свойства: поскольку ALTER TABLE ... PARTITION FIELD - это просто новая запись в partition-specs внутри metadata.json, а не специфичная для конкретного движка операция, любой другой движок, читающий ту же таблицу через тот же каталог, увидит новую спецификацию немедленно после commit'а - без необходимости перезапуска, переконфигурации или какой-либо синхронизации между движками.

# Эволюция выполнена из Spark
spark.sql("ALTER TABLE lakehouse.analytics.logs ADD PARTITION FIELD hours(event_ts)")
-- Через секунду после commit'а Trino, подключённый к ТОМУ ЖЕ каталогу,
-- видит обновлённую структуру без перезапуска кластера Trino:
DESCRIBE lakehouse.analytics.logs;
-- (включает обновлённую информацию о партиционировании)

-- И Trino корректно выполняет Split-Query Planning для запроса,
-- охватывающего и старые, и новые данные - используя СВОЮ
-- реализацию алгоритма проекции предиката, независимую от Spark,
-- но следующую ТОЙ ЖЕ открытой спецификации Iceberg
SELECT service, count(*)
FROM lakehouse.analytics.logs
WHERE event_ts >= TIMESTAMP '2026-05-15' AND event_ts < TIMESTAMP '2026-06-21'
GROUP BY service;

Это свойство - не специфика конкретной реализации, а прямое следствие того, что Split-Query Planning, разобранный в этом уроке, полностью описан спецификацией Iceberg, а не является внутренней деталью реализации Spark. Любой движок, корректно реализующий чтение partition-specs и алгоритм проекции предиката, обязан давать идентичный по корректности результат - различия между движками могут быть только в скорости или деталях оптимизации, но не в наборе файлов, которые в итоге признаются релевантными запросу.


Производственный кейс: три года роста и три эволюции партиционирования

Ситуация. Команда IoT-платформы запустила таблицу sensors.readings без явного партиционирования вообще (identity-схема не задавалась, partition spec был пустым) - на старте проекта объём данных составлял несколько мегабайт в день, и партиционирование казалось избыточным усложнением для MVP. Через год объём вырос до нескольких гигабайт в день, ещё через год - до сотен гигабайт в день, по мере подключения новых датчиков и клиентов.

Эволюция год за годом.

# Год 0: таблица без партиционирования - MVP, несколько МБ/день
spark.sql("""
    CREATE TABLE lakehouse.sensors.readings (
        sensor_id BIGINT, value DOUBLE, reading_ts TIMESTAMP
    )
    USING iceberg
""")

# Год 1: объём вырос до нескольких ГБ/день - добавляем партиционирование
# по месяцам, минимально нарушая существующие пайплайны
spark.sql("""
    ALTER TABLE lakehouse.sensors.readings
    ADD PARTITION FIELD months(reading_ts)
""")

# Год 2: объём вырос до сотен ГБ/день - месяц стал слишком грубым,
# переходим на дни и добавляем bucket для защиты от hotspot записи
# (множество датчиков пишут одновременно - см. четвёртый урок модуля)
spark.sql("""
    ALTER TABLE lakehouse.sensors.readings
    REPLACE PARTITION FIELD months(reading_ts) WITH days(reading_ts)
""")
spark.sql("""
    ALTER TABLE lakehouse.sensors.readings
    ADD PARTITION FIELD bucket(32, sensor_id)
""")

Результат. На протяжении всех трёх лет ни один из примерно 40 downstream-пайплайнов и дашбордов, читающих sensors.readings, не потребовал ни единого изменения кода - все они продолжали фильтровать по reading_ts и sensor_id как по обычным бизнес-колонкам, ровно как было задумано Hidden Partitioning в четвёртом уроке модуля. Каждая эволюция была однострочной командой ALTER TABLE, выполненной без какого-либо окна обслуживания и без единого переписанного байта исторических данных.

Метрика Год 0 (без партиций) Год 1 (months) Год 2 (days + bucket)
Объём данных в сутки Несколько МБ Несколько ГБ Сотни ГБ
Активная partition spec spec-id=0 (пусто) spec-id=1 (months) spec-id=2 (days+bucket)
Изменений в downstream-коде - 0 0
Время выполнения каждой эволюции - <1 секунды <1 секунды
Объём переписанных исторических данных - 0 байт 0 байт

Дополнительное наблюдение команды. Спустя полгода после второй эволюции аналитики заметили, что запросы за период «год 0 - год 1» (данные без партиционирования и с месячным партиционированием) выполняются заметно медленнее запросов за недавний период - что ожидаемо, учитывая разницу в эффективности pruning между тремя эрами. Команда explicitно решила не запускать rewrite_data_files для нормализации всей истории под актуальный spec, потому что объём исторических запросов к данным старше года был статистически крайне мал (менее 0.1% от общего числа запросов) - решение, прямо иллюстрирующее принцип из раздела про инженерные нюансы: нормализация старых данных - это explicit экономическое решение, взвешивающее стоимость rewrite против частоты обращения к затронутому периоду, а не автоматическое требование архитектуры.

Эта временная шкала наглядно показывает суть архитектурного компромисса, принятого командой: чем дальше в историю уходит диапазон запроса, тем больше вероятность, что он пересечётся с менее эффективными по pruning ранними spec - но поскольку частота таких запросов крайне мала, суммарная стоимость владения (включая потенциальный rewrite_data_files, который сам по себе стоит I/O и вычислений) оказывается ниже при оставлении истории как есть, чем при её принудительной нормализации.


Типичные заблуждения про Partition Evolution

«После ALTER TABLE ADD/DROP PARTITION FIELD старые файлы автоматически переписываются в фоне» - неверно, и это прямо противоречит главному тезису урока. Старые файлы остаются физически нетронутыми навсегда, если не запущена явная maintenance-операция (rewrite_data_files). Никакого фонового процесса миграции не существует.

«Partition Evolution работает как ALTER TABLE в реляционной БД - блокирует таблицу на время изменения» - неверно. Как было показано в разделе про анатомию операции, ALTER TABLE ... PARTITION FIELD не создаёт новый снапшот и не требует ничего, кроме одного атомарного commit метаданных (CAS-операция из второго урока модуля) - читатели и писатели не блокируются ни на миллисекунду дольше, чем любой обычный commit.

«Если таблица содержит несколько spec, запросы к ней всегда работают медленнее» - неточно как абсолютное утверждение. Запросы, диапазон которых полностью укладывается в одну историческую эру (например, только последний месяц при недавней эволюции), не испытывают никакого дополнительного overhead - Split-Query Planning лишь применяет проекцию относительно той единственной spec, манифесты которой реально пересекаются с диапазоном запроса. Замедление проявляется только для запросов, явно охватывающих несколько эр одновременно, и даже тогда оно пропорционально числу эр в диапазоне, а не общему числу эволюций за всю историю таблицы.

«bucket(32, col) - это просто более гранулярная версия bucket(16, col), и переход безопасен как REPLACE» - неверно, разобрано отдельно в разделе про инженерные нюансы: смена числа бакетов - это смена самой функции распределения, а не уточнение существующей. Значения старого и нового бакетирования не имеют предсказуемого соответствия друг другу.

«Чтобы добавить bucket-измерение к существующему партиционированию, нужно сначала DROP, потом ADD» - не нужно для добавления нового, независимого измерения (как в производственном кейсе урока, где bucket(32, sensor_id) был добавлен поверх уже изменённого days(reading_ts) через простой ADD). DROP нужен только тогда, когда конкретное измерение перестаёт быть нужным вообще, а не просто дополняется новым.

«REPLACE PARTITION FIELD - это просто синтаксический сахар над DROP + ADD, без разницы в результате» - неточно в одной важной детали. И REPLACE, и последовательность DROP + ADD в итоге создают новую версию spec без старого поля и с новым - но REPLACE делает это за один commit метаданных (одна новая запись в partition-specs, один атомарный CAS), в то время как раздельные DROP и ADD создают две промежуточные версии spec последовательно. Для большинства практических целей разница не критична, но если между раздельными DROP и ADD команд успеет произойти запись данных, эта запись окажется привязана к промежуточной версии spec (без какого-либо партиционного поля для затронутой колонки вообще) - лишняя, не нужная сущность в истории, которой можно избежать, используя REPLACE для атомарной замены.

«Удалённая колонка автоматически удаляет связанные с ней партиционные поля» - неверно, и порядок действий здесь строго обратный, как было показано в разделе про совместную эволюцию схемы и партиционирования: Iceberg запрещает DROP COLUMN для колонки, на которую ссылается активное партиционное поле, и требует сначала явно выполнить DROP PARTITION FIELD. Это сделано намеренно, чтобы у инженера была возможность явно решить, что нужно архитектуре дальше, а не получить тихое автоматическое изменение партиционирования как побочный эффект изменения схемы.


Мостик к следующим урокам

Этот урок завершает тему структуры хранения и планирования чтения, начатую в первом уроке модуля - все четыре предыдущих урока (анатомия дерева метаданных, snapshot-модель, scan planning, hidden partitioning) и этот урок про эволюцию партиционирования вместе формируют полную картину того, как Iceberg организует и находит данные. Следующие уроки модуля переключаются на принципиально другую тему - модификацию данных на уровне строк.

  • Уроки 6 и 7 («Copy-on-Write» и «Merge-on-Read») разбирают, как именно UPDATE/DELETE/MERGE физически реализованы на уровне файлов - и partition spec, разобранный в этом уроке, прямо влияет на то, в какие физические файлы попадают изменённые строки: операция модификации обязана учитывать актуальную spec при определении целевого расположения новых версий строк.

  • Урок 8 («MERGE INTO») строит production-паттерн upsert для CDC-потоков, который на практике часто запускается на таблицах, прошедших через одну или несколько эволюций партиционирования - типичный жизненный цикл production-таблицы, начинающейся с простой схемы и усложняющейся по мере роста, как в производственном кейсе этого урока.

  • Урок 10 («Table Maintenance») детально раскрывает экономику и механику rewrite_data_files для нормализации смешанных spec, упомянутую здесь только как возможность - включая то, как принять решение о том, когда нормализация оправдана, основываясь на реальном паттерне обращения к историческим данным.

  • Урок 13 («Выбор формата») сравнивает механизм Partition Evolution Iceberg с аналогами в Delta Lake и Hudi - у обоих форматов изменение партиционирования исторически было значительно более ограниченным или требовало более явной миграции данных, что становится одним из практических критериев выбора формата для систем с непредсказуемым ростом.


Домашнее задание

  1. Напишите PySpark-скрипт, эмулирующий жизненный цикл таблицы за три условных «года», аналогично производственному кейсу этого урока. Каждый «год» объём тестовых данных должен удваиваться относительно предыдущего, и после каждого года выполняйте ALTER TABLE, последовательно усложняя схему партиционирования: год 0 - без партиций, год 1 - переход на months(), год 2 - переход на days() + добавление bucket(). После каждой эволюции выполните единый запрос, охватывающий данные всех прожитых «лет», и убедитесь через EXPLAIN, что результат корректен (правильное количество строк) независимо от того, что данные физически распределены по трём разным историческим spec.

  2. Используя ту же таблицу, выполните SELECT spec_id, count(*) FROM <table>.manifests GROUP BY spec_id после каждой эволюции и постройте график (текстовый или через matplotlib) изменения доли манифестов каждого spec_id со временем - наглядная иллюстрация «расслоения» истории таблицы по эпохам партиционирования.

  3. Создайте сценарий, в котором происходит REPLACE PARTITION FIELD bucket(16, col) WITH bucket(32, col), запишите данные до и после замены, и для одного конкретного значения col вычислите bucket(16, col) и bucket(32, col) вручную (используя формулу из четвёртого урока модуля). Подтвердите экспериментально через EXPLAIN, что Predicate Projection для equality-предиката col = X корректно использует РАЗНЫЕ спроецированные значения для манифестов разных spec.

  4. Сформулируйте письменно (2-3 абзаца) экономическое обоснование решения из производственного кейса урока - почему команда IoT-платформы решила НЕ запускать rewrite_data_files для нормализации старой истории. Какие метрики вы бы использовали в своей собственной команде, чтобы принять аналогичное решение (запускать нормализацию старых данных под новый spec или оставить их как есть)?

  5. Изучите (по документации Apache Iceberg или экспериментально) поведение ALTER TABLE ... DROP PARTITION FIELD, если после него выполнить ALTER TABLE ... ADD PARTITION FIELD с тем же именем transform и source-колонкой, что был удалён. Получит ли новое поле тот же field-id, что и удалённое? Подтвердите ответ через инспекцию partition-specs в metadata.json напрямую (как в Кейсе 2 второго урока модуля).

  6. Повторите Кейс 4 практического блока (ручная проверка field-id), но вместо DROP + ADD выполните раздельные команды с записью данных МЕЖДУ ними (как описано в разделе про различие REPLACE и раздельных DROP+ADD в заблуждениях урока). Найдите промежуточную версию spec в partition-specs и убедитесь, что хотя бы один манифест ссылается на неё через partition_spec_id. Объясните, почему REPLACE избегает создания этой промежуточной сущности.

  7. Спроектируйте и опишите письменно (без необходимости полной реализации) сценарий, в котором эволюция партиционирования была бы архитектурной ошибкой, а не обоснованным решением - например, эволюция, выполненная слишком часто без устойчивого изменения паттерна нагрузки, или замена bucket(N) на bucket(2N) без понимания эффекта, разобранного в разделе про инженерные нюансы. Обоснуйте, какие метрики помогли бы обнаружить эту ошибку до того, как она нанесёт ощутимый вред производительности.

  8. На основе раздела про взаимодействие с write.distribution-mode создайте таблицу, эволюционирующую от days(ts) до days(ts) + bucket(16, id), и измерьте число файлов на одинаковый объём batch-записи до и после эволюции при distribution-mode='hash'. Объясните результат через изменение числа уникальных комбинаций партиционных значений, а не через изменение самого distribution-mode.

  9. Если у вас доступны два разных движка, читающих один и тот же self-hosted Iceberg-каталог (например, Spark и Trino, или Spark и DuckDB с поддержкой Iceberg), повторите эксперимент из раздела про мультидвижковую согласованность: выполните ALTER TABLE ... PARTITION FIELD из одного движка, и сразу выполните запрос с широким историческим охватом из другого. Подтвердите, что оба движка дают идентичный по количеству строк результат, и письменно опишите, как быстро второй движок «увидел» изменение (требовался ли явный refresh каталога/метаданных).


Полная картина: жизненный цикл одной партиционной спецификации

Завершая урок, полезно собрать весь материал в единую диаграмму жизненного цикла - от создания таблицы с первым spec до запроса, который читает данные через несколько накопленных версий одновременно.

Каждый блок этой диаграммы был подробно разобран в отдельном разделе урока: создание таблицы и первый spec - в начале практического блока; атомарность и отсутствие нового снапшота при ALTER TABLE - в разделе про анатомию операции; неперезаписываемость field-id - в разделе про безопасность; разделение манифестов по partition_spec_id - в разделе про сосуществование специфика­ций; и, наконец, Split-Query Planning, объединяющий все эти элементы в единый алгоритм, прозрачный для конечного пользователя SQL.


Итоги

Partition Spec версионируется так же, как версионируется схема колонок. Массив partition-specs в metadata.json хранит полную историю изменений партиционирования, и default-spec-id указывает, какая версия активна для новых записей - старые версии никогда не удаляются автоматически и продолжают описывать уже записанные файлы.

ADD, DROP, REPLACE и RENAME - четыре разных операции с разными последствиями для commit'а. Только RENAME не создаёт новую версию spec (это чисто косметическое изменение); три остальные операции инкрементируют spec-id, но ни одна из них не создаёт новый снапшот данных и не трогает физические файлы.

field-id никогда не переиспользуется - это правило безопасности, а не бюрократическая формальность. Без него движок не смог бы достоверно отличить значения партиции, вычисленные по разным историческим формулам, что сделало бы Predicate Projection потенциально некорректным - то есть способным ошибочно отбросить файлы с реальными совпадениями.

Split-Query Planning - естественное расширение алгоритма Scan Planning из третьего урока, а не отдельный механизм. Проекция предиката выполняется независимо для каждой версии spec, встреченной среди манифестов снапшота, и результаты объединяются прозрачно для аналитика - ни одна часть этого процесса не требует ручного участия пользователя или изменения SQL-запроса.

Эволюция партиционирования - архитектурное решение, принимаемое редко и обдуманно, а не инструмент ежедневной настройки. Каждая операция дешева сама по себе, но накопление множества исторических spec увеличивает число веток Split-Query Planning для запросов с широким историческим охватом - и нормализация старой истории под актуальный spec через rewrite_data_files остаётся отдельным, осознанным экономическим решением, а не автоматическим следствием эволюции.

Schema Evolution и Partition Evolution связаны через source-id, но защищены друг от друга явными ограничениями. Переименование колонки не требует обновления partition spec благодаря ID-based ссылке; удаление колонки, используемой как источник партиции, явно запрещено до тех пор, пока инженер не примет осознанное решение через DROP PARTITION FIELD - Iceberg предпочитает явную ошибку валидации тихому несогласованному состоянию.

Накладные расходы Split-Query Planning пренебрежимо малы по сравнению со стоимостью неэффективного партиционирования исторических данных. Сама проекция предиката через несколько версий spec стоит доли миллисекунды; реальное замедление запросов с широким историческим охватом объясняется тем, что более ранние spec обычно соответствуют более грубому партиционированию, а не механизмом Split-Query Planning как таковым.


Краткий глоссарий терминов урока

  • Partition Evolution - механизм изменения партиционной спецификации таблицы для новых записей без переписывания уже существующих data-файлов.

  • spec-id - уникальный идентификатор версии partition spec в массиве partition-specs; инкрементируется при каждой операции ADD/DROP/REPLACE.

  • default-spec-id - поле metadata.json, указывающее, какая версия spec используется для новых записей в текущий момент.

  • ADD / DROP / REPLACE / RENAME PARTITION FIELD - четыре SQL-команды эволюции партиционирования; только RENAME не создаёт новую версию spec.

  • void transform - внутренний маркер-трансформ, представляющий «удалённое» партиционное поле в новой версии spec без физического сдвига позиций partition tuple.

  • Split-Query Planning - расширение алгоритма Scan Planning, при котором проекция предиката выполняется независимо для каждой версии partition spec, встреченной среди манифестов снапшота.

  • partition_spec_id (поле манифеста) - ссылка внутри manifest list, фиксирующая, какая версия spec использовалась для всех записей конкретного manifest file; один манифест всегда относится ровно к одной версии spec.

  • Нормализация исторических данных - явная maintenance-операция (rewrite_data_files), приводящая старые файлы к актуальной версии partition spec; не выполняется автоматически после эволюции.

  • Hotspot записи (повтор из прошлого урока) - повышенная конкуренция commit'ов при партиционировании только по времени; одна из частых причин добавления bucket() как дополнительного измерения в рамках эволюции, как в производственном кейсе этого урока.

  • source-id (партиционного поля) - ссылка на ID колонки схемы (ID-based, как и Schema Evolution), через которую partition spec остаётся валидным при переименовании колонки и через которую устанавливается защита от удаления используемой колонки.

  • REPLACE PARTITION FIELD - атомарная замена transform для одной и той же исходной колонки за один commit метаданных, в отличие от раздельных DROP + ADD, создающих промежуточную версию spec.

  • Промежуточная версия spec - временная, обычно нежелательная сущность в истории partition-specs, возникающая при раздельном выполнении DROP и ADD вместо атомарного REPLACE.

  • Защита целостности source-колонки - механизм, запрещающий DROP COLUMN для колонки, на которую ссылается активное партиционное поле, до явного выполнения DROP PARTITION FIELD.

  • Байтовое представление для bucket() при расширении типа - тонкость, при которой расширение типа колонки (intlong) меняет байтовое представление, используемое формулой bucket(), для будущих записей относительно прошлых.

  • Сосуществование спецификаций (Spec Coexistence) - нормальное долгосрочное состояние Iceberg-таблицы, прошедшей через одну или несколько эволюций, при котором манифесты разных partition_spec_id присутствуют в снапшоте одновременно.

  • Жизненный цикл партиционной спецификации - последовательность: создание таблицы с первым spec → запись данных → эволюция (ADD/DROP/REPLACE) → новый spec становится default → старые и новые файлы сосуществуют → Split-Query Planning объединяет их прозрачно для запросов.

  • Мультидвижковая согласованность Partition Evolution - свойство, при котором эволюция партиционирования, выполненная одним движком (например, Spark), немедленно и корректно видна любому другому движку, читающему тот же каталог, благодаря тому, что Split-Query Planning описан открытой спецификацией, а не специфичен для одной реализации.

  • DESCRIBE TABLE EXTENDED для нескольких спецификаций - команда, отображающая ВСЕ исторические версии partition spec (как активные, так и неактивные) в секции # Partitioning, в отличие от обычной схемы, которая всегда показывает только бизнес-колонки.

  • Экономика нормализации (rewrite vs as-is) - явное архитектурное решение, взвешивающее стоимость I/O операции rewrite_data_files против частоты обращения к историческому периоду с менее эффективным partition spec; не имеет универсально правильного ответа и зависит от реального паттерна запросов конкретной таблицы.