Kravchenko

Web Lab

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

Kravchenko

Web Lab

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

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

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

•

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

•

ОГРНИП: 324784700339743

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

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

Разработка и технологии31 марта 2026 г.
Схема базы данных меняется — бизнес растёт. Но каждая миграция может остановить продажи, если сделать её в лоб. Разбираем надёжные приёмы, как эволюционировать схему и переносить данные без блокировок, простоев и ночных «окон».
Миграции БД без простоя: как менять схему и данные, не теряя заказы и выручку

Оглавление

  • Зачем это бизнесу
  • Где ломаются миграции: типичные риски
  • Принципы миграций без простоя
  • Паттерн «расширяй и сжимай» (expand-and-contract)
    • Пример: меняем тип столбца и формат данных
  • Индексы и ограничения: как включать без блокировок
    • Индексы
    • Ограничения
    • Таймауты и безопасность
  • Перенос данных (backfill) без боли и перегрева
    • Шаблон для PostgreSQL: UPDATE порциями со SKIP LOCKED
    • Пример воркера на Python (psycopg)
    • Мониторинг backfill
  • Переименование столбцов и таблиц: безопасные приёмы
  • Крупные изменения: перенос в новую таблицу и разбиение
    • Стратегия «новая таблица + двойная запись»
    • Перенос частями по ключу
  • Наблюдаемость миграций и контроль рисков
  • Откат: как вернуться, не повредив данным
  • Чек-лист перед запуском
  • Частые вопросы
    • Можно ли сразу добавить NOT NULL DEFAULT?
    • Что делать с огромным блоатом после массовых UPDATE?
    • Триггеры или двойная запись из приложения?
  • Примеры команд для PostgreSQL и MySQL
    • PostgreSQL: безопасные шаблоны
    • MySQL: индексы и ptox
  • Итог

Зачем это бизнесу

База данных — сердце продукта. Любая блокировка таблицы, долгий апдейт или неудачный индекс могут заморозить приём заказов, поднять количество ошибок и съесть репутацию. Миграции без простоя позволяют:

  • выпускать фичи без «ночных окон» и ручных танцев;
  • не терять заказы во время изменений;
  • равномерно использовать ресурсы — без пиков, очередей и скачков задержек;
  • ускорить цикл «идея → прод» и снизить страх перед изменениями.

Где ломаются миграции: типичные риски

  • Долгие блокировки DDL. Простое ALTER TABLE может взять эксклюзивный блок и остановить записи/чтения.
  • Табличный перепис (table rewrite). Добавление столбца с DEFAULT или NOT NULL в старых версиях может переписать всю таблицу.
  • Индексы, создаваемые «в лоб». Во время построения индекс может блокировать запись.
  • Массовые UPDATE/DELETE. Большие транзакции — рост репликационного лага, блоат, автовакаум не справляется.
  • «Сломанные» ограничения. Включили NOT NULL или CHECK — и внезапно упали тысячи записей.
  • Переименование столбцов — приложение не успело обновиться, запросы рухнули.

Принципы миграций без простоя

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

Паттерн «расширяй и сжимай» (expand-and-contract)

Идея: сначала добавляем новое рядом со старым (расширяем), переводим чтение/запись на новое, убеждаемся, что всё работает, потом удаляем старое (сжимаем).

Пример: меняем тип столбца и формат данных

Допустим, в таблице orders поле phone хранится как строка, но нужно нормализовать и хранить в формате E.164, а потом обеспечить уникальность.

Шаг 1. Добавляем новый столбец nullable — быстро и без блокировок:

-- PostgreSQL
ALTER TABLE orders ADD COLUMN phone_e164 text; -- без NOT NULL и DEFAULT

Шаг 2. Создаём индекс «на лету»:

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_phone_e164 ON orders (phone_e164);

Шаг 3. Переносим данные батчами (подробно — ниже). Параллельно меняем код приложения: при записи заказа — нормализуем телефон и пишем и в phone, и в phone_e164 (двойная запись); при чтении — сначала пробуем phone_e164, если пусто — читаем phone и нормализуем на лету.

Шаг 4. Когда backfill завершён и трафик читает новый столбец, включаем ограничение:

-- NOT VALID сначала проверит только новые строки
ALTER TABLE orders
  ADD CONSTRAINT phone_e164_not_null CHECK (phone_e164 IS NOT NULL) NOT VALID;

-- Фоновая проверка существующих строк
ALTER TABLE orders VALIDATE CONSTRAINT phone_e164_not_null;

Шаг 5. Переключаем чтение только на phone_e164, останавливаем двойную запись.

Шаг 6. Чистим старый столбец, когда убедились по метрикам и логам, что обращения к нему прекратились:

ALTER TABLE orders DROP COLUMN phone;

Индексы и ограничения: как включать без блокировок

Индексы

  • PostgreSQL: всегда CREATE INDEX CONCURRENTLY. Для уникальных — CREATE UNIQUE INDEX CONCURRENTLY.
  • MySQL/InnoDB: ALTER TABLE ... ADD INDEX ... , ALGORITHM=INPLACE, LOCK=NONE; (зависит от версии и типа изменения). При сомнениях — pt-online-schema-change.

Пример для PostgreSQL:

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

-- наблюдаем прогресс
SELECT phase, tuples_done, tuples_total
FROM pg_stat_progress_create_index;

Ограничения

  • NOT NULL: сначала заполняем, потом CHECK ... NOT VALID + VALIDATE CONSTRAINT. Для реального NOT NULL — отдельный шаг:
-- мягкая проверка
ALTER TABLE users ADD CONSTRAINT email_not_null CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT email_not_null;

-- теперь можно жёстко
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
  • Уникальность: сначала индекс, затем ограничение на основе индекса:
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_users_email ON users (email);
ALTER TABLE users ADD CONSTRAINT users_email_unique UNIQUE USING INDEX ux_users_email;

Таймауты и безопасность

Используйте короткие таймауты блокировок и запросов, чтобы миграция не зависла и не положила прод:

SET lock_timeout = '1s';
SET statement_timeout = '30s';
SET idle_in_transaction_session_timeout = '30s';

Перенос данных (backfill) без боли и перегрева

Главная ошибка — один гигантский UPDATE. Делайте маленькими порциями, с паузами, учитывая нагрузку.

Шаблон для PostgreSQL: UPDATE порциями со SKIP LOCKED

-- Таблица: orders(id bigint PK, phone text, phone_e164 text)
-- Функция нормализации пусть будет в приложении, а здесь — перенос уже подготовленных данных

-- В приложении/воркере запускаем цикл:
-- 1) Берём партию идентификаторов, которые ещё не перенесены
WITH batch AS (
  SELECT id
  FROM orders
  WHERE phone_e164 IS NULL AND phone IS NOT NULL
  ORDER BY id
  LIMIT 1000
  FOR UPDATE SKIP LOCKED
)
UPDATE orders o
SET phone_e164 = normalize_phone(o.phone) -- для примера может быть SQL-функция
FROM batch b
WHERE o.id = b.id;

Если нормализация в приложении, сначала читаем партию ID, потом апдейтим. Между партиями — небольшая пауза (например, 50–200 мс), чтобы дать вздохнуть репликам и автовакауму.

Пример воркера на Python (psycopg)

import time
import psycopg

BATCH = 1000
SLEEP = 0.1

SQL_SELECT = """
  SELECT id, phone FROM orders
  WHERE phone_e164 IS NULL AND phone IS NOT NULL
  ORDER BY id
  LIMIT %s
  FOR UPDATE SKIP LOCKED
"""

SQL_UPDATE = "UPDATE orders SET phone_e164 = %s WHERE id = %s"

def normalize_phone(phone: str) -> str:
    # примитивный пример: оставим только цифры и добавим +
    digits = ''.join(ch for ch in phone if ch.isdigit())
    if not digits.startswith('7'):
        digits = '7' + digits
    return '+' + digits

with psycopg.connect("postgresql://app:pass@localhost/db") as conn:
    conn.execute("SET statement_timeout='30s'")
    conn.execute("SET lock_timeout='1s'")
    while True:
        with conn.transaction():
            rows = conn.execute(SQL_SELECT, (BATCH,)).fetchall()
            if not rows:
                break
            for _id, phone in rows:
                conn.execute(SQL_UPDATE, (normalize_phone(phone), _id))
        time.sleep(SLEEP)
print("done")

Мониторинг backfill

  • Сколько строк осталось: SELECT count(*) FROM orders WHERE phone_e164 IS NULL;
  • Нагрузка на реплики: следите за лагом репликации.
  • Ошибки нормализации: отдельный счётчик и «мёртвые письма» в таблицу с причинами.

Переименование столбцов и таблиц: безопасные приёмы

Переименование «в лоб» ломает приложение. Надёжнее так:

  1. Добавьте новый столбец с нужным именем. Включите двойную запись в коде.
  2. Заполните новый столбец из старого.
  3. Переключите чтение на новый столбец.
  4. Убедитесь по метрикам и логам, что старый столбец больше не читается.
  5. Удалите старый столбец.

Если всё-таки нужно единоразово «переименовать» на уровне SQL, можно временно создать представление со старым именем столбца:

ALTER TABLE users RENAME COLUMN full_name TO name;
CREATE OR REPLACE VIEW v_users AS SELECT id, name AS full_name, email FROM users;

Но лучше держать совместимость в приложении и не полагаться на представления под горячим трафиком.

Крупные изменения: перенос в новую таблицу и разбиение

Иногда проще создать новую таблицу с нужной схемой и постепенно «перелить» данные.

Стратегия «новая таблица + двойная запись»

  • Создаём orders_v2 с нужными колонками и индексами.
  • В приложении включаем двойную запись: все новые и изменённые заказы пишем и в orders, и в orders_v2.
  • Фоново переносим исторические данные партиями в orders_v2.
  • Переключаем чтение на orders_v2 для определённых маршрутов/пользователей (поэтапно).
  • Когда доля чтения ≈ 100% и валидаторы довольны — удаляем orders.

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

Если таблица огромна, переносите по «окнам» ключей или по дате:

INSERT INTO orders_v2 (id, user_id, total, created_at, ...)
SELECT id, user_id, total, created_at, ...
FROM orders
WHERE created_at >= now() - interval '90 days'
ON CONFLICT (id) DO NOTHING;

Наблюдаемость миграций и контроль рисков

  • Метрики блокировок: включите log_lock_waits и следите за событиями ожиданий.
  • Задержки запросов и доля ошибок 5xx во время миграции — алерты заранее.
  • На PostgreSQL смотрите блокировки:
SELECT bl.pid       AS blocked_pid,
       a.usename    AS blocked_user,
       ka.query     AS blocking_query,
       now() - ka.query_start AS blocking_age,
       kl.pid       AS blocking_pid,
       a.query      AS blocked_query,
       now() - a.query_start  AS blocked_age
FROM pg_catalog.pg_locks bl
JOIN pg_catalog.pg_stat_activity a
  ON a.pid = bl.pid
JOIN pg_catalog.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_catalog.pg_stat_activity ka ON ka.pid = kl.pid
WHERE NOT bl.granted;
  • Прогресс индексации: pg_stat_progress_create_index.
  • Репликация: лаг по pg_last_wal_replay_lsn и pg_stat_replication.

Откат: как вернуться, не повредив данным

  • Двухфазность на уровне фичи: пока есть двойная запись и двойное чтение — откат прост: переключите чтение обратно и выключите новую ветку.
  • Не делайте необратимых шагов (удаление колонок, жёсткий NOT NULL) до подтверждения стабильности.
  • Держите план очистки отдельно: «сжимаем» только когда уверены.
  • Для DDL — короткие шаги, каждый обратим. Например, вместо RENAME COLUMN — новый столбец + миграция.

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

  • Разбили изменения на мелкие шаги? Каждый шаг имеет план отката?
  • Есть тест на копии прод-данных (без персонализации) с замером времени?
  • Включены таймауты (lock_timeout, statement_timeout)?
  • Индексы создаются конкурентно?
  • Backfill идёт батчами с паузами и метриками?
  • Наблюдаемость настроена: лаг репликации, блокировки, ошибки?
  • Релиз приложения с двойной записью/чтением выкатили заранее?
  • Пик трафика учли? Миграция стартует в «окно» минимальной нагрузки.

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

Можно ли сразу добавить NOT NULL DEFAULT?

В PostgreSQL 11+ добавление столбца с «простым» DEFAULT не переписывает таблицу, но всё же лучше отделять схему и перенос данных: так вы контролируете нагрузку и валидацию. В старых версиях — переписывает таблицу и опасно.

Что делать с огромным блоатом после массовых UPDATE?

  • Делайте апдейты маленькими партиями.
  • Следите за автовакаумом, при необходимости увеличьте autovacuum_vacuum_cost_limit, включите ручной VACUUM в тихое время.
  • Для экстремальных случаев — VACUUM FULL (требует эксклюзива и окна), лучше избегать ситуаций, когда он нужен.

Триггеры или двойная запись из приложения?

Триггеры — удобно, но дороже по производительности и сложнее отлаживать. Предпочтительнее двойная запись в коде: прозрачнее и проще контролировать откат. Триггеры — только если без них никак.

Примеры команд для PostgreSQL и MySQL

PostgreSQL: безопасные шаблоны

-- 1) Добавить колонку быстро
ALTER TABLE invoices ADD COLUMN paid_at timestamptz; -- без NOT NULL/DEFAULT

-- 2) Индекс без блокировки
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invoices_paid_at ON invoices (paid_at);

-- 3) Мягкое ограничение и валидация
ALTER TABLE invoices ADD CONSTRAINT paid_at_in_past CHECK (paid_at <= now()) NOT VALID;
ALTER TABLE invoices VALIDATE CONSTRAINT paid_at_in_past;

-- 4) Жёсткое ограничение (после валидации и очистки)
-- ALTER TABLE invoices ALTER COLUMN paid_at SET NOT NULL; -- отдельным шагом

-- 5) Переименование без падений (лучше — новый столбец, но если нужно)
-- ALTER TABLE invoices RENAME COLUMN amount TO total_amount; -- делайте, когда код готов

-- 6) Таймауты на сессию миграции
SET lock_timeout = '1s';
SET statement_timeout = '30s';
SET application_name = 'db-migration';

Проверка оставшихся блокировок:

SELECT relname, mode, granted, pid
FROM pg_locks l
LEFT JOIN pg_class c ON c.oid = l.relation
WHERE relname IS NOT NULL
ORDER BY granted, relname;

MySQL: индексы и ptox

-- Добавить индекс без полного блокирования (зависит от версии/движка)
ALTER TABLE users ADD INDEX idx_users_email (email) , ALGORITHM=INPLACE, LOCK=NONE;

Если команда выше недоступна или рискованна — используйте pt-online-schema-change (перенос с триггерами, постепенно):

pt-online-schema-change \
  --alter "ADD INDEX idx_users_email (email)" \
  --execute \
  D=app,t=users

Итог

Миграции без простоя — это не магия, а дисциплина маленьких обратимых шагов: сначала создаём совместимость, потом переводим трафик, затем убираем старое. Индексы — конкурентно, ограничения — с валидацией, перенос данных — батчами. С метриками и планом отката изменения становятся рутинной операционной задачей, а не стрессом. Вы выиграете скорость вывода фич, стабильность и доверие бизнеса к инженерной практике.


PostgreSQLмиграцииБД