Row-level операции: CoW, MoR, deletion vectors
В уроке 2 мы разобрали, что каждый коммит создаёт новый snapshot, а данные в файлах иммутабельны. Но как тогда работают DELETE, UPDATE и MERGE INTO, если файлы нельзя модифицировать?
Iceberg предлагает три стратегии: Copy-on-Write (V1+), Merge-on-Read с delete files (V2), и Deletion Vectors (V3). Каждая — с разным балансом между стоимостью записи и скоростью чтения.
Примеры кода — на Python с pyiceberg 0.11.1 (март 2026). Версия спецификации — V2 (с пометками о V3 deletion vectors). V3 ратифицирован в июне 2025, deletion vectors доступны в Spark, Flink, AWS EMR 7.12.
Зачем нужны row-level deletes?
Data lakehouse — не только append. Реальные сценарии требуют удаления и обновления строк:
| Сценарий | Операция | Пример |
|---|---|---|
| GDPR / Right to be forgotten | DELETE | Удалить все данные конкретного пользователя |
| CDC (Change Data Capture) | MERGE INTO | Применить changelog из OLTP-базы |
| Коррекция данных | UPDATE | Исправить ошибочные значения |
| Дедупликация | DELETE + INSERT | Удалить дубликаты, вставить уникальные |
| SCD Type 2 | UPDATE + INSERT | Закрыть текущую версию, вставить новую |
Во всех случаях проблема одна: Parquet-файлы иммутабельны. Нельзя “зайти” в файл и изменить одну строку.
Copy-on-Write (CoW)
Простейшая стратегия, доступная с V1:
Идея: чтобы удалить/обновить строку в файле, перезаписать весь файл без этой строки (или с обновлённым значением).
data-001.parquet (1M строк)
Исходный Parquet-файл с 1 000 000 строк. DELETE WHERE customer_id = 42 затрагивает одну строку. Но файл иммутабельный — нельзя удалить строку in-place.Прочитать → Отфильтровать → Записать
CoW: движок читает ВСЕ 1 000 000 строк, фильтрует customer_id=42, записывает 999 999 строк в НОВЫЙ файл. Старый файл отмечается как deleted в manifest. Огромный I/O для одной строки.Характеристики CoW:
| Аспект | Оценка |
|---|---|
| Write amplification | [!] Высокая — перезапись целых файлов (256-512 MB) ради одной строки |
| Read performance | [OK] Отличная — нет overhead при чтении, все данные актуальны |
| Latency записи | [!] Высокая — пропорциональна размеру файла, не числу изменённых строк |
| Конкурентность | [OK] Простая — нет delete files для reconciliation |
CoW оптимален для bulk операций (DELETE целого partition, полная перезапись таблицы) и для таблиц с редкими обновлениями. Для CDC/streaming — слишком дорого.
Merge-on-Read (MoR) — V2
V2 спецификация добавила delete files — специальные файлы, которые описывают удалённые строки без перезаписи data files.
Идея: вместо перезаписи — записать маленький файл-маркер “эти строки удалены”. При чтении — merge data file с delete file и отфильтровать удалённые строки.
Position delete files
Position delete file — Parquet-файл с двумя колонками:
file_path (string) | pos (long)
---------------------------------
"s3://.../data-001.parquet" | 42
"s3://.../data-001.parquet" | 1337
"s3://.../data-001.parquet" | 99999
Каждая запись — координата (файл, позиция строки в файле). Позиция — 0-based индекс строки в Parquet row group.
data-001.parquet (1M строк)
Data file содержит 1M строк. Три строки нужно удалить. Вместо перезаписи всего файла — создаём маленький position delete file с координатами удалённых строк.Read: merge data + deletes
При чтении: движок загружает data file + position delete file. Сканируя строки, пропускает позиции 42, 1337, 99999. Overhead: загрузка и merge delete file при каждом чтении.Equality delete files
Equality delete file — Parquet-файл с значениями предикатов:
customer_id (long)
------------------
42
73
Все строки во всех data files, где customer_id = 42 или customer_id = 73, считаются удалёнными.
Read: проверить каждую строку
При чтении: движок загружает equality delete file, строит фильтр, применяет к каждой строке каждого data file. Overhead значительно выше, чем у position deletes — проверка каждой строки.Sequence numbers — корректность при конкурентных записях
Как отличить строку, добавленную ДО delete от строки, добавленной ПОСЛЕ? Без этого delete мог бы удалить строки из будущих INSERT-ов.
V2 использует sequence numbers:
Snapshot 5 (seq=5): INSERT → data-001.parquet
Snapshot 6 (seq=6): DELETE customer_id=42 → equality-delete-001.parquet (seq=6)
Snapshot 7 (seq=7): INSERT → data-002.parquet (содержит customer_id=42!)
Equality delete с seq=6 применяется только к файлам с seq ≤ 6. Файл data-002.parquet с seq=7 — не затронут, даже если содержит customer_id=42.
Без sequence numbers equality deletes были бы опасны: DELETE WHERE customer_id=42 убил бы ВСЕ будущие INSERT-ы с customer_id=42. Sequence numbers — обязательный компонент V2.
Deletion Vectors (V3)
V3 спецификация (ратифицирована в июне 2025) заменяет position delete files deletion vectors — компактными бинарными битмапами.
Проблема position deletes в V2
Position delete files — Parquet-файлы с координатами. При активных UPDATE/DELETE:
- Proliferation: каждая операция создаёт новый position delete file. После 1000 UPDATE-ов — 1000 delete files для одного data file
- Read overhead: при чтении нужно загрузить и merge все 1000 delete files
- Нет compaction гарантий: спецификация V2 не требовала от writers объединять delete files. Многие не делали — таблицы деградировали
V3 решение: Roaring bitmaps в Puffin files
V2: Position deletes (Parquet)
V2: каждая DELETE-операция создаёт отдельный position delete file (Parquet). 100 операций = 100 файлов, которые нужно merge при чтении. I/O и CPU растут линейно с числом удалений.V3: Deletion Vector (Roaring bitmap)
V3: один deletion vector (Roaring bitmap) на один data file. Bitmap сжимает позиции удалённых строк в компактную бинарную структуру. Хранится в Puffin-файле — бинарном контейнере для metadata blobs.Формат deletion vector
- Roaring bitmap: компактная структура для разреженных множеств целых чисел. Каждый бит = одна строка: 0 = жива, 1 = удалена
- Puffin file: бинарный контейнер (аналог Parquet footer для blob-ов). Один Puffin-файл может содержать deletion vectors для нескольких data files
- Правило одного DV: в snapshot максимум один deletion vector на data file. Writer обязан merge новых deletes с существующим DV
data-001.parquet → file-001.puffin [DV: bitmap, positions 42, 1337, 99999]
data-002.parquet → file-001.puffin [DV: bitmap, positions 7, 15, 28]
data-003.parquet → (нет DV — все строки живы)
Read path с deletion vectors
1. Открыть data file → начать сканировать строки
2. Загрузить DV для этого data file (один blob из Puffin)
3. Для каждой строки: проверить бит в bitmap
- бит = 0 → строка жива, вернуть
- бит = 1 → строка удалена, пропустить
Roaring bitmap — O(1) проверка per-bit (lookup по контейнеру + bit test). Минимальный CPU overhead.
Совместимость с V2
- V3 deprecates position delete files — writers не должны создавать новые position delete files в V3 таблицах
- Существующие position deletes валидны — при upgrade V2→V3 старые position delete files читаются
- Writer обязан при обновлении deletes для data file: прочитать существующие position deletes + DV → записать объединённый DV
При upgrade V2→V3 не нужно перезаписывать delete files. Они продолжают работать. Новые deletes пишутся как DVs. Со временем, при compaction, все position deletes мигрируют в DVs.
UPDATE и MERGE INTO
UPDATE и MERGE INTO — комбинация DELETE + INSERT:
UPDATE
UPDATE orders SET status = 'cancelled' WHERE order_id = 42;
UPDATE orders SET status=‘cancelled’ WHERE order_id=42
UPDATE в Iceberg — не in-place модификация. Это DELETE строки с order_id=42 + INSERT новой строки с обновлённым status='cancelled'. Два действия в одном атомарном коммите.DELETE: old row
Шаг 1: удалить старую строку. В CoW — перезаписать data file без этой строки. В MoR — записать position delete / deletion vector для позиции этой строки.INSERT: new row
Шаг 2 (MoR): записать новую строку с обновлённым status в отдельный data file. Delete marker + new data file = обновление без перезаписи оригинала.MERGE INTO
MERGE INTO target USING source
ON target.id = source.id
WHEN MATCHED AND source.op = 'delete' THEN DELETE
WHEN MATCHED AND source.op = 'update' THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
MERGE INTO — основная операция для CDC. Один statement обрабатывает delete, update и insert:
- Join source и target по ключу
- Matched + delete → position delete / DV для строки
- Matched + update → position delete / DV + INSERT новой строки
- Not matched → INSERT
- Всё в одном атомарном snapshot
CoW vs MoR vs Deletion Vectors
Количественное сравнение
| Метрика | CoW | MoR (V2) | DV (V3) |
|---|---|---|---|
| DELETE 1 строки из 1M-строчного файла | Перезапись 256 MB | +1 Parquet file (~200 B) | +1 DV blob (~50 B) |
| 1000 DELETE-ов по 1 строке | 1000 перезаписей файлов | +1000 delete files | Один merged DV |
| Read overhead после 1000 delete-ов | Merge 1000 files | 1 bitmap check | |
| Storage overhead | ≈ 2x на каждый UPDATE | +1 файл на операцию | +1 blob на data file |
Конфигурация стратегии
В Spark:
-- Установить MoR для DELETE/UPDATE
ALTER TABLE orders SET TBLPROPERTIES (
'write.delete.mode' = 'merge-on-read',
'write.update.mode' = 'merge-on-read',
'write.merge.mode' = 'merge-on-read'
);
-- Или CoW
ALTER TABLE orders SET TBLPROPERTIES (
'write.delete.mode' = 'copy-on-write',
'write.update.mode' = 'copy-on-write',
'write.merge.mode' = 'copy-on-write'
);
Для V3 таблиц MoR автоматически использует deletion vectors вместо position delete files. Переключение — только format-version=3 в свойствах таблицы.
Row lineage (V3)
V3 добавляет row lineage — метаданные для отслеживания изменений на уровне строк:
_row_id— уникальный идентификатор строки (присваивается при записи)_last_updated_sequence_number— sequence number snapshot-а, в котором строка была последний раз изменена
Это позволяет:
- Incremental CDC:
SELECT * FROM orders WHERE _last_updated_sequence_number > 5— все изменённые строки с snapshot 5 - Audit trail: точная история изменений каждой строки
- Efficient replication: только изменённые строки, без полного scan
-- Создать V3 таблицу с row lineage
CREATE TABLE orders (
order_id BIGINT,
amount DECIMAL(10,2),
status STRING
) USING iceberg
TBLPROPERTIES ('format-version' = '3');
-- Все строки с изменениями после snapshot 5
SELECT * FROM orders WHERE _last_updated_sequence_number > 5;
Row lineage — engine-agnostic: метаданные записываются в Parquet-файлы согласно спецификации. Не нужен специфичный движок для чтения — любой движок с поддержкой V3 увидит _row_id и _last_updated_sequence_number.
Сравнение с Delta Lake
| Аспект | Delta Lake | Iceberg V2 | Iceberg V3 |
|---|---|---|---|
| CoW | (по умолчанию) | ||
| MoR | Deletion vectors | Position/equality deletes | Deletion vectors |
| DV формат | Roaring bitmap (проприетарный) | — | Roaring bitmap (Puffin) |
| Equality deletes | |||
| Row lineage | Row tracking (proprietary) | (открытая спецификация) | |
| Spec requirement: merge DVs | — | (writer обязан) | |
| Interop | Spark + Databricks | Spark, Flink, Trino, DuckDB… | Spark, Flink, EMR 7.12… |
Итоги
- Copy-on-Write — перезапись файлов; дорогая запись, дешёвое чтение. Для bulk/редких обновлений
- Position deletes (V2) — координаты (file_path, pos) в Parquet; дешёвая запись, растущий read overhead
- Equality deletes (V2) — предикаты (customer_id=42); мощные для GDPR, но дорогие при чтении (per-row check)
- Sequence numbers — защита от ложного удаления будущих INSERT-ов; обязательны в V2+
- Deletion vectors (V3) — Roaring bitmaps в Puffin files; O(1) per-row check, writer обязан merge, один DV per data file
- Row lineage (V3) —
_row_id+_last_updated_sequence_numberдля incremental CDC и audit - UPDATE = DELETE + INSERT, MERGE INTO — комбинация в одном атомарном snapshot