Skip to content
PПромтбук
RUEN
04База данных

Zero-downtime миграция БД

Expand/contract, dual-write, backfill, switch-over, rollback — миграция без остановки сервиса.

Спланируй zero-downtime миграцию для: {{change}}. Масштаб: {{scale}}.

Принцип: код и схема развиваются независимыми шагами. На каждом шаге обе стороны (старая и новая) валидны одновременно. Это называется expand/contract.

Карта фаз expand → migrate → contract

Phase 0: only old           ← steady state
Phase 1: expand schema       ← schema accepts old AND new
Phase 2: dual-write          ← code writes to old AND new
Phase 3: backfill            ← copy historical data oldnew
Phase 4: dual-read + verify  ← read from both, compare
Phase 5: switch reads        ← read only from new
Phase 6: stop writing old    ← writes only to new
Phase 7: contract            ← drop old structure

Каждая стрелка — отдельный deploy. Откат возможен на любом шаге до Phase 7.

Шаблон: разделение колонки (full_name → first_name + last_name)

Phase 1: expand

ALTER TABLE users ADD COLUMN first_name text;
ALTER TABLE users ADD COLUMN last_name text;

nullable. Никаких NOT NULL. Никаких defaults, требующих rewrite.

Phase 2: dual-write

Код:

async function updateUser(id, payload) {
  const { firstName, lastName } = splitName(payload.fullName);
  await db.update(id, {
    full_name: payload.fullName,      // старое
    first_name: firstName,             // новое
    last_name: lastName,
  });
}
  • Источник истины — пока старое
  • Новое пишется но не читается
  • Если split падает — логируем, не валим запрос

Phase 3: backfill

Batches с lock_timeout:

SET lock_timeout = '2s';
UPDATE users
SET first_name = split_part(full_name, ' ', 1),
    last_name  = split_part(full_name, ' ', 2)
WHERE id BETWEEN :from AND :to
  AND first_name IS NULL;
  • Размер батча: 1-10k строк
  • Пауза между батчами: pg_sleep(0.1)
  • Прогон только в off-peak
  • Метрики backfill: rows/sec, errors, replication lag

Phase 4: dual-read + verify

Код читает из обеих, сравнивает:

const user = await db.read(id);
const computed = `${user.first_name} ${user.last_name}`;
if (computed !== user.full_name) {
  metrics.inc('migration.mismatch');
  logger.warn({ id, computed, full: user.full_name });
}
return { firstName: user.first_name, lastName: user.last_name };

Жди пока mismatch rate < 0.001%. Если ползёт — backfill пропустил окно, повторить.

Phase 5: switch reads

Удаляем чтение старого. Источник истины — новое.

Phase 6: stop writing old

Прекращаем писать full_name. Параллельно ставим триггер БД (на 1-2 дня), который проверяет: если кто-то пишет — лог и alert.

Phase 7: contract

Через 1-2 недели тишины:

ALTER TABLE users DROP COLUMN full_name;

DROP мгновенный, но необратимый. Делается после того как все клиенты (включая аналитику, ETL, BI) перестали использовать.

Backfill для очень больших таблиц

Если 100M+ строк:

  • Используй cursor / keyset pagination (WHERE id > :last_id ORDER BY id LIMIT 5000)
  • Idempotent (повторный запуск не портит)
  • Resumable (можно остановить и продолжить)
  • Прогресс в отдельной таблице: migration_state(id, last_processed_id, started_at)
  • Throttling по replication lag: если slave отстал — пауза

Rollback по фазам

PhaseОткат
1 (expand)DROP COLUMN — безопасно, новых данных нет
2 (dual-write)Деплой назад — старое поле всегда заполнено
3 (backfill)Остановить backfill, остаток не критичен
4 (dual-read)Деплой назад на read из старого
5 (switch reads)Деплой назад — но только если dual-write ещё активен
6 (stop writing old)Опасно: старое поле начнёт расходиться. Только в течение часов
7 (contract)НЕВОЗМОЖЕН. Восстановление из backup

Каждый деплой должен быть feature-flag-able или быстро откатываемый.

Online schema change (для MySQL)

Когда нужно изменить тип / разбить таблицу — используй pt-online-schema-change или gh-ost:

  • Создаёт shadow-таблицу с новой схемой
  • Триггеры или binlog копируют изменения
  • Atomic swap в конце

Не пытайся написать это руками.

Postgres specifics

  • CREATE INDEX CONCURRENTLY — никогда без CONCURRENTLY на проде. Не в транзакции
  • ALTER TABLE ... ADD COLUMN ... DEFAULT ... — с PG 11 это metadata-only (мгновенно), раньше — rewrite
  • ALTER ... ADD CONSTRAINT NOT VALID + VALIDATE CONSTRAINT — split на быстрый ALTER + долгая валидация без exclusive lock
  • pg_repack для перестроения таблиц без блокировки

Observability миграции

  • Дашборд: rows backfilled, errors, replication lag, mismatch rate
  • Алерты на каждое: backfill stuck > 10 min, mismatch > threshold, lock contention
  • On-call знает план и точку отката для каждой фазы

Анти-паттерны

  • "Сделаем downtime в воскресенье в 3 утра" — техдолг навсегда, бизнес не любит
  • Schema change + code change в одном деплое
  • Backfill единым UPDATE
  • DROP COLUMN через час после прекращения чтения (а если кеш / репликация / BI?)
  • Миграция без отката-плана для каждой фазы
  • Запуск backfill в пиковую нагрузку

В конце

  • Phase-by-phase план с конкретным SQL и кодом
  • Estimated duration каждой фазы и общий timeline
  • Rollback plan для каждой фазы
  • Метрики и алерты до старта
  • Точка no-return явно отмечена
К подразделу «База данных»
Похожие промты