Всем привет!

Я думаю, что многие использовали движок ReplacingMergeTree в ClickHouse, но при этом сталкивались с проблемой наличия нескольких версий документов, т.к. старые версии ClickHouse не удаляет моментально.

Согласно документации нет четкого расписания, по которому ClickHouse удаляет дубли, а оператор FINAL оказывает влияние на производительность запросов. Также есть возможность периодически удалять старые версии с помощью оператора OPTIMIZE TABLE ... FINAL, либо в настройках таблицы указать min_age_to_force_merge_seconds.

Однако хотелось решить проблему на уровне запросов к данным, чтобы избежать просадки производительности при принудительном выполнении фоновых процессов.

Как альтернатива рассматривался движок VersionedCollapsingMergeTree, но для применения этого движка требовалось дополнительно хранить состояние каждого документа, чтобы иметь возможность его в будущем отменить. Также большой размер массивов в столбцах влияют на производительность данного движка, а структура документа у нас была достаточно сложной (включала массивы и вложенные таблицы)

Я опишу несколько подходов, которые, возможно, будут вам полезны в работе с ReplacingMergeTree.
Для примеров используется таблица Document следующей структуры:

CREATE TABLE Document (
   Id UInt64,                         -- Идентификатор документа
   Version UInt32,                    -- Версия документа
   CreatedAt DateTime DEFAULT now(),  -- Дата и время создания
   IsDeleted UInt8 DEFAULT 0,         -- Флаг удаления
   Title String,                      -- Заголовок
   Content String                     -- Содержимое
 ) ENGINE = ReplacingMergeTree(Version, IsDeleted)
 ORDER BY (Id);

Агрегатные функции, возвращающие приблизительное значение

Допустим, что нужно посчитать количество уникальных документов. Если для бизнес-логики не требуется получить точное значение, то можно использовать функцию uniq, которая вернет приблизительное количество уникальных значений:

SELECT uniq(DocumentId) FROM Document

Погрешность по сравнению с uniqExact небольшая, а производительность существенно выше (и меньше используется памяти для выполнения запроса).

Оконные функции

Если точность выборки важна, то можно использовать оконные функции для того, чтобы вернуть актуальные записи:

SELECT * FROM (
 SELECT
   d.*,
   ROW_NUMBER() OVER (PARTITION BY Id ORDER BY Version DESC) AS RowNum
   FROM Document d
   WHERE <Условие для поиска документов>
 ) AS ranked
 WHERE RowNum = 1;

Фильтрация на клиенте

При небольшом объеме выборки выбор актуальной записи можно выполнить на стороне клиента. Это избавит от лишней нагрузки на ClickHouse и сработает достаточно быстро.

Легковесное удаление записей

Если требуется удалять неактуальные записи как можно скорее, то можно использовать легковесное удаление записей. Подчеркну, что речь идет именно про удаление, а не изменение значение поля IsDeleted

Алгоритм следующий:

Шаг 1. Вставляем пачку документов в Document.

Шаг 2. Находим записи для удаления. Ищем старые версии документов по идентификаторам пачки, вставленной на шаге 1:

SELECT
   DocumentId,
   Version,
   CreatedAt
 FROM (
   SELECT
     DocumentId,
     Version,
     CreatedAt,
     ROW_NUMBER() OVER (PARTITION BY DocumentId ORDER BY Version DESC, CreatedAt DESC) AS RowNum
   FROM Document
   WHERE DocumentId IN (<Идентификаторы документов пачки>)
 ) ranked
 WHERE ranked.RowNum > 1
 FORMAT Values

Сохраняем найденные кортежи в памяти приложения.

Шаг 3. Удаляем старые версии документов:

DELETE FROM Document WHERE (Id, Version, CreatedAt) IN (<Кортежи из памяти приложения>)

Мы использовали дополнительно настройку lightweight_deletes_sync = 0, чтобы оператор DELETE возвращал управление немедленно:

DELETE FROM Document WHERE (Id, Version, CreatedAt) IN (<Кортежи из памяти приложения>) 
SETTINGS lightweight_deletes_sync = 0

При этом старые версии какое-то время оставались видимыми для запросов, т.к. фоновые мутации завершались позже. Однако, в любом случае, такой подход наиболее предсказуем с точки зрения удаления старых версий записей.

Возникает вопрос, для чего используется CreatedAt. Рассмотрим ситуацию: на шаге 1 пачка документов успешно сохранена, но на шаге 3 удаление старых версий завершилось с ошибкой. В этом случае вы можете реализовать повторную попытку выполнения всех шагов с задержкой, например, в секунду. Благодаря использованию значения CreatedAt, на шаге 1 будут отобраны в том числе и записи, добавленные во время неудачной попытки, что позволит удалить их на шаге 3.

Минусы такого подхода: однопоточный режим работы. Однако вставка больших объемов данных в одном потоке потребует меньше ресурсов от ClickHouse, чем при параллельной вставке. Так что это не такой большой минус.