Миграция схемы — это изменение структуры базы (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. Идея — не менять старое на новое одним движением, а на время держать оба и переключаться постепенно. Четыре фазы:
- Expand (расширь). Добавь новое, ничего не ломая. Новые колонки имя и фамилия добавляются рядом со старым full_name. Старый код продолжает работать со старым полем, новые колонки пока пустые и необязательные.
- Backfill (перелей данные). Заполни новые колонки для старых строк — обычно скриптом, который читает full_name и раскладывает его на имя и фамилию. Делается фоном, пачками, чтобы не положить базу.
- Migrate code (переключи код). Выкати новую версию кода, которая пишет и читает уже новые колонки. На время можно писать в оба поля сразу (dual-write), чтобы данные не разъезжались, пока не весь код переключился.
- 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 нельзя делать раньше, чем переключился весь код.