Go: SQL
Теория: Миграции с goose
Миграции решают две задачи: синхронизируют схему базы между разработчиками и окружениями и фиксируют историю изменений так, чтобы можно было уверенно откатываться. Инструмент goose берёт на себя нумерацию файлов, порядок применения и учёт версии в служебной таблице. Код пишет SQL, а goose гарантирует, что одна и та же последовательность шагов выполнится локально, на CI и в продакшене.
Установка проходит через стандартный go install — goose ставится как обычный CLI-инструмент. Бинарь попадает в $GOPATH/bin (или ~/go/bin по умолчанию), после чего команда goose доступна в терминале. Проверка делается вызовом:
Флаг -v включает подробный вывод и пригодится во всех сценариях отладки. Миграции обычно хранятся в каталоге migrations, где каждый файл начинается с номера или метки времени и короткого описания:
Создание и структура миграций
Новая миграция создаётся через генератор goose. Команда:
создаст в папке migrations пару файлов для шага «вверх» и «вниз» (UP и DOWN). Внутри используются специальные комментарии-директивы, которые читает goose. Верхняя часть описывает шаг Up, нижняя — Down для отката.
Пример минимальной SQL-миграции для PostgreSQL:
Комментарии -- +goose Up и -- +goose Down отмечают границы блоков. Пара StatementBegin / StatementEnd объединяет несколько SQL-операторов в один логический шаг, который выполняется внутри одной транзакции.
По умолчанию goose запускает SQL-миграции в транзакции. Это защищает от частично применённых изменений: либо все операции шага прошли, либо не прошла ни одна. Если СУБД или конкретный DDL не поддерживает транзакции (экзотические случаи, отдельные расширения), в шапке блока добавляется:
Тогда goose выполнит этот блок без обёртки в BEGIN/COMMIT. Такой режим стоит использовать осознанно: откат на уровне транзакции в нём уже невозможен, и «опасные» операции лучше разбивать на мелкие, проверяемые шаги.
Применение миграций из CLI
Базовый сценарий — применить все неприменённые миграции к базе. goose для этого достаточно одной команды:
Первый позиционный аргумент (postgres) — драйвер подключения, второй — DSN. Команда up пройдёт по всем файлам в migrations, которых ещё нет в служебной таблице, и выполнит их секции Up по порядку.
Для отката есть несколько вариантов:
С флагом -v goose будет печатать каждый выполняемый SQL и время его работы, что удобно для диагностики:
Миграции данных и backfill
Миграции нужны не только для изменения структуры, но и для аккуратного переноса данных. В секции Up допускаются любые DML-операции (update, insert, delete). Чтобы схему и данные менять атомарно, всё оборачивают в StatementBegin / StatementEnd.
Пример: добавляется новое поле username, которое сначала nullable, затем заполняется из email, и только потом на него вешается not null:
Порядок важен: сначала колонка появляется «мягкой», затем выполняется backfill, и только в конце добавляется ограничение NOT NULL. Такой сценарий предсказуем на продакшене и не ломает существующие записи.
Если нужно обновить миллионы строк, длинный update лучше не запускать в одной транзакции: он может держать блокировки и мешать другим запросам. В таких случаях тяжёлый апдейт разбивают на батчи по первичному ключу или выносят в отдельный фоновый процесс, а сама миграция отвечает только за добавление колонок, индексов и флагов.
go и миграции внутри приложения
Миграции можно запускать не только из CLI, но и из Go-кода. Это удобно для тестов, утилит и локальных окружений, где хочется, чтобы схема накатывалась автоматически при старте. Для этого используют встроенную файловую систему через go:embed и embed.FS.
go:embed — это директива компилятора, которая включает файлы с диска (SQL, HTML, шаблоны, статические ресурсы) прямо внутрь бинарника. Тип embed.FS — представление такого «встроенного» набора файлов как read-only файловой системы. В результате миграции лежат в репозитории, но при сборке упаковываются в приложение, и бинарь можно запускать в любом окружении без копирования отдельной директории migrations.
Пример модуля, который накатывает все миграции при старте:
Здесь строка //go:embed migrations/*.sql говорит компилятору Go взять все .sql в каталоге migrations и положить их в переменную migrationsFS типа embed.FS. Вызов goose.SetBaseFS(migrationsFS) перенастраивает goose так, чтобы он читал миграции не с реальной файловой системы, а из встроенной. Дальше goose.Up(db, "migrations") работает так же, как и в CLI-режиме, но путь "migrations" теперь относителен к embed.FS.
В тестах это позволяет поднять временную базу (например, через testcontainers), подключиться к ней через *sql.DB, вызвать migrate.Up(db) и получить актуальную схему, идентичную продакшену. После этого тесты могут работать в транзакциях и откатываться по завершении, не заботясь о ручной подготовке структуры.
Ошибки миграций и контроль версий
Служебная таблица goose хранит историю применённых шагов. Если миграция упала в середине из-за ошибки синтаксиса, блокировки или несовместимого DDL, транзакция откатится, а версия в таблице останется на предыдущем шаге. Поведение одно и то же для CLI и встроенного режима.
Важно правило: уже применённые миграции на продакшене нельзя переписывать. Если в старом файле была ошибка, исправление оформляется новой миграцией с новым номером. Тогда история изменений остаётся линейной и повторяемой, а внешние окружения не расходятся.
Для локальной разработки есть быстрый приём — goose redo. Эта команда откатывает последнюю миграцию и тут же применяет её снова. Удобно, когда файл только что изменился и нужно проверить его на чистой базе. На продакшене такой сценарий использовать нельзя: там миграции должны быть неизменяемыми, как истории в журнале.
Тяжёлые изменения и стратегические миграции
При сложных развёртываниях безопаснее разделять схему и код по шагам. Распространённая стратегия «expand → migrate data → contract» выглядит так:
- Миграцией добавляется новая схема, совместимая и со старой, и с новой версией приложения (новые поля, таблицы, индексы, но без жёстких ограничений).
- Отдельный этап или фоновый процесс выполняет перенос данных, backfill и проверку консистентности.
- После того как новое представление данных стабильно, второй набор миграций убирает устаревшие поля и включает строгие ограничения (
not null,check, удаление старых колонок).
Такой порядок снижает риск простоя: приложение не ломается при половинчатом развёртывании, а откат становится управляемым.
Связка goose и sqlc
goose и sqlc дополняют друг друга. Миграции описывают истинную схему, sqlc читает те же SQL-файлы как источник правды и генерирует по ним Go-типы и методы. Рабочий цикл выглядит предсказуемо:
- Добавляется новая миграция и накатывается локально (
goose up). - Запускается
sqlc generate, который читает обновлённую схему и пересобирает модели и функции. - Компилятор Go сразу показывает, где код больше не совпадает с базой.
В CI всё это собирается в понятный сценарий: поднимается окружение базы, выполняется goose up, затем проходят интеграционные тесты (в том числе поверх sqlc), и только после этого публикуется артефакт. В продакшене миграции прогоняются тем же goose с -v и журналируются отдельно от приложений.
Такой процесс даёт повторяемость и прозрачность: схема меняется только через миграции, история хранится в одном месте, sqlc всегда видит актуальное состояние, а откат не превращается в ручное редактирование таблиц через консоль.


