коротко

Миграция схемы — это изменение структуры базы (ALTER таблиц), когда поменялись требования: добавить колонку, разбить поле, переименовать. Бэкфилл — заполнить новое поле данными для уже существующих, старых строк. Нельзя просто переименовать или удалить колонку «на лету»: старый код, который ещё работает в проде, обращается к старому имени и сломается — это та же логика обратной совместимости, что и в версионировании API. Безопасный способ менять схему без даунтайма — паттерн expand / contract: сначала расширь схему (добавь новое, не трогая старое), затем перелей данные и переключи код, и только в конце сузь (удали старое, когда им никто не пользуется). Роль аналитика — продумать сам переход, а не только конечное состояние схемы.

Однажды я написал в спеке «разбить поле „ФИО“ на „Имя“ и „Фамилия“» и нарисовал красивую новую схему. Разработчик честно сделал ALTER, удалил старую колонку full_name, выкатил. И прод лёг. Потому что старая версия сервиса (которая ещё крутилась на половине серверов во время раскатки) лезла в колонку full_name, а её больше не было. Плюс у двух миллионов старых клиентов новые поля «Имя» и «Фамилия» оказались пустыми — никто их не заполнил. Я описал, как должно стать, и совершенно не подумал, как туда перейти. А переход — это и есть самое сложное.

Конечное состояние схемы нарисовать легко. Тяжело — провести работающую систему с миллионами строк из состояния А в состояние Б так, чтобы в каждый момент перехода всё оставалось рабочим. Это инженерная задача, но продумать её последовательность — работа аналитика. Если в спеке только «как должно быть» и нет «как переходим» — спека дырявая.

Что такое миграция схемы и бэкфилл

Миграция схемы — это изменение структуры базы под новые требования. Бизнес попросил хранить отдельно имя и фамилию вместо одного поля «ФИО» — значит, надо менять таблицу. Технически это команды ALTER TABLE: добавить колонку, удалить, переименовать, поменять тип. Миграции версионируют и применяют по порядку, как коммиты, — но нам сейчас важна не механика, а логика перехода.

Бэкфилл (backfill) — отдельная и часто забываемая половина. Допустим, вы добавили новую колонку. У новых строк она заполнится сразу. А что со старыми — с теми миллионами клиентов, что уже в базе? У них новое поле пустое. Бэкфилл — это процесс заполнения нового поля данными для старых строк. Без него миграция формально прошла, а половина данных битая.

Почему нельзя просто переименовать колонку

Кажется, что переименовать колонку — это одна команда, секундное дело. Проблема не в команде, а в том, что в момент раскатки старый и новый код работают одновременно. Современный деплой не выключает всё разом: он постепенно заменяет старые версии сервиса новыми. Несколько минут (а иногда дольше) половина серверов крутит старый код, половина — новый.

И вот старый код ещё ходит в full_name, а вы эту колонку уже переименовали в last_name. Старый код падает с ошибкой «нет такой колонки». Это ровно та же боль обратной совместимости, что и в версионировании API: нельзя сломать контракт, пока есть кто-то, кто на него рассчитывает. Только там контракт — это endpoint и поля в JSON, а здесь — имена колонок, на которые опирается работающий код. Принцип один: меняй так, чтобы старый потребитель не заметил, пока сам не переключится.

Старый код переживает деплой

Главная интуиция, которую надо усвоить: схема и код деплоятся не в один момент. Между «применили миграцию» и «весь код стал новым» есть окно, где они должны быть совместимы. Любое изменение, которое мгновенно ломает старый код (удалить колонку, переименовать, сделать NOT NULL без значения по умолчанию), — это потенциальный даунтайм. Поэтому опасные изменения разбивают на безопасные шаги.

Паттерн expand / contract на пальцах

Безопасный способ менять схему без остановки системы называется expand / contract (расширь / сузь), он же parallel change. Идея — не менять старое на новое одним движением, а на время держать оба и переключаться постепенно. Четыре фазы:

  1. Expand (расширь). Добавь новое, ничего не ломая. Новые колонки имя и фамилия добавляются рядом со старым full_name. Старый код продолжает работать со старым полем, новые колонки пока пустые и необязательные.
  2. Backfill (перелей данные). Заполни новые колонки для старых строк — обычно скриптом, который читает full_name и раскладывает его на имя и фамилию. Делается фоном, пачками, чтобы не положить базу.
  3. Migrate code (переключи код). Выкати новую версию кода, которая пишет и читает уже новые колонки. На время можно писать в оба поля сразу (dual-write), чтобы данные не разъезжались, пока не весь код переключился.
  4. Contract (сузь). Когда убедились, что старым полем больше никто не пользуется, — удали его. Только теперь, и ни секундой раньше.

Между фазами — паузы и проверки. Никто не делает все четыре шага за один деплой; expand и contract часто разделены днями или неделями. Расписанный план для нашего примера:

Миграция: разбить full_name на first_name + last_name

Шаг 1. EXPAND (релиз N)
  ALTER TABLE client ADD COLUMN first_name VARCHAR(100);
  ALTER TABLE client ADD COLUMN last_name  VARCHAR(100);
  -- старая колонка full_name на месте, новые пока пустые и nullable

Шаг 2. BACKFILL (фоновый скрипт)
  -- обновляем пачками по 5000 строк, чтобы не залочить таблицу
  UPDATE client
     SET first_name = split_part(full_name, ' ', 1),
         last_name  = split_part(full_name, ' ', 2)
   WHERE first_name IS NULL
   LIMIT 5000;
  -- повторять, пока остались незаполненные строки

Шаг 3. MIGRATE CODE (релиз N+1)
  -- код пишет и читает first_name/last_name
  -- на переходный период dual-write: пишем и в full_name тоже

Шаг 4. CONTRACT (релиз N+2, через неделю)
  -- убедились, что full_name больше нигде не читается
  ALTER TABLE client DROP COLUMN full_name;

На каждом шаге система рабочая. После шага 1 старый код спокойно живёт со старым полем. После шага 2 у всех строк заполнены новые поля. После шага 3 код работает с новыми. И только после паузы, убедившись, что старое поле мёртвое, на шаге 4 его убирают. Ни в один момент мы не выключаем прод.

Этапы на схеме

flowchart LR
  A["Expand: добавили
  first_name, last_name
  (full_name на месте)"] --> B["Backfill: перелили
  данные пачками
  из full_name"]
  B --> C["Migrate code: код
  пишет/читает новые
  поля (dual-write)"]
  C --> D["Contract: удалили
  full_name, когда
  им никто не пользуется"]

Схема показывает четыре фазы expand/contract слева направо. Сначала Expand — добавляем новые колонки рядом со старой, не трогая её, поэтому старый код продолжает работать. Затем Backfill — фоновым скриптом заполняем новые колонки данными старых строк, пачками, чтобы не перегрузить базу. Потом Migrate code — выкатываем код, который пишет и читает новые поля, какое-то время дублируя запись и в старое поле. И только в конце Contract — удаляем старую колонку, но лишь после паузы, когда точно убедились, что её больше никто не читает. Стрелки в одну сторону неслучайны: фазы идут строго по очереди, и contract нельзя делать раньше, чем переключился весь код.

Даунтайм и как его избежать

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

Expand/contract — это и есть способ обойтись без даунтайма: ни один шаг не требует остановки, потому что в каждый момент схема совместима с работающим кодом. Trade-off честный: zero-downtime-миграция дольше по календарю (несколько релизов вместо одного) и требует дисциплины — легко забыть про фазу contract и оставить мёртвую колонку навсегда. Зато пользователь ничего не замечает.

Роль аналитика — продумать переход

Аналитик не пишет ALTER и не запускает бэкфилл — это работа разработчика и DBA. А если данные приходят не миграцией, а регулярными выгрузками файлов по расписанию — это уже файловые и батч-интеграции, отдельная тема. Но именно аналитик в спеке должен описать не только конечную схему («должно быть два поля»), но и переход: что делаем со старыми строками (бэкфилл), как разбираем full_name, который содержит три слова или одно, что с записями, где имя пустое. Эти граничные случаи — чистая аналитика, и если их не проговорить, разработчик решит за вас, и часто неудачно. Связь данных одной сущности на разных уровнях детализации — в записи три уровня модели данных.

Откуда это взялось

Раньше схему меняли просто: останавливали приложение, катили ALTER, запускали обратно — даунтайм был нормой, потому что релизили редко и по ночам. Всё изменилось с приходом непрерывной доставки и zero-downtime deployment в конце 2000-х — начале 2010-х: сервисы стали выкатывать по многу раз в день и постепенно (rolling deploy, blue-green), а значит старый и новый код неизбежно работали бок о бок. Тогда и оформился паттерн expand/contract (он же parallel change) — его популяризировали practitioners continuous delivery (заметную роль сыграли тексты Мартина Фаулера и его коллег). Идея «эволюционировать схему маленькими обратносовместимыми шагами» — прямой родственник того, как версионируют API.

Как это спрашивают на собесе

«Как изменить схему работающей базы без даунтайма?» — отвечайте паттерном expand/contract по шагам: расширить (добавить новое, не трогая старое) → бэкфилл (перелить данные старым строкам) → переключить код (dual-write на переходе) → сузить (удалить старое, когда им никто не пользуется). Обязательно назовите, почему нельзя сразу удалить/переименовать: во время раскатки старый код ещё жив и сломается — это обратная совместимость, как с API. Подвох: «А бэкфилл сразу на всю таблицу?» — нет, пачками, чтобы не залочить базу. Хороший ответ показывает, что вы думаете про переход и граничные случаи, а не только про финальную схему.

Частые вопросы

Что такое бэкфилл (backfill)?

Это заполнение нового поля данными для уже существующих, старых строк. Когда вы добавляете колонку, у новых записей она заполнится сразу, а у миллионов старых останется пустой — бэкфилл это чинит, обычно фоновым скриптом, который проходит по таблице пачками и заполняет новое поле. Без бэкфилла миграция формально прошла, но часть данных битая. Бэкфилл — отдельная фаза, которую легко забыть.

Почему нельзя просто переименовать колонку на проде?

Потому что во время раскатки старый и новый код работают одновременно: деплой постепенный, несколько минут половина серверов крутит старую версию. Старый код ходит в старое имя колонки — и если вы её уже переименовали или удалили, он падает. Это та же обратная совместимость, что в версионировании API: нельзя ломать контракт, пока на него кто-то рассчитывает. Поэтому опасные изменения разбивают на безопасные шаги через expand/contract.

Что такое паттерн expand / contract?

Это способ менять схему без даунтайма в четыре фазы. Expand — добавить новое рядом со старым, ничего не ломая. Backfill — перелить данные в новые поля для старых строк. Migrate code — переключить код на новые поля, на переходе писать в оба (dual-write). Contract — удалить старое, когда им точно никто не пользуется. Фазы идут строго по очереди и часто разнесены на несколько релизов; contract нельзя делать раньше, чем переключился весь код.