Представьте себе Oracle DBA. Ему уже за тридцать, он слегка полноват, носит жилетку, на шее у него висит секретный токен доступа ко всем базам, а в резюме полстраницы пройденных им сертификаций. Суббота. День большого релиза. Кульминация. Время накатывать изменения на базу данных. Он набирает sqlplus, нажимает ENTER и по черному экрану куда-то вверх, в пустоту, устремляются километры SQL команд. Совсем как в звездных войнах. Спустя пять минут все готово. Через час релиз завершен. Работа сделана, день удался. Теперь можно и по паре пива.
Совсем другое дело — понедельник. Выясняется, что некоторые команды не выполнились из-за ошибок, что, впрочем, не остановило скрипт в его безудержном стремлении в черную пустоту. И без того непростая задача разобраться, что сломалось, осложняется еще и некоторым давлением со стороны руководства. В общем, понедельник не задался.
Конечно, это выдуманная история. Ни с кем никогда такого не происходило. По крайней мере, не произошло, если бы работы по изменению схемы базы данных были организованы через миграции.
Что такое инструмент миграции баз данных?
Идея управления изменениями схемы базы данных через миграции крайне проста:
- Каждое изменение оформляется в виде отдельного миграционного файла.
- Миграционный файл включает в себя как прямое, так и обратное изменение.
- Применение миграций к базе данных осуществляется специальной утилитой.
Простейший пример миграции:
-- 20180618152059: create sequence for some_table
CREATE SEQUENCE some_table_seq;
--//@UNDO
DROP SEQUENCE some_table_seq;
Такой подход дает множество преимуществ по сравнению с организацией изменений в общем SQL файле. Одно только отсутствие merge конфликтов чего стоит.
Тем удивительнее, что сам подход получил популярность сравнительно недавно. Кажется, что общую известность подходу принес фреймворк Ruby on Rails, в котором инструмент миграции был встроен изначально, это был конец 2005 года. Немногим ранее о подходе писал Martin Fowler, 2003. Вероятно, все дело в том, что разработка начала активно адаптировать использование системы контроля версий только в начале этого века. Еще в 2000 году первый пункт теста Joel Spolsky звучал “Do you use source control?” — это наводит на мысль о том, что системами контроля версий тогда пользовались далеко не все. Но мы отвлеклись.
Восемь лет с MyBatis Migrations
Мы в Wrike начали использовать подход изменения баз данных через миграции в 2010 году, 29 марта, в половину первого. С тех пор мы реализовали 1440 миграций, содержащий 6436 прямых изменений и 5015 обратных. В целом, у нас накопился некоторый опыт использования инструмента MyBatis Migrations в связке с PostgreSQL.
Если коротко, мы ни разу не пожалели. Случись такое, что вы еще не используете Migrations или нечто подобное, самое время начать. Да, Pentium 4 тоже устарел.
Но о достоинствах чего-либо говорить скучно, давайте сразу к трудностям.
Специфика работы с PostgreSQL
С написанием миграций для Postgres нет никаких сложностей, пожалуй, кроме двух:
- Нельзя создавать индексы,
- Нельзя добавлять NOT NULL колонки.
Нет, на самом деле можно, просто не совсем очевидным образом. При создании индекса нужно всегда указывать CREATE INDEX CONCURRENTLY, в противном случае вы сломаете production, поскольку Postgres заблокирует таблицу на время создания индекса, а это может быть достаточно долго. Конечно, разработчики через раз об этом забывают, приходится всегда иметь эту тонкость в виду. Здесь можно было бы написать тест. Но это лишь небольшое неудобство.
Создание NOT NULL колонки хитрее, тут нужно делать изменение в четыре приема:
- Создать NULL колонку (в Postgres это бесплатно).
- Задать колонке DEFAULT значение.
- В цикле, порциями обновить NULL значения в DEFAULT.
- Установить SET NOT NULL.
Самый большой подвох тут в третьем пункте. NULL значения нужно обновлять порциями, поскольку
UPDATE some_table SET some_column=’’ WHERE some_column IS NULL
; заблокирует таблицу, как и в случае с индексом, с теми же последствиями. А Migrations умеет выполнять только SQL команды, так что такие скрипты приходится накатывать в production руками. Удовольствие ниже среднего. Вот если бы в Migrations можно было написать цикл, проблемы бы не возникло. Возможно, это реализуемо через hooks.Создание
UNIQUE
индекса и смена PRIMARY KEY
также требуют определенной сноровки, но эти операции делаются сравнительно редко, чтобы на них останавливаться.Специфика работы с кластером
Инструмент управления миграциями базы данных хорош до тех пор, пока у вас одна база данных. Все веселее, если баз у вас несколько. Особенно если у вас несколько типов баз данных, каждой из которых по нескольку экземпляров.
В итоге, после
git pull
разработчик должен накатить изменения на первый экземпляр первой базы данных, потом на второй экземпляр, потом на первый экземпляр второй базы данных и так далее – такой принцип. Тут впору писать утилиту управления утилитой управления миграциями базы данных. Тотальная автоматизация.Жонглирование ролями
Не секрет, что роли как сущности живут не на уровне отдельной базы данных, но на уровне всего сервера баз данных, по крайней мере, в Postgres. При этом в миграции может потребоваться указать
REVOKE INSERT ON some_table FROM some_role
; Ожидать, что роли будут заранее сконфигурированы в production еще можно, но для dev или staging это уже затруднительно. При этом в разработке, конечно, все базы существуют на одном локальном сервере, так что просто написать в миграции CREATE ROLE
нельзя, а IF NOT EXISTS
не поддерживается. Все решается просто: DO $$ BEGIN
IF NOT EXISTS (SELECT * FROM pg_roles WHERE rolname = 'some_role') THEN
CREATE ROLE "some_role" NOLOGIN;
END IF;
END; $$;
Смотрите! Я их ловлю и подбрасываю, ловлю и подбрасываю, это же так просто.
Немного реальности разработки
Разработчики делают ошибки, и даже в SQL миграциях, такое случается. Обычно ошибки удается заметить на ревью, но бывает и необычно. Если говорить о прямых изменениях, то там косяки до production все-таки не доезжают – слишком много этапов проверки. А вот с обратными изменениями могут возникнуть казусы. Чтобы избежать ошибок в UNDO миграции, при тестировании миграции нужно выполнять не просто
./migrate up
, но ./migrate up
, затем ./migrate down
, затем опять ./migrate up
. В этом нет ничего сложного, нужно только добиться, чтобы сорок разработчиков всегда так делали. По-хорошему, утилита могла бы выполнять такое комбо для окружения разработчика автоматически.Тестовые окружения
Если тестовое окружение короткоживущее: скажем, вы создаете контейнер, инициализируете базу данных и запускаете интеграцонные тесты, проблем вроде быть не должно. Вызываем
./migrate bootstrap
, затем ./migrate up
, и все готово. Вот только, когда количество миграций переваливает за тысячу, этот процесс может затянуться. Обидно, когда база данных инициализируется дольше, чем выполняются тесты. Приходится изворачиваться.С долгоживущими окружениями все еще сложнее. QA, знаете ли, не любят, приходя на работу, видеть безукоризненно чистую базу данных. Я не знаю, почему так, но факт есть факт. Так что состояние баз, используемых в ручном тестировании, приходится поддерживать в целостности. А это не всегда просто.
Тонкость в том, что если миграция применена к базе данных, в нее записывается идентификатор миграции. А если код миграции позже был изменен, базы это уже не коснется. Если изменения некритичные, код может успешно доехать до production. Рассинхрон. Безусловно, это безобразие. Первый принцип работы с миграциями — никогда не менять написанные миграции, а всегда создавать новые. Но иногда так хочется схалтурить — я вот тут чуть-чуть поменяю, ничего же не сломается, ведь правда. Конечно! Валяйте!
Если бы миграции подписывались после ревью, можно было бы запретить применять черновики к staging. А еще можно было бы сохранять в
changelog
не только идентификатор миграции, но и checksum
— тоже полезно. Верните как было
Особенно коварный поворот случается, когда задачу отменяют: делали-делали и передумали. Вполне нормальная ситуация. Раз код больше не нужен, ветку следует удалить. А там же была миграция… а она же уже в staging… а, … опа. Хороший повод проверить, умеете ли вы восстановить бекап репозитория. Хотя вспомнить, что там было, пожалуй, проще.
При этом миграция — это текст. И можно было бы этот текст сохранить туда же, в
changelog
. Тогда, если миграция из кода пропала, уже не важно, по каким причинам, ее всегда можно было бы откатить. И даже автоматически. Сделайте UNDO еще раз
Секция UNDO, безусловно, нужна. Но зачем ее писать? Конечно, бывают и заковыристые случаи, но большинство изменений — это
CREATE TABLE
или ADD COLUMN
или CREATE INDEX
. Для них утилита могла бы генерировать обратные операции автоматически, прямо по SQL коду. Конечно, тут есть специфика. CREATE TABLE ${name}
— это же такая особенная команда, вдруг нестандартная. Да и чтобы сгенерировать DROP TABLE ${name}
, нужно уметь распарсить выражение аж до третьего слова. Хотя, в целом, это вполне реализуемая техническая задача. Могло бы быть из коробки.Заключение
Конечно, я придираюсь. MyBatis Migrations задумывалась как простая и универсальная утилита, минимально завязанная на специфику баз данных. И она себя более чем оправдывает. Но кажется, что несколько небольших улучшений сделали бы ее гораздо лучше, особенно при использовании на длинной дистанции.
—
Дмитрий Мамонов / Wrike