Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Миграции базы данных без простоя: как менять схему и данные и не терять выручку

Разработка и технологии28 апреля 2026 г.
Даже безобидное «добавить колонку» способно уронить прод, если зацепить блокировки или перегенерацию индексов. Разбираем стратегию expand/contract, безопасные приёмы для PostgreSQL и MySQL, пошаговые сценарии и готовые команды. Вы сможете менять схему и данные онлайн, не теряя заказы и не растягивая релизы.
Миграции базы данных без простоя: как менять схему и данные и не терять выручку

  • Почему миграции роняют прод и как этого избежать
  • Стратегия безостановочных миграций: expand/contract
  • Инструменты и безопасные приёмы в PostgreSQL
  • Пошаговые сценарии: от простого к сложному
    • 1) Добавляем колонку с дефолтом и NOT NULL
    • 2) Переименование колонки без простоя
    • 3) Создание индекса онлайн
    • 4) Миграция больших данных батчами
    • 5) Меняем enum и альтернативные паттерны
    • 6) Разносим JSONB по нормализованным таблицам
  • Онлайн-миграции в MySQL: на что опереться
  • Откат и «вперёд-только» стратегии
  • Мониторинг, защита от блокировок и чек-листы
  • Экономический эффект: меньше простоев — быстрее релизы

Почему миграции роняют прод и как этого избежать

Неправильная команда DDL (изменение схемы) может взять «эксклюзивную» блокировку таблицы и остановить записи. Длинная проверка ограничения — подвесить запросы. Создание индекса «в лоб» — на часы съесть CPU и заблокировать обновления. В пиковые часы это означает потерянные заказы и всплеск ошибок 5xx.

Главная идея безостановочной миграции — разделить изменения на маленькие безопасные шаги, которые совместимы с текущей версией кода. Сначала расширяем схему (expand), затем перестраиваем данные и трафик, и только потом сужаем (contract), удаляя старое.

Стратегия безостановочных миграций: expand/contract

  • Expand (расширение): добавляем новые объекты и поля так, чтобы старый код продолжал работать. Примеры: новая колонка nullable, индекс CONCURRENTLY, новое имя поля «вторым номером».
  • Migrate data (перенос): фоновая миграция данными малыми порциями, двойная запись (при необходимости), валидация.
  • Switch (переключение): код начинает читать из нового места, старое всё ещё живо.
  • Contract (сужение): удаляем старые колонки/индексы, выключаем адаптеры, чистим временные объекты.

Такой план снимает риск «всё или ничего» и позволяет откатываться по частям.

Инструменты и безопасные приёмы в PostgreSQL

  • CREATE INDEX CONCURRENTLY и REINDEX CONCURRENTLY — создают/перестраивают индекс без блокировки записи.
  • DROP INDEX CONCURRENTLY — удаляет индекс без долгой блокировки.
  • Добавление колонки с DEFAULT в PostgreSQL 11+ — метаданные, без переписывания таблицы. Но NOT NULL — отдельная история.
  • NOT VALID для CHECK/FOREIGN KEY: сначала добавляем как NOT VALID, потом VALIDATE CONSTRAINT — это минимизирует блокировки при проверке.
  • lock_timeout и statement_timeout — страхуют от зависания на блокировках.
  • Малые батчи для обновлений/бэкфилла и паузы между ними — не душим прод.
  • Тегируйте сессии application_name — легче искать блокировки по своим миграциям.

Пример безопасного «обвеса» для миграций:

-- Выполняйте в начале каждой миграции
SET lock_timeout = '2s';           -- не стоим в очереди на блокировку
SET statement_timeout = '15min';   -- миграция не должна жить вечно
SET idle_in_transaction_session_timeout = '1min';
SET application_name = 'ddl_migration_2026_04_28';

Диагностика блокировок во время миграции:

SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE application_name = 'ddl_migration_2026_04_28';

Пошаговые сценарии: от простого к сложному

1) Добавляем колонку с дефолтом и NOT NULL

Задача: добавить колонку locale с дефолтом 'ru' и сделать её NOT NULL, не блокируя запись.

Безопасный план для PostgreSQL 11+:

-- Шаг A: расширяем схему
ALTER TABLE users ADD COLUMN locale text;                  -- быстро
ALTER TABLE users ALTER COLUMN locale SET DEFAULT 'ru';    -- метаданные

-- Шаг B: фоновый бэкфилл малыми порциями (пример ниже на Python)

-- Шаг C: вешаем CHECK как NOT VALID и валидируем
ALTER TABLE users
  ADD CONSTRAINT users_locale_nn CHECK (locale IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_locale_nn;      -- долгая проверка без долгих блокировок

-- (Опционально) Шаг D: переводим в строгий NOT NULL, когда уверены
ALTER TABLE users ALTER COLUMN locale SET NOT NULL;         -- короткая операция, когда данные уже валидны

Фоновый бэкфилл батчами на Python (psycopg2):

import os, time
import psycopg2

DSN = os.getenv("PG_DSN", "dbname=app user=app password=secret host=127.0.0.1")
BATCH = 5_000
SLEEP = 0.2

sql_pick = """
  SELECT id FROM users
  WHERE locale IS NULL
  ORDER BY id
  LIMIT %s
"""

sql_update = """
  UPDATE users u
  SET locale = 'ru'
  FROM (SELECT id FROM users WHERE locale IS NULL ORDER BY id LIMIT %s FOR UPDATE SKIP LOCKED) t
  WHERE u.id = t.id
  RETURNING u.id
"""

with psycopg2.connect(DSN) as conn:
    with conn.cursor() as cur:
        cur.execute("SET application_name = 'backfill_users_locale'")
        cur.execute("SET lock_timeout = '1s'")
        while True:
            cur.execute(sql_update, (BATCH,))
            rows = cur.fetchall()
            if not rows:
                break
            conn.commit()
            time.sleep(SLEEP)
print("done")

Ключевые моменты: SKIP LOCKED избегает конкуренции с прод-записями; коммиты — часто; паузы — щадим нагрузку.

2) Переименование колонки без простоя

На лобовую (ALTER TABLE ... RENAME COLUMN) — опасно: старый код отвалится. Делаем совместимо:

-- Expand: добавляем новую колонку
ALTER TABLE orders ADD COLUMN total_cents bigint;

-- Двойная запись (в коде): при изменении суммы пишем и в total, и в total_cents
-- Временный триггер как страховка (если правки кода катятся дольше):
CREATE OR REPLACE FUNCTION orders_mirror_total()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP IN ('INSERT', 'UPDATE') THEN
    IF NEW.total_cents IS NULL AND NEW.total IS NOT NULL THEN
      NEW.total_cents := (NEW.total * 100)::bigint;
    END IF;
  END IF;
  RETURN NEW;
END $$;

CREATE TRIGGER orders_total_mirror
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION orders_mirror_total();

-- Бэкфилл существующих строк
UPDATE orders SET total_cents = (total * 100)::bigint WHERE total_cents IS NULL;

-- Switch: код начинает читать total_cents, а total больше не используется в чтении

-- Contract: удаляем старое поле и триггер
DROP TRIGGER orders_total_mirror ON orders;
ALTER TABLE orders DROP COLUMN total;

3) Создание индекса онлайн

-- Быстро и без блокировки записи
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);

-- Удаление старого
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email_old;

Нельзя выполнять CREATE INDEX CONCURRENTLY внутри транзакции, которую открывает большинство миграционных фреймворков. Либо отключите обёртку в транзакцию для этой миграции, либо используйте отдельный шаг.

4) Миграция больших данных батчами

Общий шаблон:

  • выбираем пачку по первичному ключу (стабильный порядок);
  • UPDATE/INSERT с SKIP LOCKED;
  • короткие транзакции и паузы;
  • метрики прогресса.

SQL-пример для «раздувшейся» таблицы events, переносим поле payload_text в payload_json:

WITH moved AS (
  SELECT id FROM events
  WHERE payload_json IS NULL
  ORDER BY id
  LIMIT 2000
  FOR UPDATE SKIP LOCKED
)
UPDATE events e
SET payload_json = to_jsonb(e.payload_text)
FROM moved
WHERE e.id = moved.id;

Повторяем до опустошения очереди. После — CHECK NOT VALID + VALIDATE, затем удаляем устаревшее поле.

5) Меняем enum и альтернативные паттерны

В PostgreSQL можно добавить значение в enum:

ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'refunded';

Но удалить или переименовать значение нельзя без трюков. Поэтому для эволюции статусов лучше:

  • хранить статус как text + CHECK-ограничение (его можно менять NOT VALID/VALIDATE);
  • или вынести статусы в справочник (таблица) с внешним ключом.

Пример с CHECK:

ALTER TABLE orders
  ADD CONSTRAINT orders_status_check CHECK (status IN ('new','paid','shipped','refunded')) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_check;

6) Разносим JSONB по нормализованным таблицам

Шаблон «разнесения» без простоя:

-- Expand: новая таблица под детали
CREATE TABLE IF NOT EXISTS user_devices (
  id bigserial PRIMARY KEY,
  user_id bigint NOT NULL REFERENCES users(id),
  device_id text NOT NULL,
  platform text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_devices_user ON user_devices(user_id);

-- Функция и триггер для двойной записи из users.profile_jsonb
CREATE OR REPLACE FUNCTION users_device_mirror()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE d jsonb;
BEGIN
  IF TG_OP IN ('INSERT','UPDATE') THEN
    d := COALESCE(NEW.profile_jsonb->'devices', '[]'::jsonb);
    -- Простая стратегия: удаляем старые и вставляем новые для пользователя
    DELETE FROM user_devices WHERE user_id = NEW.id;
    INSERT INTO user_devices(user_id, device_id, platform)
    SELECT NEW.id, (x->>'id')::text, (x->>'platform')::text
    FROM jsonb_array_elements(d) AS x;
  END IF;
  RETURN NEW;
END $$;

CREATE TRIGGER users_device_mirror
AFTER INSERT OR UPDATE OF profile_jsonb ON users
FOR EACH ROW EXECUTE FUNCTION users_device_mirror();

-- Бэкфилл исторических данных батчами
INSERT INTO user_devices(user_id, device_id, platform)
SELECT u.id,
       (x->>'id')::text,
       (x->>'platform')::text
FROM users u,
     LATERAL jsonb_array_elements(COALESCE(u.profile_jsonb->'devices','[]'::jsonb)) AS x
LEFT JOIN user_devices d ON d.user_id = u.id AND d.device_id = (x->>'id')::text
WHERE d.id IS NULL
ORDER BY u.id
LIMIT 5000; -- повторяем

-- Switch: код читает из user_devices
-- Contract: убираем хранение devices в JSONB или оставляем как кэш по бизнес-решению

Онлайн-миграции в MySQL: на что опереться

В MySQL/InnoDB часть ALTER TABLE поддерживает ALGORITHM=INPLACE, LOCK=NONE, но не все типы изменений. Для тяжёлых миграций таблиц в проде — инструменты «теневого копирования»:

  • gh-ost (GitHub): создаёт теневую таблицу, реплицирует изменения триггерами, затем быстро переключает.
  • pt-online-schema-change (Percona): похожий подход.

Пример gh-ost:

gh-ost \
  --host=db.example.internal \
  --user=app --password='secret' \
  --database=shop --table=orders \
  --alter="ADD COLUMN locale varchar(8)" \
  --cut-over=default \
  --max-load=Threads_running=50 \
  --critical-load=Threads_running=80 \
  --approve-renamed-columns \
  --verbose --execute

Параметры max-load/critical-load защищают прод от деградации под миграцией.

Откат и «вперёд-только» стратегии

Классические «down»-миграции редко работают в проде: данные уже уехали. Надёжнее стратегия «вперёд-только» с мелкими шагами:

  • откат кода — всегда возможен, т.к. schema в режиме expand совместима;
  • если новый путь чтения дал ошибки — верните чтение на старый, продолжайте двойную запись;
  • contract выполняйте только когда уверены и есть бэкапы/снапшоты.

Полезно иметь «кнопку» быстрого отката чтений (конфигурация сервиса), отдельно от релиза кода.

Мониторинг, защита от блокировок и чек-листы

Мониторинг во время миграции:

  • доля ошибок 5xx/таймаутов;
  • время ответов по целевым эндпоинтам;
  • прогресс бэкфилла: сколько строк осталось;
  • блокировки в СУБД (pg_locks/pg_stat_activity).

Защита от блокировок:

  • всегда ставьте lock_timeout/statement_timeout в миграциях;
  • запускайте тяжёлые шаги вне пиков;
  • дробите изменение на «безопасные» DDL (CONCURRENTLY, NOT VALID, ADD COLUMN nullable);
  • проверяйте, нет ли долгоживущих транзакций перед стартом.

Префлайт PostgreSQL перед «взрослой» миграцией:

-- Долгие транзакции, которые могут держать старые снимки
SELECT pid, xact_start, now()-xact_start AS age, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start IS NOT NULL
ORDER BY age DESC
LIMIT 10;

-- Потенциальные блокировки целевой таблицы
SELECT bl.pid AS blocked_pid, a.query AS blocked_query,
       kl.pid AS locker_pid, ka.query AS locker_query, bl.locktype
FROM pg_locks bl
JOIN pg_stat_activity a ON a.pid = bl.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype
  AND kl.DATABASE IS NOT DISTINCT FROM bl.DATABASE
  AND kl.relation IS NOT DISTINCT FROM bl.relation
  AND kl.page IS NOT DISTINCT FROM bl.page
  AND kl.tuple IS NOT DISTINCT FROM bl.tuple
  AND kl.virtualxid IS NOT DISTINCT FROM bl.virtualxid
  AND kl.transactionid IS NOT DISTINCT FROM bl.transactionid
  AND kl.classid IS NOT DISTINCT FROM bl.classid
  AND kl.objid IS NOT DISTINCT FROM bl.objid
  AND kl.objsubid IS NOT DISTINCT FROM bl.objsubid
  AND kl.pid <> bl.pid
JOIN pg_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;

Чек-лист перед запуском:

  • формализованный план expand → migrate → switch → contract;
  • окна и лимиты нагрузки согласованы (и в коде, и в БД);
  • дамп/снапшот на случай фатальной ошибки;
  • метрики и алерты готовы;
  • тест на копии прод-данных пройден с замером времени и влияния.

Экономический эффект: меньше простоев — быстрее релизы

  • Нулевая или минимальная недоступность — нет потери выручки в пике.
  • Разбиение на маленькие совместимые шаги — меньше инцидентов и быстрее согласования релизов.
  • Повторно используемые шаблоны и обвязка (timeouts, CONCURRENTLY, батчи) — до 2–4 раз сокращают время на подготовку миграций.
  • Меньше спешки: есть возможность безопасно «пожить» с двойной записью и переключить чтение, когда метрики стабилизируются.

Итог: команды разработки доставляют изменения в данные и схему так же уверенно, как код — без ночных «окон», ручных переключений и нервов в проде.


PostgreSQLмиграции БДбез простоя