Всем привет! Эта статья продолжает первую часть, где я рассказывал о том, как мы в Just AI переводили пользовательскую аналитику на новую СУБД ClickHouse. В ней я на примерах показывал, как менялась схема данных и sql запросы, какие возникали сложности и удалось ли добиться прироста производительности. Одной из сложностей, с которыми мы столкнулись во время перехода, оказалось обновление данных. ClickHouse не поддерживает привычную реляционным базам операцию UPDATE, и, хотя существуют различные альтернативы, каждая из них имеет свои недостатки.  В рамках данной статьи я хочу рассмотреть, какие есть способы обновления данных в ClickHouse и на каком из них остановились мы. Вспомним, с какими данными мы работали в предыдущей статье. У нас есть клиенты, которые пишут сообщения ботам. Все эти сообщения мы сохраняем в таблицу message. В этой таблице есть поле comment. Его особенность заключается в том, что владелец бота может его обновлять. С реляционной базой данных никаких проблем нет, в ней мы можем использовать запрос UPDATE. С ClickHouse, к сожалению, все не так просто.  Как я уже писал выше, ClickHouse не поддерживает привычные update’ы. Какие же у нас есть варианты решения этой проблемы? ClickHouse поддерживает множество различных движков таблиц. С помощью некоторых из них, например, CollapsingMergeTree или ReplacingMergeTree, можно достичь обновления какой-либо записи.  CollapsingMergeTree позволяет удалить запись, если вставить новую строку, где все поля в ключе сортировки (ORDER BY) эквивалентны, за исключением специального поля Sign, которое может принимать значения 1 и -1. При операции обновления для нашего примера нам бы пришлось: Выполнить SELECT, чтобы получить все поля записи.  Выполнить вставку этой строки со значением поля Sign=-1. Выполнить вставку этой строки с обновленным комментарием и Sign=1. ReplacingMergeTree чем-то похож на CollapsingMergeTree. Он выполняет удаление дублирующихся записей с одинаковым значением ключа сортировки. Для нашего примера алгоритм  уменьшился бы на один пункт: Выполняем SELECT, чтобы получить все поля записи. Вставляем строку с обновленным комментарием. Каждый из этих движков привносит свои нюансы и ограничения. Одним из примеров таких особенностей является то, что записи удаляются/заменяются не сразу, а асинхронно через какое-то время. Чтобы это обработать, нам бы пришлось добавлять лишнюю агрегацию для каждого запроса SELECT, даже если нам не нужна информация о комментариях. Другим вариантом решения нашей проблемы является запись новых комментариев в отдельную таблицу. В этом случае мы могли бы производить JOIN основной таблицы message с таблицей для комментариев. Кроме того, чтобы не хранить все операции обновления одного комментария, для этой таблицы мы можем использовать движок ReplacingMergeTree. В этом варианте также есть свои ограничения. При соединении таблиц в ClickHouse правая из них должна помещаться в память. В случае, если в запросе будут участвовать очень много строк, это может сильно сказаться на производительности. В ClickHouse существует механизм мутаций, который напоминает обычные UPDATE’ы. В отличие от обновлений в OLTP базах данных, мутации не предназначены для частого использования, так как это тяжелая ресурсоемкая операция.  Область применения мутаций — достаточно тяжелые изменения, затрагивающие много строк в таблице, которые выполняются редко. По этой причине мы не можем создавать мутацию на каждое изменение данных в таблице message. Чтобы обойти все существующие проблемы, можно объединить второй и третий вариант. Мы можем сохранять операции с комментариями в отдельной таблице, и раз в какое-то время, например, ночью, создавать мутацию на перенос этих данных в основную таблицу message. За счет этого в таблице с комментариями всегда будет небольшое количество записей, и при выполнении операции join они точно будут помещаться в память.  Такое решение также позволит нам настроить кэш на уровне кода, и в случае, если для конкретного бота нет свежих операций с комментариями, вообще избежать соединения с отдельной таблицей. Перейдем к практике. Создадим новую таблицу comments, которая будет хранить операции с комментариями. Теперь, чтобы получить сообщения с конкретным комментарием, можно составить следующий запрос: Попробуем создать мутацию на перенос актуальных комментариев в таблицу message. Для этого хочется составить следующий запрос: К большому удивлению, оказывается, что в отличие от UPDATE в реляционной БД, в подзапросах при создании мутации нельзя использовать значение из родительского запроса. После долгих попыток обойти эту очередную особенность ClickHouse я наткнулся на функцию joinGet() и решил эту проблему следующим образом: 1. Перед созданием мутации создаем таблицу с движком Join.  2. Создаем мутацию следующим образом:  После создания мутация будет асинхронно обновлять записи в течение некоторого времени. После того, как она закончит, мы сможем удалить устаревшие записи в таблице comments и временную таблицу actual_comments. Мы рассмотрели несколько способов обновления данных в ClickHouse и у каждого из них есть свои недостатки. Давайте подведем итог по каждому из них: Движки таблиц CollapsingMergeTree и ReplacingMergeTree Для целей производительности операции выполняются асинхронно. Из-за этого может существовать несколько версий одной и той же строки и для обработки этого случая нужно существенно усложнять все запросы SELECT. Это усложнение также может значительно сказаться на производительности. При вставке обновленной записи нужно знать все поля существующей. Это может потребовать выполнять дополнительный SELECT запрос.  Отдельная таблица Простая вставка при обновлении. Не нужно выполнять дополнительных запросов. Усложняются запросы на получение. Для получения актуального значения поля необходимо использовать JOIN. Таблица с операциями над полем должна помещаться в память. Мутации  Простые и производительные запросы на получение. Тяжелая ресурсоемкая операция обновления. Мутации предназначены для редкого обновления большого количества данных. Гибрид 2 и 3 пункта Простая вставка при обновлении. Не нужно выполнять дополнительных запросов. Усложняются запросы на получение. Для получения актуального значения поля необходимо использовать JOIN. Нужно писать логику по переносу данных из таблицы с операциями в основную. Java/Kotlin Software Engineer