Агент написал вам миграцию. Теперь выкатите её без потери данных
Coding-агенты пишут синтаксически идеальные миграции, которые блокируют продакшн-таблицу на четыре минуты. Разбираю expand-contract, грабли Postgres и файл правил, который я даю агенту до того, как он лезет в схему.
Pavel Duglas
AI Automation & MVP Architect
Coding-агенты реально научились работать со схемой. Просишь миграцию, которая распиливает full_name на first_name и last_name, и через пять секунд получаешь аккуратный валидный файл. SQL корректный. Эта же миграция возьмёт ACCESS EXCLUSIVE на таблице в 40 миллионов строк, выстроит за собой очередь из всех входящих запросов и положит API на четыре минуты в самый пиковый час.
В этом вся суть проблемы с AI и миграциями. Сбой не в синтаксисе. Сбой в том, что SQL правильный, а поведение в рантайме - нет. И агент это поведение увидеть не может, потому что оно живёт в распределении ваших данных, в использовании индексов, в топологии деплоя и в той версии кода приложения, которая продолжает работать, пока миграция выполняется.
Ниже - workflow, который я использую на клиентских проектах, и файл ограничений, который я загружаю в контекст до того, как агент получает доступ к схеме.
Почему LLM структурно плохи в миграциях
Агент читает schema.prisma, модели, может быть папку с предыдущими миграциями. Это статичная картинка. А миграция - задача динамическая, и не хватает ровно того контекста, который и делает больно:
- Количество строк и форма данных.
ALTER COLUMN TYPEмгновенный на 5 тысячах строк и полный rewrite таблицы на 50 миллионах. Файл схемы в обоих случаях выглядит одинаково. - Конкурентный трафик. Блокировка, которую в три часа ночи никто не заметит, в два часа дня - инцидент. Агент не знает вашего QPS.
- Топология деплоя. Во время rolling deploy к одной базе одновременно стучится старый и новый код. Любая миграция, которая исходит из “код и схема меняются вместе”, сломана по умолчанию.
- Очередь блокировок. Вот это убивает чаще всего. В Postgres заблокированный
ALTER TABLEблокирует всё, что встало за ним, включая обычныеSELECT. Миграция, которой надо подождать лока 200 мс, может застопорить весь read path на длину самой долгой транзакции впереди.
Поэтому решение не в том, чтобы “писать промпты лучше”. Решение - дать агенту такой workflow, в котором опасные варианты структурно недоступны.
Правило первое: схема и код никогда не едут одним деплоем
Любое нетривиальное изменение схемы превращается в три деплоя. Expand, migrate, contract. Совет старый, и AI делает его не менее актуальным, а более: агенты обожают компактную версию в один коммит, где колонка переименована и все ссылки на неё поправлены разом.
Переименование full_name в display_name, как надо:
Деплой 1 (expand). Добавляем display_name как nullable. Код пишет в обе колонки. Читает из full_name. Backfill пока не запускаем. Миграция - один ADD COLUMN, в современном Postgres это чистая метадата.
Деплой 2 (migrate). Запускаем backfill-джобу (не файл миграции, об этом ниже). Когда backfill подтверждённо завершён, переключаем чтение на display_name через feature flag. Двойная запись остаётся.
Деплой 3 (contract). Перестаём писать в full_name. Ждём. Именно ждём, минимум один полный релизный цикл, лучше неделю. Потом дропаем колонку отдельной миграцией, в которой больше ничего нет.
Да, это три PR ради переименования. Зато каждый шаг откатывается независимо и без потери данных: достаточно задеплоить предыдущий образ. Сравните с переименованием в один деплой, где откат означает восстановление из бэкапа.
Агенты, кстати, отлично выполняют эту схему, если объяснить им форму. Просишь “шаг 1 expand-contract переименования, только additive, без backfill в файле миграции” - и получаешь именно это.
Правило второе: backfill - это job, а не миграция
Самое частое, что я правлю в сгенерированных миграциях. Агент пишет:
UPDATE users SET display_name = full_name WHERE display_name IS NULL;
Прямо внутри миграции. На 40 миллионах строк это одна транзакция, которая держит row-локи, раздувает WAL, разгоняет лаг репликации и не возобновляется, если умрёт на 80%.
Backfill живёт в очереди задач, батчами и идемпотентно:
async function backfillDisplayNames() {
let cursor = await loadWatermark("display_name_backfill") ?? 0;
const BATCH = 2000;
while (true) {
const rows = await db.query(
`SELECT id, full_name FROM users
WHERE id > $1 AND display_name IS NULL
ORDER BY id LIMIT $2`,
[cursor, BATCH]
);
if (rows.length === 0) break;
await db.query(
`UPDATE users SET display_name = full_name
WHERE id = ANY($1) AND display_name IS NULL`,
[rows.map(r => r.id)]
);
cursor = rows[rows.length - 1].id;
await saveWatermark("display_name_backfill", cursor);
await sleep(100); // даём репликам продышаться
}
}
Проверка IS NULL делает джобу идемпотентной. Watermark делает её возобновляемой. sleep превращает её из инцидента в фоновый процесс. А поскольку это job, за ней можно наблюдать, её можно поставить на паузу, ускорить или замедлить без деплоя.
Грабли Postgres, которые агент не увидит
Держите этот список там, где модель его прочитает. Вот те, на которые я наступаю регулярно:
ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULTбезопасен в Postgres 11+ для константных дефолтов. С volatile-дефолтом (now(),gen_random_uuid()) это полный rewrite таблицы. Агенты путают эти два случая постоянно.SET NOT NULLтребует полного скана подACCESS EXCLUSIVE. Делайте в два шага: добавитьCHECK (col IS NOT NULL) NOT VALID, затемVALIDATE CONSTRAINT(лок слабее), затемSET NOT NULL- на PG 12+ валидный констрейнт позволяет пропустить скан.- Foreign key при добавлении блокирует запись в обе таблицы на время валидации. Добавляйте
NOT VALID, валидируйте отдельно. CREATE INDEXблокирует запись. ВсегдаCREATE INDEX CONCURRENTLY. Он не работает внутри транзакции, а значит почти любому ORM-раннеру нужен явный обходной путь: Prisma - сырой SQL, Rails -disable_ddl_transaction!. Агенты забывают про это каждый раз.- Смена типа колонки обычно означает rewrite.
varchar(50)вvarchar(100)бесплатно,varcharвtextбесплатно,intвbigint- rewrite. Делайте через новую колонку плюс backfill. - Удаление колонки быстрое, но ломает любой старый инстанс приложения, который ещё делает
SELECT *. Отсюда и пауза перед третьим деплоем.
И оберните всё в таймауты, чтобы заблокированная миграция падала, а не роняла сайт:
SET lock_timeout = '3s';
SET statement_timeout = '30s';
Если лок не взялся за три секунды, миграция падает с ошибкой и деплой громко ломается. Это намного лучше, чем тихо забитая очередь запросов. При желании оберните в retry-цикл.
Файл правил
В каждом репозитории я держу docs/MIGRATIONS.md и явно ссылаюсь на него в промпте или в project instructions. Коротко, в приказном тоне, без объяснений:
- Только additive. Никаких DROP и RENAME в одном PR с изменениями кода.
- Один файл миграции = одно логическое изменение. Не бандлить.
- Каждая миграция начинается с: SET lock_timeout = '3s';
- Индексы: CREATE INDEX CONCURRENTLY, вне транзакции.
- Никаких UPDATE / DELETE / INSERT в файлах миграций. Данные меняем в src/jobs/backfills/.
- Новые колонки - nullable. Констрейнты позже: NOT VALID, затем VALIDATE.
- Таблицы больше 1M строк: в описании PR указать оценку длительности лока.
- Forward-only. Down-миграций нет. Откат = деплой предыдущей версии приложения.
Последний пункт стоит защитить. Down-миграции дают ложное чувство безопасности: их почти никогда не тестируют, а если up-миграция уничтожила данные, down их не вернёт. Forward-only плюс additive-first честнее, и это принудительно навязывает дисциплину expand-contract.
С этим файлом в контексте качество AI-миграций переходит из состояния “надо переписать” в состояние “надо прочитать”. В этом весь выигрыш.
Что я реально проверяю в диффе
Семь пунктов по порядку, полторы минуты:
- Есть ли
DROP,RENAMEили смена типа? Если да, это PR фазы contract, у которого expand уже выкатан и отлежался? - Есть ли DML (
UPDATE/DELETE/INSERT) в файле миграции? Переносим в job. CREATE INDEXбезCONCURRENTLY?NOT NULLили foreign key добавлены напрямую вместоNOT VALID?- Выставлен ли
lock_timeout? - Сколько строк в каждой затронутой таблице? Считаю сам, оценке из описания PR не верю.
- Будет ли текущий задеплоенный код работать с этой схемой? Не новый код. Тот, который прямо сейчас в продакшне.
Тестируйте на реальном объёме
Миграция, прошедшая на dev-базе с двумя сотнями строк, не говорит вообще ничего. Восстановите свежий снапшот продакшна в отдельный инстанс, прогоните миграцию и замерьте время. Десять минут подготовки превращают “да вроде норм” в конкретное число.
Пока она бежит, смотрите на блокировки:
SELECT pid, wait_event_type, state,
now() - query_start AS duration, left(query, 80)
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY duration DESC;
Если во время тестовой миграции запрос вернул строки, ответ у вас есть.
Где агент действительно помогает
Я не призываю делать это руками. Агенты отлично справляются с самой нудной частью workflow: сгенерировать последовательность из трёх деплоев по описанию, написать батчевый backfill с watermark, выдать сырой SQL-обход для concurrent-индекса в том ORM, с которым вы застряли, и составить верификационный запрос, доказывающий, что backfill завершён.
Чего они не могут - решить, приемлема ли для вашего бизнеса четырёхминутная блокировка. Это управленческое решение, для которого нужно знать трафик, SLA и клиентов. Эту часть оставьте себе.
Паттерн шире, чем базы данных: пусть агент генерирует внутри набора ограничений, которые делают катастрофические варианты невозможными, а время ревью тратьте на те решения, которые ограничения принять за вас не могут.
Вопросы и ответы
У меня MVP на пару тысяч строк в таблице. Мне тоже нужен expand-contract в три деплоя?
Нет, и это важная часть здравого смысла. На таблице до сотни тысяч строк без серьёзного трафика обычная миграция в один деплой пройдёт за миллисекунды, и три PR ради переименования - оверинжиниринг. Но два правила я включаю с первого дня даже на MVP: `lock_timeout` в каждой миграции и запрет на DML внутри файлов миграций. Они ничего не стоят сейчас и спасают позже, когда таблица незаметно вырастет до миллионов строк и никто не вспомнит, что процесс пора менять.
Как заставить агента реально следовать файлу правил, а не игнорировать его?
Ссылки в промпте недостаточно, модель забывает правила через несколько шагов. Три вещи работают надёжнее. Первое: положите правила в постоянный контекст проекта (CLAUDE.md, .cursorrules, project instructions), а не в разовое сообщение. Второе: сделайте CI-проверку, которая грепает файлы миграций на `DROP`, `RENAME`, `UPDATE`, `CREATE INDEX` без `CONCURRENTLY` и отсутствие `lock_timeout` - агент видит красный пайплайн и исправляется сам. Третье: держите в репозитории два-три образцовых файла миграций, агент подхватывает стиль из существующего кода гораздо охотнее, чем из инструкций.
Что делать, если миграция уже упала на середине в продакшне?
Первым делом посмотрите `pg_stat_activity` и решите, кто кого блокирует: часто миграция не упала, а стоит в очереди за долгой транзакцией, и правильное действие - убить эту транзакцию, а не миграцию. Дальше всё зависит от дисциплины: если правило additive-only соблюдалось, упавшая миграция ничего не уничтожила, вы просто откатываете деплой приложения на предыдущий образ и разбираетесь спокойно. Если внутри миграции был `UPDATE` на миллионы строк, вы сейчас узнаете, насколько свежий у вас бэкап и работает ли PITR. Именно поэтому data-изменения выносятся в возобновляемые джобы: упавшая на 80% джоба продолжится с watermark, упавшая транзакция откатит всю работу.
Похожие статьи
Сделаю под ключ
Соберу платформу с кабинетами, ролями и оплатой
Личный кабинет, админка, интеграции с платежами и CRM и структура, которая переживёт вторую версию.
от 3 000 $ · 3-5 недель