Пошук уроків, статей та іншого контенту
Розберете транзакції на рівні коду: межі, помилки, тайм-аути, повторні спроби та з’єднання з пулу.
Транзакція об’єднує кілька SQL-операцій в одну логічну дію:
або всі зміни фіксуються через COMMIT;
або всі зміни скасовуються через ROLLBACK.
Межа транзакції має відповідати бізнес-операції, а не окремому SQL-запиту. Наприклад, переказ коштів складається щонайменше з двох змін:
зменшити баланс рахунку відправника;
збільшити баланс рахунку отримувача.
Якщо виконати їх окремо, помилка між запитами залишить дані в неконсистентному стані.
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
COMMIT;Якщо другий UPDATE завершиться помилкою, застосунок повинен виконати ROLLBACK, а не продовжувати роботу з тією самою транзакцією.
Типова послідовність така:
отримати окреме з’єднання з пулу;
виконати BEGIN;
виконати всі запити через це саме з’єднання;
виконати COMMIT;
звільнити з’єднання через release().
При помилці:
виконати ROLLBACK;
звільнити з’єднання;
передати помилку на рівень, який вирішує, чи потрібна повторна спроба.
Для транзакції не можна використовувати різні з’єднання з пулу:
await pool.query("BEGIN");
await pool.query("UPDATE ...");
await pool.query("COMMIT");Кожен виклик pool.query() може отримати інше з’єднання. У такому коді 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();
}У PostgreSQL помилка всередині транзакції переводить її в aborted state. Після цього звичайні запити не виконуються:
BEGIN;
SELECT 1 / 0;
SELECT 2;
-- ERROR: current transaction is aborted
ROLLBACK;Тому після помилки не можна просто продовжити роботу або виконати COMMIT. Потрібен ROLLBACK.
Це особливо важливо в коді застосунку:
try {
await client.query("INSERT INTO users ...");
await client.query("UPDATE profiles ...");
} catch (error) {
// Після помилки транзакція вже непридатна для звичайних запитів.
await client.query("ROLLBACK");
throw error;
}Якщо ROLLBACK також завершився помилкою, з’єднання може бути пошкодженим або втраченим. Таке з’єднання не слід повертати в пул як здорове.
Щоб не дублювати однакову логіку в кожному сервісному методі, межі транзакції зазвичай інкапсулюють у функцію-помічник.
Приклад для Node.js та node-postgres:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function isRetryablePostgresError(error) {
// 40001 — serialization_failure.
// 40P01 — deadlock_detected.
return error && (error.code === "40001" || error.code === "40P01");
}
async function withTransaction(work, options = {}) {
const {
maxAttempts = 3,
isolationLevel = "SERIALIZABLE",
} = options;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const client = await pool.connect();
let result;
let failure = null;
let releaseError = null;
let retry = false;
let transactionStarted = false;
try {
await client.query(`BEGIN ISOLATION LEVEL ${isolationLevel}`);
transactionStarted = true;
// Обмеження діють лише для цієї транзакції.
await client.query("SET LOCAL lock_timeout = '2s'");
await client.query("SET LOCAL statement_timeout = '5s'");
await client.query(
"SET LOCAL idle_in_transaction_session_timeout = '10s'"
);
result = await work(client);
await client.query("COMMIT");
transactionStarted = false;
} catch (error) {
failure = error;
if (transactionStarted) {
try {
await client.query("ROLLBACK");
} catch (rollbackError) {
// Таке з'єднання не можна повертати в пул як справне.
releaseError = rollbackError;
}
}
retry =
isRetryablePostgresError(error) &&
attempt < maxAttempts &&
releaseError === null;
} finally {
// release(error) вилучає клієнт із пулу замість повторного використання.
client.release(releaseError);
}
if (failure === null) {
return result;
}
if (!retry) {
throw failure;
}
// Збільшуємо затримку між повторними спробами.
const backoff = 50 * 2 ** (attempt - 1);
await sleep(backoff);
}
throw new Error("Транзакція не виконалася");
}
async function transferMoney(fromId, toId, amount) {
return withTransaction(async (client) => {
// Визначений порядок блокування зменшує ймовірність deadlock.
const firstId = Math.min(fromId, toId);
const secondId = Math.max(fromId, toId);
const accounts = await client.query(
`
SELECT id, balance
FROM accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR UPDATE
`,
[firstId, secondId]
);
if (accounts.rowCount !== 2) {
const error = new Error("Один із рахунків не існує");
error.code = "ACCOUNT_NOT_FOUND";
throw error;
}
const sender = accounts.rows.find((row) => row.id === fromId);
if (Number(sender.balance) < amount) {
const error = new Error("Недостатньо коштів");
error.code = "INSUFFICIENT_FUNDS";
throw error;
}
await client.query(
`
UPDATE accounts
SET balance = balance - $1
WHERE id = $2
`,
[amount, fromId]
);
await client.query(
`
UPDATE accounts
SET balance = balance + $1
WHERE id = $2
`,
[amount, toId]
);
return { fromId, toId, amount };
});
}
async function main() {
try {
const transfer = await transferMoney(1, 2, 100);
console.log("Переказ виконано:", transfer);
} finally {
await pool.end();
}
}
main().catch((error) => {
console.error("Операція не виконана:", error);
process.exitCode = 1;
});Для цього прикладу таблицю можна створити так:
CREATE TABLE accounts (
id bigint PRIMARY KEY,
balance numeric(12, 2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts (id, balance)
VALUES
(1, 1000.00),
(2, 500.00);clientФункція work не повинна самостійно викликати pool.connect(). Їй передається вже вибране з’єднання:
await withTransaction(async (client) => {
await client.query("...");
await client.query("...");
});Так зберігається гарантія, що всі запити виконуються в межах однієї транзакції та на одному PostgreSQL-сеансі.
Помилки PostgreSQL мають SQLSTATE-коди. Для логіки застосунку важливо розрізняти щонайменше такі категорії:
23505 — порушення унікальності;
23503 — порушення зовнішнього ключа;
23514 — порушення CHECK;
40001 — serialization failure;
40P01 — deadlock;
57014 — запит скасовано, зокрема через statement_timeout;
55P03 — неможливо отримати блокування в межах lock_timeout.
Не кожна помилка означає, що операцію потрібно повторити.
Наприклад, повторна спроба після 23505 зазвичай знову завершиться такою самою помилкою. Це може бути очікуваний конфлікт, який потрібно перетворити на відповідь на кшталт «ресурс уже існує».
Натомість 40001 і 40P01 є типовими кандидатами для повторної спроби всієї транзакції.
Повторювати транзакцію можна, якщо:
PostgreSQL явно повідомив про serialization failure або deadlock;
транзакція не встигла виконати зовнішні побічні ефекти;
кількість спроб обмежена;
між спробами є затримка.
Повторювати потрібно всю транзакцію, а не лише запит, який завершився помилкою. Попередні запити вже були частиною невдалої транзакції, і їхній результат не можна безпечно відокремити від решти операції.
Неправильно:
try {
await client.query("UPDATE ...");
} catch (error) {
await client.query("UPDATE ...");
}Правильно — повторно викликати функцію, яка створює нову транзакцію та виконує всі її кроки з початку.
Без обмеження повторні спроби можуть створити нескінченний цикл і додатково перевантажити базу даних.
Практично використовують:
невелику максимальну кількість спроб;
exponential backoff;
випадкову складову до затримки, якщо багато процесів можуть повторювати операцію одночасно.
Повторна спроба не повинна приховувати систематичну помилку в SQL або бізнес-логіці.
Транзакція PostgreSQL не відкочує дії в інших системах. Наприклад:
await withTransaction(async (client) => {
await client.query("UPDATE orders SET status = 'paid' ...");
await paymentProvider.charge(); // Зовнішній побічний ефект
});Якщо після списання коштів виникне 40001 і транзакція буде повторена, зовнішня платіжна операція може виконатися вдруге.
Тому функція, яку можна повторити, не повинна безпосередньо виконувати невідкличні зовнішні дії. Для таких сценаріїв застосовують ідемпотентні ключі або окрему надійну схему доставки подій. У межах цієї транзакції важливо запам’ятати головне: повторюваною має бути саме база даних, а не довільний код із зовнішніми ефектами.
Довга транзакція утримує блокування та займає з’єднання з пулу. Тому для критичних операцій потрібно встановлювати обмеження часу.
statement_timeoutstatement_timeout обмежує час виконання окремого SQL-запиту. Він не є загальним тайм-аутом всієї транзакції.
BEGIN;
SET LOCAL statement_timeout = '5s';
SELECT * FROM large_table;
COMMIT;Якщо запит перевищить ліміт, PostgreSQL скасує його. Поточна транзакція після цього буде в стані помилки, тому застосунок повинен виконати ROLLBACK.
SET LOCAL важливий тим, що значення діє лише до завершення поточної транзакції. Воно не забруднює наступного користувача цього з’єднання з пулу.
lock_timeoutlock_timeout обмежує час очікування блокування:
SET LOCAL lock_timeout = '2s';Це не обмеження повного часу SQL-запиту. Запит може завершитися раніше через неможливість отримати потрібний lock, навіть якщо його загальний час виконання був би меншим за statement_timeout.
idle_in_transaction_session_timeoutЦей параметр завершує сеанс, який надто довго перебуває бездіяльним усередині транзакції:
SET LOCAL idle_in_transaction_session_timeout = '10s';Такий стан часто виникає, коли застосунок:
виконав BEGIN;
отримав блокування;
почав чекати HTTP-відповідь або введення користувача;
не виконав COMMIT чи ROLLBACK.
Не можна відкривати транзакцію перед повільною зовнішньою операцією. Дані, отримані з API або від користувача, потрібно підготувати до BEGIN, якщо це можливо.
Рівень ізоляції визначає, як транзакції бачать зміни одна одної.
Для складних операцій, у яких важливо перевірити набір даних і потім змінити його, може використовуватися SERIALIZABLE:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance
FROM accounts
WHERE id = 1;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
COMMIT;PostgreSQL може завершити таку транзакцію з кодом 40001, якщо паралельна транзакція створила конфлікт. Це не означає пошкодження даних. Це сигнал, що застосунок повинен повторити всю операцію в новій транзакції.
Рівень ізоляції потрібно вибирати для конкретної бізнес-операції. Просте додавання SERIALIZABLE без обмеження спроб і контролю тайм-аутів може збільшити кількість помилок та навантаження.
Пул містить обмежену кількість з’єднань. Під час транзакції з’єднання зайняте від моменту pool.connect() до client.release().
Правильна структура завжди містить finally:
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE accounts SET balance = balance - 10 WHERE id = 1");
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}Якщо забути release(), з’єднання залишиться зайнятим. Після кількох таких помилок пул може вичерпатися, а нові запити почнуть чекати в черзі.
Не можна повертати клієнт у пул до COMMIT або ROLLBACK:
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE ...");
client.release();
// Транзакція все ще відкрита на вже звільненому клієнті.
await client.query("COMMIT");
} catch (error) {
client.release();
}Після release() клієнт належить пулу, тому використовувати його далі не можна.
Якщо під час ROLLBACK сталася помилка з’єднання, клієнт не повинен повторно використовуватися пулом:
try {
await client.query("ROLLBACK");
} catch (rollbackError) {
client.release(rollbackError);
throw rollbackError;
}У node-postgres передача помилки в release(error) повідомляє пулу, що клієнта потрібно вилучити, а не повернути для повторного використання.
PostgreSQL не підтримує незалежні вкладені транзакції через повторний BEGIN:
BEGIN;
BEGIN;
-- WARNING: there is already a transaction in progressЯкщо частину операції потрібно скасувати, але продовжити зовнішню транзакцію, використовують savepoint:
BEGIN;
INSERT INTO audit_log(message)
VALUES ('Основна операція');
SAVEPOINT optional_step;
INSERT INTO optional_records(value)
VALUES ('дані');
ROLLBACK TO SAVEPOINT optional_step;
INSERT INTO audit_log(message)
VALUES ('Основна операція продовжена');
COMMIT;ROLLBACK TO SAVEPOINT скасовує зміни після savepoint, але не завершує всю транзакцію.
Savepoint не замінює правильну обробку помилок на рівні сервісу. Він потрібен лише тоді, коли бізнес-операція справді може продовжуватися після локального скасування частини роботи.
pool.query() між BEGIN і COMMITЗапити можуть виконатися на різних з’єднаннях. Для транзакції потрібно використовувати один клієнт, отриманий через pool.connect().
ROLLBACKПісля помилки транзакція залишається aborted. Її потрібно явно завершити через ROLLBACK.
finallyНавіть після помилки клієнт потрібно звільнити. Інакше пул поступово втратить доступні з’єднання.
Для serialization failure або deadlock повторюють усю транзакцію з BEGIN, а не один SQL-запит.
Помилки валідації, порушення унікальності та помилки SQL зазвичай не виправляються повтором. Повторювати потрібно лише помилки, для яких це безпечно та передбачено логікою операції.
HTTP-запит, виклик платіжного сервісу або очікування повідомлення можуть надовго утримувати з’єднання та блокування. Транзакція має містити коротку послідовність операцій PostgreSQL.
Транзакція без тайм-аутів може нескінченно чекати блокування, утримувати ресурси та вичерпати пул.
Якщо клієнт більше не можна використовувати, його потрібно вилучити з пулу через client.release(error), а не просто повернути як справний.
Транзакція має охоплювати одну атомарну бізнес-операцію.
Усі запити транзакції виконуються через один клієнт, отриманий із пулу.
Після будь-якої помилки потрібен ROLLBACK; після нього клієнт звільняють у finally.
statement_timeout обмежує окремий запит, lock_timeout — очікування блокування, а idle_in_transaction_session_timeout — бездіяльність у відкритій транзакції.
Для 40001 і 40P01 можна повторювати всю транзакцію з обмеженням кількості спроб і backoff.
Повторні спроби небезпечні для невідкличних зовнішніх побічних ефектів.
Якщо клієнт або ROLLBACK завершилися помилкою з’єднання, клієнта потрібно вилучити з пулу.