Go: SQL

Теория: Миграции с goose

Миграции решают две задачи: синхронизируют схему базы между разработчиками и окружениями и фиксируют историю изменений так, чтобы можно было уверенно откатываться. Инструмент goose берёт на себя нумерацию файлов, порядок применения и учёт версии в служебной таблице. Код пишет SQL, а goose гарантирует, что одна и та же последовательность шагов выполнится локально, на CI и в продакшене.

Установка проходит через стандартный go install — goose ставится как обычный CLI-инструмент. Бинарь попадает в $GOPATH/bin (или ~/go/bin по умолчанию), после чего команда goose доступна в терминале. Проверка делается вызовом:

goose -v

Флаг -v включает подробный вывод и пригодится во всех сценариях отладки. Миграции обычно хранятся в каталоге migrations, где каждый файл начинается с номера или метки времени и короткого описания:

migrations/
  0001_init.sql
  0002_add_users.sql
  20250101_create_orders.sql

Создание и структура миграций

Новая миграция создаётся через генератор goose. Команда:

goose -dir ./migrations create add_users sql

создаст в папке migrations пару файлов для шага «вверх» и «вниз» (UP и DOWN). Внутри используются специальные комментарии-директивы, которые читает goose. Верхняя часть описывает шаг Up, нижняя — Down для отката.

Пример минимальной SQL-миграции для PostgreSQL:

-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS users (
    id serial PRIMARY KEY,
    email text NOT NULL UNIQUE,
    name text,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_users_created_at ON users (created_at);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS users;
-- +goose StatementEnd

Комментарии -- +goose Up и -- +goose Down отмечают границы блоков. Пара StatementBegin / StatementEnd объединяет несколько SQL-операторов в один логический шаг, который выполняется внутри одной транзакции.

По умолчанию goose запускает SQL-миграции в транзакции. Это защищает от частично применённых изменений: либо все операции шага прошли, либо не прошла ни одна. Если СУБД или конкретный DDL не поддерживает транзакции (экзотические случаи, отдельные расширения), в шапке блока добавляется:

-- +goose NO TRANSACTION

Тогда goose выполнит этот блок без обёртки в BEGIN/COMMIT. Такой режим стоит использовать осознанно: откат на уровне транзакции в нём уже невозможен, и «опасные» операции лучше разбивать на мелкие, проверяемые шаги.

Применение миграций из CLI

Базовый сценарий — применить все неприменённые миграции к базе. goose для этого достаточно одной команды:

goose -dir ./migrations \
  postgres "host=localhost port=5432 user=app password=secret dbname=app sslmode=disable" \
  up

Первый позиционный аргумент (postgres) — драйвер подключения, второй — DSN. Команда up пройдёт по всем файлам в migrations, которых ещё нет в служебной таблице, и выполнит их секции Up по порядку.

Для отката есть несколько вариантов:

goose -dir ./migrations postgres "$DSN" down   # откатить одну последнюю миграцию
goose -dir ./migrations postgres "$DSN" reset  # откатить все, вернуться к нулю
goose -dir ./migrations postgres "$DSN" status # показать текущую версию и список файлов

С флагом -v goose будет печатать каждый выполняемый SQL и время его работы, что удобно для диагностики:

goose -dir ./migrations -v postgres "$DSN" up

Миграции данных и backfill

Миграции нужны не только для изменения структуры, но и для аккуратного переноса данных. В секции Up допускаются любые DML-операции (update, insert, delete). Чтобы схему и данные менять атомарно, всё оборачивают в StatementBegin / StatementEnd.

Пример: добавляется новое поле username, которое сначала nullable, затем заполняется из email, и только потом на него вешается not null:

-- +goose Up
-- +goose StatementBegin
ALTER TABLE users
ADD COLUMN IF NOT EXISTS username text;

UPDATE users
SET username = split_part(email, '@', 1)
WHERE username IS null;

ALTER TABLE users
ALTER COLUMN username SET NOT NULL;
-- +goose StatementEnd

-- +goose Down
ALTER TABLE users
DROP COLUMN IF EXISTS username;

Порядок важен: сначала колонка появляется «мягкой», затем выполняется backfill, и только в конце добавляется ограничение NOT NULL. Такой сценарий предсказуем на продакшене и не ломает существующие записи.

Если нужно обновить миллионы строк, длинный update лучше не запускать в одной транзакции: он может держать блокировки и мешать другим запросам. В таких случаях тяжёлый апдейт разбивают на батчи по первичному ключу или выносят в отдельный фоновый процесс, а сама миграция отвечает только за добавление колонок, индексов и флагов.

go
и миграции внутри приложения

Миграции можно запускать не только из CLI, но и из Go-кода. Это удобно для тестов, утилит и локальных окружений, где хочется, чтобы схема накатывалась автоматически при старте. Для этого используют встроенную файловую систему через go:embed и embed.FS.

go:embed — это директива компилятора, которая включает файлы с диска (SQL, HTML, шаблоны, статические ресурсы) прямо внутрь бинарника. Тип embed.FS — представление такого «встроенного» набора файлов как read-only файловой системы. В результате миграции лежат в репозитории, но при сборке упаковываются в приложение, и бинарь можно запускать в любом окружении без копирования отдельной директории migrations.

Пример модуля, который накатывает все миграции при старте:

package migrate

import (
	"database/sql"
	"embed"

	"github.com/pressly/goose/v3"
)

//go:embed migrations/*.sql
var migrationsFS embed.FS

func Up(db *sql.DB) error {
	goose.SetBaseFS(migrationsFS)
	if err := goose.SetDialect("postgres"); err != nil {
		return err
	}
	return goose.Up(db, "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» выглядит так:

  1. Миграцией добавляется новая схема, совместимая и со старой, и с новой версией приложения (новые поля, таблицы, индексы, но без жёстких ограничений).
  2. Отдельный этап или фоновый процесс выполняет перенос данных, backfill и проверку консистентности.
  3. После того как новое представление данных стабильно, второй набор миграций убирает устаревшие поля и включает строгие ограничения (not null, check, удаление старых колонок).

Такой порядок снижает риск простоя: приложение не ломается при половинчатом развёртывании, а откат становится управляемым.

Связка goose и sqlc

goose и sqlc дополняют друг друга. Миграции описывают истинную схему, sqlc читает те же SQL-файлы как источник правды и генерирует по ним Go-типы и методы. Рабочий цикл выглядит предсказуемо:

  1. Добавляется новая миграция и накатывается локально (goose up).
  2. Запускается sqlc generate, который читает обновлённую схему и пересобирает модели и функции.
  3. Компилятор Go сразу показывает, где код больше не совпадает с базой.

В CI всё это собирается в понятный сценарий: поднимается окружение базы, выполняется goose up, затем проходят интеграционные тесты (в том числе поверх sqlc), и только после этого публикуется артефакт. В продакшене миграции прогоняются тем же goose с -v и журналируются отдельно от приложений.

Такой процесс даёт повторяемость и прозрачность: схема меняется только через миграции, история хранится в одном месте, sqlc всегда видит актуальное состояние, а откат не превращается в ручное редактирование таблиц через консоль.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845