Go: с нуля до своего блога Урок 31 из 50
Миграции: история базы, а не `create table if not exists`
Тридцать первый урок курса по Go. Схему перестаёт создавать программа при старте: появляются нумерованные файлы миграций, журнал применённого и правило «каждая ровно один раз». Плюс измеренная ловушка: колонку not null без значения по умолчанию пустая база примет, а рабочая откажется менять.
Зачем это нужно
В прошлых уроках схему создавала сама программа: create table if not exists при старте. Пока таблица одна и не меняется, это работает.
Ломается всё в тот день, когда таблицу нужно поменять. Допустим, статьям понадобилась колонка updated_at. if not exists тут не поможет: таблица существует, а колонки в ней нет. Значит, в код дописывают alter table — и первый запуск проходит, а второй нет:
$ sqlite3 blog.db "alter table articles add column updated_at text not null default '';"
$ sqlite3 blog.db "alter table articles add column updated_at text not null default '';"
Error: in prepare, duplicate column name: updated_at
Дальше обычно пишут заплатку: посмотреть, есть ли уже такая колонка, и добавить, только если нет. Пока изменение одно, заплатка работает. Когда их станет десять и порядок начнёт иметь значение, однажды выяснится, что на рабочем сервере применена половина, и никто не знает какая.
Миграция — это способ держать изменения схемы так же, как код: отдельными файлами, по порядку, с записью о том, что уже применено.
Сразу целиком
Новая папка, go mod init sabaq28, драйвер тот же. Рядом с main.go — папка migrations, а в ней три файла:
migrations/0001_articles.sql
create table articles (
id integer primary key,
slug text not null unique,
title text not null,
body text not null
) strict;
migrations/0002_updated_at.sql
alter table articles add column updated_at text not null default '';
migrations/0003_updated_at_index.sql
create index articles_updated_at on articles (updated_at);
main.go
package main
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log"
"path"
"sort"
"strings"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var files embed.FS
func main() {
db, err := sql.Open("sqlite", "blog.db?_pragma=foreign_keys(1)")
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := migrate(db); err != nil {
log.Fatal("миграции: ", err)
}
var version string
err = db.QueryRow(`select coalesce(max(version), 'нет') from schema_migrations`).Scan(&version)
if err != nil {
log.Fatal(err)
}
fmt.Println("схема на версии:", version)
}
func migrate(db *sql.DB) error {
_, err := db.Exec(`create table if not exists schema_migrations (
version text primary key,
applied_at text not null default (datetime('now'))
) strict`)
if err != nil {
return err
}
names, err := fs.Glob(files, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(names)
for _, name := range names {
version := strings.TrimSuffix(path.Base(name), ".sql")
var applied int
err := db.QueryRow(`select count(*) from schema_migrations where version = ?`, version).Scan(&applied)
if err != nil {
return err
}
if applied == 1 {
fmt.Println("уже применена:", version)
continue
}
body, err := files.ReadFile(name)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(string(body)); err != nil {
tx.Rollback()
return fmt.Errorf("%s: %w", version, err)
}
if _, err := tx.Exec(`insert into schema_migrations (version) values (?)`, version); err != nil {
tx.Rollback()
return fmt.Errorf("%s: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("%s: %w", version, err)
}
fmt.Println("применена:", version)
}
return nil
}
Первый запуск:
применена: 0001_articles
применена: 0002_updated_at
применена: 0003_updated_at_index
схема на версии: 0003_updated_at_index
Второй — на той же базе:
уже применена: 0001_articles
уже применена: 0002_updated_at
уже применена: 0003_updated_at_index
схема на версии: 0003_updated_at_index
Разбор
Номер в имени файла — это и есть версия
0001, 0002, 0003. Файлы читает fs.Glob, а порядок задаёт sort.Strings — обычная сортировка строк. Поэтому номер пишут с ведущими нулями: без них после 9 пошла бы 10, а строки сравниваются посимвольно, и 10 встала бы перед 9.
Имя после номера ничего не решает, оно для людей. Через полгода 0002_updated_at скажет вам больше, чем 0002.
Главное правило одно: применённый файл не меняют. Захотелось переименовать колонку — это новая миграция, а не правка старой. Старая уже применена на чужих машинах и на рабочем сервере, и там её никто не переприменит.
Журнал: таблица, которая помнит применённое
schema_migrations — обычная таблица в той же базе. Одна строка на применённый файл: версия и время.
Она в самой базе, а не в файле рядом, и это важно. Копию базы забирают на другую машину вместе с журналом, поэтому там применится ровно то, чего не хватает.
Образ. Прививочный журнал. В нём нет самих прививок — только список уже сделанных, и именно он не даёт поставить вторую такую же.
embed: миграции внутри программы
//go:embed migrations/*.sql кладёт файлы внутрь собранной программы — тот же приём, что и со статикой в уроке про CSS и картинки.
Без него на сервер пришлось бы везти папку migrations рядом с бинарником и следить, чтобы её версия совпадала с версией программы. С embed они не могут разойтись: это один файл.
Транзакция: сломанная миграция не оставляет следов
В migrations можно положить файл с несколькими командами. Тогда появляется риск: первая команда прошла, вторая упала — и схема осталась на середине.
Проверим это. Файл 0005_broken.sql, в котором вторая строка повторяет первую:
alter table comments add column author text not null default '';
alter table comments add column author text not null default '';
Запуск:
уже применена: 0004_comments
2026/09/06 13:23:38 миграции: 0005_broken: SQL logic error: duplicate column name: author (1)
exit status 1
А теперь главное — что осталось в базе:
$ sqlite3 blog.db "select version from schema_migrations order by version;"
0001_articles
0002_updated_at
0003_updated_at_index
0004_comments
$ sqlite3 blog.db "select name from pragma_table_info('comments');"
id
article_id
body
Колонки author нет вообще, хотя первая команда файла её добавила. Транзакция откатила и её, и запись в журнал: tx.Begin, а при ошибке tx.Rollback — база вернулась к тому, с чего начинала. Исправьте файл, запустите снова — миграция применится с чистого места.
Так работает не везде. В SQLite и в Postgres create table и alter table можно откатить, а в MySQL нельзя: там каждая такая команда незаметно завершает начатую транзакцию, поэтому сломанная миграция оставляет ровно ту половину, которую успела сделать. В MySQL 8 неделимой стала отдельная команда, но не миграция целиком.
Подробно Begin, Commit и Rollback разберём в следующем уроке; здесь достаточно понимать, зачем они тут стоят.
Что alter table в SQLite умеет, а что нет
Умеет немного, и это стоит знать заранее:
alter table articles rename column title to heading; -- работает
alter table articles drop column updated_at; -- работает
alter table articles alter column heading type blob; -- такой команды нет
Последняя строка — не ошибка базы, а отсутствующая возможность:
Error: in prepare, near "alter": syntax error
Поменять тип колонки или снять not null в SQLite нельзя одной командой. Официальный способ — процедура из двенадцати шагов: создать рядом новую таблицу нужного вида, перелить в неё данные через insert ... select, удалить старую и переименовать новую. Всё это — внутри одной миграции, и всё это база умеет откатить, если что-то пойдёт не так.
В Postgres такая команда есть, и это одно из немногих мест, где переезд с SQLite заметно упростит жизнь.
not null: пустая база прощает, рабочая — нет
Ловушка, которая срабатывает именно на рабочем сервере.
На пустой таблице колонка not null без значения по умолчанию добавляется молча:
alter table t add column b text not null;
Стоит появиться хотя бы одной строке — и та же команда отвечает:
Error: stepping, Cannot add a NOT NULL column with default value NULL
Логика простая: у существующих строк новой колонки нет, база обязана чем-то её заполнить, а заполнить нечем. У вас на машине база пустая, миграция проходит, вы её отправляете — и она падает там, где данные есть.
Отсюда правило: not null — всегда с default. Пустая строка, ноль, datetime('now') — что угодно осмысленное.
Назад не откатываем
Готовые инструменты умеют «откатить миграцию»: рядом с файлом up кладут файл down, который её отменяет. На бумаге красиво.
На деле откат почти никогда не возвращает то, что было. Миграция, удалившая колонку, унесла с собой данные, и down их не воскресит. Поэтому в рабочих проектах чаще идут только вперёд: ошиблись — пишете следующую миграцию, которая чинит.
В курсе мы down писать не будем. Достаточно знать, что такая штука есть и почему на неё не стоит рассчитывать.
И ещё: свой запускатель миграций мы написали, чтобы понять устройство — здесь весь механизм умещается в одну функцию. В настоящем проекте берут готовый, чаще всего golang-migrate или goose; они делают то же самое плюс блокировку, чтобы два одновременно запущенных сервера не начали применять одну миграцию вдвоём.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему номер миграции пишут как
0001, а не1? - Что останется в базе, если миграция из двух команд упала на второй?
- Почему
not nullбезdefaultпроходит у вас и падает на сервере?
Задание
Обязательное. Переведите блог на миграции. Схема больше не создаётся в Open; вместо этого появляется папка migrations, первый файл повторяет нынешнюю таблицу articles, а второй добавляет колонку updated_at, которую Update заполняет через datetime('now'). Проверьте на базе, где статьи уже есть: старые строки должны уцелеть.
Запускатель миграций уже написан в step-16 — сверьтесь с ним после того, как сделаете сами.
По желанию.
- Уберите транзакцию из
migrateи повторите опыт со сломанным файлом. Посмотрите, что теперь останется в таблице. - Добавьте в
schema_migrationsколонку с длительностью применения и выведите её при старте. - Напишите миграцию, которая переименовывает колонку
bodyвbody_md, и обновитеStore. Убедитесь, что старые статьи на месте.
Куда это встанет в блоге
Open перестаёт знать про схему: его дело — открыть базу, а форму таблиц задают файлы в migrations. Теперь блог можно менять, не теряя написанное.
Долги. Запросы по-прежнему собираются строками, и в следующем уроке выяснится, что делать этого нельзя. Update пишет все поля разом. И блокировки при миграциях у нас нет — для одного сервера это не страшно, для двух уже да.
Ответы
Показать ответы
- Потому что файлы сортируются как строки, посимвольно. Без ведущих нулей
10встанет перед9, и миграции применятся не в том порядке, в каком написаны. - Ничего: транзакция откатит и то, что успела сделать первая команда, и запись в журнал. Миграция останется непримененной целиком, и после починки файла применится заново.
- Потому что у вас таблица пустая, а на сервере в ней есть строки. Существующим строкам базе нечем заполнить новую колонку, и она отказывается:
Cannot add a NOT NULL column with default value NULL.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.