Пошук уроків, статей та іншого контенту
Дослідите причини deadlock, навчитеся відтворювати його та будувати стратегію повторної спроби.
Deadlock, або взаємне блокування, виникає, коли кілька транзакцій утримують блокування, потрібні одна одній, і тому жодна з них не може продовжити виконання.
Класичний сценарій:
Транзакція A заблокувала рядок 1 і чекає на рядок 2.
Транзакція B заблокувала рядок 2 і чекає на рядок 1.
Обидві транзакції чекають одна на одну нескінченно.
PostgreSQL виявляє цикл очікування і примусово завершує одну з транзакцій. Клієнт отримує помилку з SQLSTATE-кодом:
40P01Ця помилка називається deadlock_detected.
Deadlock відрізняється від звичайного очікування блокування:
під час звичайного очікування транзакція рано чи пізно продовжить роботу після звільнення блокування;
під час deadlock транзакції утворили цикл, тому без втручання одна з них не зможе завершитися.
Розглянемо таблицю рахунків:
CREATE TABLE accounts (
id integer PRIMARY KEY,
balance numeric(12, 2) NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts (id, balance)
VALUES
(1, 1000.00),
(2, 1000.00);Операція переказу коштів змінює два рядки:
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;UPDATE встановлює блокування рядка до завершення транзакції. Якщо дві транзакції оновлюють ті самі рядки в різному порядку, може виникнути deadlock.
Відкрийте два підключення до PostgreSQL, наприклад дві сесії psql.
У сесії A виконайте:
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;Тепер сесія A утримує блокування рядка з id = 1.
У сесії B виконайте:
BEGIN;
UPDATE accounts
SET balance = balance - 50
WHERE id = 2;Сесія B утримує блокування рядка з id = 2.
Далі в сесії A спробуйте змінити рядок, заблокований сесією B:
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;Цей запит буде чекати.
Тепер у сесії B спробуйте змінити рядок, заблокований сесією A:
UPDATE accounts
SET balance = balance + 50
WHERE id = 1;Через короткий час PostgreSQL виявить цикл і скасує одну з транзакцій. Приклад помилки:
ERROR: deadlock detected
DETAIL: Process ... waits for ShareLock on transaction ...;
blocked by process ...
HINT: See server log for query details.Транзакція, яку PostgreSQL обрав для завершення, переходить у стан помилки. Її потрібно відкотити:
ROLLBACK;Інша транзакція може завершитися успішно:
COMMIT;Точний порядок повідомлень і те, яка саме транзакція буде скасована, не слід вважати фіксованим.
PostgreSQL не може розв'язати deadlock очікуванням, тому що кожна транзакція чекає на ресурс, який утримує інша транзакція з циклу.
Після виявлення deadlock PostgreSQL:
знаходить цикл очікування;
обирає одну транзакцію як жертву;
скасовує операцію або транзакцію;
дозволяє іншим транзакціям продовжити роботу.
Зазвичай помилка виникає не миттєво. PostgreSQL спочатку дозволяє транзакціям чекати, а потім запускає перевірку deadlock. Тому в тестах між останніми операціями може бути невелика затримка.
Найкращий спосіб запобігати deadlock — усі транзакції мають блокувати спільні ресурси в однаковому порядку.
У попередньому прикладі одна транзакція змінювала рядки в порядку 1 → 2, а інша — 2 → 1. Щоб уникнути циклу, обидві повинні використовувати, наприклад, порядок за зростанням id:
BEGIN;
-- Спочатку блокуємо рахунок із меншим ідентифікатором
SELECT id
FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
UPDATE accounts
SET balance = CASE
WHEN id = 1 THEN balance - 100
WHEN id = 2 THEN balance + 100
END
WHERE id IN (1, 2);
COMMIT;Або можна виконати два UPDATE, але в кожній транзакції дотримуватися того самого порядку:
BEGIN;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
UPDATE accounts
SET balance = balance + 100
WHERE id = 2;
COMMIT;Якщо всі транзакції спочатку блокують id = 1, а потім id = 2, цикл не утворюється. Друга транзакція чекатиме на перший рядок, але не зможе спочатку заблокувати другий.
Порядок може бути будь-яким, але він має бути:
однаковим у всіх шляхах виконання;
детермінованим;
незалежним від випадкового порядку вхідних даних.
Для двох ідентифікаторів часто використовують:
min(id1, id2) → max(id1, id2)Для набору рядків — сортування за первинним ключем або іншим стабільним ключем.
Особливо важливо перевірити не лише основний код операції, а й усі альтернативні гілки. Якщо один обробник блокує рядки за зростанням, а інший — за спаданням, проблема все одно залишиться.
Deadlock не обмежується явним SELECT ... FOR UPDATE. У ньому можуть брати участь:
UPDATE;
DELETE;
SELECT ... FOR UPDATE;
SELECT ... FOR NO KEY UPDATE;
блокування таблиць;
зміни кількох таблиць в різному порядку;
тригери, які змінюють інші таблиці;
зовнішні ключі та пов'язані блокування;
DDL-операції, що потребують сильних блокувань.
Наприклад, одна транзакція може спочатку змінити orders, а потім users, тоді як інша змінює users, а потім orders. Навіть якщо кожен окремий запит виглядає безпечним, різний порядок між запитами створює ризик deadlock.
Для перегляду активних сесій можна використати pg_stat_activity:
SELECT
pid,
usename,
state,
wait_event_type,
wait_event,
transaction_start,
query_start,
query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY query_start;Якщо wait_event_type має значення Lock, сесія очікує на блокування.
Для аналізу очікування можна об'єднати pg_stat_activity з pg_locks:
SELECT
waiting.pid AS waiting_pid,
waiting.query AS waiting_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity AS waiting
JOIN pg_locks AS waiting_lock
ON waiting_lock.pid = waiting.pid
JOIN pg_locks AS blocking_lock
ON blocking_lock.locktype = waiting_lock.locktype
AND blocking_lock.database IS NOT DISTINCT FROM waiting_lock.database
AND blocking_lock.relation IS NOT DISTINCT FROM waiting_lock.relation
AND blocking_lock.page IS NOT DISTINCT FROM waiting_lock.page
AND blocking_lock.tuple IS NOT DISTINCT FROM waiting_lock.tuple
AND blocking_lock.virtualxid IS NOT DISTINCT FROM waiting_lock.virtualxid
AND blocking_lock.transactionid IS NOT DISTINCT FROM waiting_lock.transactionid
AND blocking_lock.classid IS NOT DISTINCT FROM waiting_lock.classid
AND blocking_lock.objid IS NOT DISTINCT FROM waiting_lock.objid
AND blocking_lock.objsubid IS NOT DISTINCT FROM waiting_lock.objsubid
AND blocking_lock.pid <> waiting_lock.pid
JOIN pg_stat_activity AS blocking
ON blocking.pid = blocking_lock.pid
WHERE NOT waiting_lock.granted
AND blocking_lock.granted;Цей запит показує, яка сесія чекає і яка сесія утримує відповідне блокування. Для повної діагностики також важливі тексти запитів, час початку транзакції та серверні логи.
Навіть правильно спроєктований код іноді може отримати deadlock:
у системі можуть існувати старі шляхи виконання;
блокування можуть виникати всередині тригерів;
беруть участь кілька таблиць і компонентів;
порядок блокування контролюється даними або різними сервісами;
одночасно працює багато транзакцій.
Тому клієнтський код має вміти повторити транзакцію після помилки 40P01.
Повторювати потрібно всю транзакцію, а не лише запит, який завершився помилкою. Після помилки транзакція PostgreSQL перебуває в стані aborted і не може продовжувати виконання звичайних SQL-команд.
Правильний загальний алгоритм:
почати нову транзакцію;
виконати всі операції;
зафіксувати транзакцію;
якщо отримано 40P01, зробити ROLLBACK;
зачекати з випадковою затримкою;
повторити всю транзакцію;
після обмеженої кількості спроб повернути помилку.
pgНижче наведено приклад функції, яка переказує кошти і повторює всю транзакцію після deadlock.
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function isRetryableDeadlock(error) {
return error && error.code === "40P01";
}
async function transferMoney(fromId, toId, amount, maxAttempts = 4) {
if (fromId === toId) {
throw new Error("Рахунки відправника й отримувача мають відрізнятися");
}
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const client = await pool.connect();
try {
await client.query("BEGIN");
// Визначаємо стабільний порядок блокування рахунків
const firstId = Math.min(fromId, toId);
const secondId = Math.max(fromId, toId);
await client.query(
`
SELECT id
FROM accounts
WHERE id IN ($1, $2)
ORDER BY id
FOR UPDATE
`,
[firstId, secondId],
);
const result = await client.query(
`
UPDATE accounts
SET balance = balance + CASE
WHEN id = $1 THEN -$3
WHEN id = $2 THEN $3
END
WHERE id IN ($1, $2)
AND (id = $1 OR id = $2)
RETURNING id, balance
`,
[fromId, toId, amount],
);
if (result.rowCount !== 2) {
throw new Error("Один із рахунків не існує");
}
const sender = result.rows.find((row) => row.id === fromId);
if (Number(sender.balance) < 0) {
throw new Error("Недостатньо коштів");
}
await client.query("COMMIT");
return result.rows;
} catch (error) {
try {
await client.query("ROLLBACK");
} catch {
// З'єднання могло бути розірване до виконання ROLLBACK
}
if (!isRetryableDeadlock(error) || attempt === maxAttempts) {
throw error;
}
// Експоненційна затримка з невеликим випадковим розкидом
const baseDelay = 50 * 2 ** (attempt - 1);
const jitter = Math.floor(Math.random() * 50);
await sleep(baseDelay + jitter);
} finally {
client.release();
}
}
throw new Error("Операція не виконана");
}
try {
const updatedAccounts = await transferMoney(1, 2, 100);
console.log(updatedAccounts);
} finally {
await pool.end();
}У цьому прикладі порядок блокування додатково зменшує ймовірність deadlock, а повторна спроба залишається захистом на випадок непередбачених конфліктів.
У реальному коді перевірка балансу та інші бізнес-правила мають виконуватися всередині тієї самої транзакції, що й зміни.
Не слід повторювати операцію безкінечно або виконувати всі повтори без паузи. Інакше багато конкурентних клієнтів можуть синхронно повторювати ті самі конфліктні операції та посилювати навантаження.
Зазвичай використовують:
обмежену кількість спроб;
експоненційну затримку;
випадковий jitter;
логування кожної повторної спроби.
Наприклад, затримка може зростати так:
50 мс → 100 мс → 200 мс → 400 мсВипадкова добавка розподіляє клієнтів у часі, щоб вони не повторювали запити одночасно.
Повторна спроба безпечна, якщо повторюється транзакція, а всі її зміни в базі даних були скасовані PostgreSQL.
Не можна безпосередньо повторювати операцію, якщо до або під час транзакції виконуються зовнішні побічні дії, наприклад:
надсилання електронного листа;
списання коштів у зовнішнього платіжного провайдера;
публікація повідомлення в іншій системі;
виклик зовнішнього API без ідемпотентного ключа.
У такому разі повтор базової транзакції може спричинити дублювання зовнішньої дії. Для таких сценаріїв потрібен окремий дизайн ідемпотентності, а зовнішню дію не слід вважати автоматично безпечною для повтору.
Також слід враховувати, що повторний запуск транзакції може побачити вже інший стан даних. Тому код транзакції має бути коректним не лише для першого виконання, а й для повторного.
Ці ситуації мають різну природу:
deadlock_detected має код 40P01;
перевищення часу очікування блокування зазвичай має код 55P03 (lock_not_available), якщо встановлено lock_timeout.
Наприклад, NOWAIT не чекає на блокування:
BEGIN;
SELECT *
FROM accounts
WHERE id = 1
FOR UPDATE NOWAIT;Якщо рядок уже заблокований, запит негайно завершиться помилкою замість очікування.
SKIP LOCKED пропускає заблоковані рядки:
SELECT id
FROM accounts
ORDER BY id
FOR UPDATE SKIP LOCKED;Цей режим може бути корисним для черг завдань, але він змінює семантику вибірки: заблокований рядок не буде оброблений поточним запитом. Його не слід використовувати як універсальне виправлення deadlock.
Після deadlock поточна транзакція вже перервана. Не можна просто виконати наступний SQL-запит у ній.
Потрібно:
ROLLBACK;
BEGIN;
-- Повторюємо всю транзакціюЯкщо різні частини системи змінюють однакові ресурси в різному порядку, deadlock залишатиметься можливим.
Потрібно задокументувати порядок для пов'язаних ресурсів і дотримуватися його в усіх транзакціях.
Безкінечні повтори можуть приховати справжню помилку та створити додаткове навантаження. Завжди встановлюйте максимальну кількість спроб.
Повтор SQL-транзакції не робить зовнішні виклики ідемпотентними. Побічні дії потрібно відокремлювати або захищати власним механізмом ідемпотентності.
Збільшення часу очікування не усуває deadlock. Воно лише змушує клієнтів довше чекати перед отриманням помилки.
Спочатку потрібно знайти цикл і привести порядок блокування до єдиного правила.
Для операцій, які змінюють кілька ресурсів:
Визначте всі рядки та таблиці, які можуть блокуватися.
Задайте глобальний детермінований порядок доступу до них.
За потреби явно заблокуйте всі рядки на початку транзакції через SELECT ... FOR UPDATE.
Виконуйте перевірки та зміни в межах однієї транзакції.
Не утримуйте транзакцію довше, ніж потрібно.
Обробляйте SQLSTATE 40P01 на рівні клієнта.
Повторюйте всю транзакцію з обмеженням кількості спроб.
Додайте експоненційну затримку та випадковий розкид.
Логуйте deadlock із контекстом операції та ідентифікаторами ресурсів.
Перевіряйте, що повторення не дублює зовнішні побічні дії.
Deadlock — це цикл очікування блокувань між транзакціями.
PostgreSQL виявляє такий цикл і скасовує одну з транзакцій.
Код помилки deadlock — 40P01.
Найкраща профілактика — єдиний детермінований порядок блокування ресурсів.
Після deadlock потрібно відкотити поточну транзакцію і повторити всю операцію в новій.
Повтори мають бути обмеженими, із затримкою та випадковим розкидом.
SQL-транзакцію можна повторити безпечно лише разом із контролем побічних ефектів за межами бази даних.