Как я написал свой мигратор для ClickHouse — и почему он до сих пор жив

от автора

В 2020-м мне понадобилось версионировать схему ClickHouse в CI/CD, а готового инструмента под Go с поддержкой ClickHouse не нашлось ни одного. Пришлось написать свой — db-migrator. Со временем он оброс поддержкой Postgres, MySQL, а недавно и Iceberg, и разошёлся среди коллег по отрасли. В статье расскажу, чем миграции в ClickHouse отличаются от привычного Postgres/MySQL и какие инженерные развилки из-за этого пришлось пройти: ON CLUSTER для обычного реплицированного кластера, режим Replicated-базы, аккуратная таблица истории миграций на шардированном кластере и маскирование секретов в логах.

Инструмент: https://github.com/raoptimus/db-migrator.go


С чего всё началось

В 2020-м мне нужно было версионировать схему ClickHouse в CI — а готового инструмента под Go с поддержкой ClickHouse не нашлось ни одного. Хотелось того же, что уже давно есть в мире Postgres: миграций, которые лежат в git рядом с кодом и проходят через пайплайн. А в ответ — пусто.

Все известные мне утилиты (goose, golang-migrate и прочие) жили в мире «обычных» баз: одно подключение, транзакция, накатил — откатил. ClickHouse в эту модель не укладывался.

Вариантов оставалось два: катить схему руками и дальше (спасибо, не хочется) или написать инструмент под себя. Я выбрал второе. Так появился db-migrator — сначала заточенный именно под ClickHouse, а уже потом доросший до Postgres, MySQL и (экспериментально) Tarantool.

Тогда я писал его под одну свою боль и на свой прод. Что им будут пользоваться незнакомые мне люди в других компаниях — я не закладывал. Но об этом ниже; сначала — про то, почему ClickHouse вообще пришлось обходить не так, как Postgres.

Зачем вообще версионировать схему: короткая иллюстрация

Прежде чем уходить в технику — момент, ради которого всё и затевалось.

Одной смежной команде я помогал внедрить миграции ClickHouse в CI/CD на базе db-migrator. До этого схему они катали руками, и выглядело это так. На dev всё зелёное. На прод тот же инженер переносит DDL руками — и где-то теряет шаг. Расхождение всплывает уже на живом кластере [подставь свою реальную деталь: например, ночью, когда падает отчёт / когда данные едут не в ту колонку]. И цена ошибки тут не «откатить транзакцию», а перелить терабайты на работающем проде.

Корень проблемы простой: то, что протестировано, и то, что приехало на прод, — это два разных набора изменений. Где-то опечатка, где-то «поправил по ходу», где-то забыли шаг.

Версионированные миграции в CI/CD убирают этот класс ошибок в принципе: один и тот же файл проходит весь путь dev → test → prod без ручного вмешательства. Но дело не только в том, что «руками не трогаем». Подход даёт ещё несколько вещей, которых при ручной доставке схемы просто нет:

  • Контроль изменений через git. Каждое изменение схемы — это файл в репозитории. Видно, кто, когда и что менял; история БД лежит рядом с историей кода и связана с ней. Откатиться к нужному состоянию или понять, «когда же появилась эта колонка», становится тривиально.

  • Ревью. Раз миграция — это файл в PR, её можно ревьюить как обычный код. Опасный ALTER, забытый индекс, неверный движок таблицы — всё это ловится глазами коллег до прода, а не после. Для ClickHouse это особенно важно: цена ошибки в DDL на большом кластере высокая.

  • Автоматизация. Накат миграций становится обычным шагом пайплайна — предсказуемым, повторяемым, одинаковым во всех окружениях. Человек убран из критичного пути, а вместе с ним убран и человеческий фактор.

  • Быстрый откат. Каждая миграция — это версия с парой up/down, поэтому откатить неудачное изменение можно одной командой, а не вспоминать в спешке, что и как правили руками. (С важной оговоркой про ClickHouse — про границы атомарности будет отдельно ниже.)

Ради этой связки «версионирование + ревью + автоматизация» такой инструмент и нужен. А дальше начинается специфика ClickHouse.

Чем ClickHouse отличается с точки зрения миграций

Нет полноценных транзакций. В Postgres ты заворачиваешь миграцию в транзакцию и спишь спокойно: упало на середине — откатилось целиком. В ClickHouse так не выйдет. «Атомарно накатить или атомарно откатить» — не про него, и любой честный мигратор должен это признавать, а не делать вид.

Распределённость. ClickHouse в проде — это обычно не одна нода, а кластер: шарды и реплики. DDL нужно раскатать согласованно по всем нодам, а не выполнить на одной. Отсюда конструкция ON CLUSTER <имя_кластера> и реплицируемые движки семейства ReplicatedMergeTree.

Два разных режима репликации. И вот тут начинается то, ради чего во многом и писалась поддержка ClickHouse.

Режим 1: обычный реплицированный кластер (ON CLUSTER)

Самый распространённый вариант. DDL выполняется с явным указанием кластера, таблицы создаются на реплицируемом движке:

CREATE TABLE IF NOT EXISTS events ON CLUSTER {cluster} (    id UInt64,    event_time DateTime,    user_id UInt32) ENGINE = ReplicatedMergeTreeORDER BY (event_time, id);

Имя кластера не зашивается в SQL руками, а подставляется через плейсхолдер {cluster} из переменной окружения MIGRATION_CLUSTER_NAME (или флага cn). Это удобно: один и тот же файл миграции едет в dev/stage/prod с разными именами кластеров без правки SQL — что, как мы только что обсудили, и есть главная цель.

DSN="clickhouse://user:pass@host:9000/mydb?compress=true" \MIGRATION_PATH=./migrations \MIGRATION_CLUSTER_NAME=my_cluster \MIGRATION_REPLICATED=true \db-migrator up

Режим 2: Replicated-база

А вот тут интереснее — и именно это понадобилось той самой смежной команде на их продакшен-кластере.

Когда база создана на движке Replicated, модель работы с DDL меняется: репликацией DDL занимается сама база, и указывать ON CLUSTER ... в запросах уже не нужно — более того, это становится лишним. То есть один и тот же CREATE TABLE для обычного кластера и для Replicated-базы должен выглядеть по-разному: в первом случае с ON CLUSTER {cluster}, во втором — без него, потому что репликацию база берёт на себя сама.

Небольшая историческая ремарка: раньше этот движок включался флагом allow_experimental_database_replicated и считался экспериментальным. К текущим версиям ClickHouse он стабилизировался, но встретить упоминание старого флага в чужих конфигах и статьях всё ещё можно — отсюда и его частая ассоциация с этим режимом.

Для мигратора это означает, что он не может генерировать DDL «вслепую» — ему нужно знать, в каком режиме работает кластер, и вести себя соответственно. В db-migrator это решается переключателем: переменная окружения MIGRATION_REPLICATED (флаг cr). В этом режиме мигратор не навязывает ON CLUSTER, и миграции пишутся под модель, где репликацию DDL обеспечивает сам ClickHouse.

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

Таблица истории миграций на шардированном кластере

Отдельная тонкость, которую легко упустить. Мигратор хранит историю применённых миграций в служебной таблице (по умолчанию migration). В обычной БД это просто табличка. А на шардированном/реплицированном кластере сама таблица истории тоже должна создаваться правильно — иначе на разных нодах история разъедется, и мигратор будет видеть разное состояние в зависимости от того, на какую ноду он попал. А это прямой путь к повторному применению миграции или, наоборот, к её пропуску.

Поэтому db-migrator создаёт таблицу истории согласованно по кластеру — через ON CLUSTER и на реплицируемом движке, единую на весь кластер, с учётом тех же настроек MIGRATION_CLUSTER_NAME / MIGRATION_REPLICATED, что и пользовательские миграции. Тогда какую бы ноду ни выбрал мигратор, он видит одну и ту же, общую для кластера историю.

Секреты в DDL: словари ClickHouse и маскирование в логах

Ещё один практический момент, специфичный для ClickHouse. Иногда учётные данные нужно передавать прямо в DDL — например, при создании словарей (DICTIONARY) с источником из внешней БД: чтобы словарь ходил за данными, в его определении указывают логин и пароль для подключения к источнику.

Здесь возникают сразу две задачи. Первая — не зашивать логин/пароль в текст миграции руками, иначе они утекут в репозиторий. Для этого есть плейсхолдеры {username} и {password}, которые подставляются из DSN на лету:

CREATE DICTIONARY IF NOT EXISTS my_dict ON CLUSTER {cluster} (    id UInt64,    name String)PRIMARY KEY idSOURCE(CLICKHOUSE(    USER '{username}'    PASSWORD '{password}'    DB 'source_db'    TABLE 'source_table'))LAYOUT(HASHED())LIFETIME(300);

Вторая задача — чтобы эти секреты не утекли в другую сторону, в логи. Мигратор пишет выполняемый SQL в вывод (консоль, CI-логи, debug), и без защиты пароль из словаря оказался бы прямо в логах сборки. Поэтому значения {username} / {password} в выводе автоматически маскируются на ****:

> execute SQL: CREATE DICTIONARY ... SOURCE(CLICKHOUSE(USER '****' PASSWORD '****' ...)) ...

Мелочь, но именно из таких мелочей складывается разница между «работает на dev» и «можно катить в CI на прод, не боясь утечки секретов».

release / rollback — и честно про границы атомарности

Кроме обычных up / down есть две команды, работающие с релизом как с единицей:

  • release — накатить все ожидающие миграции одной пачкой, пометив их общим apply_time;

  • rollback — откатить последний релиз целиком (батч определяется по MAX(apply_time)).

Идея в том, чтобы оперировать релизом, а не отдельными миграциями.

Но тут важно не приукрашивать. Атомарно, в одной транзакции, это работает только в PostgreSQL и частично в MySQL. Кстати, отсюда же и суффикс .safe. в именах файлов миграций: он помечает миграцию, которую можно выполнить в одной транзакции — там, где СУБД это умеет. В ClickHouse транзакций нет, поэтому release/rollback там — это батч, объединённый общим apply_time, но без транзакционного отката: если что-то упадёт в середине, автоматического «как будто ничего не было» не случится. Это не недоработка инструмента, а ограничение самой СУБД, и его надо просто держать в голове при планировании релизов на ClickHouse.

Как инструмент вырос за пределы моей задачи

Я писал db-migrator под конкретную боль — свою. Но когда выложил его в открытый доступ, случилось то, ради чего вообще стоит делиться кодом: им начали пользоваться другие. Сначала коллеги, потом знакомые из смежных команд, потом люди, которых я не знаю лично. Кому-то он закрыл ровно ту же дыру с ClickHouse, кому-то оказался удобен как один инструмент на несколько СУБД. Обратная связь от них, в свою очередь, толкала инструмент дальше — так он и оброс тем, чего в первой версии не было.

Что в нём есть сейчас, помимо разобранной выше специфики ClickHouse:

  • Несколько СУБД в одном инструменте — ClickHouse, PostgreSQL, MySQL и (экспериментально) Tarantool. Один синтаксис команд, один формат истории миграций.

  • Поддержка Iceberg. Появилась работа с Iceberg через его API — версионировать схему теперь можно не только для «классических» СУБД, но и для таблиц в data lake. Тема большая и заслуживает отдельного разговора, поэтому здесь просто отмечу, что направление есть и развивается.

  • CLI и Go-библиотека. Мигратор можно запускать как обычную консольную утилиту в пайплайне, а можно встроить в свой Go-код и вызывать миграции программно — например, при старте сервиса.

  • dry-run. Можно прогнать миграцию «вхолостую» и увидеть, какой именно SQL уйдёт в базу, ничего при этом не применяя. На проде и в ревью это бесценно.

  • Разные способы установки. go install, готовый Docker-образ, а теперь ещё и собранные бинарники под разные ОС и архитектуры и установка через Homebrew (brew). Никакой обязательной сборки из исходников — берёшь готовое под свою систему.

Ничего из этого не появилось по грандиозному плану. Это просто накопившийся ответ на реальные запросы людей, которые взяли инструмент в работу.

Про Tarantool — честно, это эксперимент

Поддержку Tarantool я добавлял скорее как эксперимент, чтобы держать всё в одном инструменте: Lua-миграции вместо SQL, базовые операции работают. Но кластер там не поддержан, и фичу я не довёл. Если вам нужны миграции в Tarantool всерьёз — у него есть штатный путь: фреймворк Cartridge с миграциями из коробки, и в большинстве случаев правильнее взять его. Поддержку в db-migrator я оставил для случаев, когда удобнее один инструмент на всё, — и не более того.

Когда брать db-migrator, а когда — нет

Я не пытаюсь продать инструмент всем подряд, поэтому — честно про обе стороны.

Стоит посмотреть, если:

  • у вас ClickHouse, особенно кластер с шардами/репликами или Replicated-база;

  • хочется один инструмент на несколько СУБД (ClickHouse + Postgres + MySQL);

  • нужен и CLI, и использование как Go-библиотеки в коде;

  • версионируете не только БД, но и таблицы Iceberg и хотите единый подход.

Скорее не нужно / подумайте об альтернативах, если:

  • у вас только Postgres без экзотики — здесь хватит любого привычного мигратора;

  • вам нужны миграции Tarantool всерьёз — берите Cartridge;

  • вы рассчитываете на гарантированную атомарность миграций в ClickHouse — её нет ни у кого, это ограничение самой СУБД, а не инструментов.


Инструмент с открытым кодом: ставится через go install, Homebrew или Docker, есть готовые бинарники под разные системы, поддерживает dry-run и работу как Go-библиотека — https://github.com/raoptimus/db-migrator.go

Я писал это в 2020-м, чтобы больше никогда не катать ClickHouse руками. Оказалось, руками его катают до сих пор — поэтому и делюсь. Если гоняете кластер: расскажите в комментариях, как у вас устроено версионирование, — особенно если пробовали Replicated-базу на проде.

ссылка на оригинал статьи https://habr.com/ru/articles/1075014/