Table Types и Merge Engines
В Уроке 01 мы разобрали LSM-дерево — фундаментальную структуру Paimon. Но LSM-дерево само по себе не отвечает на вопрос: что происходит, когда в таблице появляются две записи с одинаковым primary key? Ответ определяется двумя вещами: типом таблицы и merge engine’ом.
Paimon разделяет таблицы на два фундаментально разных типа, а для primary key таблиц предлагает 5 merge engine’ов — каждый определяет свою семантику объединения записей.
Два типа таблиц
Primary Key Table
Primary Key Table: определяет primary key при CREATE TABLE. LSM-дерево используется для merge записей с одинаковым PK. Поддерживает upsert, delete, partial update. Основной тип для OLTP-подобных workload'ов.Append-Only Table
Append-Only Table: НЕ определяет primary key. Все записи — immutable, append-only. Нет upsert, нет delete (по PK). Работает как Delta Lake / Iceberg в простейшем режиме. Оптимально для event log, fact tables.-- 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'
);
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 | Колонка, определяющая порядок версий. Запись с бо́льшим значением побеждает при merge |
Без sequence.field | Используется _SEQUENCE_NUMBER — порядок записи в таблицу |
Всегда указывайте 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):
Когда использовать: CDC из RDBMS (Debezium), полные snapshot-обновления, любой pipeline где каждое обновление содержит полную строку.
Ограничение: если обновление содержит только изменённые колонки (partial update), deduplicate перезапишет всю строку — неизменённые колонки получат значения из последнего батча, даже если это NULL.
Merge Engine: partial-update
partial-update — merge engine для колоночных обновлений. Объединяет записи по колонкам: берёт последнее не-NULL значение каждой колонки:
Когда использовать:
- Несколько микросервисов обновляют разные колонки одной сущности
- Wide table с десятками колонок, где каждое событие обновляет 2-3 колонки
- Real-time feature store: разные pipeline’ы вычисляют разные фичи одного пользователя
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. Каждая колонка может иметь свою функцию агрегации:
Поддерживаемые функции агрегации:
| Функция | Описание | Типы |
|---|---|---|
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'
);
Aggregate engine идеально подходит для pre-aggregation в стриминге: вместо сырых событий таблица хранит уже агрегированные метрики. Это аналог материализованного представления, но внутри storage layer — без дополнительного вычислительного движка.
Merge Engine: first-row
first-row — merge engine, который оставляет первую запись с данным PK и игнорирует все последующие:
Когда использовать:
- Дедупликация стриминговых событий (at-least-once delivery из Kafka)
- Таблица первых касаний (first-touch attribution)
- Идемпотентный append: каждый event_id записывается ровно один раз
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-логирование (каждое изменение — отдельная запись)
Если PK дублируется и merge engine = none, при batch-чтении вернутся все версии. Это не баг — это design. Для changelog-producer (Урок 03) merge engine ‘none’ используется как промежуточный шаг.
Сводная таблица merge engine’ов
Delete семантика
Удаление в Paimon зависит от типа таблицы:
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 как обычная запись.Append-Only таблицы (Deletion Vectors)
Append-only таблицы не имеют PK, поэтому delete-маркеры не работают. Начиная с Paimon 1.0, поддерживаются deletion vectors:
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.Merge при чтении vs при компакции
Ключевой аспект: когда происходит merge?
Merge-on-Read
Merge-on-Read: при каждом SELECT все sorted runs merge'атся на лету. Reader открывает все sorted runs с пересекающимися key ranges и выполняет merge-sort. Результат — корректные данные, но overhead на чтение.Merge-on-Compaction
Merge-on-Compaction: merge выполняется при компакции (фоновый процесс). После компакции sorted runs объединены — чтение не требует merge. Но до компакции — данные могут содержать дубликаты.Paimon всегда выполняет merge-on-read для корректности. Компакция — это оптимизация, сокращающая количество sorted runs и ускоряющая merge при чтении.
Ключевые выводы
- Два типа таблиц: primary key (LSM + merge engine) и append-only (Parquet + deletion vectors)
- 5 merge engine’ов: deduplicate (default, full-row), partial-update (column-level), aggregate (SUM/MAX/MIN), first-row (idempotent), none (все версии)
- Sequence field — критичен для out-of-order events. Без него — merge по internal _SEQUENCE_NUMBER, который отражает порядок записи, не бизнес-порядок
- Delete: PK-таблицы используют delete-маркеры (_VALUE_KIND=-D), append-only — deletion vectors (bitmap)
- Merge-on-Read — Paimon всегда merge-ит при чтении для корректности; компакция — оптимизация
- Выбор merge engine определяет семантику таблицы — это архитектурное решение, а не tuning parameter