Суть проблемы
Заказчик выкатил фичу «тарифные планы у аккаунтов» и сразу получил три独立的проблемы с данными. Ни одна не воспроизводилась локально у разработчика — на проде была БД с миллионами строк и реальной нагрузкой.
- !Миграция падает на проде
prisma migrate deployна проде завершается сP3008: Migration failed, при этом на локальной пустой БД та же миграция проходит чисто. Деплой фичи заблокирован. - !Записи «молча» не сохраняютсяСоздание заказа через API возвращает
201 Created, но в БД либо нет строки заказа, либо есть заказ без позиций. В логах — тишина, клиент видит «успех» и потом теряет данные. - !Связи рассыпаютсяУ заказов пропадают или задваиваются позиции. При удалении автора (пользователя) его посты остаются с
authorId, указывающим на несуществующий id — «висячие» ссылки ломают выборки. - !Список заказов тормозитСтраница со списком из 50 заказов выполняет 51 запрос к БД — по одному на позиции каждого заказа. На проде с реальным объёмом время ответа уходит за 2 секунды.
Миграция блокирует деплой, создание заказов теряет данные, связи неконсистентны, список заказов делает 51 запрос. Разработчик не понимает, почему локально всё работает, а на проде — нет.
Миграции безопасно катятся на БД с данными, записи создаются атомарно либо полностью откатываются, связи защищены каскадами и уникальными индексами, список заказов — 2 запроса.
Как диагностировал
Подключился к прод-БД в read-only (через psql) и к репозиторию. Разобрал каждую проблему отдельно, но быстро понял, что корень общий — схема и код писались под пустую БД, без учёта существующих данных и ограничений.
- Проверил состояние миграцийВыполнил
prisma migrate statusна проде — миграция20260115_add_plan_idв статусеfailed. Открыл файл миграции:ALTER TABLE "Account" ADD COLUMN "planId" TEXT NOT NULL;— колонкаNOT NULLбезDEFAULT. На пустой локальной БД это проходит; на проде с 1.2 млн существующих строк PostgreSQL не может поставить значение — нарушение констрейнта. - Воспроизвёл потерю записиВключил
prisma:queryв логах и повторил создание заказа. Увидел: первыйINSERTвOrderпроходил, второйINSERTвOrderItemпадал поunique constraint(дублирующая позиция), но ошибка попадала в.catch()без отката и без проброса наружу. Контроллер возвращал201по первому успеху. - Проверил валидациюСхема Zod валидировала тело запроса, но не проверяла уникальность
(orderId, productId)— это ответственность БД. Дубликат приходил из фронтенда (двойной клик), БД его отклоняла, а код это проглатывал. - Осмотрел схему связейВ
schema.prismaу связиPost.authorне былоonDelete— по умолчанию Prisma ставитNoAction, но при ручном удалении черезprisma.user.delete()записи-сироты оставались, потому что не было ни каскада, ни явногоRestrict, а старый код удалял через$executeRawв обход ORM. - Нашёл причину задвоения позицийПозиции заказа создавались через
prisma.orderItem.create()в цикле без уникального индекса на(orderId, productId). Двойной сабмит формы добавлял позицию дважды — БД не возражала. - Подтвердил N+1В коде списка заказов —
prisma.order.findMany(), затем в циклеprisma.orderItem.findMany({ where: { orderId } }). 50 заказов = 51 запрос. Логprisma:queryэто подтвердил дословно. - Сверил с прод-даннымиЗапросом
SELECT COUNT(*) FROM "Post" WHERE "authorId" NOT IN (SELECT id FROM "User");нашёл 8 340 висячих постов. То же по позициям — 1 542 дубля(orderId, productId). Корневые причины подтверждены на данных.
Что исправил
Каждую проблему чинил у корня: переписал миграцию на безопасный multi-step паттерн, обернул создание заказа в транзакцию с корректной обработкой ошибок, добавил каскады и уникальные индексы в схему, устранил N+1 через include.
1. Безопасная миграция колонки NOT NULL
Разбил одну разрушительную миграцию на три безопасных шага: добавить колонку nullable → заполнить существующие строки значением по умолчанию → поставить NOT NULL. Каждый шаг совместим с БД, в которой уже есть данные.
-- Падает на проде: 1.2 млн строк без значения для NOT NULL-колонки
ALTER TABLE "Account" ADD COLUMN "planId" TEXT NOT NULL;
-- Prisma помечает миграцию как failed, деплой останавливается
-- Шаг 1: добавляем колонку как nullable — совместимо с любым объёмом данных
ALTER TABLE "Account" ADD COLUMN "planId" TEXT;
-- Шаг 2: backfill — заполняем существующие строки значением по умолчанию
UPDATE "Account" SET "planId" = 'free' WHERE "planId" IS NULL;
-- Шаг 3: закрываем колонку — теперь NOT NULL не нарушает ни одной строки
ALTER TABLE "Account" ALTER COLUMN "planId" SET NOT NULL;
-- Шаг 4: дефолт для будущих вставок + индекс на связь
ALTER TABLE "Account" ALTER COLUMN "planId" SET DEFAULT 'free';
CREATE INDEX "Account_planId_idx" ON "Account"("planId");
ALTER ... NOT NULL на проде с строками гарантированно падает.2. Атомарное создание заказа в транзакции
Обернул оба инсерта в prisma.$transaction(). Теперь либо сохраняются и заказ, и все позиции, либо откатывается всё. Ошибка пробрасывается наружу и превращается в корректный HTTP-ответ, а не проглатывается.
async function createOrder(data: OrderInput) {
// Два независимых INSERT без транзакции
const order = await prisma.order.create({
data: { userId: data.userId, total: data.total },
});
for (const item of data.items) {
await prisma.orderItem.create({
data: { orderId: order.id, productId: item.productId, qty: item.qty },
}).catch(() => {
// Ошибка проглатывается — позиция молча теряется
console.log('item skipped');
});
}
// Возвращаем 201 даже если позиции не записались
return order;
}
async function createOrder(data: OrderInput) {
// Проверка уникальности позиций на входе — раньше это делала только БД
const seen = new Set();
for (const item of data.items) {
if (seen.has(item.productId)) {
throw new ValidationError('Дубликат позиции в заказе');
}
seen.add(item.productId);
}
// Атомарно: либо заказ + все позиции, либо откат целиком
return prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: { userId: data.userId, total: data.total },
});
await tx.orderItem.createMany({
data: data.items.map(i => ({
orderId: order.id,
productId: i.productId,
qty: i.qty,
})),
});
return order;
});
// При ошибке транзакция откатится, исключение уйдёт в контроллер → 4xx
}
В контроллере — корректная обработка: PrismaClientKnownRequestError с кодом P2002 (нарушение уникальности) отдаёт 409 Conflict, ValidationError — 422, прочие — 500. Раньше любой из этих сценариев возвращал 201.
3. Связи: каскады, уникальный индекс, include
В схеме прописал onDelete для каждой связи явно — Cascade там, где дочерние записи не имеют смысла без родителя (посты автора), и Restrict там, где удалять нельзя, пока есть зависимости. Добавил @@unique на составной ключ позиций заказа.
model User {
id String @id @default(cuid())
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
authorId String
author User @relation(fields: [authorId], references: [id])
// onDelete не указан — нет ни каскада, ни защиты от висячих ссылок
}
model Order {
id String @id @default(cuid())
items OrderItem[]
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
qty Int
order Order @relation(fields: [orderId], references: [id])
// Нет @@unique([orderId, productId]) — дубль проходит
}
model User {
id String @id @default(cuid())
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
authorId String
// Посты не имеют смысла без автора — удаляем каскадом
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}
model Order {
id String @id @default(cuid())
items OrderItem[]
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
qty Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
// Одна позиция на продукт в заказе — на уровне схемы
@@unique([orderId, productId])
}
Загрузка заказа с позициями — без N+1
// 1 запрос на заказы + по запросу на позиции каждого — N+1
const orders = await prisma.order.findMany({ take: 50 });
for (const order of orders) {
order.items = await prisma.orderItem.findMany({
where: { orderId: order.id },
});
}
// 50 заказов → 51 запрос к БД
// Один запрос с include — Prisma собирает JOIN за один заход
const orders = await prisma.order.findMany({
take: 50,
include: { items: true },
});
// 50 заказов → 2 запроса (orders + orderItems с IN)
Дублирование позиций — upsert по составному ключу
// Если позиция для этого товара уже есть — обновляем количество
await tx.orderItem.upsert({
where: {
orderId_productId: { orderId, productId },
},
create: { orderId, productId, qty },
update: { qty: { increment: qty } },
});
Результат
Все три проблемы закрыты у корневой причины. Миграции теперь совместимы с БД любого объёма, создание заказа атомарно, связи защищены на уровне схемы, список заказов не делает лишних запросов.
Дополнительно: время ответа ручки GET /orders на проде упало с ~2,1 c до 120 мс — сняли 49 лишних round-trip к БД. Висячие посты и дубли позиций вычистил разовым скриптом с бэкапом. Для всех новых связей в схеме заведён код-ревью-чеклист: явно указывать onDelete и уникальные индексы.