Миграции базы без простоя: расширить, перенести, сузить
Почему переименование колонки роняет продакшн, как работает подход expand/contract, как переносить данные пачками и что делать вместо отката базы.
Пока пользователей мало, миграции делают просто: остановили приложение, поменяли схему, запустили новую версию. Минута простоя никого не волнует. Потом простой начинает стоить денег, деплой становится плавным, и тут выясняется, что безобидная миграция умеет ронять продакшн.
Почему это вообще ломается
Причин две.
Первая — во время деплоя старая и новая версии кода работают одновременно. Новые экземпляры уже подняты, старые ещё дообслуживают запросы. Если миграция переименовала колонку name в full_name, старый код несколько минут обращается к колонке, которой больше нет. А если новую версию придётся откатить, она будет работать со схемой, которую не понимает.
Вторая — блокировки. Некоторые изменения схемы на время выполнения блокируют таблицу. На таблице в сотню строк этого не видно, на таблице в несколько миллионов запросы встают в очередь, пул соединений заканчивается, и приложение перестаёт отвечать целиком, хотя миграция трогала одну таблицу.
Расширить, перенести, сузить
Подход, который снимает первую проблему, называют expand/contract. Опасное изменение разбивается на несколько безопасных, и каждое выкатывается отдельным релизом. На примере переименования колонки:
- Добавить новую колонку
full_name, старую не трогать. - Выкатить код, который пишет в обе колонки, а читает из старой.
- Перенести данные из старой колонки в новую для существующих строк.
- Выкатить код, который читает из новой.
- Выкатить код, который перестал писать в старую.
- Удалить старую колонку.
Выглядит длинно для одного переименования, и иногда честнее спросить, так ли оно нужно. Но на каждом шаге и текущая, и предыдущая версии кода совместимы со схемой, а значит, любой релиз можно откатить без последствий.
Как не заблокировать таблицу
В Postgres несколько правил покрывают большую часть случаев:
- Индекс на живой таблице создаётся с
concurrently. Это дольше, но не блокирует запись. Такая команда не может выполняться внутри транзакции, поэтому её выносят в отдельную миграцию. - Проверку вида
check (email is not null)или внешний ключ на большой таблице добавляют в два шага: сначала с пометкойnot valid, потом отдельной командойvalidate constraint, которая проверяет строки, не блокируя запись. - Перед миграцией ставят короткий
lock_timeout. Если таблицу не удалось захватить за несколько секунд, миграция падает, а не висит, собирая за собой очередь запросов.
set lock_timeout = '3s';
create index concurrently if not exists orders_user_id_idx
on orders (user_id);
Перенос данных пачками
Заполнение новой колонки для миллиона строк одним update держит блокировки на всех этих строках и раздувает журнал. Делай это отдельным скриптом, а не в миграции:
- пачками по несколько тысяч строк, по возрастанию первичного ключа;
- с небольшой паузой между пачками, чтобы не забрать всю базу себе;
- с возможностью продолжить с места остановки, если скрипт прервался;
- с итоговой проверкой: сколько строк осталось с пустым новым полем.
Откат
Миграции «вниз» часто дают ложное чувство безопасности. Удалить колонку обратно легко, но данные, которые успели в неё записаться, пропадут. Откатить удаление колонки нельзя вообще — данных уже нет.
Поэтому практичнее другое правило: откатывается код, а схема идёт только вперёд. Если миграция оказалась ошибочной, её исправляют следующей миграцией. Подход expand/contract именно это и обеспечивает: схема в любой момент совместима с двумя последними версиями кода, и откат релиза — это просто деплой предыдущего артефакта.
Перед удалением чего-либо полезно убедиться, что старую колонку действительно никто не читает: поиск по коду, по отчётам, по скриптам аналитики, которые кто-то написал год назад и забыл.
SUBXCAT может разобрать миграции, которые ждут своего часа, и разложить их на безопасные шаги до того, как таблицы станут большими.