Изменения базы данных без простоев: подход expand-contract
Узнайте, как выполнять изменения в базе данных без простоев с помощью подхода expand-contract: добавляйте безопасные поля, мигрируйте данные поэтапно, сохраняйте работу старого кода и только потом удаляйте устаревшие части.

Почему изменения в базе данных приводят к простоям
Большая часть простоев при работах с базой данных случается, когда код и схема меняются одновременно, но обновляются не в одном порядке повсюду. Серверы приложений, фоновые задания и планировщики задач не обновляются мгновенно. Некоторое время старый и новый код работают бок о бок. Если любая из версий ожидает то, чего база больше не даёт, пользователи это почувствуют.
Классическая ошибка — думать, что «просто запустить миграцию» — это один безопасный шаг. Миграция может блокировать таблицы, переписывать много строк или удалить столбец, который ещё читается каким‑то процессом. Даже «маленькие» изменения вроде переименования столбца могут сломать прод, если какой‑то запрос всё ещё ожидает старое имя.
Признаки простоя обычно такие:
- 500‑е ошибки, когда код читает столбец или таблицу, которой больше нет
- Отсутствующие или неверные данные, когда старый и новый код читают и пишут разные формы
- Таймауты, когда миграция блокирует записи или вызывает медленные запросы
- «У меня работает» — баги, когда обновлены не все сервера
- Фоновые задания, которые падают, ретрайтся и нагружают систему
«Обратная совместимость» означает, что старый код может продолжать работать безопасно, пока база меняется. На практике это значит не удалять и не менять ничего, от чего зависит старый код. Вы добавляете новые поля или таблицы так, чтобы обе версии понимали данные, а затем постепенно переносите данные.
Изменения без простоев сложны, потому что база — это общее состояние. Одна рискованная миграция может повлиять на каждый запрос и каждую задачу сразу. Подход expand-contract снижает риск, устраняя момент «всё и сразу»: сначала расширяете схему, затем плавно мигрируете данные на фоне, а затем чистите только после того, как новый путь доказал стабильность.
Идея expand-contract в одной картинке
Представьте, что вы ремонтируете кухню, продолжая готовить каждый день. Вы не вырываете старую мойку сразу. Сначала добавляете новую, постепенно переходите на неё и только потом удаляете старую.
Time ->
1) EXPAND 2) MIGRATE (gradual) 3) CONTRACT
Add new parts Copy/backfill data safely Remove old parts
Keep old path Run both paths for a while After new path is proven
- Expand: добавьте то, что нужно (новый столбец, новую таблицу, новый индекс) так, чтобы старый код не сломался.
- Migrate: переносите данные малыми партиями, пока приложение работает. Некоторое время старые и новые формы сосуществуют.
- Contract: удаляйте старые части только после того, как новый путь стабилен в проде.
Это снижает риск, потому что каждый шаг меньше, его проще приостановить и понять, чем одна большая миграция.
Проектирование обновления схемы с обратной совместимостью
Изменение схемы с обратной совместимостью означает, что старые и новые версии приложения могут работать с одной базой.
Начинайте с добавления — новые столбцы, таблицы или join‑таблицы, но оставляйте старую форму до тех пор, пока не убедитесь, что от неё никто не зависит. Если нужно переименовать что‑то — сначала добавьте новое имя (например, через view или дублирование столбца), а старое оставьте до удаления.
На этапе expand выбирайте значения по умолчанию, которые не удивят старый код. Nullable‑поля обычно безопаснее на первый день, чем требуемые NOT NULL. Если поле должно быть non‑null, введите его сначала с безопасным значением по умолчанию, а уж потом ужесточайте правила.
Несколько правил, которые предотвратят большинство поломок:
- Не удаляйте столбцы и не меняйте смысл до этапа contract.
- Избегайте переименований как первого шага. Сначала добавьте, затем мигрируйте, затем почистите.
- Добавляйте ограничения постепенно (NOT NULL, UNIQUE, внешние ключи) после того, как данные на месте.
- Планируйте изменения индексов так, чтобы не вызывать долгие блокировки на запись (используйте онлайн‑варианты, если СУБД их поддерживает).
- Убедитесь, что для каждого нового поля есть история заполнения для старых строк.
Решите заранее, как будут работать чтения и записи, пока обе версии живут вместе. Распространённые подходы:
- Новый код пишет и в старую, и в новую форму (dual‑write).
- Новый код пишет только в новую форму, а слой совместимости поддерживает старую.
- Сначала переключают чтение, с откатом на старый источник до завершения backfill.
Например: если вы разбиваете users.full_name на first_name и last_name, не удаляйте full_name. Добавьте новые столбцы, пусть новый код пишет все три поля, а старые чтения остаются на full_name, пока вы не уверены.
Пошаговый рабочий процесс expand-contract
Изменения без простоев лучше планировать с временным «промежуточным» состоянием. Этот мост позволяет старому и новому коду сосуществовать, пока вы переносите данные.
Expand: добавьте новые пути, не ломая старые
Выберите целевую модель и спроектируйте мостовую модель, которая сможет представлять обе версии (обычно старые столбцы плюс новые).
Расширьте схему безопасно: сначала добавьте столбцы или таблицы, сохраните старые поля и сделайте новые nullable или дайте им безопасные значения по умолчанию. Если нужны индексы — добавляйте их так, чтобы не блокировать записи.
Деплойте код, совместимый с обеими формами: код, который может читать и старые, и новые места и писать так, чтобы данные оставались консистентными.
Миграция и переключение: переместите данные, затем смените трафик
Делайте backfill малыми батчами. Задача должна быть перезапускаемой и безопасной при повторном запуске.
Сначала переключайте чтения, затем записи. Чтения легче наблюдать и откатывать, потому что они не меняют данные. Когда чтения стабильны, переходите к записям по этапам (часто через dual‑write), а затем убирайте откат.
Contract выполняйте только после верификации. Определите «верификацию» заранее (совпадение числа строк, выборочные проверки, нормальный уровень ошибок и задержек).
Как постепенно мигрировать данные, не ломая записи
Самый безопасный подход — делать backfill старых строк, пока приложение обслуживает трафик. Ключ — маленькие батчи и гарантия, что новые записи не пропустят новую форму.
Запускайте backfill в батчах, которые успевают быстро выполняться, с короткими паузами между батчами, чтобы обычный трафик оставался плавным.
Отслеживайте прогресс, чтобы можно было возобновить: migrated_at timestamp, булев флаг или маркер «последний обработанный id». Добавьте простой запрос «сколько осталось», чтобы видеть продвижение.
Пока backfill идёт, новые записи продолжают приходить. Решайте это тем, что приложение пишет новые поля для всех новых и обновлённых строк. Если вы не можете сделать это везде сразу — временно делайте dual‑write, затем читайте из новых полей с откатом на старые.
Делайте задачу идемпотентной. Её безопасно запускать несколько раз на одной и той же строке:
- Обновляйте только строки, которые ещё не промигрированы
- Используйте детерминированные преобразования (тот же вход — тот же выход)
- Избегайте append‑обновлений, которые дублируют данные
- Логируйте ошибки по строкам и продолжайте, не останавливая всю работу
Также опишите всех писателей, а не только основной API: воркеры, вебхуки, админ‑инструменты, импорты и скрипты. Один пропущенный писатель может тихо сорвать план.
Стратегия релиза: сохраняйте старый код рабочим во время изменений
Предполагая, что старые и новые формы данных будут сосуществовать, выкладывайте код, который умеет читать обе и не падает, если поле отсутствует, дублируется или ещё не заполнено.
Контролируемое переключение (feature flag или конфиг) помогает менять поведение по маленьким шагам. Простой порядок релиза:
- Деплойте код, который читает и старое, и новое место.
- Включайте новые чтения для небольшой части (одно окружение, один клиент или небольшой процент трафика).
- Наблюдайте за ошибками и задержками, затем расширяйте.
- Когда чтения стабильны, начните dual‑write или поэтапный переход записей.
- Держите старый путь доступным, пока миграция не завершена и не проверена.
Откат должен быть простым. Идеально — можно вернуться к старому чтению и остановить новые записи без потери данных. При dual‑write откат часто означает продолжать писать старую форму, пока вы разбираетесь.
Прежде чем переходить к contract (удалению столбцов/таблиц), ищите сигналы стабильности: нет неожиданных NULL в новых полях, совпадают счёты строк, нет растущей очереди миграции и объем поддержки в норме.
Типичные ошибки, которые приводят к простоям
Большинство аварий в expand-contract происходят из двух причин: база блокируется, или разные части приложения по‑разному интерпретируют данные.
Ошибки, которые бьют сильнее всего:
- Долгие блокировки. Смена типа столбца в большой таблице или добавление индекса «простым» способом может блокировать чтения и записи на минуты.
- Тихое рассогласование при dual‑write. Пропустили один путь записи — и старые и новые формы расходятся. Пользователи получают «случайные» ошибки.
- Переименования, которые ломают всё остальное. API может работать, а экспорты, дашборды и ad‑hoc скрипты начнут падать.
- Забытие нефронтовых путей. Cron‑задачи, воркеры, админки и скрипты тоже нуждаются в плане совместимости.
- Слишком ранний contract. Удалили старый столбец слишком рано — потеряли возможность отката.
Простой пример: вы переключили чтение на profile_json, но email‑воркер всё ещё использует last_name и начинает отправлять «Hi ,» пользователям. Без простоя, но всё равно инцидент в проде.
Быстрый чек‑лист до, во время и после изменения
Изменения без простоев терпят неудачу по скучным причинам: таблица больше ожидаемого, всплеск трафика или один код‑путь всё ещё ожидает старую схему.
До начала подтвердите объём и тайминг (размер таблицы, churn, окно низкой нагрузки) и совместимость (старый и новый код могут безопасно работать одновременно).
Во время релиза наблюдайте приложение (ошибки, задержки) и базу (CPU, блокировки, отставание реплик, медленные запросы). Замедлите или приостановите backfill, если растут тайм‑ауты.
После — докажите, что можно contract: чтения и записи больше не обращаются к старой схеме, дашборды чисты в течение полного бизнес‑цикла, и временные флаги удалены.
Практический способ найти скрытые зависимости: в staging временно заставьте старые столбцы возвращать null и прогоните обычные сценарии (signup, checkout, редактирование профиля). Если что‑то ломается — вы не готовы убирать унаследованные части.
Пример: изменение схемы профиля пользователя без простоев
Предположим, в таблице users есть один столбец name ("Ada Lovelace"), а вам нужны first_name и last_name для поиска, сортировки и персонализации писем.
Expand
Добавьте first_name и last_name как nullable‑столбцы. Сохраните name. Пока не ставьте NOT NULL.
Обновите приложение так, чтобы каждая запись заполняла оба варианта: продолжайте заполнять name, а также first_name и last_name. Чтения пока могут использовать name.
Миграция и выкладка
Backfill существующих строк фоновым заданием малыми батчами. Простая логика: разбивать по первому пробелу; для сложных случаев ("Prince", "Mary Jane Watson‑Parker") сделать best‑effort и оставить пустым last_name, если не уверены.
Практическая последовательность:
- Deploy 1: добавить
first_name,last_name - Deploy 2: dual‑write (обновлять старую и новую форму)
- Backfill: миграция пользователей по батчам
- Deploy 3: читать
first_name/last_nameв приоритете, с откатом наname - Верификация: подтвердить, что новые поля заполнены для активных пользователей и новых регистраций
Когда новый код стабилен, переключите UI и экспорты на новые поля (с безопасным откатом для отображения имени).
Contract
После подтверждения, что ничто не зависит от name (включая скрипты и воркеры), прекратите запись в него и удалите в последующем релизе.
Фаза contract: безопасная очистка и избегание долговечности сложности
Фаза contract удаляет временные наработки, которые сделали изменение безопасным. Пропуск этого шага оставит постоянную сложность и усложнит будущие миграции.
«Готово» — значит старую схему действительно никто не использует. Простое определение для команды:
- Никакой код не читает и не пишет старые столбцы или таблицы
- Нет воркеров, cron‑задач или скриптов, которые их используют
- Логика dual‑write удалена
- Нет feature‑флагов, поддерживающих только старый путь
- Мониторинг показывает использование только нового пути
Перед удалением проведите целевую проверку: поиск по кодовой базе старых имён, ревью запланированных задач, проверка логов запросов на чтение старых объектов и верификация дашбордов и рукописей запуска.
После удаления удалите и вспомогательные вещи: скрипты backfill, временные метрики и любые валидации, которые существовали только в переходный период.
Запишите, что изменилось и почему: старая и новая схема, как переместились данные и когда вы объявили старый путь мёртвым. В следующий раз вы пойдёте быстрее.
Следующие шаги: спланируйте изменение и попросите второго человека посмотреть
Expand‑contract оправдан, когда изменение схемы затрагивает «горячую» таблицу, аутентификацию, платежи или всё, что приложение записывает постоянно. Если вы не можете позволить себе даже короткое окно обслуживания, относитесь к миграции как к релизу, а не к «быстрой миграции». Для внутренних низкотрафиковых инструментов или одноразовых reporting‑таблиц плановое окно простоя может быть достаточным.
Чтобы оценить риск, смотрите на радиус поражения и возможность отката. Радиус поражения — сколько путей кода читают или пишут эти данные (включая задания и админ‑инструменты). Откатываемость — можно ли откатить приложение и всё ещё работать с базой.
Если ваш проект начался как AI‑генерация, миграции часто ломаются по предсказуемым причинам: сырой SQL, пропущенные фоновые писатели, половинчатая логика dual‑write и допущения о схеме, разбросанные по коду. Если нужно распутать это перед продакшен‑изменением, FixMyMess (fixmymess.ai) может просмотреть код и план миграции и указать рискованные места, которые обычно вызывают простои.
Часто задаваемые вопросы
Почему миграции базы данных ломают прод, даже если изменение кажется небольшим?
Потому что ваш флот серверов редко обновляется одновременно. Некоторое время старый и новый код работают рядом, и если один из них ожидает столбец, таблицу или ограничение, которых уже нет (или которые ещё не добавлены), запросы начнут падать или записывать данные в неправильной форме.
Что означает «обратная совместимость» для изменения схемы?
Это значит, что вы можете менять схему, пока старый код всё ещё работает — без падений и порчи данных. На практике это означает не удалять и не менять смысл того, от чего зависит старый код, пока новый путь не станет полностью активен и стабильным.
Что такое подход expand-contract простыми словами?
Expand-contract — это более безопасный способ выкатывания: сначала вы добавляете новые элементы схемы, затем постепенно мигрируете данные, пока старый и новый пути работают одновременно, и только после этого удаляете старые части. Это уменьшает риск «большого взрыва», когда одна миграция может повалить всё.
С чего начать на этапе «expand»?
Начните с добавления новых столбцов или таблиц, не трогая старые. Сделайте новые поля nullable или дайте им безопасные значения по умолчанию, чтобы существующие записи и записи при записи не падали, и задеплойте код, который не упадёт, если новых полей нет или они пустые.
Когда безопасно удалить старый столбец или таблицу?
Самый быстрый путь к 500-м — удалить или переименовать столбец, который всё ещё читают старые процессы. Даже если основной API обновлён, фоновые задания, cron, админки и скрипты могут обращаться к старому имени в течение часов или дней.
Как мигрировать существующие данные, не блокируя живой трафик?
Запускайте перезапускаемый backfill, который выполняет миграцию по небольшим батчам и безопасно перезапускается. Параллельно убедитесь, что новые записи заполняют новую форму (обычно через dual-write или слой совместимости), чтобы не получить постоянно меняющуюся цель, которую никогда не закончить.
Что такое dual-write и когда его стоит использовать?
Это когда новый код записывает и старую, и новую форму данных в переходный период. Это полезно для безопасности и отката, но может привести к рассогласованию, если вы пропустите хотя бы один путь записи — поэтому обязательно инвентаризируйте всех писателей и делайте трансформацию детерминированной.
Что сначала переключать: чтения или записи?
Сначала переключайте чтение: вы можете наблюдать ошибки и откатиться, не меняя данные. Когда чтение стабильно и backfill почти завершён, переходите к поэтапному переключению записи и только после верификации убирайте откат.
Что нужно мониторить, чтобы быстро обнаружить проблемы?
Следите за блокировками базы, медленными запросами и отставанием репликации при изменениях схемы и индексов, а также за ошибками и задержками в приложении во время релиза. Если тайм-ауты растут — замедлите или приостановите бэкофилл и исправьте план запроса или размер батча.
Как FixMyMess может помочь, если мой AI-сгенерированный проект постоянно ломается при миграциях?
Если кодовая база сгенерирована такими инструментами, как Lovable, Bolt, v0, Cursor или Replit, предположения о схеме часто разбросаны по сырому SQL, заданиям и половинчатым миграциям. FixMyMess может сделать бесплатный аудит кода, найти рисковые писатели, сломанный auth, утёкшие секреты и опасные места миграций, а затем помочь быстро выкатить безопасный expand-contract план.