Пошук уроків, статей та іншого контенту
Спроєктуєте транзакційну обробку реального API з межами транзакцій, валідацією та коректним відкатом змін.
Транзакція об’єднує кілька операцій із базою даних в одну логічну зміну:
або виконуються всі операції;
або не зберігається жодна з них.
Розглянемо API переказу коштів між рахунками. Один запит має:
перевірити вхідні дані;
знайти обидва рахунки;
перевірити достатність коштів;
списати кошти з одного рахунку;
зарахувати кошти на інший;
записати операцію переказу.
Якщо між кроками 4 і 5 станеться помилка, кошти не повинні залишитися списаними лише з одного рахунку.
Межа транзакції визначає, які саме операції виконуються атомарно.
Для переказу правильною межею буде весь набір змін у базі:
BEGIN
заблокувати рахунки
перевірити баланс
списати кошти
зарахувати кошти
записати переказ
COMMITВалідацію формату запиту краще виконати до початку транзакції. Вона не потребує блокування рядків і не змінює базу даних.
До транзакції можна винести:
перевірку наявності обов’язкових полів;
перевірку типів;
перевірку діапазонів;
перевірку, що рахунки різні;
перевірку формату суми.
Усередині транзакції повинні виконуватися перевірки, результат яких може змінитися через паралельні запити:
існування рахунків;
актуальний баланс;
можливість зміни конкретних рядків.
Для роботи із грошима використовуємо цілі числа — кількість копійок. Не варто зберігати грошові значення у float, оскільки операції з плаваючою крапкою можуть давати похибки.
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
owner_name TEXT NOT NULL,
balance_cents BIGINT NOT NULL CHECK (balance_cents >= 0)
);
CREATE TABLE transfers (
id BIGSERIAL PRIMARY KEY,
from_account_id BIGINT NOT NULL REFERENCES accounts(id),
to_account_id BIGINT NOT NULL REFERENCES accounts(id),
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO accounts (owner_name, balance_cents)
VALUES
('Олена', 100000),
('Андрій', 25000);У цьому прикладі:
100000 — це 1000,00 у валютних одиницях;
25000 — це 250,00;
transfers зберігає історію успішних переказів.
Для PostgreSQL використаємо пакет pg. Важливо отримати окремий клієнт із пулу, оскільки всі запити однієї транзакції повинні виконуватися через одне й те саме з’єднання.
Приклад залежностей:
npm install express pgФайл server.js:
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());
const pool = new Pool({
connectionString:
process.env.DATABASE_URL ||
'postgres://postgres:postgres@localhost:5432/transfers'
});
class AppError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
function parseTransferInput(body) {
const fromAccountId = Number(body.fromAccountId);
const toAccountId = Number(body.toAccountId);
const amountCents = Number(body.amountCents);
if (
!Number.isSafeInteger(fromAccountId) ||
fromAccountId <= 0
) {
throw new AppError(400, 'Некоректний fromAccountId');
}
if (
!Number.isSafeInteger(toAccountId) ||
toAccountId <= 0
) {
throw new AppError(400, 'Некоректний toAccountId');
}
if (
!Number.isSafeInteger(amountCents) ||
amountCents <= 0
) {
throw new AppError(400, 'amountCents має бути додатним цілим числом');
}
if (fromAccountId === toAccountId) {
throw new AppError(
400,
'Рахунок відправника і рахунок отримувача мають відрізнятися'
);
}
return {
fromAccountId,
toAccountId,
amountCents: BigInt(amountCents)
};
}
app.post('/transfers', async (req, res, next) => {
let client;
let transactionStarted = false;
try {
// Формат і базові правила перевіряємо до початку транзакції.
const input = parseTransferInput(req.body);
client = await pool.connect();
await client.query('BEGIN');
transactionStarted = true;
// Блокуємо обидва рахунки до завершення транзакції.
// Сортування ідентифікаторів допомагає уникати взаємного блокування.
const accountIds = [
input.fromAccountId,
input.toAccountId
].sort((a, b) => a - b);
const accountsResult = await client.query(
`
SELECT id, balance_cents
FROM accounts
WHERE id = ANY($1::bigint[])
ORDER BY id
FOR UPDATE
`,
[accountIds]
);
if (accountsResult.rows.length !== 2) {
throw new AppError(404, 'Один або обидва рахунки не існують');
}
const accounts = new Map(
accountsResult.rows.map((row) => [
Number(row.id),
{
id: Number(row.id),
// PostgreSQL повертає BIGINT як рядок.
balanceCents: BigInt(row.balance_cents)
}
])
);
const sourceAccount = accounts.get(input.fromAccountId);
const targetAccount = accounts.get(input.toAccountId);
if (sourceAccount.balanceCents < input.amountCents) {
throw new AppError(409, 'Недостатньо коштів');
}
const newSourceBalance =
sourceAccount.balanceCents - input.amountCents;
const newTargetBalance =
targetAccount.balanceCents + input.amountCents;
await client.query(
`
UPDATE accounts
SET balance_cents = $1
WHERE id = $2
`,
[newSourceBalance.toString(), sourceAccount.id]
);
await client.query(
`
UPDATE accounts
SET balance_cents = $1
WHERE id = $2
`,
[newTargetBalance.toString(), targetAccount.id]
);
const transferResult = await client.query(
`
INSERT INTO transfers (
from_account_id,
to_account_id,
amount_cents
)
VALUES ($1, $2, $3)
RETURNING id, created_at
`,
[
sourceAccount.id,
targetAccount.id,
input.amountCents.toString()
]
);
await client.query('COMMIT');
transactionStarted = false;
res.status(201).json({
id: transferResult.rows[0].id,
fromAccountId: sourceAccount.id,
toAccountId: targetAccount.id,
amountCents: input.amountCents.toString(),
createdAt: transferResult.rows[0].created_at
});
} catch (error) {
if (client && transactionStarted) {
try {
await client.query('ROLLBACK');
} catch (rollbackError) {
// Основну помилку не замінюємо помилкою відкату.
console.error('Не вдалося виконати ROLLBACK', rollbackError);
}
}
next(error);
} finally {
if (client) {
client.release();
}
}
});
app.use((error, req, res, next) => {
console.error(error);
if (error instanceof AppError) {
return res.status(error.status).json({
error: error.message
});
}
return res.status(500).json({
error: 'Внутрішня помилка сервера'
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`API запущено на порту ${port}`);
});Запуск:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/transfers node server.jsПриклад запиту:
curl -X POST http://localhost:3000/transfers \
-H "Content-Type: application/json" \
-d '{
"fromAccountId": 1,
"toAccountId": 2,
"amountCents": 1500
}'У запиті використано:
SELECT ...
FROM accounts
WHERE id = ANY($1::bigint[])
ORDER BY id
FOR UPDATEFOR UPDATE блокує вибрані рядки до завершення транзакції. Інший переказ, який намагається змінити ті самі рахунки, чекатиме завершення першої транзакції.
Це важливо для такого сценарію:
На рахунку є 1000 копійок.
Одночасно надходять два запити на списання по 800 копійок.
Перший запит блокує рядок і списує 800.
Другий запит чекає.
Після блокування він читає вже актуальний баланс — 200.
Другий переказ завершується помилкою через недостатній баланс.
Без блокування обидва запити могли б прочитати старе значення 1000 і помилково дозволити обидва списання.
Уявімо два паралельні перекази:
переказ із рахунку 1 на рахунок 2;
переказ із рахунку 2 на рахунок 1.
Якщо перша транзакція спочатку заблокує рахунок 1, а друга — рахунок 2, кожна чекатиме на ресурс, утримуваний іншою. Це взаємне блокування, або deadlock.
Сортування ідентифікаторів перед SELECT ... FOR UPDATE задає однаковий порядок блокування для всіх запитів. Обидві транзакції намагатимуться спочатку заблокувати рахунок 1, а потім рахунок 2.
COMMIT і ROLLBACKТипова структура транзакції в pg:
const client = await pool.connect();
try {
await client.query('BEGIN');
// Усі пов'язані операції виконуються через client.
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}Є кілька важливих правил:
BEGIN, усі операції та COMMIT повинні використовувати один client;
pool.query() не слід використовувати для окремих кроків транзакції;
клієнт потрібно звільняти в finally;
ROLLBACK слід виконувати лише для транзакції, яку було розпочато;
після COMMIT не можна повторювати бізнес-операцію через автоматичний retry без додаткового захисту.
У прикладі змінна transactionStarted потрібна, щоб не викликати ROLLBACK, якщо помилка виникла до BEGIN.
Усередині транзакції повинні бути всі операції, які разом формують один стан.
Для переказу це:
SELECT ... FOR UPDATE
UPDATE рахунку відправника
UPDATE рахунку отримувача
INSERT переказуЯкщо INSERT у таблицю transfers завершиться помилкою, обидва UPDATE також буде скасовано.
Наприклад, якщо в таблиці додадуть обмеження, яке забороняє певну операцію, база даних відхилить запит, обробник виконає ROLLBACK, і залишки рахунків повернуться до попереднього стану.
Транзакція бази даних не охоплює зовнішні системи:
HTTP-запит до іншого сервісу;
надсилання електронного листа;
публікацію повідомлення у брокер;
виклик платіжного провайдера.
Не варто тримати транзакцію відкритою під час таких операцій:
BEGIN
змінити базу
викликати зовнішній HTTP-сервіс
COMMITЗовнішній сервіс може відповідати довго або не відповідати взагалі. За цей час база утримуватиме блокування.
У межах цього API транзакція завершується одразу після успішного збереження переказу. Після COMMIT можна виконувати незалежні побічні дії, але їхній збій уже не скасує зафіксовані зміни в базі.
Валідація має два рівні.
Виконується до підключення клієнта:
значення є числами;
ідентифікатори додатні;
сума є додатним цілим числом;
рахунки відрізняються;
число безпечно представляється в JavaScript.
Така перевірка швидко повертає відповідь 400 Bad Request і не створює навантаження на базу.
Виконується всередині транзакції:
рахунки існують;
баланс достатній;
рядки залишаються заблокованими до завершення змін.
Недостатній баланс — це не помилка формату запиту. Формат правильний, але поточний стан рахунку не дозволяє виконати операцію. Тому в прикладі використано статус 409 Conflict.
У прикладі помилки поділено на два типи:
AppError — очікувані помилки бізнес-логіки;
інші помилки — непередбачені помилки сервера.
Очікувані помилки:
400 — некоректні дані;
404 — рахунок не знайдено;
409 — недостатньо коштів або конфлікт стану.
Не слід повертати клієнту текст внутрішньої помилки бази даних. Він може містити назви таблиць, SQL або технічні деталі. Такі дані потрібно записати в серверний журнал, а клієнту повернути стабільне загальне повідомлення.
Неправильно:
await pool.query('BEGIN');
await pool.query('UPDATE accounts SET ...');
await pool.query('COMMIT');Пул може видати різні з’єднання для цих викликів. Тому транзакція не буде надійно прив’язана до всіх операцій.
Правильно — отримати один клієнт і використовувати його всюди:
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET ...');
await client.query('COMMIT');
} finally {
client.release();
}Неправильно спочатку прочитати баланс окремим запитом, а потім почати транзакцію. За цей час інший запит може змінити баланс.
Перевірку актуального балансу потрібно виконувати в транзакції після FOR UPDATE.
ROLLBACKЯкщо не виконати відкат після помилки, з’єднання може залишитися в стані незавершеної транзакції. Наступний запит через той самий клієнт буде виконуватися в неправильному контексті.
finallyЯкщо client.release() викликається лише після успішного COMMIT, будь-яка помилка може залишити клієнт зайнятим. Поступово пул вичерпає всі з’єднання.
floatЗначення на кшталт 10.50 краще перетворювати на 1050 копійок до початку бізнес-операції. Для цього API поле названо amountCents, а в базі використано BIGINT.
Якщо різні маршрути блокують одні й ті самі таблиці або рядки в різному порядку, зростає ризик deadlock. Для пов’язаних рахунків слід використовувати узгоджений порядок блокування.
COMMITНе можна повідомляти клієнту про успішний переказ до фіксації транзакції. Якщо після відповіді виникне помилка і відбудеться ROLLBACK, API повідомить про успіх, хоча зміни не збережені.
Для такого endpoint варто перевірити щонайменше:
успішний переказ;
неіснуючий рахунок;
переказ на той самий рахунок;
нульову або від’ємну суму;
суму з дробовою частиною;
недостатній баланс;
помилку під час вставки історії переказів;
два паралельні перекази з одного рахунку;
два паралельні перекази між тими самими рахунками в протилежних напрямках.
Для останніх двох сценаріїв важливо перевірити не лише HTTP-відповіді, а й стан бази:
баланс не став від’ємним;
сума списань відповідає сумі зарахувань;
не залишилося переказів без відповідних змін балансів;
після помилки часткові зміни відсутні.
Межа транзакції повинна охоплювати всі зміни, які формують одну бізнес-операцію.
Формат вхідних даних перевіряють до початку транзакції.
Перевірки актуального стану виконують усередині транзакції.
Для конкурентної зміни рахунків використовують SELECT ... FOR UPDATE.
Усі запити транзакції виконуються через один клієнт pg.
Помилка на будь-якому кроці повинна призводити до ROLLBACK.
Клієнт пулу потрібно звільняти у finally.
Грошові значення краще зберігати як цілі числа в мінімальних одиницях.
Зовнішні HTTP-виклики та інші побічні дії не слід виконувати всередині транзакції бази даних.