EvolCode
треки/Базы данных·05 / 07
11 мин чтения

Транзакции и ACID — почему деньги не пропадают

Простой сценарий: Анна переводит 100₽ Боре. Код делает два UPDATE: списывает у Анны, добавляет Боре. Между ними сервер падает — деньги списались, но не зачислились. Они исчезли. Это самый понятный пример того, зачем нужны транзакции. Без них любая операция, состоящая из нескольких шагов, в случае сбоя оставляет данные в полу-разрушенном состоянии. С транзакцией — либо ВСЁ выполнилось, либо НИЧЕГО не выполнилось.

ACID — четыре буквы про надёжность

Atomicity (атомарность) — транзакция либо вся прошла, либо вся откатилась. Половин не бывает. Consistency (консистентность) — после транзакции БД в валидном состоянии (все CHECK-constraints, FK, типы). Isolation (изоляция) — параллельные транзакции не видят промежуточных состояний друг друга. Durability (долговечность) — после COMMIT данные сохранены даже если через секунду упало питание (записаны на диск). Это контракт, который Postgres соблюдает по умолчанию для всех запросов внутри транзакции.

BEGIN, COMMIT, ROLLBACK — как пишется руками

BEGIN — начало транзакции. UPDATE — твои операции. COMMIT — зафиксировать все изменения. ROLLBACK — отменить ВСЕ изменения после BEGIN. Если между BEGIN и COMMIT произошла ошибка (упало питание, ушёл коннект, бросило exception) — всё откатывается автоматически. Postgres гарантирует: либо все INSERT/UPDATE/DELETE между BEGIN и COMMIT увидят все остальные пользователи, либо никто не увидит.

Транзакции в Prisma: $transaction

Prisma даёт два способа. Первый — массив операций: prisma.$transaction([op1, op2, op3]) — выполнятся атомарно, любая ошибка откатит все. Второй (мощнее) — interactive: prisma.$transaction(async (tx) => { ... }) — внутри коллбека ты делаешь любые tx.user.update(), включая логику между ними («если баланс < 0, бросай ошибку»). Если коллбек выкинул exception или вернулся с ошибкой — всё откатилось. Используй interactive для всего, где есть условия между шагами.

Когда транзакция обязательна — три критичных случая

Первое: финансы. Списание+зачисление, оплата+обновление статуса заказа, начисление бонусов. Второе: связанные изменения, где промежуточное состояние невалидно. Создаёшь юзера + его профиль + дефолтные настройки — если профиль не создался, юзера тоже не должно быть. Третье: race conditions с проверкой. «Если на складе ≥ 1 — забронируй». Без транзакции два юзера могут одновременно увидеть «есть 1», оба забронируют, склад уйдёт в минус. С транзакцией + правильным уровнем изоляции — нет.

Цена транзакций — что НЕ делать

Транзакция держит блокировки. Длинная транзакция блокирует другие запросы — может вообще остановить сайт. Не делай внутри транзакции: запросы к внешним API (HTTP может занять минуты), отправку email, тяжёлые расчёты. Только записи в БД. Email отправляй ПОСЛЕ COMMIT — иначе если транзакция откатится, юзер получит «спасибо за заказ» которого не существует. Правило: транзакция — короткая (миллисекунды), только локальные операции с БД.

примерts
// ❌ ОПАСНО — без транзакции
await prisma.account.update({
  where: { id: fromId },
  data: { balance: { decrement: amount } },
});
// ⚡ если сервер упал ЗДЕСЬ — деньги списались, но не дошли
await prisma.account.update({
  where: { id: toId },
  data: { balance: { increment: amount } },
});


// ✅ Простая транзакция — массив операций
await prisma.$transaction([
  prisma.account.update({
    where: { id: fromId },
    data: { balance: { decrement: amount } },
  }),
  prisma.account.update({
    where: { id: toId },
    data: { balance: { increment: amount } },
  }),
]);
// Любая ошибка → обе операции откатятся


// ✅ Interactive — с проверкой между шагами
await prisma.$transaction(async (tx) => {
  const from = await tx.account.findUniqueOrThrow({ where: { id: fromId } });
  if (from.balance < amount) {
    throw new Error('Недостаточно средств');
    // throw → автоматический ROLLBACK
  }
  await tx.account.update({
    where: { id: fromId },
    data: { balance: { decrement: amount } },
  });
  await tx.account.update({
    where: { id: toId },
    data: { balance: { increment: amount } },
  });
  // лог ТОЖЕ внутри транзакции — иначе при rollback лог останется
  await tx.transferLog.create({
    data: { fromId, toId, amount, status: 'success' },
  });
});

// Email — ПОСЛЕ транзакции, не внутри!
await sendEmail(toId, 'Деньги пришли');

Перевод денег — без транзакции и с транзакцией

главное
  • 01ACID = Atomicity, Consistency, Isolation, Durability. Контракт, что данные не сломаются
  • 02Транзакция = «всё или ничего». Половин не бывает
  • 03Любая операция из 2+ шагов с зависимостью между ними должна быть в транзакции
  • 04Postgres откатывает транзакцию автоматически при exception — не нужно ловить руками
  • 05Используй $transaction(async tx => ...) когда нужна логика между операциями
  • 06НЕ делай в транзакции: HTTP-запросы, отправку email, тяжёлые расчёты — только БД
  • 07Email и побочные эффекты — после COMMIT, иначе при rollback пользователь получит уведомление о несуществующем событии
проверь себя
01Зачем в коде перевода денег `await prisma.transferLog.create()` стоит ВНУТРИ $transaction, а не после?
02Что произойдёт если внутри `prisma.$transaction(async (tx) => { ... })` бросить exception?
03Почему НЕЛЬЗЯ делать `await sendEmail()` внутри $transaction?
следующий урок: Миграции на проде — как менять схему живой БД