HTTP/2 и HTTP/3 в Node.js: когда использовать и как мигрировать
Транзакции — это фундамент надёжности реляционных баз данных. PostgreSQL реализует полный спектр ACID-гарантий, но дьявол кроется в деталях: неправильный выбор уровня изоляции приводит к race conditions, а игнорирование механизмов блокировок — к deadlock'ам в production. Разберём, как работают транзакции на низком уровне и как избежать типичных ловушек.
ACID и уровни изоляции транзакций
ACID — это четыре свойства, которые гарантирует СУБД:
- Atomicity (атомарность): транзакция либо выполняется полностью, либо откатывается целиком
- Consistency (согласованность): данные переходят из одного валидного состояния в другое
- Isolation (изолированность): параллельные транзакции не видят промежуточные состояния друг друга
- Durability (долговечность): после COMMIT данные сохраняются даже при сбое
Isolation — самое сложное свойство. SQL-стандарт определяет четыре уровня изоляции, но PostgreSQL реализует только три (Read Uncommitted работает как Read Committed):
Read Committed (по умолчанию)
Каждый запрос видит snapshot данных на момент начала запроса. Другие транзакции могут изменить данные между запросами:
-- Сессия 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- Сессия 2 делает UPDATE и COMMIT
SELECT balance FROM accounts WHERE id = 1; -- 1500 (изменилось!)
COMMIT;
Защищает от: dirty reads (чтение незакоммиченных данных).
Не защищает от: non-repeatable reads, phantom reads.
Repeatable Read
Транзакция видит snapshot данных на момент первого запроса. Все последующие SELECT возвращают одинаковый результат:
-- Сессия 1
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- Сессия 2 делает UPDATE и COMMIT
SELECT balance FROM accounts WHERE id = 1; -- 1000 (не изменилось)
COMMIT;
Защищает от: dirty reads, non-repeatable reads.
Не защищает от: phantom reads (в теории), но PostgreSQL использует MVCC и фактически блокирует их.
Serializable
Самый строгий уровень. PostgreSQL использует Serializable Snapshot Isolation (SSI) — если параллельные транзакции создают конфликт, одна из них откатывается с ошибкой:
-- Сессия 1
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(balance) FROM accounts; -- 5000
-- Сессия 2
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
INSERT INTO accounts (balance) VALUES (1000);
COMMIT;
-- Сессия 1
INSERT INTO accounts (balance) VALUES (500);
COMMIT; -- ERROR: could not serialize access due to read/write dependencies
Защищает от: всех аномалий, включая write skew.
Цена: повышенный риск serialization failures, требует retry-логики в приложении.
Аномалии чтения: dirty, non-repeatable, phantom
Dirty reads (грязное чтение)
Чтение незакоммиченных изменений другой транзакции. PostgreSQL не допускает на всех уровнях изоляции:
-- Сессия 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE id = 1;
-- НЕ делаем COMMIT
-- Сессия 2
SELECT balance FROM accounts WHERE id = 1; -- Всегда видит старое значение
Non-repeatable reads (неповторяющееся чтение)
Повторный SELECT возвращает другой результат из-за UPDATE в параллельной транзакции. Возможно в Read Committed:
-- Сессия 1 (Read Committed)
BEGIN;
SELECT price FROM products WHERE id = 100; -- 50
-- Сессия 2
UPDATE products SET price = 60 WHERE id = 100;
COMMIT;
-- Сессия 1
SELECT price FROM products WHERE id = 100; -- 60 (изменилось!)
COMMIT;
Проблема: если между SELECT вы делаете бизнес-логику (например, расчёт скидки), результат может быть некорректным.
Phantom reads (фантомное чтение)
Повторный SELECT возвращает новые строки из-за INSERT/DELETE в параллельной транзакции:
-- Сессия 1 (Read Committed)
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 10
-- Сессия 2
INSERT INTO orders (status) VALUES ('pending');
COMMIT;
-- Сессия 1
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 11 (появилась новая строка)
COMMIT;
В PostgreSQL Repeatable Read фактически блокирует phantom reads благодаря MVCC, но стандарт SQL этого не гарантирует.
Deadlock detection и prevention
Deadlock (взаимная блокировка) возникает, когда две транзакции ждут друг друга:
-- Сессия 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- Блокирует строку 1
-- Сессия 2
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- Блокирует строку 2
-- Сессия 1
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- Ждёт сессию 2
-- Сессия 2
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- Ждёт сессию 1
-- DEADLOCK! PostgreSQL откатит одну из транзакций
PostgreSQL детектирует deadlock через deadlock_timeout (по умолчанию 1 секунда) и откатывает одну из транзакций с ошибкой:
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890;
blocked by process 12346.
Стратегии предотвращения deadlock
1. Упорядочивание блокировок: всегда блокируйте ресурсы в одном порядке (например, по возрастанию ID):
-- Правильно: сортируем ID перед UPDATE
BEGIN;
UPDATE accounts SET balance = balance - amount
WHERE id IN (1, 2)
ORDER BY id; -- Гарантирует порядок блокировок
COMMIT;
2. Явные блокировки: используйте SELECT FOR UPDATE для захвата блокировок в начале транзакции:
BEGIN;
SELECT * FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- Теперь можно безопасно делать UPDATE
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
3. Короткие транзакции: чем меньше время удержания блокировок, тем ниже вероятность deadlock.
4. Retry-логика: при получении deadlock error повторите транзакцию с exponential backoff.
Pessimistic vs optimistic locking
Pessimistic locking (пессимистичная блокировка)
Блокируем строку сразу при чтении, чтобы никто другой не мог её изменить:
BEGIN;
SELECT * FROM inventory WHERE product_id = 100 FOR UPDATE;
-- Строка заблокирована, другие транзакции будут ждать
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 100;
COMMIT;
Варианты блокировок:
FOR UPDATE— эксклюзивная блокировка (никто не может читать FOR UPDATE или изменять)FOR SHARE— разделяемая блокировка (можно читать FOR SHARE, но нельзя изменять)FOR UPDATE SKIP LOCKED— пропустить заблокированные строки (для очередей задач)
Плюсы: гарантированная консистентность, нет конфликтов при UPDATE.
Минусы: снижает параллелизм, риск deadlock.
Optimistic locking (оптимистичная блокировка)
Не блокируем при чтении, но проверяем версию при UPDATE:
-- Добавляем колонку version
ALTER TABLE inventory ADD COLUMN version INTEGER DEFAULT 0;
-- Чтение без блокировки
SELECT product_id, quantity, version FROM inventory WHERE product_id = 100;
-- quantity = 10, version = 5
-- UPDATE с проверкой версии
UPDATE inventory
SET quantity = 9, version = version + 1
WHERE product_id = 100 AND version = 5;
-- Если affected rows = 0, значит кто-то изменил строку — retry
Плюсы: высокий параллелизм, нет блокировок при чтении.
Минусы: требует retry-логики, не подходит для высококонкурентных операций.
Когда использовать: pessimistic — для критичных операций (платежи, инвентарь), optimistic — для редко конфликтующих данных (профили пользователей).
Практический пример: банковский перевод
Реализуем перевод денег между счетами с правильной изоляцией и обработкой ошибок:
CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0),
version INTEGER DEFAULT 0
);
-- Неправильная реализация (Read Committed, race condition)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- Если другая транзакция сделает UPDATE здесь, мы не узнаем
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Проблема: между SELECT и UPDATE баланс может измениться, и мы спишем деньги с некорректного значения.
Решение 1: Pessimistic locking
BEGIN;
-- Блокируем обе строки в порядке возрастания ID
SELECT id, balance FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
-- Проверяем баланс
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- Если balance < 100, делаем ROLLBACK
-- Выполняем перевод
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
Плюсы: гарантированная консистентность, нет race conditions.
Минусы: блокировки снижают throughput при высокой конкуренции.
Решение 2: Repeatable Read
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 1000
-- Если другая транзакция изменит баланс и закоммитится,
-- наш UPDATE увидит конфликт и вернёт ошибку
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- Может вернуть serialization failure
Плюсы: высокий параллелизм, нет явных блокировок.
Минусы: требует retry при serialization failure.
Решение 3: Optimistic locking с версионированием
-- Чтение без блокировки
SELECT id, balance, version FROM accounts WHERE id = 1;
-- balance = 1000, version = 42
BEGIN;
-- UPDATE с проверкой версии
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 42;
-- Проверяем affected rows
GET DIAGNOSTICS affected = ROW_COUNT;
IF affected = 0 THEN
ROLLBACK;
-- Retry с новой версией
END IF;
UPDATE accounts
SET balance = balance + 100, version = version + 1
WHERE id = 2;
COMMIT;
Обработка deadlock в приложении
async function transferMoney(fromId, toId, amount, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await db.query('BEGIN');
// Блокируем в порядке возрастания ID
const ids = [fromId, toId].sort((a, b) => a - b);
await db.query(
'SELECT id, balance FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE',
[ids]
);
// Проверяем баланс
const { rows } = await db.query('SELECT balance FROM accounts WHERE id = $1', [fromId]);
if (rows[0].balance < amount) {
throw new Error('Insufficient funds');
}
// Выполняем перевод
await db.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId]);
await db.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toId]);
await db.query('COMMIT');
return { success: true };
} catch (error) {
await db.query('ROLLBACK');
// Retry при deadlock
if (error.code === '40P01' && attempt < maxRetries - 1) {
await sleep(Math.pow(2, attempt) * 100); // Exponential backoff
continue;
}
throw error;
}
}
}
Чеклист для production
- Используйте Repeatable Read или Serializable для критичных операций (платежи, инвентарь)
- Всегда блокируйте ресурсы в одном порядке (по возрастанию ID)
- Добавьте retry-логику для deadlock (код ошибки 40P01) и serialization failures (40001)
- Мониторьте
pg_stat_database.deadlocks— рост числа deadlock'ов сигнализирует о проблемах - Держите транзакции короткими — долгие транзакции увеличивают вероятность конфликтов
- Используйте
FOR UPDATE SKIP LOCKEDдля реализации очередей задач - Тестируйте конкурентные сценарии — race conditions проявляются только под нагрузкой
Правильная работа с транзакциями — это баланс между консистентностью и производительностью. Read Committed подходит для 80% задач, но для критичных операций не экономьте на Repeatable Read или явных блокировках — цена ошибки в production несопоставима с небольшим снижением throughput.