Learning Platform
Глоссарий Troubleshooting
Урок 15.02 · 40 мин
Продвинутый
Apache PaimonPrimary Key TableAppend-Only TableMerge EngineDeduplicatePartial UpdateAggregateFirst RowSequence FieldDelete Semantics

Table Types и Merge Engines

В Уроке 01 мы разобрали LSM-дерево — фундаментальную структуру Paimon. Но LSM-дерево само по себе не отвечает на вопрос: что происходит, когда в таблице появляются две записи с одинаковым primary key? Ответ определяется двумя вещами: типом таблицы и merge engine’ом.

Paimon разделяет таблицы на два фундаментально разных типа, а для primary key таблиц предлагает 5 merge engine’ов — каждый определяет свою семантику объединения записей.

Два типа таблиц

Primary Key Table vs Append-Only Table

Primary Key Table

Primary Key Table: определяет primary key при CREATE TABLE. LSM-дерево используется для merge записей с одинаковым PK. Поддерживает upsert, delete, partial update. Основной тип для OLTP-подобных workload'ов.
ХранениеДанные организованы как LSM-дерево внутри каждого bucket'а. Записи с одинаковым PK могут существовать в разных sorted runs — merge engine определяет, как их объединить при чтении или компакции.
ЗаписьINSERT: добавляет запись. Если PK уже существует — поведение зависит от merge engine. UPDATE: записывает новую версию с тем же PK. DELETE: записывает delete-маркер (_VALUE_KIND = -D). Все операции — append в MemTable.

Append-Only Table

Append-Only Table: НЕ определяет primary key. Все записи — immutable, append-only. Нет upsert, нет delete (по PK). Работает как Delta Lake / Iceberg в простейшем режиме. Оптимально для event log, fact tables.
ХранениеДанные хранятся как обычные Parquet файлы без LSM-дерева. Нет sorted runs, нет merge. Каждая запись — финальная. Компакция возможна только для объединения мелких файлов (bin-packing).
ЗаписьТолько INSERT. Нет механизма определить 'ту же запись' — нет PK. Delete возможен через deletion vectors (Paimon 1.0+): отмечает строку как удалённую без перезаписи файла. Но это delete по row position, не по PK.
-- Primary Key Table
CREATE TABLE orders (
 order_id BIGINT,
 user_id BIGINT,
 amount DECIMAL(10, 2),
 status STRING,
 updated_at TIMESTAMP,
 PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
 'merge-engine' = 'deduplicate',
 'sequence.field' = 'updated_at'
);

-- Append-Only Table
CREATE TABLE events (
 event_id STRING,
 user_id BIGINT,
 event_type STRING,
 payload STRING,
 event_time TIMESTAMP
) WITH (
 'bucket' = '4'
);
WARNING

NOT ENFORCED в PRIMARY KEY — обязательный синтаксис. Paimon не проверяет уникальность PK при записи (это невозможно эффективно в стриминге). Вместо этого, merge engine разрешает дубликаты при чтении или компакции. Если в одном batch окажутся две записи с одинаковым PK, обе попадут в MemTable, и merge engine определит результат.

Sequence Field

Перед разбором merge engine’ов — критичная концепция: sequence field.

Когда две записи с одинаковым PK оказываются в разных sorted runs, как определить, какая из них новее? По умолчанию Paimon использует _SEQUENCE_NUMBER — монотонно растущий счётчик, присваиваемый при записи. Но в реальных pipeline’ах порядок записи может не совпадать с бизнес-порядком (out-of-order events).

Sequence Field: определение порядка версий
Sorted Run AСодержит запись: order_id=100, amount=50, updated_at=10:00, _SEQ=5. Это более ранняя запись по бизнес-времени, но записана позже (higher _SEQ).
Sorted Run BСодержит запись: order_id=100, amount=75, updated_at=10:05, _SEQ=3. Более новая по бизнес-времени, но записана раньше (lower _SEQ). Без sequence.field — merge engine выберет A (higher _SEQ). С sequence.field=updated_at — выберет B (newer timestamp).
merge (deduplicate engine)
Без sequence.fieldMerge по _SEQUENCE_NUMBER: запись A побеждает (_SEQ=5 > 3). Результат: amount=50. Это НЕПРАВИЛЬНЫЙ результат — более свежее обновление (amount=75) потеряно из-за out-of-order записи.
С sequence.field=updated_atMerge по updated_at: запись B побеждает (10:05 > 10:00). Результат: amount=75. ПРАВИЛЬНЫЙ результат — sequence field отражает бизнес-порядок событий, а не порядок записи в систему.
ПараметрОписание
sequence.fieldКолонка, определяющая порядок версий. Запись с бо́льшим значением побеждает при merge
Без sequence.fieldИспользуется _SEQUENCE_NUMBER — порядок записи в таблицу
TIP

Всегда указывайте sequence.field для primary key таблиц в стриминговых pipeline’ах. Без него out-of-order events (задержки в Kafka, retry в Flink) могут привести к потере более свежих обновлений. Идеальный кандидат — колонка updated_at или event_time.

Merge Engine: deduplicate

deduplicate — merge engine по умолчанию. При наличии нескольких записей с одинаковым PK оставляет последнюю (по sequence field):

Deduplicate: последняя запись побеждает
v1Первая версия: order_id=100, status=created, amount=50. Самая старая запись по sequence field. При merge — будет вытеснена более новыми версиями.
v2Вторая версия: order_id=100, status=paid, amount=50. Обновлён status. При merge v1 → v2: v2 полностью заменяет v1 (все колонки перезаписываются).
v3Третья версия: order_id=100, status=shipped, amount=55. Последняя по sequence field. Deduplicate engine выберет эту запись целиком. Предыдущие версии (v1, v2) удаляются при компакции.
deduplicate merge
РезультатТолько v3 остаётся. Deduplicate — это full-row replacement: вся строка заменяется последней версией. Если v3 не содержит какую-то колонку — значение из v1/v2 теряется. Нет column-level merge.

Когда использовать: CDC из RDBMS (Debezium), полные snapshot-обновления, любой pipeline где каждое обновление содержит полную строку.

Ограничение: если обновление содержит только изменённые колонки (partial update), deduplicate перезапишет всю строку — неизменённые колонки получат значения из последнего батча, даже если это NULL.

Merge Engine: partial-update

partial-update — merge engine для колоночных обновлений. Объединяет записи по колонкам: берёт последнее не-NULL значение каждой колонки:

Partial-Update: последнее не-NULL значение каждой колонки
Источник 1Первый источник обновляет name и email: order_id=100, name=Alice, [email protected]. Остальные колонки — NULL (не обновлены этим источником).
Источник 2Второй источник обновляет phone и status: order_id=100, phone=+1234, status=active. name и email — NULL (этот источник их не знает).
partial-update merge
РезультатMerge по колонкам: name=Alice (из источника 1, не-NULL), [email protected] (из источника 1), phone=+1234 (из источника 2), status=active (из источника 2). Каждая колонка берётся из последнего обновления, где она не-NULL.

Когда использовать:

  • Несколько микросервисов обновляют разные колонки одной сущности
  • Wide table с десятками колонок, где каждое событие обновляет 2-3 колонки
  • Real-time feature store: разные pipeline’ы вычисляют разные фичи одного пользователя
WARNING

partial-update не может принять delete-записи (_VALUE_KIND = -D). Если в потоке есть delete — используйте 'partial-update.remove-record-on-delete' = 'true' или переключитесь на deduplicate. Без этого параметра delete-запись вызовет ошибку при merge.

CREATE TABLE user_features (
 user_id BIGINT,
 -- Источник 1: профиль
 name STRING,
 email STRING,
 -- Источник 2: поведение
 last_login TIMESTAMP,
 session_count INT,
 -- Источник 3: платежи
 total_spent DECIMAL(10, 2),
 plan STRING,
 PRIMARY KEY (user_id) NOT ENFORCED
) WITH (
 'merge-engine' = 'partial-update',
 'sequence.field' = 'last_login'
);

Merge Engine: aggregate

aggregate — merge engine, который применяет агрегирующие функции к колонкам при merge. Каждая колонка может иметь свою функцию агрегации:

Aggregate: функции агрегации по колонкам
Batch 1Первая запись: product_id=A, view_count=10, revenue=100, last_seen=10:00. При aggregate merge — view_count суммируется, revenue суммируется, last_seen берётся максимальное значение.
Batch 2Вторая запись: product_id=A, view_count=5, revenue=50, last_seen=11:00. Merge: views = 10+5 = 15, revenue = 100+50 = 150, last_seen = max(10:00, 11:00) = 11:00.
Batch 3Третья запись: product_id=A, view_count=3, revenue=25, last_seen=10:30. Merge: views = 15+3 = 18, revenue = 150+25 = 175, last_seen = max(11:00, 10:30) = 11:00.
aggregate merge
РезультатАгрегированный результат: views = SUM(10, 5, 3) = 18, revenue = SUM(100, 50, 25) = 175, last_seen = MAX(10:00, 11:00, 10:30) = 11:00. Каждая колонка — своя функция агрегации.

Поддерживаемые функции агрегации:

ФункцияОписаниеТипы
sumСумма значенийчисловые
min / maxМинимум / максимумчисловые, timestamp, string
last_non_null_valueПоследнее не-NULL значениелюбой
last_valueПоследнее значение (включая NULL)любой
first_value / first_non_null_valueПервое значениелюбой
listaggКонкатенация строкstring
bool_or / bool_andЛогическое ИЛИ / Иboolean
collectСобирает в массивлюбой
merge_mapОбъединяет MAP значенияmap
CREATE TABLE product_metrics (
 product_id STRING,
 view_count BIGINT,
 revenue DECIMAL(10, 2),
 last_seen TIMESTAMP,
 categories STRING,
 PRIMARY KEY (product_id) NOT ENFORCED
) WITH (
 'merge-engine' = 'aggregate',
 'fields.view_count.aggregate-function' = 'sum',
 'fields.revenue.aggregate-function' = 'sum',
 'fields.last_seen.aggregate-function' = 'max',
 'fields.categories.aggregate-function' = 'listagg'
);
NOTE

Aggregate engine идеально подходит для pre-aggregation в стриминге: вместо сырых событий таблица хранит уже агрегированные метрики. Это аналог материализованного представления, но внутри storage layer — без дополнительного вычислительного движка.

Merge Engine: first-row

first-row — merge engine, который оставляет первую запись с данным PK и игнорирует все последующие:

First-Row: первая запись побеждает
v1 (первая)Первая запись с user_id=42: registration event. First-row engine зафиксирует эту запись навсегда. Все последующие записи с user_id=42 будут отброшены — и при merge, и при компакции.
v2 (игнорируется)Повторная запись с user_id=42: login event. First-row engine отбрасывает эту запись — PK уже существует. Даже если v2 содержит полезные данные — они не попадут в таблицу.
v3 (игнорируется)Ещё одна запись с user_id=42: purchase event. Также отброшена. First-row — строго idempotent дедупликация: только первое появление PK сохраняется.
first-row merge
РезультатТолько v1 сохранена. First-row engine оптимизирован: при компакции он может пропускать запись целиком, если PK уже существует на нижнем уровне LSM-дерева — это быстрее, чем deduplicate.

Когда использовать:

  • Дедупликация стриминговых событий (at-least-once delivery из Kafka)
  • Таблица первых касаний (first-touch attribution)
  • Идемпотентный append: каждый event_id записывается ровно один раз
TIP

first-row отличается от deduplicate с обратным порядком. First-row оптимизирован на уровне storage: он может раньше прервать merge, потому что точно знает — первая запись побеждает. Для таблиц с миллионами дубликатов (Kafka at-least-once) это существенная экономия CPU.

Merge Engine: none

none — специальный merge engine, который не выполняет merge. Все версии записи с одинаковым PK сохраняются:

CREATE TABLE audit_log (
 entity_id STRING,
 action STRING,
 actor STRING,
 timestamp TIMESTAMP,
 PRIMARY KEY (entity_id, timestamp) NOT ENFORCED
) WITH (
 'merge-engine' = 'none'
);

С merge-engine = 'none' Paimon ведёт себя как append-only таблица, но с primary key — это нужно для:

  • Чтение changelog-потока (все версии видны)
  • Audit-логирование (каждое изменение — отдельная запись)
WARNING

Если PK дублируется и merge engine = none, при batch-чтении вернутся все версии. Это не баг — это design. Для changelog-producer (Урок 03) merge engine ‘none’ используется как промежуточный шаг.

Сводная таблица merge engine’ов

5 Merge Engines: сравнение поведения
deduplicateПоследняя запись побеждает. Full-row replacement. Default engine. Лучший выбор для CDC из RDBMS (Debezium) и pipeline'ов с полными snapshot'ами.
partial-updateMerge по колонкам: последнее не-NULL значение каждой колонки. Для wide table с независимыми источниками. Осторожно с delete-записями.
aggregateАгрегирующие функции по колонкам: SUM, MAX, MIN, listagg, collect. Pre-aggregation в storage layer. Материализованные метрики без внешнего движка.
first-rowПервая запись побеждает, все последующие отбрасываются. Оптимизирован для дедупликации. Идеально для at-least-once стриминга и first-touch attribution.
noneВсе версии сохраняются. Нет merge. PK используется только для организации данных в LSM-дереве, не для дедупликации. Для changelog и audit logging.

Delete семантика

Удаление в Paimon зависит от типа таблицы:

Primary Key таблицы

Delete в Primary Key таблицах

DELETE FROM orders WHERE order_id = 100

DELETE FROM orders WHERE order_id = 100. Paimon записывает delete-маркер: _VALUE_KIND = -D, order_id=100. Маркер попадает в MemTable как обычная запись.
write delete marker
MemTable: {order_id=100, _VALUE_KIND=-D}Delete-маркер записывается в MemTable → flush → sorted run. Маркер содержит _VALUE_KIND = -D и primary key. Данные НЕ удаляются физически — маркер 'перекрывает' предыдущие версии при merge.
merge / compaction
При чтенииMerge engine видит delete-маркер (-D) и скрывает запись из результата. Для deduplicate: запись пропускается. Для aggregate: маркер может вычитать значения (retraction). Запись не видна пользователю.
При компакцииКомпакция физически удаляет запись И delete-маркер, если на нижних уровнях нет более старых версий. Освобождается дисковое пространство. До компакции — запись занимает место.

Append-Only таблицы (Deletion Vectors)

Append-only таблицы не имеют PK, поэтому delete-маркеры не работают. Начиная с Paimon 1.0, поддерживаются deletion vectors:

Deletion Vectors для Append-Only таблиц

DELETE FROM events WHERE event_type = ‘spam’

DELETE FROM events WHERE event_type = 'spam'. Paimon не может использовать PK-based delete (нет PK). Вместо этого — deletion vector: bitmap файл, отмечающий удалённые row positions внутри data file.
Data fileОригинальный Parquet файл не модифицируется. Строки остаются на месте физически. Deletion vector — отдельный файл рядом с data file.
Deletion VectorBitmap: отмечает row positions удалённых строк. Row 5, 12, 47 помечены как deleted. При чтении: reader пропускает эти позиции. Аналог deletion vectors в Delta Lake и position delete files в Iceberg.
чтение
РезультатReader читает data file и фильтрует строки по deletion vector. Строки 5, 12, 47 пропускаются. Компакция позже перезапишет data file без удалённых строк — и deletion vector удаляется.
NOTE

Deletion vectors в Paimon работают аналогично deletion vectors в Delta Lake (Модуль 11) и position delete files в Iceberg (Модуль 12). Все три формата решают одну задачу: delete без перезаписи файла.

Merge при чтении vs при компакции

Ключевой аспект: когда происходит merge?

Merge-on-Read vs Merge-on-Compaction

Merge-on-Read

Merge-on-Read: при каждом SELECT все sorted runs merge'атся на лету. Reader открывает все sorted runs с пересекающимися key ranges и выполняет merge-sort. Результат — корректные данные, но overhead на чтение.
ХарактеристикиДанные всегда корректны, но reader выполняет работу merge. Для point lookup: O(N) sorted runs. Для scan: merge-sort N потоков. Чем реже компакция — тем медленнее чтение.

Merge-on-Compaction

Merge-on-Compaction: merge выполняется при компакции (фоновый процесс). После компакции sorted runs объединены — чтение не требует merge. Но до компакции — данные могут содержать дубликаты.
ХарактеристикиЧтение быстрое после компакции (один sorted run). Но между компакциями: reader видит raw sorted runs без merge. Для changelog producer: нужен merge при чтении, чтобы генерировать корректные CDC-события.

Paimon всегда выполняет merge-on-read для корректности. Компакция — это оптимизация, сокращающая количество sorted runs и ускоряющая merge при чтении.

Ключевые выводы

  1. Два типа таблиц: primary key (LSM + merge engine) и append-only (Parquet + deletion vectors)
  2. 5 merge engine’ов: deduplicate (default, full-row), partial-update (column-level), aggregate (SUM/MAX/MIN), first-row (idempotent), none (все версии)
  3. Sequence field — критичен для out-of-order events. Без него — merge по internal _SEQUENCE_NUMBER, который отражает порядок записи, не бизнес-порядок
  4. Delete: PK-таблицы используют delete-маркеры (_VALUE_KIND=-D), append-only — deletion vectors (bitmap)
  5. Merge-on-Read — Paimon всегда merge-ит при чтении для корректности; компакция — оптимизация
  6. Выбор merge engine определяет семантику таблицы — это архитектурное решение, а не tuning parameter

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Paimon-таблица с merge-engine=partial-update получает записи от двух микросервисов. Сервис A обновляет колонки name и email (phone=NULL). Сервис B обновляет phone и status (name=NULL, email=NULL). Запись с PK=42 сначала приходит от A, затем от B. Какой будет результат merge?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6