Практические советы при работе с движком ReplacingMergeTree в ClickHouse
Всем привет!
Я думаю, что многие использовали движок 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, чем при параллельной вставке. Так что это не такой большой минус.