Миграция 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-стек, с которого ехали:
- PHP 5.4 + MariaDB
- Inline-PHP в шаблонах для платежей
- Session-cookies для авторизации
- URL вроде
/order.php?id=12345с прямой нумерацией - PHPSpreadsheet для генерации документов
- Разрозненные
mail()-вызовы для уведомлений
Новый стек:
- SvelteKit 2 + Svelte 5 runes + Tailwind 4 + shadcn-svelte
- Supabase PostgreSQL 17 self-hosted на 185.179.188.145
- shared-pay (TS микросервис, Альфа SBP + ЮKassa)
- shared-notifications (единая шина email/sms/telegram)
- Hatchet + Gotenberg для PDF-документов
- mTLS + HMAC + Idempotency для bridge со старой системой на время переходного периода
Ключевая метрика, которую держали всё время — поддерживаем ≥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 области автоматически:
discount_types— расхождение в типах скидок legacy/newdoctype— типы документовcountries/ep_cities— справочники- паспорт-коды — formats отличались между системами
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 миграций сложился чек-лист:
CREATE INDEX CONCURRENTLYвсегда — никогда безCONCURRENTLYна боевой таблице >100k строк.ADD COLUMN ... DEFAULT NULL— никаких computed defaults на больших таблицах (заставляет переписывать всю таблицу).- Backfill отдельным batch-update’ом с
pg_sleep(0.05)между батчами — даёт другим транзакциям дышать. NOT NULLпосле полного backfill — добавлениеNOT NULLна колонку с null’ами заблокирует таблицу целиком.- Feature-flag для rollback — каждое изменение схемы должно иметь обратимый путь без
git revert.
Что мы из этого вынесли
- Миграция — это процесс, а не событие. Воспроизводимый pipeline + drift-fix дают возможность переиграть любой этап и не накопить «непонятное состояние».
- Insert-only versioning неприкосновенен. Старые версии цен, скидок, шаблонов — никогда не перезаписываем (
valid_from-модель). Это даёт аудит и rollback одной строкой. - Backup ОБЯЗАТЕЛЬНО перед DROP+CREATE. Когда
valid_to/is_activeв legacy переписывались overwrite’ом — теряли историю. Никогда больше.
Ссылки
- Supabase docs — Database migrations — официальный workflow
- PostgreSQL — CREATE INDEX CONCURRENTLY — must-read перед миграцией prod-таблицы
- pgcli — REPL для безопасной работы с production
- Strong Migrations (Ruby пример паттернов) — список 30+ типичных опасных миграций