Содержание урока · 13 разделов
- Миграции sqlc: зачем схеме переезжать в код
- golang-migrate: пары файлов up/down
- schema_migrations: книга учёта версий
- goose migrate: аннотации в одном файле
- Миграции при старте приложения: цена удобства
- Сравнение: пары, аннотации, старт приложения
- Синхронизация: sqlc проверяет запросы по папке миграций
- Down-миграции: нужны ли — мнение урока
- CI: миграции и генерация в конвейере
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
К шестнадцатому уроку трекер — взрослый проект: запросы генерируются с плоскими сигнатурами, панель фильтров обслуживается статическими методами, страницы листаются без вранья. Но у взрослости есть непроговорённое дно: откуда взялась сама схема. В каноне курса таблица todos с колонками id, title, done, priority, created_at, deleted_at просто была — и по ней sqlc выводил типы, сигнатуры и поля моделей. Между тем каждая из этих колонок когда-то появилась: таблицу создали, приоритет добавили, мягкое удаление прикрутили. Всё это — изменения схемы, и вопрос только в том, где они записаны: в истории чата, в голове старожила или в git.
Пятнадцатый урок закончил мыслью, что фильтры появляются вместе с колонками, которые фильтруешь. Это значит: изменение схемы — рядовая часть фичи, а не исключительная операция. Если оно рядовое, у него должен быть рядовой процесс: файл, ревью, применение, проверка — как у любого кода. Миграции и есть этот процесс: изменения схемы, записанные файлами в репозиторий и применяемые инструментом, а не руками. Урок пересобирает канон задним числом: покажет миграции, из которых собрана нынешняя схема трекера, инструменты, которые их катают, и точку, в которой папка миграций и генератор становятся одной командой.
Честно о формате, как и во всём курсе: миграциям нужна живая база, поэтому уроки не запускаются в песочнице — runner-ов здесь нет. Понимание проверяют quiz, predict, fill, order и флешкарты: они гоняют то же, что вы повторите командами migrate и sqlc generate у себя. Формат имён, поведение schema_migrations и dirty-состояния сверены с README и FAQ golang-migrate, документацией goose и официальными доками sqlc: не пересказ, а проверенные факты.
Миграции sqlc: зачем схеме переезжать в код#
Миграция — это шаг изменения схемы, записанный в код: файл с SQL, у которого есть номер версии и направление. Вперёд — up: применить шаг, создать таблицу, добавить колонку. Назад — down: отменить шаг, вернуть схему к предыдущей версии. Пара up/down — минимальная единица: даже когда откат вам никогда не понадобится, его существование заставляет задуматься, как шаг развернуть, — бесплатная проверка дизайна. Сумма всех up-мигранций в порядке версий — это и есть схема: не схема где-то в живой базе, а схема в коде, которую можно прочитать, отревьюить и воспроизвести с нуля одной командой.
Зачем это. Первое — воспроизводимость: новый разработчик поднимает базу командой миграций, стенд разворачивается из пустоты, и исчезает жанр «а какой ALTER надо было сделать в марте». Второе — эволюция: продукт меняется, колонки появляются и уходят вместе с фичами, история миграций — это история продукта, записанная на SQL. Третье — ревью: изменение схемы едет в PR рядом с кодом, который её использует, и reviewer видит оба мира сразу. Четвёртое — одинаковые окружения: локальная база, CI, staging и прод ведутся одним и тем же набором файлов, и расхождение между ними перестаёт быть анекдотом.
sqlc в этой картине — придирчивый читатель: он не выполняет миграции, но проверяет по ним каждый запрос. Поэтому порядок в папке миграций превращается из стилистики в контракт: как только файлы соврали о схеме, генерация начинает врать о типах. Конфиг, который указывает sqlc на папку миграций — schema: "db/migrations" — мы записали во втором уроке; теперь наполним папку так, чтобы её хотелось читать.
Миграция номер один: пара up/down
GO-- db/migrations/000001_init.up.sql: таблица-прародительница трекера
CREATE TABLE todos (
id BIGSERIAL PRIMARY KEY, -- int64 в Go
title TEXT NOT NULL, -- string
done BOOLEAN NOT NULL DEFAULT false, -- bool
created_at TIMESTAMPTZ NOT NULL DEFAULT now() -- время ставит база
);
-- db/migrations/000001_init.down.sql: шаг назад из пустоты
DROP TABLE IF EXISTS todos;Пара, с которой начинается канон: из этого файла выросла нынешняя схема — второй шаг добавил priority, третий deleted_at, и сумма трёх up-файлов равна схеме, по которой генерировался код пятнадцати уроков. Down зеркален буквально: DROP TABLE возвращает базу к точке «таблицы не было». Пометки с Go-типами — перевод для глаз, правила соответствия разобраны в четвёртом уроке; now() — вызов в базе, не в приложении: время создания записи — забота хранилища. Обратите внимание на IF EXISTS в down-файле: откат обязан быть безопасен даже в моменте, когда таблицы уже нет.
golang-migrate: пары файлов up/down#
Самый популярный инструмент чистого SQL — github.com/golang-migrate/migrate. Его модель проста до прозрачности: одна миграция — пара файлов с одинаковым номером версии и общим названием. Имя собирается по шаблону «версия_название.up.sql» для шага вперёд и «версия_название.down.sql» для шага назад: 000001_init.up.sql и 000001_init.down.sql. Номер — целое число: по умолчанию инструмент ставит unix-время создания файла, но флаг -seq у команды create включает последовательную нумерацию с ведущими нулями — 000001, 000002, 000003; проект, который читают люди, лучше живёт с последовательным форматом.
Здесь живёт подводный камень, о котором прямо предупреждают доки sqlc: golang-migrate сортирует файлы численно, а sqlc читает папку лексикографически. Без ведущих нулей файл 10_bar.up.sql ляжет раньше 9_foo.up.sql — генератор увидит схему не в том порядке, в котором её видит база. Лечение одно: нулевое дополнение номеров. 009_foo и 010_bar дружат в обоих мирах, 9_foo и 10_bar — нет.
CLI лаконичен: источник и база задаются флагами -source (для файловой папки её короткое имя -path) и -database, дальше — глаголы: up — применить всё или N шагов, down — откатить N, version — показать текущую версию, goto V — доехать до конкретной, force V — переписать версию без выполнения файлов. create — единственная команда, работающая с файлами, а не с базой: она печатает заготовку пары up/down с очередным номером.
Библиотечная сторона — тот же механизм в Go: migrate.New(sourceURL, databaseURL) возвращает объект, у которого вызывают Up(), Steps(n), Migrate(version) — «приведи базу к версии V» — и Down(). Пустые импорты регистрируют драйвер базы и источник файлов; именно поэтому одна программа умеет вести миграции и из папки на диске, и из файловой системы, вшитой в бинарник.
CLI: четыре команды на каждый день
GO# заготовить пару: 000003_soft_delete.up.sql и 000003_soft_delete.down.sql
migrate create -ext sql -dir db/migrations -seq soft_delete
# применить всё неприменённое к локальной базе
migrate -path db/migrations -database "postgres://localhost:5432/tracker?sslmode=disable" up
# узнать версию и откатить ровно один шаг
migrate -path db/migrations -database "postgres://localhost:5432/tracker?sslmode=disable" version
migrate -path db/migrations -database "postgres://localhost:5432/tracker?sslmode=disable" down 1Один источник, одна база и набор глаголов. create работает только с файлами: печатает пару заготовок с очередным последовательным номером — содержимое пишете вы, восстановите его по пропускам в fill-задании ниже. up без аргумента доезжает до последней версии; down N — это N шагов назад по счётчику, а не «до версии N»: у возврата к конкретной версии есть отдельный глагол goto. Строка подключения — та же, что у приложения; sslmode=disable годится только локально. Повторный up на накатанной базе ничего не изменит — так работает книга учёта, о ней сразу ниже.
schema_migrations: книга учёта версий#
Инструмент помнит версию базы в самой базе: рядом с вашими таблицами появляется служебная schema_migrations — таблица с двумя колонками, version и dirty. Механика честная: перед применением шага migrate записывает его номер и ставит dirty в true — «я мигрирую»; после успеха сбрасывает флаг, оставляя версию применённого шага. Повторный migrate up на полностью накатанной базе сравнит версию из таблицы с последней в папке, не найдёт недостающих и не сделает ничего: повторный up — штатный no-op, а не ошибка. Сбой посреди шага — самый важный сценарий: dirty останется true, и любая следующая попытка ответит «Dirty database version N. Fix and force version» — конвейер миграций не поедет, пока человек не разберётся.
Разобраться — значит понять, что именно сломалось: частично применённый ALTER придётся доводить или убирать руками, потому что откат за вас никто не придумал. Когда схема приведена в порядок, версию переписывают командой force: она выставляет номер без выполнения файлов, и конвейер снова едет. Таблицу можно переименовать параметром подключения (x-migrations-table у postgres-драйвера), если имя по умолчанию не устраивает.
И про соревнование: два процесса migrate на одной базе не применят один шаг дважды — postgres-драйвер берёт advisory-лок базы (функция pg_advisory_lock), и второй процесс ждёт. Лок защищает от двойного применения, но не от долгого ожидания: процесс, вставший в очередь за длинной миграцией, просто стоит. Это знание понадобится, когда мы будем обсуждать миграции при старте сервиса.
Проверка: повторный up
База доведена до последней версии: все up-файлы применены, schema_migrations показывает номер последней миграции и dirty = false. Команда запускает migrate up ещё раз. Что произойдёт?
goose migrate: аннотации в одном файле#
Второй полюс — goose из проекта pressly. Его философию видно по формату: одна миграция — один файл, внутри которого два раздела, размеченные комментариями-аннотациями. Раздел вперёд открывает -- +goose Up — по правилам goose она ровно одна на файл и обязательна; раздел отката открывает -- +goose Down — она опциональна, но если есть, обязана стоять после Up. Всё, что следует за аннотацией до конца файла, goose относит к соответствующему направлению; SQL вне аннотаций просто пропускается — и это не только удобство, но и ловушка: заголовок уехал выше комментария, часть шага молча не применяется. Учёт версий ведётся в собственной таблице goose_db_version, где у каждого шага отмечено, применён ли он.
Команды зеркалят golang-migrate с теми же идеями: goose up — применить всё, goose down — откатить шаг, goose down-to 0 — разобрать базу до пустоты, goose status — показать, что применено. Полезное отклонение — up-by-one: по одному шагу за раз, любимый режим отладки миграций. Для процедур и функций, где точка с запятой встречается внутри тела, goose просит размечать оператор парой -- +goose StatementBegin и -- +goose StatementEnd, а сложные шаги можно вывести из транзакции аннотацией -- +goose NO TRANSACTION.
Для Go-кода у goose три двери: CLI, библиотека и embed — goose.SetBaseFS с файловой системой go:embed кладёт миграции в бинарник, и сервис несёт схему в себе. По запросу «goose migrate» вас приведут ровно сюда: команда у инструмента называется просто goose up, но смысл фразы — ведение миграций goose'ом — совпадает с темой урока.
Тот же шаг в формате goose: один файл
GO-- db/migrations-goose/000003_soft_delete.sql: тот же шаг, формат goose
-- +goose Up
ALTER TABLE todos ADD COLUMN deleted_at TIMESTAMPTZ; -- nullable: задача жива
CREATE INDEX idx_todos_open_priority ON todos (priority)
WHERE deleted_at IS NULL; -- индекс открытых для фильтров
-- +goose Down
DROP INDEX IF EXISTS idx_todos_open_priority;
ALTER TABLE todos DROP COLUMN IF EXISTS deleted_at;
# goose ведёт ту же базу: команды вместо пар файлов
goose -dir db/migrations-goose postgres "postgres://localhost:5432/tracker?sslmode=disable" up
goose -dir db/migrations-goose postgres "postgres://localhost:5432/tracker?sslmode=disable" statusВесь шаг в одном файле: два раздела, два направления, порядок фиксирован — Up раньше Down. Обратите внимание на три вещи. Первая: внутри одного раздела несколько операторов — колонка и индекс уезжают одним шагом, потому что логически это одна фича: мягкое удаление с быстрым списком открытых задач. Вторая: аннотации — комментарии для goose, а sqlc читает их как разметку и берёт из файла только SQL. Третья: форматы в одной папке не смешивают — либо пары up/down, либо файлы с аннотациями; sqlc поддерживает оба, но инструмент на проекте один. Если команда выбрала goose, пара из предыдущего примера сжимается в один файл, а содержимое раскладывается по разделам.
Миграции при старте приложения: цена удобства#
Третий способ — не инструмент, а решение: вызывать миграции из самого приложения при старте. Идея заманчива: деплой из одного шага, локальный запуск сам поднимает базу, тестовые окружения обслуживают себя сами. Библиотечный API обоих инструментов для этого и сделан: несколько строк до подключения к пулу — и база догнала код.
Теперь честно про цену. Риск первый — стартовая гонка: при раскатке обновления поднимается сразу несколько подов, и каждый пытается мигрировать. Advisory-лок сериализует попытки — один применяет, остальные ждут, — но время старта каждого пода становится временем чужой миграции, а readiness-проверки могут прибить под раньше, чем он дождётся очереди. Риск второй — отказоупорность: миграция падает — процесс не выходит из старта, инцидент схемы становится инцидентом сервиса, и чинить dirty-версию придётся руками в тот момент, когда весь деплой стоит. Риск третий — длительные шаги: реорганизация большой таблицы на десятки минут превращает обычный деплой в окно простоя, и обнаружится это в худший момент, потому что до продакшен-объёмов миграцию никто не примерил. Риск четвёртый — права: приложению нужны права на DDL в рантайме, а это расширение поверхности атаки и соблазн «заодно» поменять схему из кода.
Вывод урока: миграции при старте — честный выбор для маленького сервиса с одним инстансом и для локальной разработки. В командном продакшене миграции должны быть отдельным шагом деплоя — джобой или стадией пайплайна, с логами, таймаутами и правом падать до того, как пострадал трафик. Не запрет, а дисциплина: инструмент тот же, место другое.
API: база догоняет код при старте
GO// cmd/tracker/main.go: поднять схему до последней версии перед стартом
import (
"errors"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres" // драйвер базы
_ "github.com/golang-migrate/migrate/v4/source/file" // источник file://
)
// migrateUp применяет все недостающие миграции; вызывается до подключения пула
func migrateUp(dsn string) error {
m, err := migrate.New("file://db/migrations", dsn)
if err != nil {
return err // не нашлись папка миграций или зарегистрированный драйвер
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err // сбой шага: база в dirty, старт прерываем честно
}
return nil // ErrNoChange — не ошибка: применять нечего
}Три части API. migrate.New принимает два URL: источник миграций — file:// для папки на диске, а для схемы, вшитой в бинарник, есть источник iofs — и строку подключения к базе. Пустые импорты регистрируют драйвер и источник: без них New вернёт загадочную ошибку, пока не вспомнишь про механизм регистрации. m.Up() применяет всё недостающее; на накатанной базе он вернёт ErrNoChange — штатный повтор, а не сбой, и его отфильтровывают. Ошибку честно возвращают наверх: подниматься на базе неизвестной версии хуже, чем не подняться. Доехать до конкретной версии — Migrate(version); продвинуться по шагам — Steps(n). В продакшене тот же код лучше вызывать не из приложения, а из отдельной джобы: инструмент тот же, место другое.
Проверка: три пода стартуют разом
Сервис мигрирует базу при старте (m.Up() в main.go). Обновление раскатывается на три пода, и все три стартуют одновременно. Что произойдёт при условии, что миграции ещё не применены?
Сравнение: пары, аннотации, старт приложения#
| Критерий | golang-migrate | goose | Миграции при старте |
|---|---|---|---|
| Файлы | пара up/down на шаг | один файл с аннотациями | формат любого из двух |
| Запуск | CLI или API | CLI, API, embed-провайдер | сам процесс приложения |
| Учёт версий | schema_migrations: version + dirty | goose_db_version | таблица выбранного инструмента |
| Откат | down N по счётчику | down и down-to по версиям | обычно не предусмотрен |
| Защита от гонок | advisory-лок postgres-драйвера | запуск из одного места | лок сериализует, старт ждёт |
| Главный риск | dirty-версия требует force | SQL вне аннотаций пропускается молча | сбой миграции останавливает сервис |
| Когда выбирать | чистый SQL, CI, отдельная джоба | один файл, embed в бинарник | маленький сервис, один инстанс |
Синхронизация: sqlc проверяет запросы по папке миграций#
Вот и стык, ради которого урок живёт в курсе sqlc. Конфиг второго урока указывал schema: "db/migrations" — не файл, а папку. Теперь это перестаёт быть просто удобством: sqlc читает все файлы папки в лексикографическом порядке, собирает итоговую схему и сверяет с ней каждый запрос. Доки sqlc фиксируют рамку: sqlc не выполняет миграции — он их понимает. Он различает up и down и при разборе файлов down-миграции игнорирует; из коробки распознаются форматы golang-migrate, goose, sql-migrate и tern, а также atlas и dbmate. Поэтому ни инструменту миграций, ни генератору не нужно ничего объяснять: up-файл — правда для обоих, down — правда только для инструмента.
Выигрыш — контракт синхронизации: запрос не сгенерируется под несуществующую колонку. Пишите SELECT с колонкой, которой нет ни в одном up-файле, — генерация падает с сообщением о колонке, которой нет в схеме, и вы узнаёте об этом на ноутбуке, а не после выката. Второй урок показывал падение генерации на опечатках в конфиге; здесь тот же механизм работает на более глубоком слое — расхождение схемы и запросов ловится до компиляции. В ревью появляется правило одного PR: миграция, запросы и перегенерированный код едут вместе — reviewer видит колонку рядом с фильтром, который её читает. Фильтр из пятнадцатого урока и миграция с колонкой priority — образцовая пара.
И три привычки гигиены. Первая — ведущие нули в номерах: лексикографический порядок sqlc и численный порядок golang-migrate обязаны совпадать. Вторая — не редактировать применённые миграции: файл, накатанный на чужие базы, теперь история, ошибки исправляются новым шагом. Третья — down-файлы в тонусе: sqlc их игнорирует, но ваш инструмент — нет.
Один PR, две папки, один источник правды
GO# sqlc.yaml: схема — не файл, а папка миграций (конфиг второго урока)
version: "2"
sql:
- engine: "postgresql"
queries: "db/queries"
schema: "db/migrations"
gen:
go:
package: "db"
out: "db/sqlc"
sql_package: "pgx/v5"
-- db/migrations/000002_add_priority.up.sql: колонка для фильтров панели
ALTER TABLE todos ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
-- db/queries/todos.sql: запрос панели опирается на колонку
-- name: SearchTodosByPriority :many
SELECT * FROM todos
WHERE priority = $1 AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC;
// сгенерировано: поле появляется в Todo вместе с колонкой
Priority int32 // NOT NULL DEFAULT 0 -> плоский int32Один PR проходит цепочку целиком: миграция дописывает колонку в папку, sqlc собирает из папки схему, запрос панели проверяется против неё, генерация выдаёт поле Priority int32 — плоский тип от NOT NULL-колонки, как и положено. Ни один шаг не требует запущенного сервера: и схема, и запросы — файлы, сверка — часть sqlc generate. Down-файл пары (000002_add_priority.down.sql) в сборке схемы не участвует — sqlc его игнорирует, поэтому в примере его нет. Соблюсти стоит другое: ведущие нули в номере — 000002, а не 2, — чтобы лексикографический порядок генератора совпал с численным порядком инструмента миграций.
Предскажите исход генерации
Прочитайте код и выберите, что он выведет:
-- db/migrations/000002_add_priority.down.sql: файл есть, но он down
ALTER TABLE todos DROP COLUMN IF EXISTS priority;
-- up-файла с ADD COLUMN priority ещё нет: коллега не закоммитил
-- db/queries/todos.sql уже написан под фильтр панели:
-- name: SearchTodosByPriority :many
SELECT * FROM todos WHERE priority = $1;Down-миграции: нужны ли — мнение урока#
Down-миграции — предмет вечного спора, и у урока есть мнение, изложенное честно. Начнём с того, чего требуют инструменты: golang-migrate рекомендует включать оба файла всегда — даже когда down пуст, пара должна существовать, чтобы решение «шаг необратим» было явным; goose считает Down необязательной — без неё шаг вперёд возможен, а назад — нет. Оба подхода законны, но молчаливое отсутствие отката хуже явного пустого: по крайней мере, никто не понадеется на несуществующий скрипт.
Мнение: down-миграции — инструмент разработки, а не план спасения продакшена. В разработке они незаменимы: down-to 0 и снова up — чистая база за секунды, отладка миграций, прогоны тестов на свежих схемах, эксперименты с формой колонок держатся на откате. В проде честность другая. Down-файлы почти никогда не тестируются на реальных данных — файл, который не гоняли на объёмах, не план, а надежда. DROP COLUMN стирает данные без возврата, и «откат» на деле означает потерю. А данные, которые шаг пересчитал, вообще некуда откатывать. Аварийный путь продакшена — не назад, а вперёд: новая forward-миграция, чинящая схему и данные; откат кода деплоем; на случай катастрофы — восстановление из бэкапа. Это не отказ от down-файлов: писать их дёшево, и они полезны как тест дизайна шага. Это отказ от иллюзии, что они спасут живой прод.
Того же духа дисциплина разворачивания: колонка добавляется с DEFAULT — старый код живёт, пока новый доезжает; безобидные расширяемые шаги едут первыми, разрушающие (переименования, слияния таблиц) дробятся на серию. Схема меняется часто и понемногу — так, чтобы ни один шаг не требовал остановки мира.
Предскажите состояние книги учёта
Прочитайте код и выберите, что он выведет:
-- база доведена до версии 3 (000003_soft_delete применён):
-- schema_migrations: version = 3, dirty = false
# откатываем ровно один шаг:
# migrate -path db/migrations -database $DSN down 1Проверка: авария на проде
Миграция с ошибкой уехала на прод: колонка добавилась, но часть записей получила неверные значения. Down-файл у шага есть. Какой план возврата урок называет честным?
CI: миграции и генерация в конвейере#
Миграции размечают границу между «работает у меня» и «работает везде», поэтому их естественное место — конвейер. Картина простая: в CI поднимается эфемерная база — пустая, чистая, одноразовая; на ней прогоняются все up-миграции подряд; тесты работают на живой схеме; база гасится вместе с окружением. Такой прогон стоит копейки и проверяет главное: сумма миграций из папки действительно разворачивается с нуля — не «в базе что-то накатано руками», а именно пустота до полного состояния. Жанр «накатил руками на тестовой базе, потом удивлялся на staging» перестаёт существовать: окружения рождаются из папки миграций и умирают с ней.
Второй гейт конвейера — генерация: свежий sqlc generate против папки миграций и проверка, что рабочая копия не изменилась. Расхождение означает, что кто-то закоммитил запрос без перегенерации или схему без запросов. Инструменты для этого — sqlc diff и шаг в GitHub Actions — тема двадцать второго урока; здесь важно направление: миграции и генерация проверяются одним конвейером, и зелёный PR означает согласованность обоих миров. Интеграционные тесты на живой базе — материал двадцать первого урока.
Связка с ревью завершает картину: правило одного PR выполняется автоматически, если конвейер краснеет при частичной подаче — миграция без запросов, запросы без миграции, код без перегенерации. Папка миграций из архива проекта превращается в активно проверяемый артефакт: то, что проверяется каждый день, не может соврать.
Восстановите пару миграций мягкого удаления
Три пропуска: ключевые слова SQL в up- и down-файлах третьей миграции трекера.
Соберите путь одной колонки до продакшена
Девять строк: фича «приоритет» проезжает от заготовки пары файлов до деплоя кода. Шаги, расширяющие схему, едут раньше кода, который их читает.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: миграции вместе с sqlc
Что дальше#
Миграции встали на место: схема трекера — это сумма up-файлов в db/migrations; golang-migrate ведёт её парами и не едет дальше грязной версии; goose migrate собирает шаг в один файл с аннотациями; sqlc сверяет каждый запрос с папкой и отказывается генерировать код под несуществующую колонку. Правило одного PR связало миры: колонка появляется вместе с фильтром, который её читает, — пятнадцатый урок впервые провернул этот узел, теперь он закручен до конца.
Дальше — про масштаб: Семнадцатый урок «Несколько схем и организация» разведёт домены по пакетам queries, когда одна папка перестаёт вмещать проект. А работа этого урока переедет в конвейер: двадцать второй урок покажет sqlc diff и проверку генерации в CI — зелёный PR там означает, что миграции, запросы и сгенерированный код согласованы. Конфиг с путями — во втором уроке; панель фильтров, ради которой меняется схема, — в пятнадцатом; план остальных тем — на странице курса sqlc.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Миграции — изменения схемы файлами в git: пара up/down на шаг, сумма up-файлов в порядке версий и есть схема. Схема в коде даёт воспроизводимость, историю и ревью рядом с кодом фичи.
- golang-migrate: пары файлов с численными версиями, CLI (create, up, down N, version, goto, force) и API (migrate.New, Up, Steps, Migrate). Нули в номерах обязательны: sqlc читает папку лексикографически, инструмент — численно.
- schema_migrations помнит version и dirty: сбой посреди шага блокирует конвейер до force V; повторный up — штатный no-op; postgres-драйвер защищает базу advisory-локом.
- goose migrate: один файл с аннотациями -- +goose Up (обязательна) и -- +goose Down (опциональна), таблица goose_db_version, команды up/down/down-to/status и embed в бинарник через SetBaseFS.
- Миграции при старте — удобство с ценой: стартовая гонка подов, dirty-инцидент во время деплоя, окно простоя, DDL-права в рантайме; для командного прода — отдельный шаг деплоя.
- Синхронизация: sqlc не выполняет миграции, но читает папку и игнорирует down; запрос не сгенерируется под несуществующую колонку — расхождение ловится на ноутбуке, миграция и запросы едут одним PR.
- Down-миграции — инструмент разработки: dev откатывает смело, прод идёт вперёд forward-миграцией и бэкапами; в CI эфемерная база проверяет сумму миграций с нуля, генерация — соседний гейт конвейера.
Домашнее задание
Проведите трекер через полный миграционный цикл. Первое: заведите папку db/migrations и соберите стартовую пару 000001_init — CREATE TABLE с четырьмя колонками в up-файле и DROP TABLE в down; примените к локальной базе, найдите schema_migrations в списке таблиц и распечатайте version и dirty. Второе: допишите 000002_add_priority и 000003_soft_delete, накатите всё одним up, повторите up ещё раз и убедитесь, что второй вызов ничего не изменил. Третье: воспроизведите dirty-состояние — напишите up-файл с намеренной ошибкой (ссылка на несуществующую таблицу), примените, прочитайте сообщение о грязной версии, почините файл и вызовите force; зафиксируйте письменно, почему файл чинится до force. Четвёртое: перепишите три миграции в формате goose — один файл с аннотациями на шаг — и прогоните goose status и goose down-to 0, затем up заново; сверьте итоговую схему с суммой пар golang-migrate: она обязана совпасть. Пятое: сломайте синхронизацию намеренно — напишите запрос с колонкой due_date, не добавляя миграцию, и прочитайте падение sqlc generate; затем добавьте 000004_add_due_date парой, перегенерируйте и найдите новое поле в Todo-структуре. Шестое: проверьте лексикографическую ловушку — создайте файлы 9_test и 10_test без нулей и посмотрите, в каком порядке генератор читает папку; почините нулями и объясните в README папки миграций, почему нумерация именно такая. Седьмое: соберите в main.go функцию migrateUp из урока с фильтрацией ErrNoChange и запустите сервис дважды — второй старт обязан пройти без ошибок; затем вынесите вызов в отдельную джобу и опишите, что изменилось в поведении при сбое. Восьмое: напишите down-файлы к четырём миграциям и прогоните по одному шагу назад с проверкой schema_migrations после каждого; сформулируйте одним абзацем план аварии вашего проекта — forward-миграция, бэкап или и то и другое.
Личные заметкиПусто
Вопросы и ответы по уроку
sqlc сам выполняет миграции?
Нет: sqlc миграции не выполняет — он их понимает. Генератор читает папку, различает up и down (down игнорирует при разборе), распознаёт форматы golang-migrate, goose, sql-migrate, tern, atlas и dbmate. Применением ведают инструменты миграций или ваш код через их API; sqlc лишь гарантирует, что каждый запрос сверялся с той схемой, которая получится после всех up-файлов.
golang-migrate или goose — что выбрать?
Смотрите на два критерия. Формат: пары up/down строго разделяют направления и удобны для больших шагов; один файл с аннотациями компактнее и читается сверху вниз. Окружение: если миграции нужны внутри бинарника — у goose embed из коробки через SetBaseFS, у golang-migrate есть источник iofs для файловой системы из go:embed. Инструменты взаимозаменяемы, sqlc поддерживает оба; главное — выбрать один на проект и не смешивать форматы в папке.
Можно ли запускать миграции при старте сервиса?
Для одного инстанса и локальной разработки — да: несколько строк с m.Up() до подключения пула. Для командного продакшена — отдельный шаг деплоя: при раскатке на несколько подов каждый стартует с миграцией, время старта растягивается на чужие шаги, а сбой превращает инцидент схемы в недоступность сервиса и dirty-версию, чинимую руками посреди релиза. Джоба с логами, таймаутами и правом падать до трафика — это дисциплина, а не запрет.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
ent: графовая ORM с кодогенерацией
Курс ent: схемы, поля, связи, запросы, хуки, privacy, миграции и GraphQL. 25 уроков с примерами и проектом микроблога.
- Проектировать схемы и связи в ent
- Генерировать код и жить с кодогенерацией
- Писать запросы builder-стилем с типами
- Строить хуки, интерсепторы и privacy-правила