Уровни изоляции транзакций
Уровни изоляции транзакций
Параллельные транзакции - это место, где «работает локально» внезапно превращается в баг в проде. BEGIN и COMMIT мы уже разобрали, теперь смотрим, что происходит, когда таких BEGIN несколько одновременно.
Проблема параллелизма
Два пользователя покупают последние билеты одновременно. Оба видят «осталось 1 билет», оба нажимают «купить». Что происходит? Одному отказ? Оба купят один билет (перепродажа)? Какой-то потеряет деньги?
Уровни изоляции - это правила, по которым база решает, насколько жёстко разделять параллельные транзакции.
Три классических проблемы
Пример: Транзакция A обновила баланс на 1000, но ещё не коммитила. Транзакция B читает новый баланс 1000 и делает расчёты. Потом A сделает ROLLBACK - баланс вернулся на 500, а B уже работает с неправильными данными.
Пример: Я жду зарплату и проверяю баланс (1000). Одновременно бухгалтер делает UPDATE и выставляет 3000. Я проверяю баланс второй раз (3000). Внутри одной транзакции одно и то же поле изменилось!
Пример: 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 + блокировка на нужные строки.
Практические задания
- Создай таблицу счётов с балансом
- Откройи два разных соединения (два окна psql или два скрипта)
- В первом:
BEGIN→SELECT balance→ жди (не коммитишь) - Во втором:
UPDATE balance→COMMIT - В первом: посмотри, что видишь (Dirty Read произойдёт? Нет, это READ COMMITTED)
- Попробуй
FOR UPDATE: какие строки блокируются? - Напиши сценарий, где Phantom Read может произойти (INSERT во время SELECT COUNT)