Пошук уроків, статей та іншого контенту
Застосуємо транзакції для атомарних операцій і розглянемо інтерактивні та пакетні транзакції.
Транзакція — це група операцій із базою даних, яка виконується як єдине ціле:
або успішно виконуються всі операції;
або в разі помилки скасовуються всі зміни.
Це називають властивістю атомарності.
Наприклад, переказ коштів складається щонайменше з двох змін:
зменшити баланс рахунку відправника;
збільшити баланс рахунку отримувача.
Якщо виконати ці запити окремо, між ними може виникнути помилка. У результаті кошти спишуться з одного рахунку, але не будуть зараховані на інший.
Транзакція гарантує, що частково виконаного переказу не залишиться.
У Prisma транзакції запускаються через метод:
prisma.$transaction(...)Prisma підтримує два основні способи:
пакетні транзакції — масив незалежних операцій;
інтерактивні транзакції — callback, усередині якого можна виконувати умовну логіку.
Пакетна транзакція приймає масив Prisma-запитів:
const result = await prisma.$transaction([
prisma.account.update(...),
prisma.session.deleteMany(...),
]);Усі запити з масиву виконуються в межах однієї транзакції. Якщо один із них завершиться помилкою, Prisma скасує всі попередні зміни цієї транзакції.
Припустімо, що в схемі Prisma є моделі Account і Session:
model Account {
id String @id @default(cuid())
status String @default("ACTIVE")
}
model Session {
id String @id @default(cuid())
accountId String
expiresAt DateTime
}Метод закриття рахунку може одночасно змінити статус рахунку та видалити його сесії:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AccountsService {
constructor(private readonly prisma: PrismaService) {}
async closeAccount(accountId: string) {
const [account, deletedSessions] = await this.prisma.$transaction([
this.prisma.account.update({
where: { id: accountId },
data: { status: 'CLOSED' },
}),
this.prisma.session.deleteMany({
where: { accountId },
}),
]);
return {
account,
deletedSessions: deletedSessions.count,
};
}
}Якщо accountId не існує, update завершиться помилкою. У такому випадку видалення сесій також не буде зафіксовано.
Результати повертаються в тому самому порядку, що й операції в масиві:
const [account, deletedSessions] = await this.prisma.$transaction([
// Результат із першої операції
this.prisma.account.update(...),
// Результат із другої операції
this.prisma.session.deleteMany(...),
]);Пакетний варіант добре підходить, коли:
усі операції відомі заздалегідь;
між операціями немає складної умовної логіки;
результат попередньої операції не потрібен для побудови наступної;
потрібно атомарно виконати кілька незалежних запитів.
Пакетна транзакція не є найкращим вибором, якщо наступний запит залежить від результату попереднього. Для цього використовується інтерактивна транзакція.
Інтерактивна транзакція приймає callback:
await prisma.$transaction(async (tx) => {
// Операції всередині транзакції
});Параметр tx — це спеціальний Prisma Client, прив’язаний до поточної транзакції. Усі запити, які мають бути частиною транзакції, потрібно виконувати через нього:
await tx.account.update(...);
await tx.transfer.create(...);Не слід використовувати звичайний this.prisma усередині callback:
await this.prisma.account.update(...); // неправильно для цієї транзакціїІнакше запит може виконуватися поза поточною транзакцією.
Інтерактивний варіант дає змогу:
перевіряти результати попередніх запитів;
виконувати умовну логіку;
кидати помилки вручну;
використовувати значення, отримані під час транзакції.
Розглянемо повний сервіс для переказу коштів між рахунками. Баланс зберігатимемо як ціле число в найменших одиницях валюти, наприклад у копійках.
model Account {
id String @id @default(cuid())
balance Int
transfers Transfer[]
}
model Transfer {
id String @id @default(cuid())
fromAccountId String
toAccountId String
amount Int
createdAt DateTime @default(now())
fromAccount Account @relation("OutgoingTransfers", fields: [fromAccountId], references: [id])
toAccount Account @relation("IncomingTransfers", fields: [toAccountId], references: [id])
}Для двох зв’язків із тією самою моделлю потрібно явно вказати різні назви relation:
fromAccount Account @relation("OutgoingTransfers", ...)
toAccount Account @relation("IncomingTransfers", ...)Сервіс:
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TransfersService {
constructor(private readonly prisma: PrismaService) {}
async transfer(
fromAccountId: string,
toAccountId: string,
amount: number,
) {
if (!Number.isInteger(amount) || amount <= 0) {
throw new BadRequestException(
'Сума переказу має бути додатним цілим числом',
);
}
if (fromAccountId === toAccountId) {
throw new BadRequestException(
'Рахунки відправника й отримувача мають відрізнятися',
);
}
return this.prisma.$transaction(
async (tx) => {
const sourceUpdate = await tx.account.updateMany({
where: {
id: fromAccountId,
balance: {
gte: amount,
},
},
data: {
balance: {
decrement: amount,
},
},
});
if (sourceUpdate.count !== 1) {
const sourceAccount = await tx.account.findUnique({
where: { id: fromAccountId },
select: { id: true },
});
if (!sourceAccount) {
throw new NotFoundException('Рахунок відправника не знайдено');
}
throw new BadRequestException(
'Недостатньо коштів на рахунку відправника',
);
}
await tx.account.update({
where: { id: toAccountId },
data: {
balance: {
increment: amount,
},
},
});
return tx.transfer.create({
data: {
fromAccountId,
toAccountId,
amount,
},
include: {
fromAccount: true,
toAccount: true,
},
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 5_000,
timeout: 10_000,
},
);
}
}Послідовність операцій така:
Перевіряється, що баланс відправника достатній.
Баланс відправника зменшується.
Баланс отримувача збільшується.
Створюється запис про переказ.
Якщо будь-яка операція завершується помилкою, усі зміни відкочуються.
updateManyЗапит оновлює рахунок лише тоді, коли одночасно виконуються дві умови:
where: {
id: fromAccountId,
balance: {
gte: amount,
},
}Якщо рахунок не існує або на ньому недостатньо коштів, кількість змінених записів буде нульовою:
if (sourceUpdate.count !== 1) {
// Операцію потрібно скасувати
}Це важливіше за окрему перевірку балансу через findUnique, оскільки перевірка та списання виконуються в одному SQL-запиті. Між ними не виникає проміжного стану, у якому інша паралельна операція могла б змінити баланс.
Будь-яка помилка, викинута всередині callback, скасовує транзакцію:
await this.prisma.$transaction(async (tx) => {
await tx.account.update(...);
throw new Error('Операцію потрібно скасувати');
// Цей запит не буде зафіксований
await tx.transfer.create(...);
});У NestJS можна викидати стандартні винятки, наприклад BadRequestException або NotFoundException. Prisma передасть їх далі, а зміни транзакції будуть відкочені.
Рівень ізоляції визначає, як транзакції взаємодіють одна з одною під час паралельного виконання.
У Prisma його можна вказати в параметрах транзакції:
await this.prisma.$transaction(
async (tx) => {
// Операції транзакції
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
);Serializable забезпечує найсуворішу ізоляцію: результат паралельного виконання транзакцій має відповідати такому, ніби вони виконувалися послідовно.
Це може бути корисно для операцій, де важливі:
залишки коштів;
ліміти;
кількість доступних товарів;
резервування ресурсу;
уникнення подвійного використання одного значення.
Підтримка конкретних рівнів ізоляції залежить від бази даних. Prisma використовує можливості СУБД, тому перед вибором рівня потрібно перевірити, які режими підтримує ваша база.
Чим суворіший рівень ізоляції, тим вища ймовірність конфлікту між паралельними транзакціями. У такій ситуації база даних може вимагати повторити транзакцію.
Для конфліктів серіалізації Prisma може повернути помилку з кодом P2034.
Повторювати потрібно всю транзакцію, а не окремий запит. Зручніше винести одну спробу в окрему функцію:
import { Prisma } from '@prisma/client';
async function runWithRetry<T>(
operation: () => Promise<T>,
attempts = 3,
): Promise<T> {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await operation();
} catch (error) {
const isWriteConflict =
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2034';
if (!isWriteConflict || attempt === attempts) {
throw error;
}
}
}
throw new Error('Транзакцію не вдалося виконати');
}Використання:
return runWithRetry(() =>
this.prisma.$transaction(
async (tx) => {
// Усі операції однієї спроби транзакції
return tx.account.update({
where: { id: accountId },
data: {
balance: {
increment: 100,
},
},
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
),
);Повторення має бути обмеженим. Якщо транзакція постійно конфліктує, це може вказувати на надто довгу транзакцію, високий рівень конкуренції або невдалу структуру запитів.
Інтерактивна транзакція може приймати додаткові параметри:
await this.prisma.$transaction(
async (tx) => {
// Операції транзакції
},
{
maxWait: 5_000,
timeout: 10_000,
},
);maxWait — скільки мілісекунд Prisma чекатиме на отримання з’єднання для транзакції;
timeout — максимальна тривалість виконання транзакції.
Транзакції потрібно робити якомога коротшими. Не варто розміщувати всередині них:
HTTP-запити до інших сервісів;
тривалі обчислення;
очікування користувацької дії;
роботу з файлами;
повільні зовнішні API.
Поки транзакція активна, вона утримує з’єднання з базою та може блокувати інші операції.
Неправильний приклад:
await this.prisma.$transaction(async (tx) => {
const account = await tx.account.findUnique({
where: { id: accountId },
});
// Не слід виконувати зовнішній HTTP-запит усередині транзакції
await fetch('https://example.com/notify');
return tx.account.update({
where: { id: accountId },
data: { status: 'PROCESSED' },
});
});Краще спочатку виконати коротку транзакцію, а зовнішню дію — після її успішного завершення. При цьому потрібно враховувати, що зовнішня дія може завершитися помилкою вже після commit транзакції.
Використовуйте масив операцій, коли:
await prisma.$transaction([
prisma.account.update(...),
prisma.session.deleteMany(...),
]);операції незалежні;
усі параметри відомі до початку виконання;
не потрібні перевірки між запитами;
не потрібно використовувати результат одного запиту в іншому.
Використовуйте callback, коли:
await prisma.$transaction(async (tx) => {
const record = await tx.account.findUnique(...);
if (!record) {
throw new Error('Запис не знайдено');
}
return tx.account.update(...);
});наступний крок залежить від попереднього;
потрібні перевірки або умовні переходи;
необхідно створити помилку за бізнес-правилом;
потрібно повернути результат кількох пов’язаних операцій.
Неправильно:
await this.prisma.$transaction(async (tx) => {
await tx.account.update(...);
await this.prisma.transfer.create(...);
});Правильно:
await this.prisma.$transaction(async (tx) => {
await tx.account.update(...);
await tx.transfer.create(...);
});Усі запити, які мають бути атомарними, повинні використовувати tx.
Небезпечний варіант:
const account = await this.prisma.account.findUnique({
where: { id: accountId },
});
if (account.balance >= amount) {
await this.prisma.account.update({
where: { id: accountId },
data: {
balance: {
decrement: amount,
},
},
});
}Між findUnique та update інша операція може змінити баланс.
Надійніший варіант — використати умову безпосередньо в updateMany:
const result = await tx.account.updateMany({
where: {
id: accountId,
balance: {
gte: amount,
},
},
data: {
balance: {
decrement: amount,
},
},
});
if (result.count !== 1) {
throw new Error('Недостатньо коштів або рахунок не існує');
}Неправильно:
try {
await this.prisma.$transaction(async (tx) => {
await tx.account.update(...);
});
} catch {
return null;
}Якщо помилку потрібно перехопити, її слід або коректно перетворити, або повторно викинути. Інакше API може повернути успішну відповідь, хоча операція не відбулася.
Транзакція не повинна охоплювати зайву роботу. Чим довше вона працює, тим більше з’єднань і блокувань може утримувати.
updateMany або deleteManyМетоди updateMany і deleteMany не викидають помилку, якщо жоден запис не відповідає умові. Вони повертають результат із count.
Тому бізнес-логіка має явно перевіряти:
if (result.count !== 1) {
throw new Error('Очікуваний запис не було змінено');
}Транзакція гарантує атомарність групи операцій.
У Prisma транзакції запускаються через $transaction.
Пакетна форма приймає масив готових Prisma-запитів.
Інтерактивна форма приймає callback із транзакційним клієнтом tx.
Усі запити всередині інтерактивної транзакції потрібно виконувати через tx.
Інтерактивні транзакції підходять для перевірок, умовної логіки та залежних операцій.
Для конкурентних операцій можна налаштувати Serializable.
Конфлікти серіалізації можуть вимагати повторного виконання всієї транзакції.
Транзакції мають бути короткими та не повинні містити зовнішніх HTTP-запитів або тривалих операцій.
Результати updateMany і deleteMany потрібно перевіряти через поле count.