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.
Правила дедупликации
Без версионного столбца
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 удаляет все версии этого ключа. Экспериментальная функция — требует явного разрешения.
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
Не используйте ReplacingMergeTree как замену PostgreSQL UPSERT. Дедупликация — eventual, не immediate. Между INSERT и merge может пройти минуты или часы. Если приложение требует мгновенной консистентности — используйте FINAL или пересмотрите архитектуру.
OPTIMIZE TABLE FINAL: физическая дедупликация
-- Принудительно объединить все parts (дедупликация произойдёт физически)
OPTIMIZE TABLE users FINAL;
После OPTIMIZE обычный SELECT (без FINAL) вернёт корректный дедуплицированный результат — потому что все parts уже объединены.
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"
Ключевые выводы
- Дедупликация происходит при merge, НЕ при INSERT. Сразу после INSERT все версии строки видны в SELECT. Это не баг — это by design.
- Используйте версионный столбец (
ReplacingMergeTree(ver)) — без него порядок дедупликации ненадёжен. - FINAL — query-time дедупликация. Гарантирует корректность, но добавляет overhead.
- OPTIMIZE TABLE FINAL — физическая дедупликация. Дорого, но после неё обычный SELECT возвращает корректный результат.
- ReplacingMergeTree не заменяет UPSERT. Если нужна мгновенная консистентность — используйте FINAL или выбирайте другую архитектуру.