Когда миграции базы данных падают в продакшене: три сценария, которые не дадут вам спать по ночам
Вы только что выполнили миграцию в продакшене. Она завершилась успешно. Никаких ошибок, таймаутов или заблокированных таблиц. Вы с облегчением выдыхаете и переходите к следующей задаче.
Через два часа звонит телефон. Отчёт сломан. Данные выглядят некорректно. Сервис, о котором вы забыли, пишет null'ы в критические таблицы. Миграция прошла успешно, но ваша продакшен-система разваливается на части.
Это кошмар миграций базы данных. В отличие от развёртывания приложений, где сбои обычно проявляются сразу и очевидно, ошибки миграций могут прятаться часами или днями. К тому моменту, когда вы их замечаете, ущерб уже нанесён.
Позвольте показать вам три реальных сценария, где миграции идут не так — не потому, что SQL упал, а потому что побочные эффекты застали всех врасплох.
Сценарий первый: новая колонка, которая всё сломала
Вашей команде нужно добавить колонку phone_number в таблицу users. Миграция отлично работает в стейджинге. Все тесты проходят. Вы пушите в продакшен с уверенностью.
Колонка создана. Без ошибок. Но через секунду приложение начинает вести себя странно.
Вот что произошло: продакшен-приложение ещё не обновлено. Старый код всё ещё работает и отправляет запросы вроде SELECT * FROM users. Это нормально — новая колонка просто игнорируется. Настоящая проблема в другом. Другой кусок кода начинает вставлять данные в phone_number, но использует другой формат, чем ожидает новое приложение. Номера телефонов приходят вперемешку: с кодами стран и без, с дефисами и без.
Рассмотрим миграцию, которая запустила этот сценарий:
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);
Это выглядит безобидно. Но без ограничения NOT NULL или значения по умолчанию колонка принимает любой формат. Хуже того, если таблица большая, этот ALTER TABLE блокирует запись в таблицу на всё время операции. В продакшене такая блокировка может поставить в очередь сотни запросов за секунды. Реальная опасность не в самом SQL — а в том, что схема изменилась до того, как все экземпляры приложения были готовы это обработать.
Теперь у вас несогласованные данные в колонке, от которой будут зависеть несколько систем. Ваша команда стоит перед неприятным выбором: попытаться очистить существующие данные или выпустить новую версию приложения в продакшен до того, как она будет готова.
Корень проблемы — синхронизация по времени. Схема изменилась до того, как код приложения, который её понимает, был полностью развёрнут. В распределённой системе не все экземпляры обновляются одновременно. На короткое время — иногда дольше — старый код взаимодействует с новой схемой.
Сценарий второй: изменение типа, которое сломало ночной отчёт
Этот случай более тонкий. Ваша команда решает изменить колонку price с integer на decimal. Хорошая идея — цены требуют точности. Миграция выполняется идеально. Никаких немедленных ошибок. Приложение работает нормально.
Но шесть месяцев назад кто-то написал запрос для отчёта, который трактует price как целое число. Этот запрос не используется на основных страницах. Он запускается раз в ночь для финансового отчёта. В 2 часа ночи отчёт полностью падает. Каждый запрос, сравнивающий цены с целыми числами, теперь выдаёт ошибку несоответствия типов.
Это то, что инженеры называют блокирующим изменением. Изменение схемы не сломало ничего видимого в течение дня, но молча разрушило критический пакетный процесс, который работает ночью. К утру финансовый отдел спрашивает, почему вчерашние цифры не сходятся.
Опасная часть? Вы можете не обнаружить этот сбой часами. И исправление не простое. Вы не можете просто откатить изменение типа без ещё одной миграции, которая несёт свои риски.
Сценарий третий: удалённая колонка, которая отравила три таблицы
Это самый опасный сценарий. Ваша команда уверена, что колонка old_status больше не используется. Она была объявлена устаревшей месяцы назад. Никто на неё не ссылается в основном приложении. Вы пишете миграцию для её удаления.
Миграция проходит гладко. Колонка исчезает. Никаких ошибок.
Но есть фоновый сервис — задание синхронизации данных, написанное командой, которая уволилась два года назад, — которое всё ещё периодически читает old_status. Оно не падает, когда колонка отсутствует. Оно просто начинает писать NULL в другие таблицы. Null'ы распространяются. Целостность данных молча нарушается в трёх разных таблицах в течение следующих двух часов.
К тому времени, когда кто-то замечает, ущерб уже нанесён. Вы не можете просто «отменить» удаление колонки. Данные в тех других таблицах уже повреждены. Восстановление требует понимания, какие именно строки были затронуты, восстановления пропущенных значений из резервных копий и выполнения осторожных скриптов восстановления.
Почему миграции базы данных отличаются от развёртывания приложений
Эти три сценария объединяет общий паттерн: миграция выполнилась успешно, но побочные эффекты проявились позже. Это делает миграции базы данных принципиально отличными от развёртывания приложений.
Три сценария выше демонстрируют чёткий паттерн: успешное изменение схемы вызывает отложенный сбой. Следующая диаграмма отображает каждый сценарий от первопричины до последствия.
Когда развёртывание приложения падает, вы обычно узнаёте об этом сразу. Ошибки появляются в логах. Пользователи сообщают о проблемах. Срабатывают оповещения мониторинга. Вы можете откатить версию приложения и быстро восстановить сервис.
Миграции базы данных работают иначе. Изменение схемы может:
- Создавать несоответствия, которые проявляются только при поступлении новых данных
- Ломать запросы, которые выполняются по расписанию, а не непрерывно
- Вызывать повреждение данных, которое медленно распространяется по связанным таблицам
- Влиять на сервисы, о существовании которых вы забыли или не знали
Худшая часть? Как только ущерб нанесён, вы не можете просто «отменить» изменение схемы, как откатываете изменение кода. Удалённую колонку нелегко восстановить, особенно если другие таблицы уже зависят от её отсутствия. Изменённый тип данных требует обратной миграции, которая несёт свои риски.
Практический чек-лист перед следующей продакшен-миграцией
Прежде чем запускать следующую миграцию в продакшене, пройдитесь по этим пунктам:
- Определите всех потребителей. Составьте список всех сервисов, cron-задач, отчётов и пайплайнов данных, которые затрагивают изменяемую таблицу. Не предполагайте, что вы знаете их все.
- Проверьте отложенное выполнение. Найдите запросы, которые выполняются по расписанию, пакетные процессы или фоновые задания. Именно они будут молча падать часами позже.
- Проверьте обратную совместимость. Может ли старый код приложения всё ещё работать с новой схемой? Как минимум в течение одного цикла развёртывания ваша схема должна поддерживать и старый, и новый код.
- Подготовьте план восстановления. Знайте точно, как вы будете восстанавливать данные, если что-то пойдёт не так. Протестируйте процесс восстановления, а не только миграцию.
- Выполняйте миграцию в часы низкой нагрузки. Даже со всеми предосторожностями дайте себе запас времени, чтобы поймать проблемы до того, как они затронут пользователей.
Конкретный вывод
Успешная миграция — это не та, которая выполняется без ошибок. Успешная миграция — это та, которая ничего не ломает: ни сейчас, ни через час, ни в 3 часа ночи, когда запускается ночной отчёт. Относитесь к каждому изменению схемы как к потенциальной бомбе замедленного действия и проверяйте, что все системы, а не только очевидные, могут работать с новой структурой, прежде чем считать миграцию завершённой.