В поиске оптимального подхода к миграциям в Go
Привет! Меня зовут Нина, и я Golang разработчик.
Недавно перед нами встала задача систематизировать и унифицировать инструменты, используемые для создания миграций в различных Go-сервисах и командах.
В данной статье я хочу поделиться опытом работы с миграциями в Go и провести сравнительный анализ существующих инструментов. Я расскажу о требованиях, с которыми мы столкнулись при работе с миграциями, и объясню, почему мы выбрали определенный инструмент. А также расскажу как работать с этим инструментом.
О статье
Это русский оригинал моей статьи, которая раньше была переведена на английский язык и опубликована в моем блоге на medium.
Требования и критерии
В приложениях Go создание, применение и поддержка миграций является важной частью кода. Также, работа с миграциями является важной частью процесса CI/CD.
Выбирая инструмент, важно определить требования и критерии выбора. Вот что было актуально внутри наших команд:
Поддержка PostgreSQL.
Хорошо, если инструмент поддерживает множество баз данных. Но в нашем случае, мы работаем в основном с БД PostgreSQL, поэтому именно ее поддержка нам нужна.
Формат миграции.
Миграции могут быть написаны в разных форматах: в виде .go
файла или .json
. Но самый популярный и универсальный формат — это .sql
, то есть сырой SQL. Такие миграции легко читаемы и с ними проще работать.
Создание и версионирование миграций
Удобнее всего создавать миграции не вручную, а через специальную консольную команду. Кроме того, при создании миграции ее имя должно быть уникальным.
Для версионирования миграции в качестве префикса может использоваться порядковый номер, например, 000014_create_database.sql
.
Но, если над одним проектом работает несколько программистов, то с порядковым версионированием могут возникать коллизии. Поэтому, лучше всего в качестве префикса использовать временную метку: создать миграции с одинаковой меткой секунда в секунду меткой маловероятно.
Пример формата временной метки: 20230403102355_create_new_table.sql
.
Применение и откат миграций
Если мы говорим про CI/CD, то необходима возможность применять и откатывать миграции с помощью CLI инструмента.
В некоторых библиотеках есть также возможность применять миграции из кода при запуске приложения.
Плюсом такого подхода является простота реализации, но при этом у нас остается меньше контроля над выполнением миграций, а также более высокий риск проблем с совместимостью.
В нашем случаи миграции будут являться частью рабочего процесса развертывания CI/CD, а для этого удобнее всего использовать консольный инструмент.
Поддержка транзакций
Транзакции помогают поддерживать консистентность данных в случае неудачного применения миграций. Изменения в базе данных, при выполнении миграции которая завершилась с ошибкой, откатываются и остается состояние, соответствующее предыдущей успешно примененной миграции.
Также, иногда бывает важным иметь возможность запускать миграции вне транзакций. Например, операцию создания индексов, выполнение которой может занимать очень много времени (но, конечно, по возможности избегайте этого).
Ошибка при неправильном применении миграций
Если сразу несколько программистов работает над проектом, то возможны неприятные случаи во время применения миграций. От коллизии в названиях миграций мы с большой вероятностью избавились с помощью версионирования миграции с помощью временной метки.
Но также необходимо отслеживать неправильный порядок применения миграций: при попытки применить миграции не по порядку должна чтобы возникать ошибка.
Результат выполнения миграций
Для диагностики работы приложения иногда необходимо посмотреть на список всех примененных миграций и дату их применения. В базе данных должна существовать служебная таблица со списком всех миграций, временной меткой их применения, а также их статус.
Теперь, когда мы выяснили что же нам нужно от миграций, попробуем найти инструмент, лучше всего удовлетворяет этим требованиям.
Инструменты
Существует несколько библиотек для работы с миграциями в Go:
Goose: https://github.com/pressly/goose
Gormigrate: https://github.com/go-gormigrate/gormigrate
Migrate: https://github.com/golang-migrate/migrate
go-pg/migrations: https://github.com/go-pg/migrations
sql-migrate: https://github.com/rubenv/sql-migrate
Самые популярные из них:
golang-migrate/migrate (11.1K звезд на github)
pressly/goose (3.7K звезд на github)
rubenv/sql-migrate (2.8K звезд на github)
Именно их мы и будем сравнивать между собой.
Для удобства составим таблицу, в которой будут сравнены все нужные нам критерии.
migrate | goose | sql-migrate | |
Поддерживаемые БД | Postgres, MySQL, MongoDB, ClickHouse и другие. | Postgres, MySQL, SQLite, ClickHouse, etc | Postgres, MySQL, SQLite, MSSQL |
Формат миграций | .sql, .go, .json, .toml | .sql, .go | .sql |
Версионирование с временной меткой | да | да | да |
Применение и откат миграций с помощью CLI инструмента | да | да | да |
Поддержка транзакций | да | да | да |
Выполнение миграции вне транзакции | нет | да | да |
Ошибка при неправильно примененной миграции | да | да | нет |
Результат выполнения миграций | Сохраняется только последняя версия примененной миграции и ее статус (успешно или БД в состоянии dirty) | Сохраняется версия миграции, время ее применения, а также статус — была ли она применена. | Сохраняется список всех примененных миграций и время их применения |
Если руководствоваться нужными нам критериями, то больше всего нам подходит библиотека pressly/goose. Ее мы и выбрали.
Давайте рассмотрим как с ней можно работать.
Как работать с goose
Соглашение о терминах
Я буду применять термины, которые мы используем в разговорной речи между коллегами, например, накатывание и откатывание миграций. Возможно, есть какие-то академически более приятные для чтения термины, но я считаю, что живая речь важнее.
Установка
Для установки пакета pressly/goose вы можете использовать консольную команду go install
:
go install github.com/pressly/goose/v3/cmd/goose@latest
Для установки на MacOS можно воспользоваться пакетным менеджером brew:
brew install goose
Создание
Теперь создадим первую миграцию new_user_table
с новой таблицей user
. Для хранения миграций будем использовать папку db/migrations
mkdir -p db/migrations
goose -dir db/migrations create new_user_table sql
Создался новый файл с именем 20230416135213_new_user_table.sql
, где 20230416135213
— это временная метка (год, месяц, число, час, минуты и секунды)
Автоматически в этом файле указывается две области: для написания миграции --+goose Up
, которая применяется при накатывании, и миграции -- +goose Down
, которая выполняется при откатывании миграции.
-- +goose Up
-- +goose StatementBegin
SELECT 'up SQL query';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd
Накат и откат
При накатывании миграции будем создавать таблицу, а при откате удалять ее:
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS users;
-- +goose StatementEnd
Теперь накатим миграцию в нашу БД:
goose -dir db/migrations postgres "postgresql://goose:password@127.0.0.1:8092/go_migrations?sslmode=disable" up
Здесь мы уточняем что миграции накатываются из папки db/migrations
, используются драйвера postgres, далее идет ссылка на подключение к базе данных и команда для применения миграции up.
Для удобства вы можете в переменных окружения задать драйвера базы данных и ссылку на базу данных:
export GOOSE_DRIVER=postgres
export GOOSE_DBSTRING=postgresql://goose:password@127.0.0.1:8092/go_migrations?sslmode=disable
И тогда запускать миграцию через короткую команду:
goose -dir db/migrations up
Сообщения при успешно примененной миграции:
2023/04/16 14:42:06 OK 20230416135213_new_user_table.sql (27.79ms)
2023/04/16 14:42:06 goose: no migrations to run. current version: 20230416135213
После этого в нашей базе данных создается нужная нам таблица users:
А также служебная таблица goose_db_version
, которая содержит информацию о примененных миграциях и их статусе:
Мы видим что наша миграция применилась успешно.
CI/CD
Применять миграции можно из кода (например, в главной функции main вызывать код, который применяет миграции).
Но использование миграций базы данных как часть CI/CD может помочь обеспечить более безопасное, предсказуемое и упрощенное развертывание вашего приложения, особенно если вы работаете с крупными и сложными проектами.
Самым безопасным и быстрым способом применять миграции — это применять их на шаге деплоя вашего приложения. Т.к. в таком случае будет меньше времени в состоянии сдвига миграций: когда изменения в базе данных уже были применены, а новая версия приложения еще не была накачена. К тому же, в случае если миграции были не успешно применены, можно быстрым способ откатить приложение на предыдущую версию.
Для запуска миграции в вашем CI/CD на шаге деплоя необходимо запускать команды:
export GOOSE_DBSTRING=
goose -dir db/migrations postgres up
Сценарий «кривые руки»
Что будет если мы случайно создадим миграцию, которая не сможет быть применена?
Например, мы хотим добавить функционал, который позволял бы создавать роли пользователей, а также связывать уже существующих пользователей с этими ролями.
Создадим еще две миграции: new_table_roles
и alter_table_users
:
Первая миграция создает таблицу roles
:
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS roles(
id SERIAL PRIMARY KEY,
role VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE roles;
-- +goose StatementEnd
Вторая миграция изменяет существующую нам таблицу users
и добавляет поле role_id
, которое хранит внешний ключ, ссылающийся на первичный ключ id
в таблице roles
:
-- +goose Up
-- +goose StatementBegin
ALTER TABLE user(
ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE user
DROP COLUMN role_id;
-- +goose StatementEnd
Если мы применим наши миграции, то первая миграция применяется успешно, а вторая упадет с ошибкой:
OK 20230416144625_new_table_roles.sql (13.41ms)
2023/04/16 14:56:39 goose run: ERROR 20230416144634_alter_table_users.sql: failed to run SQL migration: failed to execute SQL query "ALTER TABLE user\n ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE;": ERROR: syntax error at or near "user" (SQLSTATE 42601)
Потому что мы ошиблись в миграции и пытались обновить несуществующую таблицу: user
вместо users
.
В нашей системной таблице будет отображена только вторая примененная миграция:
Откат миграций
Мы столкнулись с очень неприятной ситуацией: в нашем релизе применилась только одна миграция и это может привести к плачевным последствиям. В нашем релизе мы собирались создавать новые роли, а затем привязывать пользователей к существующим ролям (через поле role_id
). Если создать роль у нас получится, то привязать пользователя к роли нет, потому что поля role_id
не существует в таблице users
.
Если мы используем CI/CD, то в случае непременной миграции наша новая версия приложения не будет применена (т.к. данный шаг на этап применения миграции завершиться с ошибкой). И получается наше приложение будет в предыдущей версии кода и с частично примененной миграцией в базе.
В данном случае можно предпринять несколько вариантов действия:
Вручную откатить миграции до нужной версии, чтобы наше приложение работало стабильно.
Плюс данного подхода в том, что у вас больше контроля над ситуацией, вы можете точечно откатить ненужные изменения в БД.
Но минусы в том, что ручной откат требует больше времени на выполнение, так что будет большой простой в работе приложения. Необходимо активное участие разработчика, у которого есть доступы к продовской базе. Возможно, придется будить зло тимлида. Кроме того, при откате вручную можно совершить ошибки, связанные с человеческим фактором, и сделать еще хуже.
Откатывать миграции автоматически с помощью CI/CD.
Откатить миграцию можно с помощью консольного скрипта:
export GOOSE_DBSTRING=
goose -dir /db/migrations postgres down-to
— версия миграции, до которой вы хотите вернуть изменения.
Плюсы данного подхода в том, что система быстрее восстановится, нет необходимости разработчикам вмешиваться в процесс и мониторить его. Меньше ошибок, связанных с человеческим фактором при откате миграций.
Но с другой стороны, здесь меньше контроля над процессом отката. И если автоматический откат не срабатывает корректно, то это может привести к еще большим проблемам.
Не откатывать вообще!
Такое возможно, если вы каждый раз релизите приложение с поддержкой обратной совместимости между версиями.
Что это значит? В миграциях новой версии приложения не должно быть таких изменений, которые бы нарушили работу текущей версии.
Основные принципы такого подхода:
Структура базы данных предыдущей версии должна быть сохранена в новой версии: таблицы, колонки, индексы и т.д. должны быть сохранены в новой версии базы данных. Например, если нужно удалить какой-то столбец или таблицу, то мы удаляем их через одну миграцию, когда точно убедимся, что релиз с изменениями прошел успешно.
Данные должны быть перенесены в новую версию без потерь или повреждений.
Запросы, написанные для предыдущей версии базы данных, должны продолжать работать в новой версии.
Перед релизом новой версии базы данных должно проводиться тестирование на обратную совместимость, чтобы убедиться, что приложения, созданные для предыдущей версии базы данных, продолжают работать без изменений в новой версии.
Процесс отката миграций сложный процесс, который может привести к неконсистентному состоянию базы данных и перевести состояние системы в нерабочее состояние.
Ручной откат занимает много времени, а откат через CI/CD требует сложной настройки, чтобы правильно определить нужное количество откатываемых миграций: например, если в релизной ветке было 10 миграций, 5-я из которых упала с ошибкой, получается нужно откатить 4 предыдущие миграции.
Обратная совместимость между версиями исключает данные негативные эффекты, поэтому является предпочтительным.
Также, используя принцип обратной совместимости, вы сможете выделить применение миграции в CI/CD отдельный от деплоя шаг, что позволит убедиться, что миграции прошли успешно, а затем выполнить релиз новой версии приложения.
Заключение
Для своего проекта вы можете использовать инструменты и подходы, которые лучше всего подходят для вас. Но в рамках нашего проекта мы сформулировали для себя следующие правила работы с миграциями:
Использовать библиотеку pressly/goose.
Использовать формат миграций .sql.
Для версионирования миграций нужно использовать временные метки.
Накат миграций должен быть частью процесса CI/CD. Миграции должны запускать на шаге деплоя приложения.
Откат миграций является нежелательным: для того, чтобы этого не делать приложение должно поддерживать обратную совместимость между версиями.