Apache Iceberg. Обновления и удаления
Изменяем неизменяемое
Содержание
Обычно аналитические системы работают с историческими данными — с тем, что уже произошло и не может «не произойти». Зачем тогда нужны UPDATE и DELETE? Почему бы просто не вставить?
Реальность такова, что с ростом real‑time и streaming‑аналитики объекты, с которыми мы работаем, часто находятся в процессе изменения. Поэтому upserts становятся критически важными. Исправление ошибок, удаление данных по GDPR и другим регуляторным требованиям — всё это операции, которые нам приходится выполнять.
В этом уроке мы увидим, как Iceberg делает это возможным.
Проблемы обновления и удаления в неизменяемом хранилище
Первая проблема: данные неизменяемы. Как только вы записали Parquet‑файл, он остаётся там навсегда. Вы не можете просто открыть файл и изменить строку 42. Так не работает.
Однако обновление и удаление конкретных строк — это уже не просто «приятная возможность», а фундаментальные операции, которые Iceberg обязан поддерживать. И самое главное: при обновлении и удалении нам по‑прежнему нужны ACID‑гарантии, о которых мы говорили в предыдущем уроке.
Никто не хочет, чтобы с его расчётного счета списали деньги, а на сберегательный они так и не поступили из‑за сбоя посередине транзакции. В data lake то же самое: нельзя получить результат одного изменения без соответствующего ему другого изменения.
Пример: обновляем Боба
Представьте таблицу customers с тремя записями: Алиса, Боб и Клара. Боб только что продлил подписку — отлично для него! Нам нужно обновить таблицу и изменить его статус членства с lapsed на active.
Кажется простым? Но мы в мире объектных хранилищ. Нельзя просто дотянуться до файла и обновить запись на месте. В Parquet нет страниц, файлы неизменяемы.
К счастью, есть способ. На самом деле даже два способа. Iceberg предлагает две стратегии для обработки обновлений и удалений:
- Copy‑on‑Write (копирование при записи)
- Merge‑on‑Read (слияние при чтении)
Мы рассмотрим обе на примере нашей таблицы customers и взвесим компромиссы.
Copy‑on‑Write: переписываем всё
Создадим таблицу Iceberg в режиме copy‑on‑write для обновлений и удалений (синтаксис может немного отличаться между движками: Trino, Flink, Spark SQL и т.д.).
create table customers ( customer_id BIGINT, first_name STRING, last_name STRING, membership_status STRING) TBLPROPERTIES ( 'write.update.mode' = 'copy-on-write', 'write.delete.mode' = 'copy-on-write'); insert into customers value (1, 'Алиса', 'Иванова', 'active'); insert into customers value (2, 'Боб', 'Шакиров', 'lapsed'), (3, 'Клара', 'Сидорова', 'active');
Вставим Алису, а потом Боба и Клару с их текущим статусом (Боб — lapsed). После вставки структура таблицы выглядит так:
- Алиса в одном файле данных, Клара и Боб — в другом.
- Манифест отслеживает эти файлы, список манифестов, метаданные со снапшотом S0.
graph TD
Metadata["метаданные<br/>s0"] --> MList["список манифестов"]
MList --> Manifest["манифест"]
Manifest --> DF1["данные"]
Manifest --> DF2["данные"]
DF1 --- Alice["Алиса (active)"]
DF2 --- Clara["Клара (active)"]
DF2 --- Bob["Боб (lapsed)"]
classDef activeClass fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
classDef lapsedClass fill:#ffebee,stroke:#c62828,color:#b71c1c
class Alice,Clara activeClass
class Bob lapsedClassДела у нашего татарина пошли в гору, он продлил подписку, так что выполняем простой UPDATE, чтобы обновить Боба до active.
update customersset membership_status = 'active'wherefirst_name = 'Боб';
Что происходит?
- Iceberg читает файл, где находится Боб.
- Обновляет Боба до
activeи записывает новый файл с новым состоянием Боба и старым состоянием Клары (которая не менялась). - Старый файл остаётся нетронутым — снапшот S0 по‑прежнему на него ссылается (для time travel и параллельного чтения).
Это и есть copy‑on‑write: мы меняем что‑то и записываем целый новый файл. Старый файл не трогаем.
Затем Iceberg записывает новый файл метаданных, создаёт снапшот S1, сохраняет ссылку на S0, создаёт новые манифесты. Новый манифест указывает на новый файл данных, а также на старый файл, но помечает его как удалённый (deleted). Так движки понимают, что сканировать этот файл не нужно.
graph TD
MD_S0["метаданные s0"]
MD_S1["метаданные s0 s1"]
ML_S0["список манифестов"]
ML_S1["список манифестов"]
Man_Left["манифест"]
Man_Right["манифест"]
DF1["данные"]
DF2["данные"]
DF3["данные"]
MD_S0 --> ML_S0
MD_S1 --> ML_S0
MD_S1 --> ML_S1
ML_S0 --> Man_Left
ML_S1 --> Man_Left
ML_S1 --> Man_Right
Man_Left --> DF1
Man_Left --> DF2
Man_Right --> DF3
Man_Right-- стёрт --> DF2
Label1["Алиса (active)"]
Label2["Клара (active)"]
Label3["Боб (lapsed)"]
Label4["Клара (active)"]
Label5["Боб (active)"]
DF1 --- Label1
DF2 --- Label2
DF2 --- Label3
DF3 --- Label4
DF3 --- Label5
style DF2 stroke-dasharray: 5 5, opacity:0.3
style Label1 fill:none,stroke:none,color:#2e8b57
style Label2 fill:none,stroke:none,color:#2e8b57
style Label3 fill:none,stroke:none,color:#cd5c5c
style Label4 fill:none,stroke:none,color:#2e8b57
style Label5 fill:none,stroke:none,color:#2e8b57
Проблема copy‑on‑write: write amplification
Если вы обновляете одну строку в файле на миллион строк, остальные 999 999 строк перезаписываются. В случае частых и мелких обновлений, это создаст огромную ненужную нагрузку.
Такая ситуация называется write amplification (усиление записи) — очень распространённая проблема в любых нетривиальных базах данных. Вы всегда в итоге записываете больше данных, чем фактически изменили.
Если у вас таблица с постоянно эволюционирующими сущностями (например, заказы в real‑time системе), где статус заказа часто меняется, copy‑on‑write может сильно ударить по производительности.
Merge‑on‑Read: пишем только изменения
Создадим ту же таблицу, но установим режим обновлений и удалений в merge‑on‑read. Вставляем те же три записи, получаем ту же начальную структуру.
create table customers ( customer_id BIGINT, first_name STRING, last_name STRING, membership_status STRING) TBLPROPERTIES ( 'write.update.mode' = 'merge-on-read', 'write.delete.mode' = 'merge-on-read'); insert into customers value (1, 'Алиса', 'Иванова', 'active'); insert into customers value (2, 'Боб', 'Шакиров', 'lapsed'), (3, 'Клара', 'Сидорова', 'active');
Теперь обновляем Боба до active. Merge‑on‑read действует иначе:
- Iceberg записывает новый файл данных только с обновлённой записью Боба.
- Одновременно пишет файл удалений (delete file), который отслеживает, какие конкретные записи в исходном файле больше не валидны (старый Боб).
- Исходный файл остаётся в игре, потому что Клара всё ещё там.
Файлы данных и файлы удалений хранятся в отдельных манифестах. Новый файл метаданных со снапшотом S1 ссылается на всё это: исходные файлы данных, новый файл с обновлением Боба и файл удалений.
graph TD
MD_S0["метаданные s0"]
MD_S1["метаданные s0 s1"]
ML_S0["список манифестов"]
ML_S1["список манифестов"]
Man_Left["манифест"]
Man_Right1["манифест"]
Man_Right2["манифест"]
DF1["данные"]
DF2["данные"]
DF3["данные"]
DF4["удаление"]
MD_S0 --> ML_S0
MD_S1 --> ML_S0
MD_S1 --> ML_S1
ML_S0 --> Man_Left
ML_S1 --> Man_Left
ML_S1 --> Man_Right1
ML_S1 --> Man_Right2
Man_Left --> DF1
Man_Left --> DF2
Man_Right1 --> DF3
Man_Right2 --> DF4
Label1["Алиса (active)"]
Label2["Клара (active)"]
Label3["Боб (lapsed)"]
Label4["Боб (active)"]
DF1 --- Label1
DF2 --- Label2
DF2 --- Label3
DF3 --- Label4
DF4 --- Label3
style Label1 fill:none,stroke:none,color:#2e8b57
style Label2 fill:none,stroke:none,color:#2e8b57
style Label3 fill:none,stroke:none,color:#cd5c5c
style Label4 fill:none,stroke:none,color:#2e8b57
Что происходит при чтении?
Когда движок запросов читает таблицу, он на лету сливает все данные, применяет удаления, отфильтровывает устаревшие записи и подставляет новые. Это и есть merge‑on‑read.
Компромисс: запись становится быстрее (меньше write amplification), но чтение замедляется, потому что движку приходится выполнять дополнительную работу по слиянию.
Ещё одна проблема: слишком много мелких файлов
Проблема «слишком много мелких файлов» преследует data lakes уже почти 20 лет. Со временем запросы замедляются, потому что движку приходится обрабатывать сотни файлов данных и удалений.
Много мелких файлов — это всегда больше метаданных, больше сетевых запросов, меньше полезной нагрузки. Iceberg решает это с помощью компактификации — периодического слияния данных и файлов удалений в новые консолидированные файлы данных.
Компактификация помогает, но не устраняет накладные расходы полностью, потому что она выполняется периодически, и всегда будет некоторый «горячий» набор мелких файлов.
Какую стратегию выбрать?
Давайте сравним copy‑on‑write и merge‑on‑read по ключевым метрикам:
| Критерий | Copy‑on‑Write | Merge‑on‑Read |
|---|---|---|
| Производительность записи | Низкая (перезапись целых файлов) | Высокая (пишутся только изменения) |
| Производительность чтения | Высокая (сканирование готовых файлов) | Низкая (слияние на лету) |
| Накладные расходы на хранилище | Высокие (дублирование данных) | Низкие (хранятся только дельты) |
| Необходимость в обслуживании | Минимальная | Требуется регулярная компактификация |
| Идеальный сценарий | Read‑heavy workloads, аналитические запросы | Write‑heavy workloads, streaming ingest, CDC |
Итоговая рекомендация
- Copy‑on‑Write блестит для нагрузок, ориентированных на чтение, где важна скорость запросов.
- Merge‑on‑Read идеален для интенсивной записи: streaming‑ингestion, CDC‑пайплайны, сущности, которые постоянно эволюционируют в реальном времени.
Как и во многих инженерных решениях, здесь нет «правильного» или «неправильного» выбора — есть компромиссы. Вы должны принимать решение на основе специфики вашей системы.
Хорошая новость: вы не заперты в одном выборе навсегда. Если характер нагрузки на таблицу со временем меняется, вы можете сменить стратегию.
Изменять неизменяемое — звучит как оксюморон, но Iceberg превращает это в инженерное искусство. Как реставратор, который не перерисовывает всю картину, а аккуратно вносит точечные правки, сохраняя историю каждого мазка.
Что дальше? В следующем уроке мы изучим Типы данных и эволюцию схемы — как Iceberg позволяет безопасно менять структуру таблиц без перезаписи данных.