Learning Platform
Глоссарий Troubleshooting
Урок 04.02 · 25 мин
Средний
ReplacingMergeTreeДедупликацияFINALMergever

ReplacingMergeTree: дедупликация при merge, НЕ при INSERT

ReplacingMergeTree — самый неправильно понимаемый движок в ClickHouse. Многие думают, что INSERT в ReplacingMergeTree работает как UPSERT в PostgreSQL: “вставь, а если ключ уже есть — замени”. Это не так.


Заблуждение: INSERT заменяет строки

Из официальной документации ClickHouse:

Data deduplication occurs only during a merge. Merging occurs in the background at an unknown time, so you can’t plan for it. Some of the data may remain unprocessed.

Дедупликация происходит только при фоновом merge. Между INSERT и merge проходит неопределённое время. В течение этого времени все версии строки видны в обычном SELECT.


Конкретный пример: две версии одной строки

CREATE TABLE users (
    id UInt32,
    name String,
    updated_at UInt32
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id;

-- Первая версия
INSERT INTO users VALUES (1, 'Alice', 100);
-- Вторая версия (тот же id, другое имя, больший updated_at)
INSERT INTO users VALUES (1, 'Alice Updated', 200);

Сразу после двух INSERT:

-- БЕЗ FINAL: возвращает ОБЕ строки!
SELECT * FROM users;
-- id=1, name='Alice',         updated_at=100
-- id=1, name='Alice Updated', updated_at=200

-- С FINAL: возвращает одну строку (query-time дедупликация)
SELECT * FROM users FINAL;
-- id=1, name='Alice Updated', updated_at=200

Две строки с одинаковым ORDER BY ключом (id=1) сосуществуют в таблице до merge. Это не баг — это by design. ReplacingMergeTree гарантирует eventual дедупликацию, не immediate.

ReplacingMergeTree: timeline дедупликации
INSERT #1 -> part_1INSERT #1: создаёт part_1 с записью (id=1, name='Alice', ver=100). Part записывается на диск как immutable директория. Никаких проверок на дубликаты при INSERT.
INSERT #2 -> part_2INSERT #2: создаёт part_2 с записью (id=1, name='Alice Updated', ver=200). Отдельный immutable part. ClickHouse НЕ проверяет, существует ли уже запись с id=1 в part_1. Это append-only семантика MergeTree.
SELECT: 2 строки!SELECT без FINAL: ClickHouse читает оба part и возвращает все строки. Результат: 2 строки с id=1. Это ожидаемое поведение -- дедупликация ещё не произошла.
MERGE -> 1 строкаMERGE (фоновый или OPTIMIZE TABLE FINAL): ClickHouse объединяет part_1 и part_2. Для записей с одинаковым ORDER BY ключом оставляет строку с максимальным ver (updated_at=200). Результат: один part с одной строкой.

Правила дедупликации

Без версионного столбца

ENGINE = ReplacingMergeTree()
ORDER BY id

При merge из строк с одинаковым ORDER BY ключом остаётся последняя по порядку вставки. Порядок определяется позицией в part, не временем INSERT. Это ненадёжно — используйте версионный столбец.

С версионным столбцом (рекомендуется)

ENGINE = ReplacingMergeTree(updated_at)
ORDER BY id

При merge остаётся строка с максимальным значением версионного столбца. Тип столбца: UInt*, Date, DateTime. Это детерминированно и предсказуемо.

is_deleted с версионным столбцом (soft delete)

ENGINE = ReplacingMergeTree(updated_at, is_deleted)
ORDER BY id
-- Требует: SET allow_experimental_replacing_merge_with_cleanup = 1

Столбец is_deleted UInt8: значение 1 означает “удалена”. При merge строка с is_deleted=1 и максимальным ver удаляет все версии этого ключа. Экспериментальная функция — требует явного разрешения.

SCD Type 2: полная история изменений через add-row

FINAL: query-time дедупликация

Модификатор FINAL заставляет ClickHouse выполнить дедупликацию на лету при чтении:

-- Гарантированно корректный результат
SELECT * FROM users FINAL WHERE id = 42;

FINAL сканирует все parts, содержащие записи с совпадающим ORDER BY ключом, и применяет логику дедупликации. Результат всегда корректен, но есть цена: FINAL отключает некоторые оптимизации и может быть медленнее на больших таблицах.

Когда FINAL допустим

  • Небольшие таблицы (миллионы строк, не миллиарды)
  • Ad-hoc запросы где корректность важнее скорости
  • Критичные по корректности reads — финансовые данные, состояние заказа

Когда избегать FINAL

  • High-throughput аналитика на больших таблицах — FINAL добавляет overhead на каждый запрос
  • Дашборды с частым обновлением — лучше периодический OPTIMIZE TABLE FINAL + обычный SELECT
DANGER

Не используйте ReplacingMergeTree как замену PostgreSQL UPSERT. Дедупликация — eventual, не immediate. Между INSERT и merge может пройти минуты или часы. Если приложение требует мгновенной консистентности — используйте FINAL или пересмотрите архитектуру.


OPTIMIZE TABLE FINAL: физическая дедупликация

-- Принудительно объединить все parts (дедупликация произойдёт физически)
OPTIMIZE TABLE users FINAL;

После OPTIMIZE обычный SELECT (без FINAL) вернёт корректный дедуплицированный результат — потому что все parts уже объединены.

WARNING

OPTIMIZE TABLE FINAL блокирует reads на время merge. На production — расписание на off-peak часы. На таблице с миллиардами строк OPTIMIZE FINAL может занять часы и создать колоссальную нагрузку на I/O.


Паттерн production-использования

-- 1. Создание таблицы
CREATE TABLE orders (
    order_id UInt64,
    status String,
    total Decimal64(2),
    updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY order_id;

-- 2. Вставка обновлений (каждое изменение статуса -- новый INSERT)
INSERT INTO orders VALUES (1001, 'created', 99.99, '2024-01-15 10:00:00');
INSERT INTO orders VALUES (1001, 'paid',    99.99, '2024-01-15 10:05:00');
INSERT INTO orders VALUES (1001, 'shipped', 99.99, '2024-01-15 14:00:00');

-- 3. Корректное чтение (всегда с FINAL для актуального статуса)
SELECT order_id, status, total
FROM orders FINAL
WHERE order_id = 1001;
-- Результат: order_id=1001, status='shipped', total=99.99

-- 4. Для аналитики -- периодический OPTIMIZE + обычный SELECT
-- Cron: 0 3 * * * clickhouse-client --query="OPTIMIZE TABLE orders FINAL"

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

  1. Дедупликация происходит при merge, НЕ при INSERT. Сразу после INSERT все версии строки видны в SELECT. Это не баг — это by design.
  2. Используйте версионный столбец (ReplacingMergeTree(ver)) — без него порядок дедупликации ненадёжен.
  3. FINAL — query-time дедупликация. Гарантирует корректность, но добавляет overhead.
  4. OPTIMIZE TABLE FINAL — физическая дедупликация. Дорого, но после неё обычный SELECT возвращает корректный результат.
  5. ReplacingMergeTree не заменяет UPSERT. Если нужна мгновенная консистентность — используйте FINAL или выбирайте другую архитектуру.

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

Результат: 0 из 0
Аналитический
Вопрос 1 из 4. Инженер INSERT двух строк с одинаковым ORDER BY ключом в ReplacingMergeTree. Сразу после INSERT выполняет SELECT count() и получает 2. Это баг?

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

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

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

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