Атрибуция: Этот материал является переводом и адаптацией статьи Apache Doris 4.1 on Iceberg V3: Running the Full Lakehouse Lifecycle from One SQL Engine за авторством Mingyu Chen (Rayner), опубликованной по лицензии CC BY 4.0. Адаптация выполнена для русскоязычной аудитории Хабра с дополнительным контекстом по архитектурным компромиссам.
Сегодня поговорим про Iceberg V3 и Apache Doris 4.1. Но не в формате «у нас теперь есть поддержка новой версии формата, хлопаем в ладоши». Гораздо интереснее другой вопрос: что меняется, когда SQL-движок умеет не только читать Iceberg-таблицу, но и делать маленькие исправления, reconciliation, maintenance и диагностику без отдельного Spark-кластера на каждое телодвижение?
Спойлер: Spark никто не отменял. Flink тоже. Но есть целый класс ежедневной работы, где переключение движка дороже самой операции.
Если вы когда-нибудь писали Spark job ради исправления одной строки, эта статья может вызвать лёгкие флешбеки.
Хотите сразу попробовать? Переходите в Часть 8: Quick Start там путь от нуля до V3-таблицы за пять минут.
Часть 1: 3 часа ночи, одна строка сломана
Представим конкретную ситуацию.
Data engineer в e-commerce компании сидит в Doris и разбирает Iceberg-таблицу с заказами. Видит, что вчерашний batch из ERP проставил status = 'cancelled' для 200 заказов, которые на самом деле были оплачены — upstream-система отправила кривой snapshot. Запрос подтверждает: вот они, 200 строк с неверным статусом. Исправление - банальный WHERE order_id IN (...) и SET status = 'paid'.
В идеальном мире это выглядит так:
UPDATE orders_iceberg
SET status = 'paid'
WHERE order_id IN (SELECT order_id FROM bad_batch_ids);
Что происходит дальше в реальном мире — красиво описано в оригинальной статье Mingyu Chen. Инженер закрывает SQL-клиент и начинает другой рабочий процесс:
Пишет Spark job.
Подстраивает scheduler.
Открывает PR.
Ждёт review.
Чаще всего — передаёт задачу платформенной команде, потому что у него нет прямого доступа к write-кластеру.
Утром возвращается в Doris и проверяет, что всё отработало.
Полный цикл — 14 часов. Код по смыслу — один UPDATE.
Есть и менее драматичный, но более частый сценарий. CDC-поток пишет в Iceberg, small files и delete files копятся, запросы постепенно деградируют. Все понимают, что нужен rewrite_data_files.
В командах с self-service инфраструктурой инженер запустит compaction сам. Но во многих средних и крупных организациях write-доступ к Iceberg-таблицам живёт у выделенной platform team — и тогда задача, которая по сути один SQL-вызов, превращается в ticket, maintenance window и ожидание. Именно этот организационный паттерн описывает автор оригинальной статьи, и он встречается чаще, чем хотелось бы.
Iceberg решил огромную часть проблемы: он сделал таблицу открытым transactional-форматом поверх файлов в object storage. Но работа с этой таблицей часто остаётся не такой уж открытой:
читаем в одном движке;
пишем в другом;
compaction и maintenance делаем через третий;
CDC и streaming ingestion живут отдельно.
Получается два налога на каждое маленькое действие: switch-engine tax и cross-team tax. Apache Doris 4.1 пытается уменьшить оба.
Часть 2: Где Doris находится в Iceberg-экосистеме
Важно сразу снять лишнее ожидание: Doris не пытается заменить Spark во всех задачах.
Apache Doris — это MPP SQL engine, который начинал как data warehouse, а за последние два мажорных релиза сместил центр тяжести в сторону lakehouse workloads. Spark при этом остаётся нормальным инструментом для cross-source backfills, тяжёлого ETL, больших batch-job’ов и широкой batch/streaming-экосистемы. Flink остаётся естественным выбором для streaming ingestion и continuous CDC write paths.
Роль Doris в этом контексте узкая и конкретная: real-time query layer для Iceberg, который постепенно забирает соседний workflow:
вы уже пришли в SQL-клиент с запросом;
увидели проблему или расхождение;
хотите сделать небольшое исправление;
хотите запустить incremental reconciliation;
хотите выполнить maintenance или диагностику;
не хотите ради этого прыгать в другой кластер.
В Doris 4.1 матрица Iceberg-поддержки выглядит довольно широко:
Зона | Что поддерживается |
|---|---|
Read | V1/V2/V3, time travel, branch/tag, views, system tables, Position/Equality/DV delete |
Write |
|
DDL | create/drop table, schema change, partition evolution, branch/tag management |
Maintain |
|
Diagnose | data file distribution, dangling delete |

Архитектура: один SQL-движок для Read, Write, DDL, Maintenance и Diagnostics на Iceberg V3 таблицах. Источник: оригинальная статья Mingyu Chen.
Но в этой статье нас интересует не вся матрица. Фокус на двух вещах:
DML completeness:
UPDATE,DELETE,MERGE INTOпрямо из SQL-клиента.Iceberg V3 mechanics: Deletion Vectors и Row Lineage, без которых DML быстро начинает копить технический долг.
Часть 3: DML возвращается в query layer
Начнём с простого.
Исправить одну строку:
UPDATE iceberg_tbl
SET name = 'Alice-fixed'
WHERE id = 1;
Откатить плохой batch:
DELETE FROM iceberg_tbl
WHERE dt = '2026-04-01'
AND source = 'bad_pipeline';
Сделать upsert по incremental batch:
MERGE INTO iceberg_tbl t
USING (
SELECT 1 AS id, 'Alice_new' AS name, 26 AS age, 'U' AS flag
UNION ALL SELECT 2, 'Bob', 30, 'D'
UNION ALL SELECT 4, 'Dora', 28, 'I'
) s
ON t.id = s.id
WHEN MATCHED AND s.flag = 'D' THEN DELETE
WHEN MATCHED THEN UPDATE SET name = s.name, age = s.age
WHEN NOT MATCHED THEN INSERT (id, name, age) VALUES (s.id, s.name, s.age);
MERGE INTO здесь самая важная операция. Именно на ней держатся типичные CDC/upsert-сценарии:
изменения из PostgreSQL или MySQL прилетают через Flink CDC или похожий инструмент;
downstream Iceberg-таблица должна принять inserts, updates и deletes вместе;
incremental materialized views должны обновлять wide tables по change key.
Без MERGE INTO вы либо пересобираете данные целиком, либо уносите логику в Spark/Flink и вручную обслуживаете отдельный pipeline.
В Doris 4.1 MERGE INTO поддерживает partitioned targets, subqueries как source и expressions в UPDATE-части, например age = age * 2 + 1.
Но caveat сразу: target table должен быть format-version >= 2, а сам MERGE INTO в оригинальной статье помечен как experimental. Это не «сразу катим в прод в пятницу вечером», а «делаем POC на своём workload».
Часть 4: Почему DML без V3 быстро копит долги
Может показаться, что раз Doris теперь умеет UPDATE, DELETE, MERGE INTO на Iceberg — задача решена.
Не совсем. Наличие DML — это ещё не хороший DML.
В Iceberg V2 каждая такая операция создаёт Position Delete file. Это отдельный parquet-файл, в котором записано: «вот эти позиции из data file нужно считать удалёнными». Чтобы вернуть корректный результат, reader вынужден выполнять anti-join: прочитать data files, прочитать все связанные delete files, отфильтровать помеченные строки.
Один delete file — ничего страшного. Десять — терпимо. Сотни и тысячи маленьких DML commits в CDC-таблице — уже неприятно.
Проблема растёт линейно:
каждый DML commit добавляет новые delete files;
каждый последующий query вынужден открывать и объединять все релевантные delete files;
периодически нужен
rewrite_data_files, чтобы вернуть таблицу в нормальное состояние;а значит, снова maintenance job, scheduler и платформа — тот самый Jira-ticket из начала статьи.
Это первый долг: performance debt. DML вроде есть, но каждый commit медленно ухудшает чтение.
Есть второй долг: observability debt. После того как commit произошёл, downstream-системы больше не знают, когда конкретная строка была последний раз изменена. Они полагаются на snapshot diff. Но compaction и rewrite_data_files тоже создают новые snapshots, хотя данные логически не менялись.
В результате downstream pipeline видит физический rewrite и интерпретирует его как новые данные — и начинает переобрабатывать строки, которых никто не трогал.
Если коротко:
DML без V3 быстро превращается в два долга: performance debt и observability debt.
Iceberg V3 отвечает на них двумя механизмами:
Deletion Vectors — чтобы DML не раздувал delete files.
Row Lineage — чтобы row-level changes были наблюдаемыми.
Часть 5: Deletion Vectors — меньше delete files, меньше anti-join
Deletion Vector — это bitmap, который хранит, какие строки в data file считаются удалёнными. В Iceberg V3 он хранится в Puffin file format.
Контраст с V2 такой:
V2: каждый DML commit создаёт отдельный Position Delete parquet.
V3: все пометки на удаление для одного data file схлопываются в один Puffin Deletion Vector.
V2 reader: читает data file, потом anti-join со всеми связанными delete files.
V3 reader: читает data file + DV и применяет bitmap за один проход. Anti-join исчезает.

V2 копит Position Delete parquet-файлы при каждом DML commit. V3 схлопывает все пометки в один Puffin Deletion Vector. Источник: оригинальная статья Mingyu Chen.
Мини-пример V2:
CREATE TABLE orders_v2 (
id INT,
status STRING,
amount DECIMAL(10,2)
) PROPERTIES ('format-version' = '2');
INSERT INTO orders_v2 VALUES
(1, 'pending', 100),
(2, 'pending', 200),
(3, 'pending', 300);
UPDATE orders_v2 SET status = 'shipped' WHERE id = 1;
UPDATE orders_v2 SET status = 'shipped' WHERE id = 2;
DELETE FROM orders_v2 WHERE id = 3;
После трёх DML commits в V2 появляются три Position Delete files.
Та же логика на V3:
CREATE TABLE orders_v3 (
id INT,
status STRING,
amount DECIMAL(10,2)
) PROPERTIES ('format-version' = '3');
INSERT INTO orders_v3 VALUES
(1, 'pending', 100),
(2, 'pending', 200),
(3, 'pending', 300);
UPDATE orders_v3 SET status = 'shipped' WHERE id = 1;
UPDATE orders_v3 SET status = 'shipped' WHERE id = 2;
DELETE FROM orders_v3 WHERE id = 3;
Теперь вместо набора parquet delete files рядом с data file лежит Puffin Deletion Vector. SQL на поверхности тот же, физическая механика другая.
Проверить это можно через system table:
SELECT content, file_path, record_count
FROM orders_v3$files;
В нормальном V3-сценарии вы ожидаете увидеть data file и .puffin DV, а не растущий список ...delete.parquet.
Что дают числа
В оригинальной статье приведён Doris-side benchmark на нескольких сценариях. Самое важное:
Сценарий | Iceberg V2: Position Deletes | Iceberg V3: Deletion Vector |
|---|---|---|
16 data files, 20% deleted | 336 files to open: 16 data + 320 delete | 17 files: 16 data + 1 Puffin |
100M rows, 99% deleted | 98 MiB delete storage | 3.8 MiB DV storage |
Delete metadata reduction | — | ~96% |
Latency на 16 data files и 1M rows:
Delete % | Doris V2 | Doris V3 | Speedup |
|---|---|---|---|
5% | 0.31s | 0.15s | 2.1x |
10% | 0.35s | 0.16s | 2.2x |
20% | 0.43s | 0.17s | 2.5x |
30% | 0.46s | 0.14s | 3.3x |
40% | 0.39s | 0.17s | 2.3x |
Для large file с 99% deleted:
Table version | Doris Q1 | Doris Q2 |
|---|---|---|
V2, Position Delete | 3.42s | 3.28s |
V3, Deletion Vector | 1.03s | 0.86s |
Speedup | ~3x | ~3x |

Doris V3 vs V2: latency остаётся плоской при росте delete ratio, в то время как V2 деградирует линейно. Источник: оригинальная статья Mingyu Chen.
Смысл не в конкретном множителе «всегда 3x». Паттерн другой: под V2 anti-join cost растёт вместе с числом delete files, и query latency деградирует по мере роста delete ratio. Под V3 bitmap применяется за один проход, и latency остаётся практически плоской независимо от того, сколько строк помечено на удаление.
Что именно Doris реализует для Deletion Vectors
Важный нюанс из оригинала: чтобы Deletion Vectors приносили пользу, движок должен уметь и читать, и писать DV. Read-only движок может потреблять DV, записанные кем-то другим, но сам их не создаёт. Write-only движок создаёт DV, которые никто не прочитает.
Doris 4.1 поддерживает обе стороны:
Read: Puffin-format Deletion Vectors читаются без дополнительной настройки. V3-таблицы queryable сразу.
Write:
DELETE,UPDATE,MERGE INTOна V3-таблице автоматически создают Puffin DV вместо Position Delete files. Пользователю достаточно указатьformat-version = 3при создании таблицы — SQL-синтаксис не меняется.
Caveats по Deletion Vectors
Три практических ограничения:
DELETE,UPDATE,MERGE INTOтребуютformat-version >= 2.Deletion Vectors появляются только при
format-version = 3.Concurrent writes используют Iceberg optimistic concurrency control, так что конфликты будут всплывать как transaction exceptions. Для high-conflict workloads нужны retry или сериализация.
Часть 6: Row Lineage — watermark для CDC без snapshot diff
Deletion Vectors отвечают на performance debt. Теперь про observability debt.
В Iceberg V1/V2 change tracking в основном живёт на уровне snapshot. Это нормальная модель для многих задач, но у неё есть неприятный эффект: физический rewrite может выглядеть как логическое изменение.
Например:
downstream-система подписана на изменения Iceberg-таблицы;
она сравнивает snapshots;
между checkpoint’ами прошёл
rewrite_data_files;физические файлы новые, snapshot новый;
downstream не знает, изменились строки логически или просто переехали между файлами.
И начинает переобрабатывать лишнее.
Iceberg V3 добавляет Row Lineage: две скрытые системные колонки для каждой строки.

Row Lineage: _row_id даёт стабильную идентичность строки, _last_updated_sequence_number — watermark для CDC. Источник: оригинальная статья Mingyu Chen.
Колонка | Что означает |
|---|---|
| стабильный numeric identifier строки |
| sequence number последнего логического изменения строки |
Обе колонки поддерживаются системой. Пользователь не записывает их руками.
Самая важная часть: _last_updated_sequence_number меняется при настоящем UPDATE или MERGE INTO, но не меняется при compaction и physical rewrite. Значит, его можно использовать как CDC watermark.
Пример:
CREATE TABLE users_v3 (
id INT,
name STRING,
email STRING
) PROPERTIES ('format-version' = '3');
SET show_hidden_columns = true;
INSERT INTO users_v3 VALUES
(1, 'Alice', 'alice@x.com'),
(2, 'Bob', 'bob@x.com'),
(3, 'Carol', 'carol@x.com');
SELECT id, name, email, _row_id, _last_updated_sequence_number
FROM users_v3;
После первого insert все три строки получают стабильные _row_id, а _last_updated_sequence_number равен 1.
Дальше меняем Bob:
UPDATE users_v3
SET email = 'bob@newmail.com'
WHERE id = 2;
У Bob _row_id остаётся тем же, но _last_updated_sequence_number становится 2. Alice и Carol остаются с SN = 1.
Добавляем Dora:
INSERT INTO users_v3
VALUES (4, 'Dora', 'dora@x.com');
Dora получает новый _row_id и SN = 3.
Теперь downstream, который синхронизирован по watermark 1, может запросить только реальные изменения:
SELECT id, name, email, _last_updated_sequence_number
FROM users_v3
WHERE _last_updated_sequence_number > 1;
Вернутся Bob и Dora. Alice и Carol не попадут в pipeline. Если между checkpoint’ами случится compaction, они всё равно не попадут, потому что physical rewrite не двигает _last_updated_sequence_number.
Это и есть главный выигрыш: downstream хранит один integer watermark, а не пытается угадывать смысл snapshot diff.
Как меняется CDC-pipeline
Контраст в архитектуре:
V2 (snapshot diff): Upstream (PostgreSQL и т.д.) → Flink CDC → Iceberg V2 Sink → Downstream делает snapshot diff, но не может отличить реальное изменение от compaction/rewrite → false positives → переобработка.
V3 (Row Lineage): Upstream → Flink CDC → Iceberg V3 Sink → Downstream делает WHERE _last_updated_sequence_number > :watermark → только реальные изменения → без false positives.
Разница не только в точности, но и в простоте: вместо инфраструктуры для snapshot diffing — один SQL-запрос с одним integer watermark.
Что Doris реализует для Row Lineage
В Doris скрытые колонки можно читать явно или включить их отображение:
SET show_hidden_columns = true;
UPDATE и MERGE INTO на V3-таблицах автоматически обновляют _last_updated_sequence_number. Пользователь не добавляет отдельный audit trigger, не пишет отдельную таблицу lineage, не синхронизирует внешний log.
Но caveat снова важный: Row Lineage в оригинале помечен как experimental. Проверять на своём workload обязательно.
Часть 7: Audit trail через _row_id
Ещё один полезный сценарий — аудит.
Финансовый аудитор спрашивает: «Order 102 менялся три раза. Какие суммы были на каждом шаге?»
В V2 обычно приходится идти во внешнюю audit log, потом пытаться совместить её с текущим состоянием таблицы, потом учитывать compaction, file rewrite и прочие радости жизни. Это возможно, но хрупко: audit system и table state живут в разных местах.
В V3 появляется более удобная опора: _row_id остаётся lifetime identity строки.
Условно:
Snapshot SN | Amount | Event |
|---|---|---|
SN=1 | 80.00 | Order created |
SN=2 | 90.00 | Customer complaint, partial refund |
SN=3 | 100.00 | Final settlement |
Сама Row Lineage не заменяет time travel и не хранит полную историю «магически». Важная деталь именно в связке:
_row_idдаёт стабильный ключ строки;snapshots/time travel дают возможность смотреть прошлые состояния;
_last_updated_sequence_numberпоказывает логическую хронологию изменений;compaction не ломает эту идентичность.
Примерный запрос для восстановления состояния:
SELECT order_id, amount
FROM orders_v3 FOR VERSION AS OF <snapshot_id_at_SN2>
WHERE _row_id = 1;
Да, вам всё ещё нужны snapshots. Но _row_id и _last_updated_sequence_number — это логические свойства строки, а не физические. Если между шагами прошёл ALTER TABLE orders_v3 EXECUTE rewrite_data_files(), физические файлы будут новые, а _row_id останется прежним и SN не изменится. Под V2 тот же compaction создал бы новый snapshot, и без внешней audit log вы бы не знали, менялись строки логически или нет.
Часть 8: Quick Start
Если хочется быстро потрогать механику руками, минимальный путь такой.
Prerequisites
Нужны:
Apache Doris 4.1.0 или новее (скачать или
docker pull apache/doris:4.1.0).Iceberg catalog с поддержкой V3.
Object store, доступный Doris BE: S3, MinIO, OSS или HDFS.
По catalog’ам в оригинале рекомендация такая:
REST Catalog — наиболее надёжный путь для V3. Например, Apache Polaris, Lakekeeper или Tabular open-source REST catalog image.
Hive Metastore-backed catalogs подходят для V1/V2 reads, но отстают по V3.
AWS Glue и Aliyun DLF на момент оригинальной статьи указаны как read-only.
1. Подключить catalog
CREATE CATALOG iceberg_v3 PROPERTIES (
'type' = 'iceberg',
'iceberg.catalog.type' = 'rest',
'uri' = 'http://your-rest-catalog:8181',
'warehouse' = 's3://your-bucket/warehouse',
's3.endpoint' = 'https://s3.us-west-2.amazonaws.com',
's3.access_key' = '<AK>',
's3.secret_key' = '<SK>',
's3.region' = 'us-west-2'
);
SWITCH iceberg_v3;
CREATE DATABASE IF NOT EXISTS demo;
USE demo;
2. Создать V3-таблицу и сделать DML
CREATE TABLE orders (
id INT,
status STRING,
amount DECIMAL(10,2)
) PROPERTIES ('format-version' = '3');
INSERT INTO orders VALUES
(1, 'pending', 100),
(2, 'pending', 200),
(3, 'pending', 300);
UPDATE orders SET status = 'shipped' WHERE id = 1;
DELETE FROM orders WHERE id = 3;
3. Проверить V3-поведение
Проверка Deletion Vector:
SELECT content, file_path, record_count
FROM orders$files;
Ожидаем .puffin DV рядом с data file, а не россыпь parquet delete files.
Проверка Row Lineage (скрытые колонки по умолчанию не видны):
SET show_hidden_columns = true;
SELECT id, status, _row_id, _last_updated_sequence_number
FROM orders;
4. Попробовать MERGE INTO
MERGE INTO orders t
USING (SELECT 1 AS id, 'delivered' AS status, 110 AS amount) s
ON t.id = s.id
WHEN MATCHED THEN UPDATE SET status = s.status, amount = s.amount
WHEN NOT MATCHED THEN INSERT (id, status, amount)
VALUES (s.id, s.status, s.amount);
Напоминание: MERGE INTO experimental. Это хороший кандидат для POC, но не для blind rollout в production.
Если orders$files показывает один Puffin file рядом с data file, а выжившие строки несут корректные значения _row_id / _last_updated_sequence_number — ваш стек V3-ready end to end.
Полный справочник по конфигурации: Doris Iceberg catalog docs.
Часть 9: Common gotchas
Короткий список того, обо что легко удариться:
format-version = 3нужно задать при создании таблицы или мигрировать существующую V2-таблицу черезALTER TABLE ... SET PROPERTIES ('format-version' = '3').Hidden columns (
_row_id,_last_updated_sequence_number) работают только на V3. На V1/V2 таблицах запрос к ним даст ошибку.Если
MERGE INTOне парсится, проверьте Doris 4.1.0+ и target table V2/V3.DML требует
format-version >= 2; Deletion Vectors появляются только на V3.Concurrent writers могут ловить optimistic concurrency conflicts. Для high-conflict workloads нужны retry, backoff или сериализация.
REST Catalog behavior зависит от конкретной реализации. Для production лучше свериться с support matrix в Doris docs.
AWS Glue и Aliyun DLF в оригинале указаны как read-only на момент статьи.
Часть 10: Где граница Doris, Spark и Flink
Самая опасная версия этой статьи звучала бы так: «Теперь Doris заменяет Spark для Iceberg».
Это плохая версия. Не надо так.
Более честная матрица:
Инструмент | Где уместен |
|---|---|
Doris | real-time queries, small DML, incremental reconciliation, row-level provenance, day-to-day maintenance, диагностика Iceberg tables из SQL |
Spark | heavy backfills, cross-source ETL, long-running batch jobs |
Flink | streaming ingestion, continuous CDC write paths |
Doris 4.1 не заменяет Spark. Он сокращает число случаев, когда Spark приходится запускать ради мелкой операции вокруг проблемы, которую вы уже нашли.
Если запрос уже привёл вас в SQL-клиент — маленькое исправление не должно превращаться в distributed-computing ритуал с отдельным кластером.
Итог
Одна фраза хорошо описывает направление Doris на Iceberg:
Если запрос уже привёл вас в SQL-клиент, маленькое исправление, incremental reconciliation или day-to-day maintenance не должны заставлять уходить в другой движок.
Что меняется в Apache Doris 4.1:
UPDATE,DELETE,MERGE INTOзакрывают loop «query → fix → verify» внутри одного SQL-клиента.Deletion Vectors в Iceberg V3 не дают DML накапливать performance debt через растущий набор Position Delete files.
Row Lineage даёт
_row_idи_last_updated_sequence_number, чтобы CDC и audit не ломались от compaction и physical rewrite.Maintenance и диагностика становятся ближе к человеку, который уже смотрит на данные.
Что не меняется:
Spark остаётся для тяжёлых batch/backfill/ETL задач.
Flink остаётся для streaming ingestion.
MERGE INTOи Row Lineage в оригинале помечены как experimental.Production adoption требует POC на своём catalog, storage, concurrency pattern и workload.
Если коротко: Iceberg V3 делает DML архитектурно более здоровым, а Doris 4.1 пытается сделать этот DML доступным из того же SQL-движка, где вы уже нашли проблему.
Для data engineer’а это не звучит как революция. Это звучит как минус один ticket, минус один job, минус один context switch.
А иногда именно это и есть настоящая продуктивность.
Ссылки
Оригинальная статья Mingyu Chen (Rayner) — CC BY 4.0
Datanomix.pro Партнер Apache Doris / VeloDB в Казахстане и Узбекистане
Оригинал по лицензии CC BY 4.0 © 2026 Mingyu Chen (Rayner).

























