Пошук уроків, статей та іншого контенту
Розберете найсуворіший рівень ізоляції та обробку serialization failure під час конкурентних змін.
SERIALIZABLEРівень ізоляції SERIALIZABLE гарантує, що результат одночасних транзакцій буде таким самим, ніби вони виконувалися послідовно — одна після одної.
Це найсуворіший стандартний рівень ізоляції в PostgreSQL. Він не дозволяє транзакціям завершитися, якщо їхня комбінація створює результат, неможливий для жодного послідовного порядку виконання.
Важливо: PostgreSQL не завжди блокує конфліктну транзакцію. Замість цього він може дозволити транзакціям працювати паралельно, а під час COMMIT скасувати одну з них.
Така помилка називається serialization failure.
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- Запити транзакції
COMMIT;Рівень ізоляції потрібно встановити до виконання першого запиту, який читає або змінює дані.
Також можна встановити його для всіх нових транзакцій поточного з'єднання:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;Або змінити рівень лише для поточної транзакції:
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;SERIALIZABLEPostgreSQL використовує підхід SSI — Serializable Snapshot Isolation.
Кожна транзакція працює зі знімком даних, але PostgreSQL додатково відстежує:
які рядки читала транзакція;
які предикати використовувалися під час читання;
які рядки змінили інші транзакції;
залежності між транзакціями.
Наприклад, запит:
SELECT *
FROM doctors
WHERE on_call = true;залежить не лише від конкретних рядків, які були повернуті. Він також залежить від умови on_call = true: поява нового рядка, що відповідає цій умові, потенційно може змінити результат.
Якщо PostgreSQL виявляє небезпечну комбінацію залежностей, він скасовує одну з транзакцій. Це дозволяє зберегти серіалізовану поведінку без блокування всіх читань.
Такі блокування називаються предикатними. У PostgreSQL вони можуть відображатися в системному поданні pg_locks з режимом SIReadLock.
SELECT pid, locktype, mode, relation::regclass, page, tuple
FROM pg_locks
WHERE mode = 'SIReadLock';Предикатні блокування не є звичайними блокуваннями, які очікують звільнення ресурсу. Вони потрібні для виявлення конфліктів між читанням і записом.
Розглянемо правило: у лікарні повинні залишатися на чергуванні щонайменше два лікарі.
DROP TABLE IF EXISTS doctors;
CREATE TABLE doctors (
id integer PRIMARY KEY,
name text NOT NULL,
on_call boolean NOT NULL
);
INSERT INTO doctors (id, name, on_call)
VALUES
(1, 'Олена', true),
(2, 'Андрій', true);Припустімо, два лікарі одночасно вирішують, що можуть завершити чергування. Кожна транзакція:
перевіряє, що на чергуванні є щонайменше два лікарі;
вимикає себе.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*)
FROM doctors
WHERE on_call = true;
-- Результат: 2
UPDATE doctors
SET on_call = false
WHERE id = 1;В іншому з'єднанні PostgreSQL:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*)
FROM doctors
WHERE on_call = true;
-- Результат: 2
UPDATE doctors
SET on_call = false
WHERE id = 2;Обидві транзакції побачили однаковий стан. На рівні READ COMMITTED вони можуть обидві успішно завершитися:
-- У сесії A
COMMIT;
-- У сесії B
COMMIT;Після цього жодного лікаря не залишиться на чергуванні, хоча кожна окрема транзакція перевіряла правильну умову.
На рівні SERIALIZABLE одна з транзакцій буде скасована. Помилка може виникнути під час UPDATE або під час COMMIT:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.Після цього стан бази даних залишиться таким, ніби успішно виконалася лише одна з транзакцій.
serialization_failureСтандартний SQLSTATE для serialization failure у PostgreSQL — 40001.
Приклад перевірки помилки в клієнтському коді:
SQLSTATE: 40001Транзакція після такої помилки вже не може продовжувати роботу. Її потрібно повністю завершити через ROLLBACK, а потім повторити від початку.
Неправильний підхід:
BEGIN
SELECT ...
UPDATE ...
-- Помилка serialization failure
-- Не можна просто виконати UPDATE ще раз у цій самій транзакціїПравильний підхід:
BEGIN
SELECT ...
UPDATE ...
COMMIT
-- Якщо отримано 40001:
ROLLBACK
BEGIN
SELECT ...
UPDATE ...
COMMITПотрібно повторювати всю транзакцію, а не лише запит, який завершився помилкою. Попередні результати читання могли бути отримані зі знімка, який уже несумісний з іншими транзакціями.
У прикладі використовується пакет pg. Транзакція намагається вимкнути чергування одного лікаря, але лише якщо після цього залишиться щонайменше один інший лікар.
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
async function stopOnCall(doctorId, maxRetries = 5) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const client = await pool.connect();
try {
await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
const result = await client.query(`
SELECT count(*)::int AS count
FROM doctors
WHERE on_call = true
AND id <> $1
`, [doctorId]);
if (result.rows[0].count < 1) {
throw new Error('Не можна залишити лікарню без лікаря на чергуванні');
}
await client.query(`
UPDATE doctors
SET on_call = false
WHERE id = $1
AND on_call = true
`, [doctorId]);
await client.query('COMMIT');
return;
} catch (error) {
await client.query('ROLLBACK');
if (error.code === '40001' && attempt < maxRetries) {
// Невелика затримка зменшує ймовірність повторного конфлікту.
const delay = 50 * attempt;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
} finally {
client.release();
}
}
throw new Error('Не вдалося завершити транзакцію після повторних спроб');
}
stopOnCall(1)
.then(() => {
console.log('Транзакція успішно завершена');
return pool.end();
})
.catch(async error => {
console.error(error);
await pool.end();
process.exitCode = 1;
});У цьому прикладі:
кожна спроба отримує окреме з'єднання з пулу;
транзакція починається з рівнем SERIALIZABLE;
при 40001 виконується ROLLBACK;
повторюється вся операція;
інші помилки не маскуються і передаються виклику програми;
після завершення з'єднання повертається до пулу.
Зазвичай кількість повторів обмежують. Нескінченний цикл повторних спроб може приховати постійну проблему навантаження або помилку в логіці програми.
SERIALIZABLE і звичайні блокуванняSERIALIZABLE не означає, що всі запити виконуються строго по одному.
PostgreSQL може:
дозволити кільком транзакціям одночасно читати дані;
дозволити їм виконувати незалежні зміни;
скасувати одну з транзакцій лише після виявлення небезпечного конфлікту.
Тому застосунок повинен бути готовим до помилки навіть у місці COMMIT.
Наприклад, цей код також може завершитися помилкою:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance
FROM accounts
WHERE id = 1;
UPDATE accounts
SET balance = balance - 100
WHERE id = 1;
COMMIT;Перевірка лише помилок під час SELECT або UPDATE недостатня. COMMIT є частиною логіки транзакції і також може повернути 40001.
READ ONLY DEFERRABLEДля довгих звітів, яким потрібен серіалізований знімок, можна використати комбінацію:
SERIALIZABLE;
READ ONLY;
DEFERRABLE.
BEGIN ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE;
SELECT
department_id,
count(*) AS employee_count
FROM employees
GROUP BY department_id;
COMMIT;DEFERRABLE застосовується до серіалізованих транзакцій, які доступні лише для читання. PostgreSQL може зачекати перед виконанням першого запиту, щоб отримати знімок, безпечний для серіалізації.
Це може збільшити час початку транзакції, зате зменшує ризик serialization failure для тривалого узгодженого читання.
SERIALIZABLE додає накладні витрати, оскільки PostgreSQL відстежує залежності між транзакціями.
На продуктивність впливають:
тривалість транзакцій;
кількість одночасних транзакцій;
обсяг читань і записів;
частота конфліктів;
ширина предикатів, які використовують запити.
Практичні рекомендації:
тримайте транзакції короткими;
не виконуйте зовнішні HTTP-запити всередині транзакції;
не утримуйте транзакцію під час очікування дій користувача;
обмежуйте кількість повторних спроб;
записуйте кількість serialization failures у метрики;
перевіряйте, чи справді всій операції потрібен SERIALIZABLE.
Важливо відрізняти правильне повторення транзакції від спроби приховати надмірну конкуренцію. Якщо конфлікти виникають постійно, варто аналізувати порядок доступу до даних і тривалість транзакцій.
Поточний рівень ізоляції можна перевірити так:
SHOW transaction_isolation;Значення serializable означає, що нові транзакції цього з'єднання використовуватимуть найсуворіший рівень.
Також корисно перевіряти активні транзакції:
SELECT
pid,
usename,
state,
xact_start,
query
FROM pg_stat_activity
WHERE state <> 'idle';Довгі транзакції довше утримують свій знімок і можуть збільшувати кількість конфліктів та споживання ресурсів.
REPEATABLE READУ PostgreSQL REPEATABLE READ також працює на основі стабільного знімка, але він не надає повної серіалізованої гарантії.
На REPEATABLE READ PostgreSQL може скасувати транзакцію через конфлікт оновлення рядка. Проте деякі аномалії, наприклад write skew, можуть залишатися можливими.
SERIALIZABLE додатково відстежує конфлікти між читаннями та записами й скасовує транзакції, коли їхній результат не можна пояснити послідовним виконанням.
Після 40001 не можна просто виконати запит ще раз у тій самій транзакції. Потрібно зробити ROLLBACK і повторити всю транзакцію.
UPDATESerialization failure може виникнути під час:
SELECT;
INSERT;
UPDATE;
DELETE;
COMMIT.
Обробник повинен охоплювати всю транзакцію.
Транзакцію не слід повторювати безкінечно. Використовуйте обмежену кількість спроб і затримку між ними.
Повторювати потрібно щонайменше помилки з кодом 40001. Помилка валідації, порушення зовнішнього ключа або помилка синтаксису не виправиться повтором.
Чим довше триває транзакція, тим довше зберігаються її залежності та тим вища ймовірність конфлікту.
SERIALIZABLESERIALIZABLE не гарантує, що кожна транзакція завершиться успішно. Він гарантує, що успішно завершені транзакції матимуть серіалізований результат. Скасовані транзакції потрібно повторити на рівні застосунку.
SERIALIZABLE — найсуворіший рівень ізоляції в PostgreSQL.
Він забезпечує результат, еквівалентний послідовному виконанню транзакцій.
PostgreSQL реалізує його через SSI та відстеження залежностей між транзакціями.
Конфліктна транзакція може бути скасована з SQLSTATE 40001.
Serialization failure може виникнути навіть під час COMMIT.
Після 40001 потрібно виконати ROLLBACK і повторити всю транзакцію.
Повторні спроби мають бути обмеженими та контрольованими.
READ ONLY DEFERRABLE допомагає отримати безпечний серіалізований знімок для транзакцій лише для читання.
Довгі транзакції та висока конкуренція збільшують кількість конфліктів.