Menu

Миграции в SQLite: версии схемы через user_version

Разбираем, как безопасно развивать схему SQLite: версионирование через PRAGMA user_version, упорядоченные скрипты миграций и транзакции для отката изменений.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Схемы меняются. Будь к этому готов.

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

Ровно для этого и нужны миграции sqlite: последовательность небольших упорядоченных скриптов, которые переводят базу с версии N на версию N+1. Запускаешь по порядку — и любая база догоняет текущее состояние. Забьёшь на дисциплину — получишь классические баги в духе «у меня всё работает», на отлов которых уйдёт половина дня.

В SQLite для этого есть ровно один встроенный механизм — PRAGMA user_version. Это 32-битное целое число, которое база хранит за тебя, и сам SQLite его никак не использует. Что оно означает — решаешь ты.

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

Минимальный цикл миграций SQLite

Идея простая: каждая миграция — это пронумерованный SQL-скрипт. Приложение читает текущий user_version, по порядку прогоняет все скрипты с бо́льшим номером и после каждого обновляет user_version.

Вот первая миграция — создаём начальную схему:

Здесь стоит обратить внимание на две вещи. Во-первых, всё обёрнуто в BEGIN; ... COMMIT;, то есть миграция атомарна: если CREATE TABLE упадёт, user_version не увеличится, и можно будет спокойно поправить скрипт и запустить его заново. Во-вторых, PRAGMA user_version = 1 идёт последней инструкцией перед коммитом — версия переключается только тогда, когда всё остальное отработало успешно.

Теперь представим, что нужно добавить колонку created_at. Это уже миграция 2:

База с версией 0 прогонит оба скрипта. База на версии 1 — только второй. А база на версии 2 не получит ничего. Порядок здесь — это контракт.

Что умеет и чего не умеет ALTER TABLE в SQLite

ALTER TABLE в SQLite намеренно сделан скупым. Поддерживаются только четыре операции:

  • ADD COLUMN — добавить новый столбец, при желании со значением по умолчанию.
  • DROP COLUMN — удалить столбец (начиная с 3.35).
  • RENAME COLUMN — переименовать столбец (начиная с 3.25).
  • RENAME TO — переименовать саму таблицу.

И всё. Поменять тип столбца, навесить или снять NOT NULL, поправить ограничение CHECK, прицепить FOREIGN KEY к уже существующему столбцу — ничего из этого через ALTER TABLE не сделаешь.

-- Не поддерживается:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255);
ALTER TABLE users ADD CONSTRAINT users_email_check CHECK (email LIKE '%@%');

Когда SQLite не умеет внести изменение напрямую, официальный рецепт — «пересобрать таблицу». Способ многословный, зато работает железобетонно.

Пересборка таблицы для серьёзных изменений схемы

Схема такая: создаём новую таблицу нужной формы, копируем туда данные, удаляем старую, переименовываем новую на её место. Всё это — внутри одной транзакции.

В полной документации SQLite этот приём называют «рецептом из 12 шагов» — там же расписаны дополнительные тонкости с триггерами, представлениями и внешними ключами. Если предстоит трогать боевую схему, лучше один раз прочитать его целиком. Для большинства же ситуаций хватит и четырёх шагов выше.

Важный момент: если на перестраиваемую таблицу ссылаются внешние ключи, перед миграцией выполните PRAGMA foreign_keys = OFF, а после — PRAGMA foreign_keys = ON. Иначе DROP TABLE посреди процесса нарушит ссылочную целостность.

Запускаем миграции из приложения

Учёт версий настолько простой, что его несложно реализовать самому. Вот пример на Python со стандартной библиотекой:

Ключевые инварианты:

  • Миграции нумеруются последовательно начиная с 1. Без пропусков и перестановок.
  • Каждая миграция оборачивается в транзакцию вместе с PRAGMA user_version = N.
  • Если миграция уже закоммичена и уехала в продакшен — её больше не трогаем. Любые правки оформляем новой миграцией.

Именно последнее правило команды нарушают чаще всего. Стоит вам отредактировать миграцию 3 после того, как у коллеги она уже применилась к его базе, — и его база навсегда тихо разъедется с вашей.

Ведём журнал миграций

PRAGMA user_version показывает, на какой версии находится база. Но он ничего не говорит ни о том, когда выполнялся каждый шаг, ни о том, что именно он сделал. Эту проблему решает небольшая служебная таблица:

Теперь у вас по одной строке на каждую миграцию — с именем и таймстампом. Жутко удобно, когда отлаживаешь вопрос «почему в базе есть колонка, о которой код ни сном ни духом?».

Источником истины для цикла по-прежнему остаётся PRAGMA user_version, а таблица — это уже для людей.

Откат миграции SQLite: что транзакции дают, а что нет

DDL в SQLite транзакционный. Допустим, миграция 5 создаёт таблицу, перекачивает в неё данные и поднимает user_version — и где-то на середине копирования всё ломается. ROLLBACK откатит всё подчистую, включая CREATE TABLE. База останется ровно в том виде, в каком была до BEGIN.

Это всё про провалившиеся миграции. Но бывает и другая ситуация: миграция применилась успешно, а вы потом передумали. На этот случай пишут отдельную down-миграцию — скрипт, который откатывает изменения. В SQLite нет никакого автоматического обратного действия. Если миграция 7 добавила колонку, то её down-версия эту колонку удаляет. Если миграция 7 удалила колонку, down-версия данные уже не вернёт — максимум, она пересоздаст пустую колонку.

На практике в небольших проектах down-миграции часто вообще не пишут, а в качестве «отмены» полагаются на бэкапы. Это вполне рабочий подход — при условии, что бэкапы вы реально делаете.

Несколько привычек, которые избавят от боли в будущем

  • Одна миграция — одно логическое изменение. Миграция, которая добавляет три не связанных между собой колонки, и ревьюится тяжелее, и откатывается сложнее, чем три отдельные миграции.
  • Прогоняйте миграции на копии прода. Изменения схемы на больших таблицах могут идти долго, и узнавать об этом в продакшене — удовольствие сомнительное.
  • Никогда не правьте уже выкаченную миграцию. Добавьте новую.
  • Сначала бэкап. Быстрый .backup в CLI или просто копия файла при закрытой базе — это дешёвая страховка перед любой нетривиальной миграцией.
  • Не забывайте про PRAGMA foreign_keys. На время пересборки таблицы выключайте, после — обратно включайте.

Для проектов покрупнее имеет смысл взять специализированный инструмент — Alembic поверх SQLAlchemy, golang-migrate, Knex, Flyway. Они берут на себя порядок применения, одновременный запуск с нескольких инстансов и командные договорённости, которые иначе пришлось бы изобретать самим. Принципы те же, что в цикле выше, — инструмент просто убирает рутину.

Дальше: режим WAL и конкурентность

Миграции обычно выполняются, когда приложение остановлено или удерживает эксклюзивную блокировку. А вот в обычном режиме база обслуживает чтения и записи сразу из нескольких соединений — и дефолтный режим журнала в SQLite подходит для этого не всегда. На следующей странице разберём режим WAL: что он меняет и когда его стоит включать.

Часто задаваемые вопросы

Как версионировать схему в SQLite?

В SQLite на уровне базы есть встроенный 32-битный счётчик user_version, доступный через PRAGMA user_version. При старте приложения читаем его, сравниваем с номером последней миграции, известной коду, и докатываем недостающие по порядку. Отдельной таблицы не требуется, хотя многие всё же заводят её — для истории применённых миграций.

Можно ли откатить миграцию в SQLite?

Заворачивайте каждую миграцию в BEGIN; ... COMMIT;. Если внутри что-то падает, ROLLBACK откатывает шаг целиком — и DDL, и данные, потому что в SQLite изменения схемы тоже транзакционные. А вот чтобы откатить уже закоммиченную миграцию, нужен отдельный down-скрипт, который вы написали сами — SQLite сам его не сгенерирует.

Почему ALTER TABLE в SQLite такой ограниченный?

SQLite поддерживает ALTER TABLE ADD COLUMN, RENAME TABLE, RENAME COLUMN и DROP COLUMN, но не умеет произвольных правок — например, поменять тип колонки или ограничения. Обходной путь — рецепт из 12 шагов: создать новую таблицу нужной формы, выполнить INSERT INTO new_table SELECT ... FROM old_table, удалить старую и переименовать новую.

Брать готовый инструмент для миграций или писать свой?

Для небольшого приложения хватит самописного цикла по пронумерованным .sql-файлам, который смотрит на PRAGMA user_version, — это строк 30 кода, и работает нормально. На крупных проектах лучше взять готовое: Alembic (Python), golang-migrate (Go) или Knex (Node) — они уже умеют сортировку, блокировки и командные сценарии, которые иначе пришлось бы писать самому.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ