Миграция legacy PHP в Supabase: 17 тысяч заявок с верификацией без downtime

Веб Штурм
  • миграция
  • Supabase
  • PostgreSQL
  • legacy
  • PHP

Миграция реального бизнеса с legacy PHP-системы на Supabase — это не «дамп MySQL → импорт в Postgres». На проекте Круизный флот прогнали 17 620 единиц данных (10 578 речных круизов + 7 042 прогулок) с верификацией каждой записи через сравнение SQL-выборок legacy ↔ новая БД. Сейчас в проде 211 SQL миграций, и ни одна из них не давала downtime больше, чем pg_lock на 50 мс.

Контекст

Legacy-стек, с которого ехали:

Новый стек:

Ключевая метрика, которую держали всё время — поддерживаем ≥1000 одновременных пользователей (требование ТЗ).

migration_app: воспроизводимый pipeline вместо разовой операции

Многие команды рассматривают миграцию как событие: «возьмём дамп, переколбасим, готово». Это работает один раз и оставляет вас с непонятным состоянием на проде.

Мы пошли по пути воспроизводимого pipeline. migration_app/ — отдельное PHP 5.4 приложение с SSE realtime-progress в браузере, тремя режимами работы (analyze / dry-run / apply) и checkpoint’ами после каждой «волны» преобразований.

Архитектура одной волны:

[transform] PHP-функция transformOrders(legacyRow) → newRow
 → INSERT INTO new_db.orders
 → UPDATE legacy_db.aa_migration_queue SET status='migrated'
 → SSE event: progress 47%
[verify] SELECT count(*) FROM new_db.orders WHERE migrated_at >= ?
 → сравнение с count из legacy
 → отчёт о расхождениях

При расхождении — волна не помечается завершённой, в SSE летит ошибка с конкретным id, мигратор останавливает следующую волну. После фикса — apply повторяется идемпотентно благодаря aa_migration_queue.

Drift-fix как ежеседативная процедура

После выгрузки legacy → Supabase данные могут «дрейфовать»: новые продажи в legacy, ручные правки бухгалтером в Excel, не учтённые поля. Чтобы не накапливать бомбу — раз в день запускали drift-fix.sql.

Реальный кейс на ПФ: drift-fix обнаружил, что 312 тысяч платёжных записей не имели поля site_id, что ломало отчётность. За один сеанс 302 940 записей помечены site_id='buh', ещё 9 101 — site_id='alfabank'. Прод не падал ни разу.

Скрипт находил 4 high-drift области автоматически:

Drift-fix запускался cron’ом, отчёт уходил в Telegram. Если расхождений 0 — silent. Если есть — уведомление с диффом.

Public_id backfill без downtime

Одно из ранних решений — заменить bigint id в URL на 7-символьный public_id varchar(7) (base62 без двусмысленных символов). Это и SEO (нет нумерованной адресации), и безопасность (нельзя угадать соседний /order/12346), и ux (короткие ссылки).

Backfill 17 тысяч существующих заявок без блокировки таблицы:

-- 1. Добавляем колонку nullable
ALTER TABLE orders ADD COLUMN public_id varchar(7);

-- 2. BEFORE INSERT триггер для новых записей
CREATE OR REPLACE FUNCTION generate_public_id() RETURNS trigger AS $$
BEGIN
 IF NEW.public_id IS NULL THEN
 NEW.public_id := substring(md5(random()::text || clock_timestamp()::text) for 7);
 END IF;
 RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER orders_public_id BEFORE INSERT ON orders
 FOR EACH ROW EXECUTE FUNCTION generate_public_id();

-- 3. Backfill существующих batch'ами по 1000
DO $$
DECLARE
 rows_updated int;
BEGIN
 LOOP
 UPDATE orders SET public_id = substring(md5(random()::text || id::text) for 7)
 WHERE id IN (SELECT id FROM orders WHERE public_id IS NULL LIMIT 1000);
 GET DIAGNOSTICS rows_updated = ROW_COUNT;
 EXIT WHEN rows_updated = 0;
 PERFORM pg_sleep(0.1);
 END LOOP;
END $$;

-- 4. После backfill — UNIQUE constraint
CREATE UNIQUE INDEX CONCURRENTLY orders_public_id_unique ON orders(public_id);
ALTER TABLE orders ADD CONSTRAINT orders_public_id_unique UNIQUE USING INDEX orders_public_id_unique;

Ни одна транзакция не блокировалась дольше создания триггера (~10 мс).

Hot-database ALTER: 5 правил

После 211 миграций сложился чек-лист:

  1. CREATE INDEX CONCURRENTLY всегда — никогда без CONCURRENTLY на боевой таблице >100k строк.
  2. ADD COLUMN ... DEFAULT NULL — никаких computed defaults на больших таблицах (заставляет переписывать всю таблицу).
  3. Backfill отдельным batch-update’ом с pg_sleep(0.05) между батчами — даёт другим транзакциям дышать.
  4. NOT NULL после полного backfill — добавление NOT NULL на колонку с null’ами заблокирует таблицу целиком.
  5. Feature-flag для rollback — каждое изменение схемы должно иметь обратимый путь без git revert.

Что мы из этого вынесли

  1. Миграция — это процесс, а не событие. Воспроизводимый pipeline + drift-fix дают возможность переиграть любой этап и не накопить «непонятное состояние».
  2. Insert-only versioning неприкосновенен. Старые версии цен, скидок, шаблонов — никогда не перезаписываем (valid_from-модель). Это даёт аудит и rollback одной строкой.
  3. Backup ОБЯЗАТЕЛЬНО перед DROP+CREATE. Когда valid_to/is_active в legacy переписывались overwrite’ом — теряли историю. Никогда больше.

Ссылки