Copy-on-Write: принцип работы, latency при write, быстрый read

Анатомия Copy-on-Write в Apache Iceberg: почему Parquet нельзя редактировать на месте, как движок находит и переписывает затронутые файлы, природа Write Amplification, конфигурация write.update/delete/merge.mode и инженерные критерии выбора CoW vs Merge-on-Read.

lakehouse storage

Почему Parquet нельзя отредактировать на месте

Первый урок модуля показал четыре архитектурные боли «голого» Parquet и объяснил, как Iceberg решает их через дерево метаданных - атомарность, листинг, изоляцию читателей и писателей. Но там же была сделана важная оговорка, отложенная до этого урока: даже после того, как у таблицы появился полноценный snapshot-слой, сами Parquet-файлы остаются неизменяемыми по своей физической природе - и это не ограничение Iceberg, а фундаментальное свойство колоночного бинарного формата, с которым приходится считаться любому table format, построенному поверх него.

Этот урок разбирает первую из двух стратегий, которыми Iceberg обходит эту неизменяемость на уровне отдельной строки - Copy-on-Write (CoW). Вторая стратегия, Merge-on-Read, - тема следующего урока; здесь же необходимо полностью разобраться в том, почему сама проблема существует и как CoW её решает, прежде чем переходить к альтернативе.

Чтобы понять, почему «просто изменить несколько байт в файле» невозможно, нужно вспомнить физическую структуру самого Parquet-файла - не его роль в табличном слое (это было темой остальных уроков модуля), а именно бинарную раскладку байтов на диске.

Ключевая деталь этой структуры, объясняющая всё дальнейшее: footer находится в конце файла и физически ссылается на байтовые смещения (offsets) данных, которые были записаны раньше его. Writer не знает заранее, сколько байт займёт сжатый column chunk - он пишет данные последовательно, накапливая фактические смещения, и только в самом конце, когда все row group уже физически на диске, формирует footer со ссылками на эти смещения. Это классический паттерн «append-only write» - писать можно только вперёд, никогда не возвращаясь назад для редактирования уже записанных байт.

Дополнительный фактор, делающий точечное редактирование невозможным даже теоретически: каждый column chunk сжат независимо (Snappy, Zstd, Gzip в зависимости от конфигурации) и часто использует словарное кодирование (dictionary encoding) для строковых и низкокардинальных колонок. Если изменить значение одной строки внутри row group, в общем случае меняется не только эта строка - может измениться сам словарь кодирования column chunk, что меняет байтовое представление всех остальных значений в этом chunk, а значит и его итоговый сжатый размер. Сдвиг размера одного column chunk сдвигает байтовые смещения всех последующих row group и делает footer, записанный в конце файла, мгновенно невалидным.

Почему нельзя дописать или изменить байты в середине

Сформулируем итог явно: операция «открыть существующий Parquet-файл, найти байты конкретной строки и заменить их новым значением» технически нереализуема без полной перезаписи файла по трём независимым причинам одновременно:

  • Колоночная раскладка. Значения одной логической строки физически разбросаны по разным column chunk (по одному на каждую колонку), а не лежат рядом друг с другом, как в строковом формате. Изменить «строку» - значит изменить N разных мест файла одновременно, каждое внутри независимо сжатого блока.

  • Зависимость от сжатия и кодирования. Любое изменение внутри column chunk потенциально меняет его сжатый размер - byte-for-byte замена значения практически никогда не сохраняет точный размер блока, особенно при словарном кодировании.

  • Footer как описание состояния "после", а не "до". Footer содержит итоговые смещения всех row group, рассчитанные после завершения записи всех данных. Любое изменение размера данных делает старый footer описанием уже не соответствующей реальности структуры файла.

Именно из этих трёх свойств следует единственно возможная стратегия изменения данных на уровне отдельной строки: прочитать логическое содержимое файла целиком, применить изменение в памяти, и записать совершенно новый файл с нуля. Это и есть Copy-on-Write в его самой буквальной, физической формулировке - название стратегии описывает не абстрактную метафору, а конкретный механический процесс на уровне байтов.


Архитектурный тупик Hive: overwrite целой партиции ради одной строки

Прежде чем переходить к тому, как Iceberg элегантно решает эту задачу на уровне отдельных файлов, полезно вспомнить, как с той же фундаментальной проблемой боролись классические Hive-таблицы поверх «голого» Parquet, разобранные в первом уроке модуля - потому что контраст между этими двумя подходами объясняет, зачем вообще нужен табличный слой метаданных для эффективного CoW.

В Hive-style таблице минимальная единица перезаписи - это партиция целиком, потому что у движка нет более тонкого инструмента отслеживания состояния: каталог (Hive Metastore) знает только путь к директории партиции, а не список конкретных файлов внутри неё с их содержимым (этот же аргумент разбирался как «боль 1» и «боль 3» в первом уроке модуля).

Грубая числовая иллюстрация масштаба проблемы: партиция за один день с 50 ГБ данных, разбитая на 50 файлов по ~1 ГБ. Исправление одной строки в Hive-подходе означает чтение и перезапись всех 50 ГБ - независимо от того, что физически изменяется один email из тысяч символов. Если такие точечные исправления происходят регулярно (а в production-системах это правило, а не исключение - опечатки в данных, повторная обработка из-за апстрим-баги, GDPR-запросы на удаление), стоимость подобных операций быстро становится доминирующей частью бюджета кластера.

Apache Iceberg решает именно эту часть проблемы благодаря архитектуре, разобранной в уроках 2 и 3 модуля: манифесты хранят min/max статистику и точное соответствие data-файлов конкретным диапазонам значений колонок. Это означает, что Iceberg может определить, какие конкретно файлы (а не вся партиция) потенциально содержат целевую строку, и переписать только их. Именно этот механизм - центральная тема следующего раздела.


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

Copy-on-Write в Apache Iceberg - это не «полная перезапись таблицы» из Hive-мира, а точная перезапись минимального набора файлов, найденных через тот же алгоритм Scan Planning, что используется для обычных SELECT-запросов (третий урок модуля). Цена этой точности - дополнительная задержка операций UPDATE/DELETE/MERGE, пропорциональная объёму переписываемых файлов, а не объёму логически изменённых строк. Выгода - после завершения операции таблица находится в идеально «чистом» состоянии: чтение происходит без какой-либо дополнительной логики объединения данных, что делает Copy-on-Write оптимальной стратегией для нагрузок, где чтение происходит значительно чаще, чем запись.

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


Физический принцип работы Copy-on-Write

Алгоритм локализации изменений: переиспользование Scan Planning

Когда выполняется UPDATE, DELETE или MERGE с условием WHERE, Iceberg не интерпретирует это как новую, специальную операцию поиска файлов - он использует тот же самый алгоритм Scan Planning, который был детально разобран в третьем уроке модуля для обычных запросов на чтение. Условие WHERE операции изменения данных - это предикат, который проходит через все четыре стадии планирования: вход через catalog → manifest list → manifest-level pruning по partition_field_summary → file-level pruning по column-level min/max статистике.

Практическое следствие этого факта прямое и важное: качество партиционирования и сбора статистики напрямую определяет дороговизну операций изменения данных, а не только скорость SELECT-запросов, как могло показаться после уроков 3 и 4. Таблица, партиционированная по bucket(32, customer_id) (четвёртый урок модуля), позволяет UPDATE ... WHERE customer_id = 100 отбросить 31 из 32 бакетов на этапе manifest-level pruning ещё до открытия единого Parquet-файла - в то время как таблица без партиционирования по этому измерению вынуждена рассматривать как кандидатов файлы из всех бакетов, в которых статистика min/max по customer_id не исключает значение 100.

Консервативность пруннинга и «лишние» переписанные файлы

Третий урок модуля подробно разобрал принцип консервативности: пруннинг никогда не должен давать false negative (отбросить файл, который реально содержит совпадения), но может давать false positive - оставить файл кандидатом, хотя по факту совпадающих строк в нём не окажется. Для обычного SELECT этот false positive стоит лишнего открытия и сканирования файла. Для Copy-on-Write эта же ситуация стоит значительно дороже: файл, ошибочно признанный кандидатом, всё равно подлежит полной перезаписи, даже если после построчной проверки внутри executor'а окажется, что ни одна строка не подошла под условие WHERE.

def explain_candidate_overcount(file_min_customer_id, file_max_customer_id, target_id=100):
    """
    Иллюстрация: статистика файла говорит "customer_id в диапазоне [50, 500]" -
    файл становится кандидатом для UPDATE WHERE customer_id = 100,
    даже если РЕАЛЬНО внутри файла нет ни одной строки с customer_id = 100
    (например, диапазон присутствующих id - {50, 51, ..., 99, 101, ..., 500},
    а 100 был удалён предыдущей операцией и просто попадает в общий min/max).
    """
    might_match = file_min_customer_id <= target_id <= file_max_customer_id
    return might_match  # True - файл становится кандидатом на ПЕРЕЗАПИСЬ,
                          # независимо от того, найдётся ли реальное совпадение


print(explain_candidate_overcount(50, 500, target_id=100))
# True -> файл будет прочитан, проверен построчно и ПОЛНОСТЬЮ ПЕРЕЗАПИСАН,
# даже если построчная проверка не найдёт ни одного совпадения внутри

Это - прямое практическое следствие того, что статистика min/max (третий урок модуля) - это диапазон, а не точное множество значений. Чем шире диапазон значений внутри одного файла (то есть чем хуже файл отсортирован или кластеризован по колонке условия WHERE), тем выше вероятность подобных «лишних» кандидатов - а значит, тем выше реальная стоимость операций UPDATE/DELETE/MERGE сверх теоретического минимума. Это ещё один практический аргумент в пользу write.distribution-mode = 'hash' и clustered write, разобранных в четвёртом уроке модуля: чем плотнее физически сгруппированы строки с близкими значениями ключа, тем точнее min/max статистика отражает реальное содержимое файла, и тем меньше лишних перезаписей при точечных изменениях.

Схема «Прочитал — Обновил — Записал»

После того как Driver построил список candidate files, начинается фаза, которая физически выполняется на executor'ах распределённо и параллельно для каждого файла-кандидата.

Принципиально важная деталь этой диаграммы, часто упускаемая при первом знакомстве с механизмом: переписывается весь файл, а не только подходящие под условие строки. Если файл на 1 миллион строк содержит ровно одну строку, подходящую под WHERE customer_id = 100, итоговый новый файл всё равно будет содержать все 999 999 неизменённых строк плюс одну изменённую - именно это и формирует основу Write Amplification, разобранного в отдельном разделе ниже.


Коммит на уровне метаданных: атомарная подмена указателей

После того как все candidate files переписаны и новые файлы загружены в object storage, начинается финальная фаза, использующая ровно тот же протокол, что был детально разобран во втором уроке модуля для обычных операций записи: атомарный CAS-commit в каталог.

Второй урок модуля подробно разбирал три статуса записи манифеста - ADDED, EXISTING, DELETED - в контексте manifest merging при обычных INSERT-операциях. Copy-on-Write - это контекст, где статус DELETED используется наиболее наглядно: старые файлы file_42.parquet и file_77.parquet не удаляются физически в этот момент - они просто помечаются как DELETED в записи нового манифеста, что означает «эти файлы были частью предыдущего снапшота, но не входят в новый». Файлы продолжают физически существовать в object storage и остаются доступными для time travel (девятый урок модуля) до тех пор, пока не будет выполнен expire_snapshots (десятый урок модуля), после которого remove_orphan_files сможет безопасно удалить их физически.

Важно зафиксировать, что с точки зрения снапшот-модели Copy-on-Write не является особым видом операции - это обычный новый снапшот, неотличимый по структуре от снапшота, созданного INSERT. Единственное отличие - содержимое summary: operation поле получает значение overwrite (для UPDATE/MERGE без чистого добавления) или delete (для чистого DELETE), и в summary появляются непустые счётчики deleted-data-files/deleted-records наряду с added-data-files/added-records.

# Системная таблица .snapshots (второй урок модуля) после CoW-операции
spark.sql("""
    SELECT snapshot_id, operation, summary
    FROM lakehouse.analytics.customers.snapshots
    ORDER BY committed_at DESC
    LIMIT 1
""").show(truncate=False)

# snapshot_id | operation  | summary
# 8821049283  | overwrite  | {added-data-files=2, deleted-data-files=2,
#                            added-records=1000042, deleted-records=1000042,
#                            changed-partition-count=2, total-data-files=4821, ...}

Обратите внимание на симметрию added-records=1000042 и deleted-records=1000042 - это не случайность, а прямое следствие механизма CoW: каждая строка переписанного файла (включая неизменённые) логически «удаляется» из старой версии файла и «добавляется» в новой, даже если её значения не менялись ни на бит. Это - количественное выражение Write Amplification на уровне summary снапшота, к которому переходит следующий раздел.


Write Amplification: цена записи

Определение и формула

Write Amplification (WA) - это коэффициент, показывающий, во сколько раз физический объём переписанных данных превышает логический объём данных, которые реально требовалось изменить:

                  объём ФИЗИЧЕСКИ переписанных байт
Write Amplification = -------------------------------------
                  объём ЛОГИЧЕСКИ изменённых байт

Для Copy-on-Write эта формула почти всегда даёт число, кратно больше единицы, потому что переписывается весь candidate file целиком, а не только подмножество изменившихся строк - прямое следствие физической неизменяемости Parquet, разобранной в начале урока.

Численный пример: экстремальный случай

def calculate_write_amplification(file_size_bytes: int, changed_row_bytes: int) -> float:
    """
    Грубая оценка Write Amplification для одной операции CoW
    над ОДНИМ candidate-файлом.
    """
    return file_size_bytes / changed_row_bytes


file_size = 128 * 1024 * 1024       # типичный target file size - 128 МБ
changed_row_size = 200               # одна строка ~200 байт (несколько полей)

wa = calculate_write_amplification(file_size, changed_row_size)
print(f"Write Amplification: {wa:,.0f}x")
# Write Amplification: 671,089x

Число 671 089x не является преувеличением учебного примера - это прямой арифметический результат того факта, что вся остальная часть файла (134 217 528 из 134 217 728 байт) была физически переписана без единого изменения содержимого, лишь для того, чтобы изменить 200 байт одной строки. Каждый байт неизменившихся данных, прошедший через память executor'а, диск/сеть и заново примененное сжатие, - это чистый, неизбежный оверхед при использовании CoW для точечных изменений.

Реалистичный пример: batch-обновление

Чистый «один ряд» - редкий частный случай. Более реалистичная иллюстрация - ночной batch job, исправляющий ошибочно посчитанные суммы заказов за прошедшие сутки, затрагивающий 2% строк, физически разбросанных по 40 файлам из 200 файлов таблицы (остальные 160 файлов отброшены пруннингом и не являются кандидатами).

def estimate_batch_write_amplification(
    total_files: int,
    candidate_files: int,
    avg_file_size_mb: float,
    fraction_rows_changed: float,
) -> dict:
    """
    Оценка WA для batch-обновления, затрагивающего долю строк
    в ограниченном подмножестве candidate-файлов.
    """
    physical_rewritten_mb = candidate_files * avg_file_size_mb
    logical_changed_mb = physical_rewritten_mb * fraction_rows_changed
    wa = physical_rewritten_mb / logical_changed_mb if logical_changed_mb else float("inf")
    return {
        "physical_rewritten_mb": physical_rewritten_mb,
        "logical_changed_mb": round(logical_changed_mb, 2),
        "write_amplification": round(wa, 1),
    }


result = estimate_batch_write_amplification(
    total_files=200, candidate_files=40, avg_file_size_mb=256, fraction_rows_changed=0.02
)
print(result)
# {'physical_rewritten_mb': 10240, 'logical_changed_mb': 204.8, 'write_amplification': 50.0}

Даже в этом значительно более «мягком» сценарии (изменено 2% строк, а не одна) Write Amplification составляет 50x - то есть кластер физически перемещает (читает + пишет) 10 ГБ данных, чтобы логически изменить чуть больше 200 МБ. Это и есть конкретное число, которое стоит держать в голове при оценке стоимости регулярных batch-обновлений на CoW-таблицах: чем меньше доля реально меняющихся строк внутри candidate-файлов, тем выше относительная цена операции.


Влияние Write Amplification на latency и нагрузку на кластер

I/O bottleneck

Каждый байт, участвующий в Write Amplification, должен пройти полный цикл: GET из object storage → распаковка row group → построчная обработка в памяти → пересборка row group → сжатие → PUT обратно в object storage. Для распределённого кластера это означает, что операция UPDATE/DELETE/MERGE создаёt сетевой и дисковый трафик, пропорциональный объёму переписанных, а не изменённых данных - и именно эта величина определяет реальное время выполнения операции, наблюдаемое в Spark UI как Shuffle Write и Bytes Written на стадии записи.

CPU нагрузка: распаковка и пересжатие

Помимо сетевого I/O, каждый байт candidate-файла проходит через CPU-интенсивные операции декомпрессии (на чтение) и компрессии (на запись), а для колонок со словарным кодированием - через пересчёт самого словаря. Если конфигурация таблицы использует Zstd с высоким уровнем сжатия (баланс размера/CPU, не разбиравшийся отдельно в этом курсе, но релевантный здесь), CPU-стоимость пересжатия большого объёма «лишних» неизменённых данных может стать доминирующим фактором длительности job'а, особенно на executor'ах с ограниченным числом ядер.

# Иллюстрация: длительность UPDATE можно грубо разложить
# на компоненты, пропорциональные объёму ПЕРЕПИСАННЫХ (не изменённых) данных
def estimate_cow_latency_ms(
    physical_rewritten_mb: float,
    io_throughput_mb_per_sec: float = 150,   # типичный сетевой throughput на executor
    cpu_throughput_mb_per_sec: float = 80,   # decompress+recompress, Zstd уровень 3
) -> float:
    io_time_ms = (physical_rewritten_mb / io_throughput_mb_per_sec) * 1000
    cpu_time_ms = (physical_rewritten_mb / cpu_throughput_mb_per_sec) * 1000
    # I/O и CPU частично перекрываются конвейерной обработкой,
    # но для грубой ВЕРХНЕЙ оценки latency берём их сумму
    return io_time_ms + cpu_time_ms


print(f"{estimate_cow_latency_ms(10240):.0f} мс")
# 196267 мс -> около 3.3 минут ТОЛЬКО на физическое перемещение
# и пересжатие 10 ГБ данных ради изменения 200 МБ логического содержимого

Эта оценка груба и не учитывает параллелизацию между executor'ами (реальная распределённая обработка кратно сокращает итоговое время за счёт одновременной работы десятков executor'ов над разными candidate-файлами) - но она наглядно показывает, почему latency CoW-операций растёт не с объёмом логических изменений, а с объёмом физически затронутых файлов, что является прямым следствием Write Amplification, разобранного выше.


write.target-file-size-bytes: баланс между Write Amplification и эффективностью хранения

Раз Write Amplification напрямую зависит от размера candidate-файлов, возникает естественный вопрос: почему не сделать файлы максимально маленькими, чтобы при точечном изменении переписывался минимум байт? Ответ - в том же свойстве write.target-file-size-bytes, которое определяет целевой размер data-файла при обычной записи, и которое уже упоминалось как существующее измерение конфигурации в предыдущих уроках модуля без подробного разбора компромисса.

# Значение по умолчанию в Apache Iceberg - 512 МБ (536_870_912 байт).
# Свойство можно переопределить на уровне таблицы:
spark.sql("""
    ALTER TABLE lakehouse.analytics.customers
    SET TBLPROPERTIES ('write.target-file-size-bytes' = '134217728')  -- 128 МБ
""")
Размер целевого файла Write Amplification при точечном UPDATE Эффективность full table scan Число файлов и манифестов
Маленький (32-64 МБ) Низкая - переписывается меньше "лишних" байт Хуже - больше overhead на открытие файлов, хуже сжатие на блок Больше файлов -> манифесты растут быстрее (третий урок модуля)
Большой (512 МБ - 1 ГБ) Высокая - каждая операция переписывает много "лишних" байт Лучше - меньше overhead на файл, эффективнее column chunk сжатие Меньше файлов -> компактные манифесты

Эта таблица - прямое практическое применение принципа, уже встречавшегося в третьем уроке модуля при разборе write.metadata.metrics.default: не существует одного «правильного» значения, есть инженерный trade-off, зависящий от соотношения частоты SELECT (выигрывающих от больших файлов) и частоты точечных UPDATE/DELETE/MERGE (выигрывающих от маленьких файлов). Для таблиц, активно использующих Copy-on-Write с частыми точечными изменениями, разумной практикой является снижение write.target-file-size-bytes относительно дефолтных 512 МБ - например, до 64-128 МБ - сознательно принимая небольшое ухудшение эффективности full scan в обмен на кратное снижение Write Amplification.

Важная оговорка, защищающая от другой крайности: уменьшение размера файла не убирает Write Amplification полностью, оно лишь снижает её абсолютную величину для одного файла - количество затронутых файлов всё равно определяется качеством пруннинга (предыдущий раздел), а не только их размером. Уменьшение write.target-file-size-bytes в 10 раз без улучшения кластеризации данных по ключу условия WHERE может всего лишь привести к тому, что вместо одного крупного файла кандидатами окажутся пять мелких - снижая WA на файл, но не меняя качественно общую картину, если сами строки физически разбросаны по множеству файлов одинаково плохо при любом размере.


Бескомпромиссная скорость чтения (Fast Read)

Идеальное состояние данных после Copy-on-Write

Вся цена, разобранная в предыдущих разделах, уплачивается ради одного конкретного результата: после успешного завершения любой CoW-операции таблица находится в состоянии, которое можно назвать «монолитным» - текущий снапшот содержит исключительно финальные, полностью актуальные data-файлы, не требующие никакой дополнительной обработки для получения корректного результата чтения.

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

Отсутствие runtime-накладных расходов при чтении

Первый урок модуля упомянул, что Table Format Spec v2 вводит delete files как механизм Merge-on-Read - и что использование такого механизма означает дополнительную стадию применения удалений во время самого чтения. Copy-on-Write полностью обходится без этой стадии: в манифестах CoW-таблицы просто не существует delete-файлов, ссылающихся на текущий снапшот, поэтому Этап сканирования, который у Merge-on-Read-таблиц требует поиска и применения position/equality deletes (детали - тема следующего урока), для CoW-таблицы тривиально пуст.

Эта диаграмма - не призыв заранее изучать механику Merge-on-Read (это полноценная тема следующего урока), а конкретная иллюстрация того, откуда берётся обещанное «бескомпромиссное чтение» Copy-on-Write: оно не результат какой-то особой оптимизации движка специально для CoW-таблиц, а прямое следствие того, что у CoW-таблиц структурно отсутствует целый класс runtime-работы, необходимый для других стратегий.

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

Практическое следствие отсутствия runtime-логики merge - предсказуемость времени выполнения запроса. Для BI-дашбордов и аналитических витрин, где десятки или сотни аналитиков выполняют похожие запросы к одной и той же таблице в течение рабочего дня, предсказуемость latency часто важнее, чем абсолютная минимизация среднего времени ответа - резкие выбросы (P99 latency) подрывают доверие пользователей к инструменту значительно сильнее, чем стабильно средняя, но предсказуемая скорость.

Copy-on-Write даёт именно такую предсказуемость: время выполнения SELECT-запроса зависит только от объёма данных, реально читаемых после pruning (стадия 3 и 4 алгоритма Scan Planning, третий урок модуля), и не зависит от того, сколько UPDATE/DELETE/MERGE-операций было выполнено над таблицей с момента последней компакции - в отличие от стратегий с накоплением runtime-overhead, где этот overhead растёт со временем между обслуживающими операциями (подробности - тема следующего урока и урока про Table Maintenance).


Copy-on-Write vs Merge-on-Read: компромисс целиком

Прежде чем переходить к практической конфигурации, полезно явно зафиксировать общую картину компромисса, который будет детально развёрнут в следующем уроке - потому что выбор write.update.mode/write.delete.mode/write.merge.mode, разобранный в следующем разделе, имеет смысл только в свете этого компромисса.

Этот компромисс - не вопрос «какая стратегия лучше», а вопрос где именно в вашем конкретном пайплайне выгоднее заплатить цену согласования изменений: на стороне writer'а один раз при записи (CoW), или на стороне каждого reader'а каждый раз при чтении (MoR, тема следующего урока). Оставшаяся часть этого урока концентрируется на инженерной стороне выбора и конфигурирования CoW; полный разбор симметричной альтернативы - в следующем уроке.


Конфигурирование Copy-on-Write

TBLPROPERTIES: явное управление режимом для каждой операции

Apache Iceberg позволяет настраивать стратегию изменения данных раздельно для трёх типов операций - UPDATE, DELETE и MERGE - потому что у команды могут быть разные требования к каждой из них даже в рамках одной таблицы. Управление осуществляется через три свойства TBLPROPERTIES:

ALTER TABLE lakehouse.analytics.customers SET TBLPROPERTIES (
    'write.update.mode' = 'copy-on-write',
    'write.delete.mode' = 'copy-on-write',
    'write.merge.mode'  = 'copy-on-write'
);

Каждое из трёх свойств принимает одно из двух значений - copy-on-write или merge-on-read - и по умолчанию (в актуальных версиях Apache Iceberg) все три свойства равны copy-on-write, если не указано иное явно при создании таблицы. Несмотря на то что это совпадает с поведением, которое было бы получено без явного указания свойств, рекомендуется всегда задавать их явно при создании таблицы - дефолтное значение является деталью реализации конкретной версии библиотеки, и явная конфигурация защищает от неожиданного изменения поведения таблицы при обновлении версии Iceberg runtime в будущем.

-- Можно задать сразу при создании таблицы, а не только через ALTER TABLE
CREATE TABLE lakehouse.analytics.customers (
    customer_id BIGINT,
    email       STRING,
    city        STRING,
    signup_date DATE
)
USING iceberg
TBLPROPERTIES (
    'format-version'    = '2',
    'write.update.mode' = 'copy-on-write',
    'write.delete.mode' = 'copy-on-write',
    'write.merge.mode'  = 'copy-on-write'
)

Обратите внимание, что format-version = '2' и copy-on-write режимы не противоречат друг другу - это частое заблуждение, разобранное отдельно в разделе про типичные заблуждения урока. Table Format Spec v2 добавляет возможность использования delete-файлов (Merge-on-Read), но не делает её обязательной - таблица v2 может работать в чистом режиме Copy-on-Write, просто не используя ту часть спецификации, которая относится к delete-файлам.

Можно ли смешивать режимы внутри одной таблицы

Да, и это осознанная гибкость дизайна: например, для таблицы, где DELETE происходит крайне редко (раз в квартал, ради GDPR-запросов на удаление), но MERGE выполняется ежечасно из CDC-потока, разумно настроить:

ALTER TABLE lakehouse.analytics.customers SET TBLPROPERTIES (
    'write.delete.mode' = 'copy-on-write',   -- редко, можно заплатить WA
    'write.merge.mode'  = 'merge-on-read'    -- часто, WA был бы слишком дорог
);

Подробный разбор того, как реализуется merge-on-read режим (delete files, position vs equality deletes) и как принять обоснованное решение о выборе режима для конкретной операции - тема следующего урока модуля; здесь важно зафиксировать сам факт гранулярности конфигурации - решение принимается не один раз «для всей таблицы», а отдельно для каждого типа операции.


Когда Copy-on-Write — правильный выбор

Идеальные use cases

Copy-on-Write показывает наилучшие результаты, когда выполняется хотя бы одно из следующих условий, а в идеале - несколько одновременно:

  • Редкие батчевые изменения. Ежедневное или ещё более редкое исправление данных - например, ночной job, пересчитывающий агрегаты или корректирующий ошибочно загруженные значения за прошедшие сутки. Latency одной batch-операции раз в день - вполне приемлемая цена.

  • Строгие требования к скорости чтения. Gold-слой Lakehouse, обслуживающий BI-дашборды с десятками одновременных пользователей - случаи, где предсказуемость и скорость SELECT критичнее, чем latency редких операций обновления.

  • GDPR / Right-to-be-forgotten запросы. Удаление данных конкретного пользователя по юридическому требованию происходит нерегулярно и некритично к latency самой операции удаления - а гарантия того, что после удаления данные физически отсутствуют в текущих файлах (а не скрыты delete-маркером поверх старого файла), часто является важным compliance-требованием само по себе.

  • Витрины с соотношением чтений к записям, измеряемым в тысячах к одному. Если на одну операцию изменения приходятся тысячи SELECT-запросов, суммарная экономия на стороне чтения практически всегда перекрывает разовую цену записи, даже при высоком Write Amplification.

Антипаттерны: когда CoW разрушит производительность кластера

  • Streaming-пайплайны с непрерывной записью. Apache Flink или Spark Structured Streaming, записывающие micro-batch каждые несколько секунд или минут в CoW-таблицу, будут постоянно переписывать одни и те же «горячие» файлы - каждый micro-batch создаёт полноценную CoW-операцию со своим Write Amplification, и это происходит десятки или сотни раз в час.

  • Change Data Capture (CDC) из транзакционных БД с высокой интенсивностью изменений. CDC-поток из PostgreSQL/MySQL через Debezium-подобный коннектор типично генерирует множество разрозненных UPDATE/DELETE по первичному ключу - именно тот сценарий, где candidate files определяются плохо (строки физически разбросаны), а частота операций высока. Восьмой урок модуля («MERGE INTO») разбирает этот паттерн отдельно.

  • Частые микро-батчи upsert. Любой пайплайн, выполняющий MERGE INTO чаще, чем раз в несколько минут, на таблице с активным трафиком записи, рискует попасть в ситуацию, где предыдущая CoW-операция не успевает завершиться до начала следующей - что приводит к нарастающей очереди commit-конфликтов (второй урок модуля) и постоянной перегрузке кластера переписыванием файлов.

Простое инженерное правило, обобщающее эти три антипаттерна: если частота операций изменения данных измеряется в минутах или секундах, а не в часах или днях - Copy-on-Write почти наверняка не подходит, и стоит рассмотреть Merge-on-Read (следующий урок) в качестве основной стратегии для соответствующего свойства (write.update.mode/write.delete.mode/write.merge.mode).


Практический демо-блок: измеряем цену Copy-on-Write в PySpark

Конфигурация SparkSession идентична прошлым урокам модуля - JDBC Catalog на базе self-hosted PostgreSQL и S3FileIO на базе self-hosted MinIO (полный листинг конфигурации - в практическом блоке первого урока модуля). Ниже разбираются четыре кейса: создание baseline-таблицы в явном режиме CoW, точечный UPDATE с анализом затронутых файлов, точный расчёт Write Amplification в байтах через системные таблицы, и наблюдение за тем, как Write Amplification растёт при MERGE с разбросанными по таблице изменениями.

Кейс 1: создание таблицы и baseline-аудит файлов

spark.sql("""
    CREATE TABLE lakehouse.analytics.customers (
        customer_id BIGINT,
        email       STRING,
        city        STRING,
        signup_date DATE
    )
    USING iceberg
    PARTITIONED BY (bucket(32, customer_id))
    TBLPROPERTIES (
        'format-version'              = '2',
        'write.update.mode'           = 'copy-on-write',
        'write.delete.mode'           = 'copy-on-write',
        'write.merge.mode'            = 'copy-on-write',
        'write.target-file-size-bytes' = '67108864'  -- 64 МБ, сознательно меньше дефолта
    )
""")

import random
from datetime import date, timedelta

baseline_rows = [
    (
        customer_id,
        f"user{customer_id}@example.com",
        random.choice(["Berlin", "Munich", "Hamburg", "Cologne"]),
        date(2024, 1, 1) + timedelta(days=random.randint(0, 700)),
    )
    for customer_id in range(1, 1_000_001)
]

baseline_df = spark.createDataFrame(
    baseline_rows,
    schema="customer_id BIGINT, email STRING, city STRING, signup_date DATE",
)
baseline_df.writeTo("lakehouse.analytics.customers").append()

Сразу после загрузки фиксируем baseline через системную таблицу .files (впервые упомянутую в первом уроке модуля как одну из встроенных metadata tables) - она даёт точный список физических файлов с их размером и числом строк:

spark.sql("""
    SELECT count(*) AS file_count,
           sum(file_size_in_bytes) AS total_bytes,
           avg(file_size_in_bytes) AS avg_file_size_bytes,
           avg(record_count) AS avg_rows_per_file
    FROM lakehouse.analytics.customers.files
""").show(truncate=False)

# file_count | total_bytes | avg_file_size_bytes | avg_rows_per_file
# 32         | 41 943 040  | 1 310 720            | 31 250

Тридцать два файла - ровно по одному на каждый bucket из bucket(32, customer_id), как и ожидалось при равномерном распределении хэша по 1 000 000 строк (четвёртый урок модуля). Средний размер файла - 1.25 МБ, что значительно меньше целевых 64 МБ: это нормально для демонстрационного объёма данных и означает, что в production-таблице с реальным объёмом каждый bucket скорее содержал бы несколько файлов, а не один.

Кейс 2: точечный UPDATE и анализ затронутых файлов

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

spark.sql("""
    UPDATE lakehouse.analytics.customers
    SET city = 'Berlin'
    WHERE customer_id = 555012
""")

spark.sql("""
    SELECT snapshot_id, operation, summary
    FROM lakehouse.analytics.customers.snapshots
    ORDER BY committed_at DESC
    LIMIT 1
""").show(truncate=False)

# snapshot_id | operation  | summary
# 7710294821  | overwrite  | {added-data-files=1, deleted-data-files=1,
#                            added-records=31250, deleted-records=31250,
#                            changed-partition-count=1, total-data-files=32}

Результат подтверждает теорию буквально: затронут ровно один файл (bucket(32, customer_id) изолировал ровно один bucket для customer_id = 555012), но этот файл содержит все 31 250 строк своего bucket'а - и added-records/deleted-records равны именно этому числу, а не единице, несмотря на то что логически изменилась ровно одна строка. Открываем Spark UI и смотрим на стадию записи этой команды: метрика Bytes Written для соответствующего job'а показывает объём, близкий к размеру переписанного файла (~1.3 МБ), а не к размеру одной строки - наглядная иллюстрация Write Amplification из теоретической части, происходящая в реальном кластере, а не только в учебной формуле.

Кейс 3: точный расчёт Write Amplification через системные таблицы

def measure_cow_write_amplification(spark, table_name: str, logical_row_bytes: float) -> dict:
    """
    Сравнивает размер файлов, помеченных DELETED в последнем снапшоте,
    с размером файлов, помеченных ADDED - и считает точный коэффициент
    Write Amplification относительно логического объёма изменённых данных.
    """
    last_snapshot = spark.sql(f"""
        SELECT snapshot_id, summary
        FROM {table_name}.snapshots
        ORDER BY committed_at DESC
        LIMIT 1
    """).collect()[0]

    summary = last_snapshot["summary"]
    added_files = int(summary["added-data-files"])
    deleted_files = int(summary["deleted-data-files"])

    # .all_manifests + .files со snapshot_id дают точные размеры -
    # для краткости здесь используем сводную статистику .files
    # на текущий снапшот, отфильтрованную по среднему размеру файла таблицы
    avg_file_size = spark.sql(f"""
        SELECT avg(file_size_in_bytes) AS avg_size FROM {table_name}.files
    """).collect()[0]["avg_size"]

    physical_rewritten_bytes = (added_files + deleted_files) * avg_file_size / 2
    write_amplification = physical_rewritten_bytes / logical_row_bytes

    return {
        "added_files": added_files,
        "deleted_files": deleted_files,
        "physical_rewritten_bytes": round(physical_rewritten_bytes),
        "write_amplification": round(write_amplification, 1),
    }


result = measure_cow_write_amplification(
    spark, "lakehouse.analytics.customers", logical_row_bytes=120
)
print(result)
# {'added_files': 1, 'deleted_files': 1, 'physical_rewritten_bytes': 1310720,
#  'write_amplification': 10922.7}

Измеренный коэффициент ~10 923x - того же порядка, что и учебный расчёт 671 089x из теоретической части, но меньше, потому что файлы в демонстрационном датасете значительно меньше production-овских 128-512 МБ. Эта зависимость наглядно подтверждает прямую связь между write.target-file-size-bytes (раздел про конфигурацию выше) и абсолютной величиной Write Amplification на одну точечную операцию: чем меньше целевой размер файла, тем меньше штраф за один точечный UPDATE - именно поэтому таблицы с активным точечным CoW-трафиком разумно настраивать на меньший write.target-file-size-bytes, чем дефолтные 512 МБ.

Кейс 4: MERGE с разбросанными изменениями - масштабирование Write Amplification

Последний кейс демонстрирует, как Write Amplification растёт при batch-обновлении, затрагивающем строки, разбросанные по большой доле bucket'ов таблицы - типичный паттерн для ночного CDC-подобного MERGE, обновляющего тысячи произвольных customer_id.

import random

changed_ids = random.sample(range(1, 1_000_001), k=20_000)
updates_df = spark.createDataFrame(
    [(cid, random.choice(["Berlin", "Munich", "Hamburg", "Cologne"])) for cid in changed_ids],
    schema="customer_id BIGINT, new_city STRING",
)
updates_df.createOrReplaceTempView("city_corrections")

files_before = spark.sql(
    "SELECT count(*) AS cnt FROM lakehouse.analytics.customers.files"
).collect()[0]["cnt"]

import time
start = time.time()

spark.sql("""
    MERGE INTO lakehouse.analytics.customers AS target
    USING city_corrections AS source
    ON target.customer_id = source.customer_id
    WHEN MATCHED THEN UPDATE SET target.city = source.new_city
""")

elapsed_seconds = time.time() - start

merge_summary = spark.sql("""
    SELECT summary FROM lakehouse.analytics.customers.snapshots
    ORDER BY committed_at DESC LIMIT 1
""").collect()[0]["summary"]

print(f"Затронуто файлов (added=deleted): {merge_summary['added-data-files']}")
print(f"Логически изменено строк: {merge_summary['deleted-records']}")
print(f"Время выполнения MERGE: {elapsed_seconds:.1f} сек")

# Затронуто файлов (added=deleted): 32
# Логически изменено строк: 1000000   (ВСЕ 32 bucket'a оказались кандидатами,
#                                       потому что 20 000 случайных customer_id
#                                       статистически покрывают все 32 bucket'a)
# Время выполнения MERGE: 4.8 сек

Результат - наглядная иллюстрация худшего случая для Copy-on-Write: 20 000 случайно распределённых изменений (2% от объёма таблицы) статистически гарантированно попадают во все 32 bucket'а, поэтому candidate files - это вся таблица целиком, а не точечное подмножество, как в Кейсе 2. Несмотря на то что логически изменилось всего 2% строк, Write Amplification здесь приближается к полной перезаписи таблицы - ровно тот сценарий, который был назван антипаттерном в теоретической части: множество разрозненных по ключу изменений, типичных для CDC-потоков, систематически сводят на нет преимущество точного пруннинга Copy-on-Write, потому что сам пруннинг не способен исключить ни один bucket, когда изменения статистически равномерно покрывают всё пространство ключей.


Производственный кейс: когда CDC «незаметно» подключили к Gold-таблице на CoW

Ситуация. Команда аналитики поддерживала таблицу lakehouse.gold.customer_profile - дименсию с обогащёнными профилями клиентов, использовавшуюся десятками BI-дашбордов. Таблица была спроектирована классически правильно для своего исходного назначения: write.merge.mode = 'copy-on-write', ночной batch job из Airflow раз в сутки подгружал исправления из CRM-системы через MERGE INTO, затрагивая обычно менее 0.5% строк. При таком профиле нагрузки Write Amplification была незначительной, а тысячи дневных SELECT-запросов от BI-инструментов получали maximally быстрое, предсказуемое чтение - ровно тот идеальный сценарий CoW, разобранный в теоретической части.

Что изменилось. Через несколько месяцев другая команда, занимавшаяся real-time персонализацией, получила задачу обновлять customer_profile значительно чаще - как можно ближе к real-time, чтобы рекомендательная система видела свежие данные о клиентах. Решение, принятое без согласования с владельцем таблицы, выглядело логичным локально: тот же MERGE INTO-запрос, который раньше запускался раз в сутки, был обёрнут в Spark Structured Streaming job с trigger(processingTime="5 minutes"), питающийся из Kafka-топика с CDC-событиями от Debezium, подключённого к продуктовой PostgreSQL.

Диагностика. Команда платформы данных, расследуя инцидент, обратилась к ровно тем же системным таблицам, что использовались в практическом блоке этого урока. Запрос к .snapshots за последние две недели показал катастрофическую картину:

spark.sql("""
    SELECT operation, count(*) AS snapshot_count,
           avg(CAST(summary['added-data-files'] AS INT)) AS avg_files_rewritten,
           avg(CAST(summary['added-records'] AS BIGINT)) AS avg_records_rewritten
    FROM lakehouse.gold.customer_profile.snapshots
    WHERE committed_at >= current_timestamp() - INTERVAL 14 DAYS
    GROUP BY operation
""").show(truncate=False)

# operation  | snapshot_count | avg_files_rewritten | avg_records_rewritten
# overwrite  | 4032           | 218                  | 14 200 000

4032 commit'а за две недели (вместо ожидаемых 14 ежедневных) - прямое следствие streaming-триггера каждые 5 минут. Хуже того, avg_records_rewritten = 14 200 000 означало, что каждый из этих commit'ов переписывал значительную часть всей таблицы (на тот момент - около 16 миллионов строк), а не точечное подмножество: CDC-события из Debezium были упорядочены по времени транзакции в исходной БД, а не по customer_id, поэтому затронутые строки оказывались физически разбросаны по всем bucket'ам таблицы - ровно тот же эффект, что был измерен в Кейсе 4 практического блока, но в production-масштабе и с частотой каждые 5 минут вместо одного раза.

Решение. Команда платформы данных применила два изменения, оба напрямую следующие из материала этого урока:

  1. Немедленная мера: для streaming-пайплайна персонализации write.merge.mode был переключён в merge-on-read (детали реализации - тема следующего урока), что устранило Write Amplification для частых операций - delete-файлы записываются почти мгновенно, без переписывания целых data-файлов.

  2. Архитектурное разделение: команды договорились о слоистой архитектуре - customer_profile осталась в режиме copy-on-write исключительно для ежедневного batch-обогащения от CRM (исходный, низкочастотный use case), а для real-time персонализации была создана отдельная таблица customer_profile_realtime с write.merge.mode = 'merge-on-read', читаемая рекомендательной системой напрямую - без смешивания двух принципиально разных профилей нагрузки в одной физической таблице.

Метрика До инцидента Во время инцидента После исправления
Частота MERGE 1 раз/сутки Каждые 5 минут 1 раз/сутки (CRM) + каждые 5 минут (MoR, отдельная таблица)
Среднее число переписанных файлов ~3 ~218 ~3 (CoW-таблица), delete files (MoR-таблица)
Latency BI-дашбордов <1 сек Таймауты (>30 сек) <1 сек
Commit-конфликтов в сутки ~0 Десятки ~0

Этот кейс - не гипотетическая иллюстрация, а типичный паттерн организационного инцидента в Lakehouse-архитектурах: проблема возникла не из-за ошибки в коде или неправильной настройки Iceberg, а из-за того, что вторая команда не знала (или не учла) исходный профиль нагрузки, под который была спроектирована таблица. Главный практический урок - режим CoW/MoR должен быть осознанным архитектурным решением, документированным и видимым для всех потребителей таблицы, а не скрытой деталью реализации, узнаваемой только постфактум через инцидент.


Типичные заблуждения про Copy-on-Write

«CoW редактирует файл напрямую, просто на уровне Iceberg, а не на уровне Parquet» - неверно категорически, и это прямо противоречит главному тезису первого раздела урока. Никакого "редактирования" не существует ни на одном уровне - физическая неизменяемость Parquet-файлов абсолютна, и Copy-on-Write называется именно так, потому что буквально копирует (создаёт заново) файл при любом изменении содержимого.

«CoW всегда означает полную перезапись всей таблицы» - неверно в общем случае, что наглядно показал Кейс 2 практического блока: качественный пруннинг через партиционирование (bucket(), как в демо, или другие transform из четвёртого урока модуля) ограничивает candidate files до точного минимума. Полная перезапись таблицы - это худший случай (продемонстрированный в Кейсе 4), возникающий при плохом соответствии между схемой партиционирования и распределением изменяемых ключей, а не неизбежное свойство стратегии.

«Format-version 2 несовместим с Copy-on-Write» - неверно, разобрано отдельно в разделе про конфигурацию. v2 добавляет возможность использования delete-файлов, но не отменяет возможность работать в чистом CoW-режиме - большинство production-таблиц на v2 продолжают использовать copy-on-write для части операций, где это уместно.

«Merge-on-Read всегда лучше, и Copy-on-Write - устаревший, не нужный механизм» - неверно, и прямо противоречит компромиссу, разобранному в разделе «CoW vs MoR: компромисс целиком». MoR переносит стоимость с записи на чтение - это выгодно для нагрузок с частыми изменениями и редким чтением, но не для read-heavy витрин и BI-дашбордов, где CoW даёт лучшую суммарную производительность системы.

«Увеличение write.target-file-size-bytes всегда улучшает производительность» - неверно, разобрано в отдельном разделе: большие файлы улучшают эффективность full table scan, но пропорционально увеличивают Write Amplification для точечных изменений. Оптимальное значение зависит от соотношения частоты чтения и точечных изменений конкретной таблицы, а не является универсальной константой.

«Write Amplification - это проблема только для очень больших таблиц» - неверно: коэффициент Write Amplification (отношение переписанных байт к логически изменённым) не зависит от абсолютного размера таблицы - он зависит от размера candidate-файлов относительно размера изменения, что одинаково проявляется и на таблице в гигабайты, и на таблице в петабайты. Абсолютная стоимость в секундах и долларах растёт с размером, но относительный коэффициент - не функция размера таблицы.

«Если задать write.merge.mode='copy-on-write' для таблицы, DELETE автоматически тоже будет CoW» - неверно: как показано в разделе про конфигурацию, три свойства (write.update.mode, write.delete.mode, write.merge.mode) полностью независимы друг от друга и должны быть заданы по отдельности; современные движки не выводят значение одного из значения другого.


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

Этот урок разобрал первую из двух стратегий построчного изменения данных в Iceberg, сделав акцент на её сильной стороне - бескомпромиссном чтении - и явной цене этой стороны - Write Amplification при записи. Следующие уроки модуля строят на этом фундаменте напрямую.

  • Урок 7 («Merge-on-Read») разбирает симметричную альтернативу, кратко анонсированную в разделе «CoW vs MoR: компромисс целиком» - механизм delete files, различие между position deletes и equality deletes, и то, как чтение реконструирует актуальное состояние строки на лету, перенося стоимость согласования с writer'а на reader'а.

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

  • Урок 9 («Time Travel») возвращается к факту, упомянутому в разделе про коммит на уровне метаданных: файлы, помеченные DELETED в результате CoW-операции, остаются физически доступными для time travel до явного expire_snapshots - этот урок покажет, как читать состояние таблицы до конкретной CoW-операции.

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


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

  1. Воспроизведите Кейс 1-3 практического блока на собственном self-hosted стенде (PostgreSQL + MinIO + Spark), но с партиционированием bucket(8, customer_id) вместо bucket(32, customer_id). Сравните измеренный Write Amplification для точечного UPDATE с результатом из урока и объясните разницу через средний размер bucket'а (третий и четвёртый уроки модуля).

  2. Повторите Кейс 4 (MERGE с разбросанными изменениями), но предварительно отсортировав исходный city_corrections DataFrame по customer_id и применив sortWithinPartitions перед записью результата компакции (четвёртый урок модуля). Изменилось ли число затронутых файлов? Объясните результат, опираясь на то, что физическое распределение данных в исходной таблице, а не порядок входного DataFrame для MERGE, определяет candidate files.

  3. Используя measure_cow_write_amplification из Кейса 3 как основу, напишите функцию, которая вычисляет Write Amplification для серии из 10 последовательных точечных UPDATE со случайными customer_id, и постройте график (текстовый или через matplotlib) зависимости среднего WA от текущего write.target-file-size-bytes таблицы, протестировав три значения: 16 МБ, 64 МБ, 256 МБ.

  4. Спроектируйте (без необходимости реализации) схему партиционирования для гипотетической таблицы orders, где 95% операций изменения - это UPDATE status по диапазону order_date за последние 7 дней, а 5% - точечные DELETE по order_id для GDPR-запросов. Обоснуйте письменно, какой transform (или комбинация) минимизирует Write Amplification для обоих паттернов одновременно, используя материал четвёртого урока модуля.

  5. На основе производственного кейса урока письменно (2-3 абзаца) опишите, какие конкретные метрики вы бы добавили в мониторинг Iceberg-таблицы, чтобы автоматически обнаруживать ситуацию «частота MERGE выросла, а профиль изменений остался разрозненным» до того, как она перерастёт в инцидент с таймаутами BI-дашбордов.

  6. Создайте таблицу с write.delete.mode = 'copy-on-write' и выполните DELETE по диапазону дат, охватывающему ровно одну партицию days(event_ts) (четвёртый урок модуля). Через .snapshots подтвердите, что deleted-data-files равен числу файлов именно этой партиции, а не всей таблицы, и объясните, почему partition-level pruning в данном случае даёт более точный результат, чем bucket()-pruning из Кейса 2.

  7. Изучите (экспериментально или по документации Apache Iceberg) поведение write.delete.mode, заданного как merge-on-read, при одновременном write.update.mode = 'copy-on-write' на одной и той же таблице. Выполните последовательно DELETE, затем UPDATE над одними и теми же строками и проверьте через .files и .snapshots, как смешанная конфигурация отражается на структуре файлов.

  8. Сравните измеренное время выполнения (elapsed_seconds из Кейса 4) для трёх вариантов одного и того же MERGE с 20 000 изменений: (а) как в Кейсе 4, без изменений; (б) с write.target-file-size-bytes, уменьшенным до 16 МБ; (в) на таблице без партиционирования вообще. Объясните полученное ранжирование через материал разделов про candidate files и про target-file-size.

  9. Напишите unit-тест (на PySpark, можно через pytest с локальным Iceberg-каталогом) который проверяет инвариант: после любой CoW-операции (UPDATE/DELETE/MERGE) сумма added-records и deleted-records в summary последнего снапшота либо равна (для чистого UPDATE без изменения числа строк), либо отличается ровно на число логически вставленных/удалённых строк (для MERGE со вставками или DELETE). Это - практическая проверка симметрии, разобранной в разделе про коммит на уровне метаданных.


Полная картина: жизненный цикл одной Copy-on-Write операции

Завершая урок, полезно собрать весь материал в единую диаграмму - от поступления UPDATE/DELETE/MERGE до момента, когда следующий SELECT-запрос пользуется результатом без какого-либо дополнительного overhead.

Каждый блок этой диаграммы был детально разобран в отдельном разделе урока: переиспользование Scan Planning для поиска candidate files - в разделе про физический принцип CoW; полная перезапись файла на executor'ах - в схеме «Прочитал-Обновил-Записал»; Write Amplification как неизбежная цена этого процесса - в одноимённом разделе с формулой и численными примерами; атомарный commit через ADDED/DELETED записи - в разделе про коммит на уровне метаданных; и, наконец, итоговое «чистое» состояние, дающее бескомпромиссное чтение - в разделе про Fast Read. Эта диаграмма - зеркальное отражение капстоун-диаграмм предыдущих уроков модуля (например, диаграммы жизненного цикла partition spec в пятом уроке): она показывает полный путь от пользовательской команды до невидимого для пользователя результата, проходящий через все механизмы, разобранные ранее в модуле.


Итоги

Copy-on-Write - не специальный режим Iceberg, а прямое следствие физической неизменяемости Parquet. Footer, ссылающийся на байтовые смещения данных, записанных ранее, и независимое сжатие column chunk делают точечное редактирование байтов в файле технически невозможным - единственный способ изменить содержимое - переписать файл целиком.

Iceberg переиспользует Scan Planning из третьего урока модуля для поиска candidate files, а не сканирует всю таблицу. Это - главное архитектурное отличие от Hive-style overwrite целой партиции: качество партиционирования и кластеризации данных напрямую определяет, насколько узким окажется множество файлов, подлежащих перезаписи.

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

Коммит CoW-операции использует тот же протокол, что и обычная запись. Старые файлы помечаются DELETED, новые - ADDED в записи нового манифеста, создаётся обычный новый снапшот с заполненными полями deleted-data-files/deleted-records, и атомарный CAS-commit переключает таблицу на новую версию - никакого специального механизма для CoW не существует на уровне снапшот-модели.

Цена, уплаченная при записи, покупает полностью бескомпромиссное чтение. После CoW-операции текущий снапшот содержит только финальные файлы без какой-либо дополнительной runtime-логики merge - что делает CoW архитектурно оптимальным выбором для read-heavy нагрузок: BI-витрин, Gold-слоя, редких батчевых корректировок.

write.update.mode, write.delete.mode и write.merge.mode настраиваются независимо друг от друга. Гранулярность конфигурации позволяет, например, держать редкие DELETE в режиме CoW (компенсация compliance-требований), а частый MERGE - в режиме Merge-on-Read, если профиль нагрузки этого требует.

Copy-on-Write не подходит для streaming и высокочастотного CDC - это архитектурное решение, а не настройка производительности. Производственный кейс урока показал, что смешение режима, спроектированного под редкие batch-обновления, с высокочастотной streaming-нагрузкой систематически приводит к перегрузке кластера и каскадным сбоям BI-слоя - правильное решение для такого профиля разбирается в следующем уроке про Merge-on-Read.


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

  • Copy-on-Write (CoW) - стратегия изменения данных, при которой любое логическое изменение строки реализуется через полную перезапись физического файла, содержащего эту строку.

  • Footer (Parquet) - блок метаданных в конце Parquet-файла, содержащий схему, смещения row group и статистику колонок; ссылается на данные, записанные ранее него, что делает append-редактирование в середине файла невозможным.

  • Row Group / Column Chunk - единицы физической организации Parquet: row group - горизонтальный сегмент строк, column chunk - вертикальный сегмент одной колонки внутри row group, сжимаемый независимо.

  • Candidate Files - минимальный набор data-файлов, найденный через Scan Planning (третий урок модуля), которые потенциально содержат строки, подходящие под условие WHERE/ON операции изменения данных.

  • Read-Modify-Write - физический паттерн выполнения CoW-операции на executor'е: полное чтение candidate-файла в память, построчное применение логики изменения, запись совершенно нового файла.

  • Write Amplification (WA) - коэффициент, равный отношению объёма физически переписанных байт к объёму логически изменённых байт; основная количественная мера цены Copy-on-Write.

  • ADDED / DELETED (manifest entry status) - статусы записей нового манифеста после CoW-операции: старые переписанные файлы помечаются DELETED, новые - ADDED, в рамках одного нового снапшота (второй урок модуля).

  • deleted-data-files / deleted-records (summary снапшота) - поля summary, заполняемые ненулевыми значениями при CoW-операциях, в отличие от чистого INSERT, где они остаются нулевыми.

  • write.target-file-size-bytes - свойство таблицы, определяющее целевой размер data-файла при записи; напрямую влияет на абсолютную величину Write Amplification при точечных изменениях (по умолчанию 512 МБ).

  • write.update.mode / write.delete.mode / write.merge.mode - три независимых свойства таблицы, определяющие стратегию (copy-on-write или merge-on-read) отдельно для каждого типа операции изменения данных.

  • Fast Read (для CoW) - свойство, при котором чтение CoW-таблицы не требует runtime-логики объединения данных, потому что текущий снапшот содержит только финальные файлы.

  • Orphan files (повтор из первого урока модуля) - файлы, физически оставшиеся в object storage после прерванного commit'а; для CoW-операций релевантны как файлы, недописанные до финального CAS, аналогично обычной записи.

  • Hidden Partitioning как фактор стоимости CoW - переосмысление концепции из четвёртого урока модуля в контексте этого урока: качество partition transform определяет не только скорость SELECT, но и точность пруннинга candidate files для операций изменения.

  • Антипаттерн «разрозненный CDC на CoW» - ситуация, при которой высокочастотные точечные изменения, физически равномерно распределённые по ключам таблицы, делают пруннинг candidate files неэффективным, приближая Write Amplification к полной перезаписи таблицы при каждой операции.

  • Compaction (предварительное упоминание, детали в десятом уроке) - управляемый, плановый аналог того же физического процесса переписывания файлов, что лежит в основе Copy-on-Write, выполняемый по расписанию для оптимизации структуры файлов, а не реактивно при каждом изменении данных.

  • Slowly Changing Dimension (контекст применения CoW) - класс таблиц (дименсии с редко меняющимися атрибутами), для которых Copy-on-Write архитектурно особенно уместен благодаря низкой частоте изменений и высокой частоте чтения.

  • Симметрия added-records/deleted-records - количественный индикатор того, что вся строка candidate-файла была физически переписана (а не только подмножество строк), наблюдаемый в summary снапшота после CoW-операции.