Пошук уроків, статей та іншого контенту
Зрозумієте атомарність операцій і реалізуєте підтвердження або відкат кількох змін у межах транзакції.
Транзакція — це група операцій із базою даних, яка виконується як єдине ціле.
Типовий сценарій:
розпочати транзакцію;
виконати кілька SQL-операцій;
підтвердити всі зміни через COMMIT;
або скасувати всі зміни через ROLLBACK, якщо сталася помилка.
Наприклад, переказ коштів між рахунками складається щонайменше з двох змін:
списати кошти з рахунку відправника;
додати кошти на рахунок отримувача.
Якщо перша операція виконалася, а друга завершилася помилкою, система опиниться в некоректному стані. Транзакція гарантує, що або виконаються обидві операції, або не виконається жодна.
Атомарність означає принцип «усе або нічого».
Без транзакції операції виконуються незалежно:
Списати 100 грн з рахунку A — успішно
Зарахувати 100 грн на рахунок B — помилка
Результат: гроші зникли з рахунку AУ транзакції:
BEGIN
Списати 100 грн з рахунку A — успішно
Зарахувати 100 грн на рахунок B — помилка
ROLLBACKПісля ROLLBACK база даних повертається до стану до початку транзакції.
Основні SQL-команди:
BEGIN; -- почати транзакцію
COMMIT; -- підтвердити зміни
ROLLBACK; -- скасувати зміниNode.js самостійно не керує транзакціями бази даних. Це робить конкретний драйвер або ORM.
Розглянемо PostgreSQL і пакет pg.
npm install pgТранзакція повинна виконуватися через одне й те саме з'єднання з базою даних. Тому потрібно:
отримати клієнт із пулу;
виконати всі запити через цей клієнт;
підтвердити або скасувати транзакцію;
повернути клієнт у пул.
Нехай є таблиця банківських рахунків:
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
owner_name TEXT NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
);
CREATE TABLE transfers (
id SERIAL PRIMARY KEY,
from_account_id INTEGER NOT NULL REFERENCES accounts(id),
to_account_id INTEGER NOT NULL REFERENCES accounts(id),
amount NUMERIC(12, 2) NOT NULL CHECK (amount > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO accounts (owner_name, balance)
VALUES
('Олена', 1000.00),
('Андрій', 500.00);Обмеження CHECK (balance >= 0) не дозволяє зберегти від'ємний баланс.
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function transferMoney(fromAccountId, toAccountId, amount) {
if (!Number.isInteger(fromAccountId) || !Number.isInteger(toAccountId)) {
throw new TypeError('Ідентифікатори рахунків мають бути цілими числами');
}
if (fromAccountId === toAccountId) {
throw new Error('Рахунки відправника й отримувача мають відрізнятися');
}
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error('Сума переказу має бути додатним числом');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const debitResult = await client.query(
`
UPDATE accounts
SET balance = balance - $1
WHERE id = $2
AND balance >= $1
RETURNING id, balance
`,
[amount, fromAccountId],
);
if (debitResult.rowCount === 0) {
throw new Error('Рахунок не знайдено або недостатньо коштів');
}
const creditResult = await client.query(
`
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
RETURNING id, balance
`,
[amount, toAccountId],
);
if (creditResult.rowCount === 0) {
throw new Error('Рахунок отримувача не знайдено');
}
await client.query(
`
INSERT INTO transfers (
from_account_id,
to_account_id,
amount
)
VALUES ($1, $2, $3)
`,
[fromAccountId, toAccountId, amount],
);
await client.query('COMMIT');
return {
fromAccount: debitResult.rows[0],
toAccount: creditResult.rows[0],
amount,
};
} catch (error) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
console.error('Не вдалося виконати відкат транзакції:', rollbackError);
}
throw error;
} finally {
client.release();
}
}
try {
const result = await transferMoney(1, 2, 100);
console.log('Переказ виконано:', result);
} catch (error) {
console.error('Переказ не виконано:', error.message);
} finally {
await pool.end();
}У цьому прикладі транзакція охоплює три операції:
списання коштів;
зарахування коштів;
запис інформації про переказ.
Якщо будь-яка з них завершується помилкою, виконується ROLLBACK.
Припустімо, на рахунку відправника недостатньо коштів.
Запит списання не змінить жодного рядка:
UPDATE accounts
SET balance = balance - 100
WHERE id = 1
AND balance >= 100;У Node.js це визначається через rowCount:
if (debitResult.rowCount === 0) {
throw new Error('Рахунок не знайдено або недостатньо коштів');
}Помилка переходить до блоку catch, де виконується:
await client.query('ROLLBACK');Оскільки зарахування та запис переказу ще не були підтверджені, жодна частина операції не залишається в базі.
Так само транзакція скасує вже виконане списання, якщо помилка виникне під час зарахування або запису переказу.
COMMIT потрібно виконувати після всіх операційПідтвердження транзакції має бути останньою операцією всередині try:
try {
await client.query('BEGIN');
await client.query('UPDATE ...');
await client.query('INSERT ...');
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}Після COMMIT зміни вважаються підтвердженими. Якщо помилка виникне після нього, ROLLBACK уже не скасує підтверджені зміни.
Неправильний варіант:
await pool.query('BEGIN');
await pool.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [
100,
1,
]);
await pool.query('COMMIT');Пул може виконати ці запити через різні з'єднання. Тоді BEGIN і UPDATE можуть належати одній сесії, а COMMIT — іншій.
Правильний варіант — отримати один клієнт і використовувати його для всіх запитів:
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE ...');
await client.query('INSERT ...');
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}Транзакція повинна мати обробку помилок на двох рівнях:
помилка будь-якого SQL-запиту;
помилка під час самого ROLLBACK.
Основна помилка має бути передана виклику функції через throw error. Це дозволяє маршруту або сервісу повернути відповідну відповідь користувачу.
Клієнт потрібно звільняти в finally, незалежно від результату:
finally {
client.release();
}Якщо не звільняти клієнти, пул поступово вичерпає доступні з'єднання, і нові запити перестануть виконуватися.
Усі запити транзакції повинні виконуватися через один об'єкт client, отриманий із пулу.
ROLLBACKЯкщо після BEGIN сталася помилка, потрібно явно виконати ROLLBACK. Інакше з'єднання може залишитися в незавершеній транзакції.
finallyНавіть після помилки клієнт потрібно повернути в пул:
finally {
client.release();
}Якщо виконати COMMIT після першої операції, наступні операції вже не будуть частиною тієї самої транзакції.
Значення потрібно передавати окремим масивом параметрів:
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, accountId],
);Не слід вставляти значення безпосередньо в SQL-рядок.
COMMITЯкщо транзакцію не підтвердити й не скасувати, з'єднання може залишатися зайнятим незавершеною транзакцією.
Транзакція об'єднує кілька операцій у єдине ціле.
Атомарність означає, що або виконуються всі зміни, або не виконується жодна.
BEGIN починає транзакцію.
COMMIT підтверджує зміни.
ROLLBACK скасовує зміни.
Усі запити транзакції потрібно виконувати через одне з'єднання.
Клієнт із пулу потрібно звільняти в finally.
Помилки будь-якої операції мають призводити до ROLLBACK.