lenec ru

← все посты

HTTP/2 и HTTP/3 в Node.js: когда использовать и как мигрировать

14K

Транзакции — это фундамент надёжности реляционных баз данных. 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.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.