Уровни изоляции транзакций

Уровни изоляции транзакций

Параллельные транзакции - это место, где «работает локально» внезапно превращается в баг в проде. BEGIN и COMMIT мы уже разобрали, теперь смотрим, что происходит, когда таких BEGIN несколько одновременно.

Проблема параллелизма

Два пользователя покупают последние билеты одновременно. Оба видят «осталось 1 билет», оба нажимают «купить». Что происходит? Одному отказ? Оба купят один билет (перепродажа)? Какой-то потеряет деньги?

Уровни изоляции - это правила, по которым база решает, насколько жёстко разделять параллельные транзакции.

Три классических проблемы

Транзакция читает данные, которые **другая транзакция ещё не подтвердила** (на COMMIT). Если та откатится - мы прочитали мусор.

Пример: Транзакция A обновила баланс на 1000, но ещё не коммитила. Транзакция B читает новый баланс 1000 и делает расчёты. Потом A сделает ROLLBACK - баланс вернулся на 500, а B уже работает с неправильными данными.

Читаем одну строку **дважды** внутри одной транзакции, но значение изменилось. Это произошло, потому что другая транзакция сделала UPDATE между нашими SELECT.

Пример: Я жду зарплату и проверяю баланс (1000). Одновременно бухгалтер делает UPDATE и выставляет 3000. Я проверяю баланс второй раз (3000). Внутри одной транзакции одно и то же поле изменилось!

Запускаем один и тот же SELECT **дважды**, а во второй раз появились новые строки. Это произошло, потому что другая транзакция сделала INSERT.

Пример: SELECT COUNT(*) FROM tasks WHERE user_id = 1 → 10 задач. Одновременно кто-то добавляет задачу этому пользователю. SELECT COUNT(*) FROM tasks WHERE user_id = 1 → 11 задач. Внутри одной транзакции размер результата изменился!

Таблица уровней изоляции

Матрица уровней изоляции: какие аномалии возможны на каждом уровне

<ComparisonTable data={ headers: ["Уровень", "Dirty Read", "Non‑Repeatable", "Phantom", "Скорость"], rows: [ ["READ UNCOMMITTED", "✗", "✗", "✗", "⚡⚡⚡ Быстро"], ["READ COMMITTED", "✓", "✗", "✗", "⚡⚡ Норм"], ["REPEATABLE READ", "✓", "✓", "✗", "⚡ Медленнее"], ["SERIALIZABLE", "✓", "✓", "✓", "🐌 Очень медленно"] ], } />

PostgreSQL default

READ COMMITTED - дефолт в PostgreSQL. Он оптимален для большинства веб-приложений:

  • Защита от Dirty Read ✓
  • Параллельность всё ещё хорошая
  • Не требует блокировок по умолчанию

Как использовать разные уровни

-- Явно устанавливаем уровень для одной транзакции
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
  SELECT * FROM accounts WHERE id = 1; - результат "заморозится"
 - другие транзакции не смогут изменить эту строку
COMMIT;

Или с одной командой:

BEGIN ISOLATION LEVEL SERIALIZABLE;
 - всё строго, как в file-based БД (но медленнее)
COMMIT;

Блокировка строк: FOR UPDATE

Если ты хочешь гарантировать, что никто другой не изменит строку пока ты её читаешь:

BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; - блокируем эту строку
-- другие транзакции будут ждать, пока мы не сделаем COMMIT
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT; - расблокируем

Это работает на любом уровне изоляции. FOR UPDATE - явная блокировка, она же пессимистичная. Её сравнение с оптимистичной (через поле version) - в уроке про агрегаты в DDD.

Если же координировать нужно не строки в одной базе, а несколько инстансов сервиса, блокировка переезжает наружу - в Redis.

Практический пример: два терминала

Открой два окна psql к одной БД:

Терминал 1:

BEGIN;
SELECT balance FROM accounts WHERE id = 1; - читаем 100
-- не коммитим, оставляем транзакцию открытой

Терминал 2:

UPDATE accounts SET balance = 200 WHERE id = 1;
SELECT balance FROM accounts WHERE id = 1; - видим 200

Терминал 1:

SELECT balance FROM accounts WHERE id = 1; - всё ещё видим 100 (READ COMMITTED)
COMMIT;
SELECT balance FROM accounts WHERE id = 1; - теперь видим 200

В READ COMMITTED транзакция видит snapshot данных на момент её начала.

Производительность vs Безопасность

СценарийРекомендуемый уровень
Веб-приложение (заказы, пользователи)READ COMMITTED + FOR UPDATE где нужно
Аналитика (отчёты в реальном времени)READ COMMITTED
Финансовые системы (деньги не врут)SERIALIZABLE или вручную блокируем
Конкуренция за редкие ресурсы (билеты, лимиты)FOR UPDATE (SELECT ... FOR UPDATE)

Когда SERIALIZABLE убивает производительность

-- Если все транзакции делают SELECT по всей таблице
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM huge_table; - 1 млн строк
-- база заблокирует все эти строки, никто больше ничего не сделает

Это редко нужно. 99% случаев решается READ COMMITTED + блокировка на нужные строки.

Если две транзакции блокируют друг друга (A ждёт B, B ждёт A) - база выкинет ошибку `DEADLOCK DETECTED`. Обработай это в приложении и переповтори транзакцию.

Практические задания

  • Создай таблицу счётов с балансом
  • Откройи два разных соединения (два окна psql или два скрипта)
  • В первом: BEGINSELECT balance → жди (не коммитишь)
  • Во втором: UPDATE balanceCOMMIT
  • В первом: посмотри, что видишь (Dirty Read произойдёт? Нет, это READ COMMITTED)
  • Попробуй FOR UPDATE: какие строки блокируются?
  • Напиши сценарий, где Phantom Read может произойти (INSERT во время SELECT COUNT)

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.