История повторяется в десятках команд, впервые берущих ClickHouse под аналитику событий. Выбирают движок ReplacingMergeTree, ведь само название обещает заменять дубликаты. Грузят события, открывают дашборд и видят счётчики, завышенные на тридцать процентов, причём числа меняются в течение дня без всякой логики. Кто-то бежит винить качество исходных данных, кто-то конвейер. А виновата неверная трактовка одного слова в названии движка. Он действительно заменяет дубликаты. Просто не тогда, когда этого ждёшь. Разберём, как устроена эта отложенная дедупликация и как построить схему, чтобы цифры на дашборде были честными.
Почему append-only архитектура требует особого движка
ClickHouse устроен принципиально иначе, чем привычные реляционные базы. Он не навязывает ограничений уникальности при вставке и вообще оптимизирован под добавление данных, а не под их изменение на месте. Вставка новой строки с тем же ключом, что у существующей, в обычном движке семейства MergeTree просто создаёт дубликат: обе строки спокойно сосуществуют. Для аналитики событий, где одно и то же событие может прилететь повторно из-за повторной обработки, поздних дозагрузок, исправлений или повторов при сбоях, это превращается в проблему.
ReplacingMergeTree создан как ответ на эту боль. Он приносит в добавочную архитектуру ClickHouse семантику обновления через замену: при слиянии частей данных он находит строки с одинаковым ключом сортировки и оставляет только одну, ту, у которой выше версия, либо последнюю вставленную. Тот, кто приходит из мира реляционных баз, должен кое-что забыть. Привычная вставка с разрешением конфликта дедуплицирует мгновенно: запросил и всегда получил одну строку. ReplacingMergeTree так не умеет.
В чём суть отложенной дедупликации и откуда берутся раздутые числа
Ключевое слово во всём этом механизме это слияние. Дедупликация происходит не при вставке, а только когда фоновый процесс слияния объединяет части данных на диске. А запускает он это слияние по собственному расписанию, исходя из множества эвристик: числа частей, соотношения их размеров, доступных ресурсов, настроенных порогов. Часть данных может слиться через секунды после создания, а может пролежать неслитой часами.
Отсюда и вся беда. ClickHouse хранит данные во множестве отдельных частей на диске, и пока эти части не слились, дубликаты живут бок о бок. Если запросить количество событий сразу после вставки обновлений, получишь раздутое число. Если задать тот же запрос через час, когда слияния отработали, число может стать точным. Эта несогласованность это особенность по замыслу, а не ошибка. Представьте дашборд, показывающий активных пользователей за последний час. Если конвейер переобрабатывает события, каждая переобработка плодит новые части с дублирующимися событиями пользователей, и счётчик завышается ровно до следующего слияния.
В одном реальном случае на конвейере пользовательских событий контроль качества отрапортовал, что счётчики на тридцать процентов выше ожидаемого. В другом доля дубликатов в сырых данных доходила до двадцати процентов, что при миллионах транзакций в день означало серьёзные искажения в аналитике и неверные бизнес-решения. Ответ "данные станут верными когда-нибудь потом" плохо звучит на вопрос директора о дневной выручке.
Как правильно описать таблицу событий
Грамотная схема начинается с двух решений: ключа сортировки и колонки версии. Ключ сортировки в ReplacingMergeTree имеет особый смысл, отличный от обычного движка. Это не просто порядок хранения, а определение того самого "со временем уникального" ключа, по которому движок и распознаёт дубликаты.
CREATE TABLE events
(
event_id UInt64,
user_id UInt64,
event_type String,
properties String,
occurred_at DateTime64(3),
version UInt64,
is_deleted UInt8 DEFAULT 0
)
ENGINE = ReplacingMergeTree(version, is_deleted)
ORDER BY (event_id)
PARTITION BY toYYYYMM(occurred_at);
Колонка версии здесь критически важна, и пренебрегать ею нельзя. Без неё при слиянии побеждает последняя вставленная строка, а какая именно строка окажется последней, не определено. С колонкой версии всё детерминированно: выживает строка с наибольшим значением версии, а при равенстве уже последняя вставленная. В роли версии обычно выступает монотонно растущее число или временная метка обновления. Реальные команды специально дорабатывают таблицы, добавляя колонку версии, именно потому что без неё дедупликация через специальный оператор опирается на порядок вставки, а он недетерминирован, и нельзя надёжно применять шаблон выборки по максимуму версии.
Колонка пометки удаления, доступная начиная с версии 23.2, решает вопрос логических удалений. Помеченные ею строки при слиянии убираются, что позволяет удалять записи без отдельных тяжёлых операций. Это избавляет от старого костыля, когда удалённые строки приходилось отфильтровывать вручную или прятать через политику строк.
Зачем нужен оператор FINAL и чем он обходится
Раз фоновое слияние гарантирует только итоговую корректность и не обещает, что строки дедуплицированы прямо сейчас, для честного ответа нужен способ дедупликации на лету в момент запроса. Эту роль играет оператор FINAL. Он заставляет ClickHouse провести дедупликацию при чтении, гарантируя, что вернётся только последняя версия каждой строки.
-- Может вернуть дубликаты, если слияние ещё не прошло
SELECT count() FROM events WHERE user_id = 12345;
-- Честный результат с принудительной дедупликацией
SELECT count() FROM events FINAL WHERE user_id = 12345;
Но за честность приходится платить производительностью. Оператор FINAL несёт накладные расходы, особенно заметные, когда запрос не фильтрует по колонкам ключа. Если в условии фильтрации используются колонки ключа сортировки, объём данных, поднимаемых для дедупликации, сокращается, и накладные расходы падают. Если же фильтр идёт по неключевой колонке, ClickHouse читает заметно больше данных.
Здесь скрывается тонкий подвох. При активном FINAL автоматическая оптимизация предварительной фильтрации, которая обычно отсеивает строки до чтения всех колонок, отключается по умолчанию. Соблазнительно дописать явную предварительную фильтрацию вручную, но это ловушка: она отсеет строки до того, как FINAL применит дедупликацию, и может выкинуть как раз ту строку-замену, вернув устаревшую версию. Правильный обход, если это узкое место, состоит в перестройке запроса через подзапрос: дедуплицировать с FINAL во внутреннем запросе, а фильтровать уже во внешнем.
Какие альтернативы FINAL стоит держать в арсенале
FINAL не единственный путь. Для больших таблиц и тяжёлых агрегатов часто выгоднее шаблон выборки значения из строки с максимальной версией через специальную агрегатную функцию. Она группирует записи по ключу и достаёт значения колонок из той строки, где версия наибольшая, фактически собирая последнюю версию записи вручную.
SELECT
event_id,
argMax(event_type, version) AS event_type,
argMax(properties, version) AS properties,
max(version) AS version
FROM events
WHERE user_id = 12345
GROUP BY event_id;
Этот подход даёт больше контроля и на агрегирующих запросах нередко обгоняет FINAL. Расплата в многословности и в том, что запрос приходится писать аккуратнее. Ещё один инструмент это принудительное слияние через явную команду оптимизации с FINAL, которое материализует дедупликацию физически и снижает накладные расходы последующих чтений. Но это дорогая операция, и гонять её на каждый чих нельзя, она хороша как периодическое уплотнение, а не как ответ на каждый запрос.
-- Дорого, но разово материализует дедупликацию
OPTIMIZE TABLE events FINAL;
Почему выбор ключа партиционирования решает половину проблем
Отдельного разговора заслуживает партиционирование, потому что именно с ним связана коварная ловушка. По умолчанию FINAL согласует дубликаты между партициями, и это дорого. Есть настройка, отключающая слияние между партициями при чтении с FINAL, и она заметно ускоряет запросы. Но работает она корректно только при одном условии: один и тот же ключ сортировки не должен появляться более чем в одной партиции.
Отсюда вытекает важнейший принцип проектирования. Ключ партиционирования нужно выбирать так, чтобы записи с одним ключом сортировки никогда не растекались по разным партициям. Для изменяемых записей ключ партиционирования вдобавок должен быть стабильным: если значение, по которому строка попадает в партицию, меняется при обновлении, обновлённая версия уедет в другую партицию, и дедупликация просто не сработает, оставив две живые версии в разных местах. Это типовая причина межпартиционных дубликатов, которые потом мучительно отлавливают.
Отследить, не накопилось ли слишком много неслитых частей, помогает запрос к служебной таблице частей. Если по какой-то партиции частей подозрительно много, значит слияния отстают и дубликатов в моменте больше обычного.
SELECT database, table, partition, count() AS parts
FROM system.parts
WHERE active
GROUP BY database, table, partition
HAVING parts > 10
ORDER BY parts DESC;
Какой стратегии придерживаться в продакшене
Главный вывод прост: ReplacingMergeTree не волшебная палочка, а инструмент с понятными правилами. Он годится туда, где немедленная согласованность не нужна и данные постоянно перечитываются после того, как слияния отработали. Для случаев, где число обязано быть точным прямо сейчас, вроде учёта складских остатков или дневной выручки в реальном времени, на одном фоновом слиянии полагаться нельзя.
Рабочая стратегия складывается из нескольких решений. Колонка версии обязательна всегда, без неё поведение непредсказуемо. Логические удаления стоит вести через колонку пометки удаления вместо тяжёлых операций изменения. Для чтения, требующего точности, применяют FINAL, по возможности фильтруя по ключевым колонкам, а на тяжёлых агрегатах переключаются на шаблон выборки по максимальной версии. Ключ партиционирования выбирают так, чтобы один ключ сортировки жил в единственной партиции, и держат его стабильным для изменяемых записей. Периодическое принудительное уплотнение помогает читающим нагрузкам, но не заменяет дедупликацию на лету.
Дубликаты в ClickHouse перестают пугать ровно в тот момент, когда перестаёшь ждать от движка мгновенной магии и начинаешь понимать, что слово "заменяющий" в его названии описывает процесс, растянутый во времени. Честная схема с версией, осознанный выбор между FINAL и выборкой по максимуму, грамотное партиционирование и наблюдение за числом частей превращают капризные раздутые счётчики в стабильные и достоверные цифры, на которые не стыдно опереться при разговоре с руководством.