Shanraq.org Shanraq.org
Миграции: история базы, а не `create table if not exists`
IT

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; они делают то же самое плюс блокировку, чтобы два одновременно запущенных сервера не начали применять одну миграцию вдвоём.

Карта урока

Карта урока: файлы по порядку, журнал и одна транзакция

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему номер миграции пишут как 0001, а не 1?
  2. Что останется в базе, если миграция из двух команд упала на второй?
  3. Почему not null без default проходит у вас и падает на сервере?

Задание

Обязательное. Переведите блог на миграции. Схема больше не создаётся в Open; вместо этого появляется папка migrations, первый файл повторяет нынешнюю таблицу articles, а второй добавляет колонку updated_at, которую Update заполняет через datetime('now'). Проверьте на базе, где статьи уже есть: старые строки должны уцелеть.

Запускатель миграций уже написан в step-16 — сверьтесь с ним после того, как сделаете сами.

По желанию.

  • Уберите транзакцию из migrate и повторите опыт со сломанным файлом. Посмотрите, что теперь останется в таблице.
  • Добавьте в schema_migrations колонку с длительностью применения и выведите её при старте.
  • Напишите миграцию, которая переименовывает колонку body в body_md, и обновите Store. Убедитесь, что старые статьи на месте.

Куда это встанет в блоге

Open перестаёт знать про схему: его дело — открыть базу, а форму таблиц задают файлы в migrations. Теперь блог можно менять, не теряя написанное.

Долги. Запросы по-прежнему собираются строками, и в следующем уроке выяснится, что делать этого нельзя. Update пишет все поля разом. И блокировки при миграциях у нас нет — для одного сервера это не страшно, для двух уже да.

Ответы

Показать ответы
  1. Потому что файлы сортируются как строки, посимвольно. Без ведущих нулей 10 встанет перед 9, и миграции применятся не в том порядке, в каком написаны.
  2. Ничего: транзакция откатит и то, что успела сделать первая команда, и запись в журнал. Миграция останется непримененной целиком, и после починки файла применится заново.
  3. Потому что у вас таблица пустая, а на сервере в ней есть строки. Существующим строкам базе нечем заполнить новую колонку, и она отказывается: Cannot add a NOT NULL column with default value NULL.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.