В этой статье мы рассмотрим основные принципы миграции БД без даунтайма и дадим быстрые рецепты для наиболее распространенных случаев.
Как работает выкладка в прод?
Давайте взглянем на типовой процесс выкладки веб-приложения в прод. Большинство приложений, рассчитанных на выкладку без даунтайма, сегодня опираются на балансировщики нагрузки и оркестрацию контейнеров:
Что происходит во время публикации новой версии приложения? Процесс деплоя заменяет прежние экземпляры приложения на новые поочередно, сначала выводя их из кластера, затем производя обновление, и затем включая обратно в кластер:
Как показано на иллюстрации выше, версия 2 заменяет прежнюю версию приложения 1 постепенно, таким образом у пользователей не возникает никаких перебоев в обслуживании.
Это все здорово, но что если в новой версии изменения были внесены не только в сам код приложения, но и в структуру базы данных? В отличие от контейнеров с приложением, база данных является общим ресурсом, который обладает собственным состоянием, поэтому мы не можем ее просто клонировать и использовать ту же технику, что и для контейнеров с приложением. Единственный реалистичный вариант - обновлять ее прямо на месте. В какой момент должно быть выполнено обновление?
Поскольку приложение версии 2 опирается на обновленную структуру базы данных, база данных должна быть обновлена до того, как первый экземпляр новой версии приложения будет запущен в продакшене. Соответственно, процесс деплоя, включающий обновление БД, будет выглядеть следующим образом:
Как запускать скрипт миграции БД
Способ запуска скрипта миграции БД важен. На первый взгляд, может показаться заманчивым просто сделать его частью команды старта приложения, типа такого:
<run-db-migrations> && <launch-the-app>
Идея такого подхода заключается в том, что первый экземпляр приложения выполнит миграцию БД, а для остальных скрипт миграции не будет делать ничего, т.к. база данных уже обновлена.
Пожалуйста, НЕ ДЕЛАЙТЕ ТАК.
Во-первых, поскольку мы параллельно запускаем множество экземпляров приложения на нашем кластере, скрипт миграции БД должен корректно обрабатывать попытки параллельного запуска. В зависимости от фреймворка, применяемого для миграций, и того, как написан скрипт, он может или обрабатывать параллельный запуск корректно, или нет. В миграции БД можно выделить три основных состояния - еще не начата, выполняется, закончена. Скрипт должен будет уметь обнаруживать, что миграция уже выполняется в другом процессе, и если так - то ждать. Если он не будет ждать, это может закончиться ошибками приложения, а в худшем случае - испорченными данными.
Во-вторых, даже если скрипт миграции БД обрабатывает попытки параллельного запуска корректно, есть проблема с повторными запусками. Важно, чтобы скрипт миграции попытался выполниться строго один раз, и если произошел сбой - выкладка должна быть немедленно остановлена и произведен откат назад. Например, если скрипт миграции упал из-за таймаута на выполнение долгой SQL-операции, не стоит пытаться повторять его автоматически снова и снова. Разумная стратегия в этом случае - немедленно все отменить, разобраться, починить скрипт и только после этого пробовать еще раз.
Именно поэтому скрипт миграции БД в иллюстрации выше запускается с CI/CD сервера. Разумеется, это не единственный вариант. К примеру, можно запускать его в виде разовой задачи в Kubernetes-кластере как часть процесса деплоя или каким-то другим образом. Главное, помнить об основном принципе - запустить скрипт миграции БД строго один раз, и если что-то пошло не так - отменить выкладку.
Как миграции БД могут вызвать даунтайм?
Как правило, даунтайм происходит по двум основным причинам:
-
Нарушение обратной совместимости. Как можно видеть на иллюстрациях выше, деплой не является моментальным. В какой-то момент времени база данных уже оказывается обновленной, но при этом в продакшене все еще работают экземпляры прежних версий приложения. Если обновленная база данных несовместима с прежними версиями приложения, это приводит к сбоям, продолжающимся до тех пор, пока новая версия приложения не заменит полностью старую.
-
Повышенная нагрузка на БД. Миграция может включать в себя “тяжелые” операции, которые приводят к повышенной нагрузке на базу данных или длительным локам, в результате чего приложение в этот момент работает медленно или не работает вовсе.
Давайте рассмотрим на конкретных примерах, как соблюсти обратную совместимость в наиболее распространенных случаях. В конце статьи также коротко поговорим о нагрузке на БД.
Пример 1: добавление нового столбца в таблицу
Представим, что мы разрабатываем новую фичу - аватарки для пользователей. Каждому пользователю после регистрации будет автоматически генерироваться случайная аватарка, и также будет возможность загрузить свою собственную. Для реализации этой фичи нам потребуется новая колонка avatar
в таблице Users
:
Как обновлять базу от версии 1 к версии 2 в таком случае? Скрипт миграции БД должен будет сделать следующие действия:
-
Добавить nullable-колонку
avatar
в таблицуUsers
; -
Обновить все существующие записи в
Users
, сгенерировать случайные аватарки; -
Когда все данные заполнены, сделать колонку
avatar
non-nullable.
В коде приложения нам потребуется добавить:
-
Генерацию новых случайных аватарок при регистрации пользователей;
-
Отображение аватарок там, где это применимо;
-
Функционал загрузки своей собственной аватарки.
Что случится, если мы просто выкатим все это в продакшен одним махом? Как мы обсуждали ранее, скрипт миграции базы данных выполнится первым. После этого прежние версии приложения будут постепенно заменяться новыми. Сразу после выполнения скрипта миграции у нас будет ситуация, когда в продакшене все еще работают старые версии приложения, но база данных уже обновлена и содержит новую колонку avatar
, о которой старые версии приложения ничего не знают.
Результатом будет сломанная регистрация пользователей во время выкладки, потому что прежние версии приложения будут пытаться вставлять записи в таблицу Users
, не указывая никаких данных для avatar
, которое теперь является обязательным полем. Разумеется, через некоторое время проблема починится сама собой, когда новые версии приложения полностью заменят старые. Однако, поскольку мы говорим о выкладке без даунтайма, нас это не устраивает. Давайте посмотрим, как выложить эту фичу без даунтайма.
Решение примера 1
Давайте не будем пытаться выкладывать эту фичу целиком в один прием, а разобьем выкладку на две фазы. Сначала мы отправим в прод первую фазу, и только после того, как ее деплой полностью завершится - отправим вторую.
Фаза 1
-
Скрипт миграции БД:
– Добавить nullable-колонкуavatar
в таблицуUsers
-
Новые фичи приложения:
– Начать генерировать рандомные аватарки для всех новых пользователей
Поскольку скрипт миграции БД только добавляет nullable-колонку, это не вызовет никаких проблем со вставками новых записей из старой версии приложения. Затем выкладывается обновленная версия приложения, которая начинает заполнять колонку avatar
для новых пользователей
Фаза 2
-
Скрипт миграции БД:
– Сгенерировать случайные аватарки для всех записей вUsers
, у которых аватарка пока пустая;
– Сделать колонкуavatar
non-nullable -
Новые фичи приложения:
– Все оставшиеся фичи связанные с аватарками - отображение их везде, где нужно, функция загрузки своей аватарки, и т.д.
Скрипт миграции БД заполняет все пустые значения avatar
для существующих пользователей и затем делает эту колонку non-nullable. Даже если во время этой операции случатся новые регистрации в приложении, колонка avatar
для них уже будет заполнена, потому что версия приложения, которую мы задеплоили в фазе 1, уже знает о ней. Таким образом, у нас не должно возникнуть никаких проблем с тем, чтобы сделать колонку avatar
обязательной и выложить все оставшиеся фичи, которые от нее зависят.
Пример 2: удаление столбца из таблицы
Что если аватарки для пользователей, которые мы рассматривали в предыдущем примере, не оправдали наших ожиданий? Фича так и не стала достаточно популярной, поэтому мы хотим ее удалить. Мы собираемся удалить весь код, работающий с аватарками, а также удалить колонку avatar
из таблицы Users
:
Как и в предыдущем примере, если мы просто попробуем выложить такое изменение в прод, это приведет к даунтайму.
Приложение будет серьезно сломано во время деплоя. Как мы обсуждали выше, скрипт миграции БД выполнится первым. Сразу после его выполнения сложится ситуация, когда прежняя версия приложения, опиравшаяся на колонку avatar
, все еще работает в продакшене, но самой этой колонки в БД уже нет. Поэтому, пока не закончится деплой и не выложится обновленная версия приложения, весь функционал, зависевший от колонки avatar
, будет сломан. Как этого избежать?
Решение примера 2
Используем тот же подход, что и в примере №1 - разобьем деплой на две фазы:
Фаза 1
-
Скрипт миграции БД:
– Сделать колонкуavatar
nullable -
Новые фичи приложения:
– Удалить весь функционал, связанный с аватарками, убрать все упоминания колонкиavatar
из кода
Превращение колонки avatar
в nullable не вызовет никаких поломок в предыдущей версии приложения, которая все еще работает в продакшене. В то же самое время, этот шаг позволяет выложить новую версию приложения, которая больше не использует avatar
.
Фаза 2
-
Скрипт миграции БД:
– Удалить колонкуavatar
-
Новые фичи приложения:
–
После того, как фаза 1 выложена, приложение больше не зависит от колонки avatar
, так что мы можем ее безопасно удалить.
Пример 3: переименование столбца таблицы или изменение его типа
Предположим, мы хотим расширить функционал аватарок, обсуждавшийся в примере №1. Вместо хранения названий файлов с картинками аватарок, мы хотим хранить их полные URL, чтобы поддерживать аватарки размещаемые на разных доменах. Полные URL заметно длиннее, поэтому нам потребуется увеличить максимальную длину для столбца avatar
. Помимо этого, уже существующие данные нужно будет преобразовать в новый формат. Наконец, сам столбец avatar
было бы неплохо переименовать в avatar_url
, чтобы его название лучше отражало содержимое:
В этом случае скрипт миграции БД включал бы:
-
Изменение типа данных столбца
varchar(100) -> varchar(2000)
; -
Преобразование существующих данных в новый формат, конвертация названий файлов в полные URL;
-
Переименование столбца
avatar -> avatar_url
.
В код приложения были бы внесены следующие изменения:
-
Запись данных в новом формате;
-
Чтение данных в новом формате и необходимые изменения для работы с ними.
Если мы просто выложим все это в прод, произойдет даунтайм.
Приложение будет серьезно сломано во время деплоя. Как мы обсуждали выше, скрипт миграции БД выполнится первым. Сразу после его выполнения в проде все еще будет работать прежняя версия приложения, опирающаяся на колонку avatar
в ее прежнем виде. Первая часть скрипта миграции БД, которая увеличивает максимальную длину этой колонки, что само по себе не вызывает никаких сбоев. Однако вторая часть скрипта, конвертирующая данные в новый формат, вызовет некорректное отображение аватарок везде, где они используются. Ну и третья часть, переименовывающая столбец таблицы, вызовет поломку приложения, включая сломанную регистрацию новых пользователей. Прежняя версия приложения не сможет ни читать, ни писать данные об аватарках, потому что имя столбца таблицы изменилось. Приложение будет оставаться сломанным до окончания деплоя, пока не выложится его обновленная версия. Как мы можем этого избежать?
Решение примера 3
Используем ту же технику, что и в примерах 1 и 2 - разобьем деплой этой фичи на фазы и будем выкладывать их последовательно. Данный пример несколько сложнее предыдущих, нам потребуются четыре фазы:
Фаза 1
-
Скрипт миграции БД:
– Добавить новую nullable колонкуavatar_url
– Не вносить пока никаких изменений в существующую колонкуavatar
-
Новые фичи приложения:
– При записи, сохранять данные в обе колонки - и вavatar
, и в новуюavatar_url
, в соответствующем формате для каждой
– Не вносить пока никаких изменений в логику чтения данных - продолжать читать аватарки из столбцаavatar
Сначала скрипт миграции БД только добавляет новую nullable-колонку avatar_url
, такое действие не вызывает никаких сбоев. Версия приложения, выкладываемая на этом этапе, начинает записывать данные сразу в обе колонки - и в новую, и в старую, подготавливаясь к следующей фазе.
Фаза 2
-
Скрипт миграции БД:
– Заполнить все пустые значения вavatar_url
данными изavatar
, конвертируя их в новый формат (имена файлов в полные URL);
– Сделать колонкуavatar_url
non-nullable -
Новые фичи приложения:
– Переключить логику чтения данных на новую колонкуavatar_url
;
– Пока что продолжать писать данные в обе колонки - и вavatar
, и вavatar_url
Скрипт миграции БД заполняет все пустые значения в новой колонке, конвертируя имена файлов из старой колонки в полные URL в новой. Затем он делает новую колонку non-nullable. Это должно сработать, потому что версия приложения, выложенная в фазе 1, уже начала заполнять avatar_url
данными для всех новых записей. Версия приложения, публикуемая в этой фазе, переключается на чтение данных из новой колонки avatar_url
, однако все еще продолжает писать данные в обе колонки, чтобы обеспечить обратную совместимость с версией приложения выложенной в фазе 1.
Фаза 3
-
Скрипт миграции БД:
– сделать колонкуavatar
nullable -
Новые фичи приложения:
– Прекратить писать данные вavatar
и удалить все упоминания ее из кода
Скрипт миграции БД делает старую колонку avatar
nullable, таким образом мы можем прекратить писать данные в нее и удалить все упоминания avatar
из кода.
Фаза 4
-
Скрипт миграции БД:
- удалить колонку avatar из таблицы Users -
Новые фичи приложения:
-
После того, как фаза 3 успешно задеплоена, мы можем удалить старую колонку avatar
из базы. Никаких изменений приложения на данной стадии не требуется, оно уже было полностью обновлено в предыдущих фазах.
Общий подход к обеспечению обратной совместимости
Как вы могли заметить, все три примера выше используют одну и ту же технику для избегания даунтайма - разделение деплоя новой фичи на две или более фаз. Разумеется, три примера выше не покрывают все возможные ситуации при миграциях БД, но они призваны помочь понять главную идею, как подходить к этому. Если вы уловили идею и полностью понимаете, как работает ваш процесс выкладки в прод, вы сможете разработать решения для других случаев самостоятельно.
Напомним:
-
Во время деплоя скрипт миграции БД выполняется первым;
-
После этого образуется ситуация, когда в проде все еще работают прежние версии приложения, но БД уже обновлена. Это потенциально рискованный момент, в который могут случиться сбои из-за нарушения обратной совместимости;
-
Вы можете избежать даунтайма, разбивая деплой новой фичи на несколько фаз при необходимости. Планируйте эту разбивку таким образом, чтобы прежняя версия приложения, которая все еще работает в продакшене, всегда была совместима с обновлением базы данных, которое вы собираетесь выкатить;
-
Примеры выше иллюстрируют, как конкретно планировать фазы деплоя, чтобы избежать нарушения обратной совместимости.
Повышенная нагрузка на БД
Вторая распространенная причина сбоев во время миграции БД - некоторые операции по внесению изменений в базу данных могут создать повышенную нагрузку на сервер БД или же вызвать длительные локи, что приведет к замедлению работы приложения или к его полной недоступности.
Как правило, подобные проблемы возникают при попытках внести изменения в таблицы, которые хранят много данных. Создание новых таблиц происходит быстро, удаление таблиц - также быстро, и внесение изменений в таблицы небольшого размера тоже обычно не создает проблем. Однако, если вы пытаетесь внести изменения в таблицу с большим количеством данных, например, добавить или удалить колонки, создать или изменить индексы или contraint’ы, это может занять много времени, порой недопустимо много. Что можно с этим поделать?
Совет №1. Используйте современную версию БД. Базы данных постоянно развиваются. Например, одной из наиболее желанных фич в MySQL-сообществе была возможность выполнять быстрые DDL-операции, которые не приводили бы к полной перезаписи таблицы. И разработчики прислушались - в MySQL 8.0 представлены значительные улучшения, включая моментальное добавление новых столбцов в таблицу при соблюдении определенных условий. Другой пример - в Postgres версий 10 и более ранних добавление новой колонки со значением по умолчанию приводило к полной перезаписи таблицы, что было исправлено в Postgres v11. Разумеется, нельзя сказать, что производительность любых DDL операций уже находится на безупречном уровне, но все же обновление версии БД потенциально может сделать вашу жизнь легче.
Совет №2. Проверяйте, что будет происходить “под капотом” при внесении изменений в большие таблицы. Во многих случаях можно снизить риск даунтайма за счет использования несколько другого набора операций, выполнить которые серверу БД будет легче. Пара полезных ссылок по этой теме:
-
Postgres - документация к пакету django-pg-zero-downtime-migrations содержит подробное объяснение, как работают локи в Postgres и какие операции могут считаться безопасными.
Совет №3. Выполняйте обновления в период наименьшего трафика. Если обновление затрагивает большую таблицу, рассмотрите возможность выполнить его в период низкой активности пользователей. Это помогает сразу двумя способами. Во-первых, сервер БД будет меньше нагружен и возможно завершит миграцию быстрее. Во-вторых, даже несмотря на тщательную подготовку, такие миграции могут быть рискованными. Порой бывает сложно полностью протестировать, как обновление сработает в условиях реальной нагрузки на продакшене. Если проблемы все же случатся, во время низкого трафика урон от них будет меньше.
Совет №4. Рассмотрите возможность применения вялотекущих миграций. Некоторые таблицы могут быть настолько большими, что традиционный скрипт миграции БД для них не выглядит жизнеспособным вариантом. В таком случае вы можете внедрить код миграции данных непосредственно в само приложение, чтобы оно работало над фоновым преобразованием данных прямо в продакшене, или использовать специальную утилиту, вроде GitHub's online schema migration for MySQL. Такая миграция обрабатывает данные небольшими порциями и может выполняться дни и даже недели. У вас будет возможность аккуратно балансировать нагрузку на БД от миграции, чтобы она не создавала проблем для конечных пользователей.
Заключение
Миграция базы данных без даунтайма требует определенных усилий, но все же это не прям уж настолько сложно. Две основные причины, по которым происходит даунтайм:
-
Нарушение обратной совместимости;
-
Повышенная нагрузка на БД.
Для решения первой проблемы вам может потребоваться разбить деплой новой фичи на несколько фаз, вместо того, чтобы выкатывать в продакшен все сразу. В этой статье мы подробно разбираем три наиболее распространенных примера и даем общие рекомендации, как избегать даунтайма в других случаях.
Секция про повышенную нагрузку на БД описывает общие рекомендации для решения связанных с этим проблем и содержит ссылки для дальнейшего чтения.
Надеюсь, эта статья поможет вам снизить риски даунтайма во время деплоев. Меньше даунтайма - чаще деплои - быстрее движется разработка. Удачных миграций!
Автор: Денис Стебунов