Разбор 06

Ошибки базы данных: миграции, записи, связи

Миграция не проходила на проде, новые записи не создавались без видимой причины, а связи между таблицами рассыпались. Чинил на PostgreSQL + ORM.

PostgreSQL Prisma ORM миграции транзакции foreign key N+1
Клиент
SaaS-платформа, CRM для малого бизнеса
Стек
Node.js, PostgreSQL 14, Prisma ORM, Express, Zod
Срок
3 дня
Итог
Миграции безопасно катятся на проде, записи атомарны, связи целостны, N+1 устранён

Суть проблемы

Заказчик выкатил фичу «тарифные планы у аккаунтов» и сразу получил три独立的проблемы с данными. Ни одна не воспроизводилась локально у разработчика — на проде была БД с миллионами строк и реальной нагрузкой.

Состояние системы До

Миграция блокирует деплой, создание заказов теряет данные, связи неконсистентны, список заказов делает 51 запрос. Разработчик не понимает, почему локально всё работает, а на проде — нет.

Состояние системы После

Миграции безопасно катятся на БД с данными, записи создаются атомарно либо полностью откатываются, связи защищены каскадами и уникальными индексами, список заказов — 2 запроса.

Как диагностировал

Подключился к прод-БД в read-only (через psql) и к репозиторию. Разобрал каждую проблему отдельно, но быстро понял, что корень общий — схема и код писались под пустую БД, без учёта существующих данных и ограничений.

  1. Проверил состояние миграцийВыполнил prisma migrate status на проде — миграция 20260115_add_plan_id в статусе failed. Открыл файл миграции: ALTER TABLE "Account" ADD COLUMN "planId" TEXT NOT NULL; — колонка NOT NULL без DEFAULT. На пустой локальной БД это проходит; на проде с 1.2 млн существующих строк PostgreSQL не может поставить значение — нарушение констрейнта.
  2. Воспроизвёл потерю записиВключил prisma:query в логах и повторил создание заказа. Увидел: первый INSERT в Order проходил, второй INSERT в OrderItem падал по unique constraint (дублирующая позиция), но ошибка попадала в .catch() без отката и без проброса наружу. Контроллер возвращал 201 по первому успеху.
  3. Проверил валидациюСхема Zod валидировала тело запроса, но не проверяла уникальность (orderId, productId) — это ответственность БД. Дубликат приходил из фронтенда (двойной клик), БД его отклоняла, а код это проглатывал.
  4. Осмотрел схему связейВ schema.prisma у связи Post.author не было onDelete — по умолчанию Prisma ставит NoAction, но при ручном удалении через prisma.user.delete() записи-сироты оставались, потому что не было ни каскада, ни явного Restrict, а старый код удалял через $executeRaw в обход ORM.
  5. Нашёл причину задвоения позицийПозиции заказа создавались через prisma.orderItem.create() в цикле без уникального индекса на (orderId, productId). Двойной сабмит формы добавлял позицию дважды — БД не возражала.
  6. Подтвердил N+1В коде списка заказов — prisma.order.findMany(), затем в цикле prisma.orderItem.findMany({ where: { orderId } }). 50 заказов = 51 запрос. Лог prisma:query это подтвердил дословно.
  7. Сверил с прод-даннымиЗапросом 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. Каждый шаг совместим с БД, в которой уже есть данные.

prisma/migrations/20260115_add_plan_id/migration.sqlбыло
-- Падает на проде: 1.2 млн строк без значения для NOT NULL-колонки
ALTER TABLE "Account" ADD COLUMN "planId" TEXT NOT NULL;

-- Prisma помечает миграцию как failed, деплой останавливается
prisma/migrations/20260115_add_plan_id_safe/migration.sqlстало
-- Шаг 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");
Миграция, меняющая схему на БД с данными — всегда multi-step: add nullable → backfill → set NOT NULL. Одна ALTER ... NOT NULL на проде с строками гарантированно падает.

2. Атомарное создание заказа в транзакции

Обернул оба инсерта в prisma.$transaction(). Теперь либо сохраняются и заказ, и все позиции, либо откатывается всё. Ошибка пробрасывается наружу и превращается в корректный HTTP-ответ, а не проглатывается.

src/services/order.service.tsбыло
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;
}
src/services/order.service.tsстало
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, ValidationError422, прочие — 500. Раньше любой из этих сценариев возвращал 201.

3. Связи: каскады, уникальный индекс, include

В схеме прописал onDelete для каждой связи явно — Cascade там, где дочерние записи не имеют смысла без родителя (посты автора), и Restrict там, где удалять нельзя, пока есть зависимости. Добавил @@unique на составной ключ позиций заказа.

prisma/schema.prismaбыло
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]) — дубль проходит
}
prisma/schema.prismaстало
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

src/controllers/order.controller.tsбыло
// 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 запрос к БД
src/controllers/order.controller.tsстало
// Один запрос с include — Prisma собирает JOIN за один заход
const orders = await prisma.order.findMany({
  take: 50,
  include: { items: true },
});
// 50 заказов → 2 запроса (orders + orderItems с IN)

Дублирование позиций — upsert по составному ключу

src/services/order.service.ts — добавление позициистало
// Если позиция для этого товара уже есть — обновляем количество
await tx.orderItem.upsert({
  where: {
    orderId_productId: { orderId, productId },
  },
  create: { orderId, productId, qty },
  update: { qty: { increment: qty } },
});

Результат

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

1 → 0
0
провальных миграций на проде
~15/мес → 0
0
потерянных и частичных записей заказов
8 340 + 1 542 → 0
0
висячих ссылок и дублей позиций
1 + N → 2
2
запроса при загрузке списка заказов

Дополнительно: время ответа ручки GET /orders на проде упало с ~2,1 c до 120 мс — сняли 49 лишних round-trip к БД. Висячие посты и дубли позиций вычистил разовым скриптом с бэкапом. Для всех новых связей в схеме заведён код-ревью-чеклист: явно указывать onDelete и уникальные индексы.

Миграции безопасно катятся на проде с данными, записи атомарны, связи целостны на уровне схемы, запросы оптимизированы. Фича с тарифными планами выкачена в тот же день.
Есть похожая проблема?

Сначала найдём причину. Потом решим, что действительно нужно исправить.

Опишите симптом и что из-за него перестало работать. Я посмотрю, где искать причину и насколько задача похожа на точечный фикс.

Описать проблему ↗