Транзакции и 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 — иначе если транзакция откатится, юзер получит «спасибо за заказ» которого не существует. Правило: транзакция — короткая (миллисекунды), только локальные операции с БД.
// ❌ ОПАСНО — без транзакции
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, 'Деньги пришли');Перевод денег — без транзакции и с транзакцией