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

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

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

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

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

Один сценарий из этой семьи мы уже разбирали отдельно: потерянное обновление с двумя банкоматами и одним счётом. Там же три способа его закрыть, не трогая уровень изоляции - атомарный UPDATE с условием, SELECT ... FOR UPDATE и версия строки. Этот урок про то, что меняется, если уровень всё-таки поднять.

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

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

Транзакция читает данные, которые **другая транзакция ещё не подтвердила** (на 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 и MySQL расходятся

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

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

REPEATABLE READ - дефолт в MySQL с InnoDB. Разница не косметическая, и знать её надо по трём причинам.

Во-первых, один и тот же код ведёт себя по-разному. В PostgreSQL каждый SELECT внутри транзакции видит свежие данные, закоммиченные к моменту начала этого запроса. В MySQL все SELECT внутри транзакции видят снимок на момент начала транзакции. Код, который дважды читает счётчик внутри транзакции и сравнивает значения, на одной базе увидит изменение, на другой - нет.

Во-вторых, READ UNCOMMITTED в PostgreSQL не существует как отдельный режим: запрос примут, но работать он будет как READ COMMITTED. Грязного чтения в PostgreSQL не бывает вообще, при любых настройках. Так что строка READ UNCOMMITTED в таблице выше - это про стандарт SQL и другие СУБД, а не про то, что можно включить в PostgreSQL.

В-третьих, миграция между базами ломается именно здесь, и ломается тихо: тесты зелёные, а на проде расходятся суммы в отчётах.

-- Проверить, что стоит у тебя
SHOW default_transaction_isolation;   -- PostgreSQL, ожидаемо read committed
SELECT current_setting('transaction_isolation');  -- уровень текущей транзакции

Отдельно стоит проверить драйвер и ORM: некоторые устанавливают уровень сами при открытии соединения, и в приложении реально работает не то, что написано в конфиге базы.

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

-- Уровень задаётся ВНУТРИ транзакции, до первого запроса
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts WHERE id = 1;  -- снимок данных зафиксировался
COMMIT;

Или короче, одной командой:

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;

Порядок здесь важен и на нём часто спотыкаются: SET TRANSACTION после первого запроса уже не сработает (ERROR: SET TRANSACTION ISOLATION LEVEL must be called before any query), а до BEGIN он относится к другой сущности - настройкам сессии, которые ставят отдельной командой:

-- Уровень для всех последующих транзакций этого соединения
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Повышение уровня изоляции меняет только то, что **видит** твоя транзакция. Другие транзакции при этом свободно меняют и коммитят те же строки - ты просто не увидишь их изменений. Если нужно, чтобы никто не менял строку, это другой инструмент: `SELECT ... FOR UPDATE` ниже.

Блокировка строк: 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;  -- autocommit, изменение зафиксировано

Терминал 1:

SELECT balance FROM accounts WHERE id = 1;  -- видим 200, хотя транзакция та же
COMMIT;

Вот тут и живёт главное недопонимание уровня READ COMMITTED. Транзакция не получает один снимок данных на всё своё время. Снимок берётся заново на каждый запрос, поэтому два одинаковых SELECT внутри одной транзакции могут вернуть разное - это и есть Non-Repeatable Read, разрешённая на этом уровне аномалия.

Теперь повтори эксперимент, поменяв первую строку в терминале 1:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- 100
-- терминал 2 делает UPDATE ... 200 и коммитит
SELECT balance FROM accounts WHERE id = 1;  -- всё ещё 100, снимок зафиксирован
COMMIT;
SELECT balance FROM accounts WHERE id = 1;  -- 200, транзакция закрыта

Вот здесь снимок действительно один на всю транзакцию. Разница в одной строке кода, а поведение противоположное - потому такие вещи проверяют руками в двух окнах, а не по памяти.

Lost update: аномалия, которой нет в таблице

Три аномалии из таблицы описаны в стандарте SQL. Самая частая в реальном коде там не упомянута, потому что возникает не внутри базы, а на стыке базы и приложения. Называется lost update, и выглядит так:

-- Транзакция A                              | Транзакция B
BEGIN;                                       | BEGIN;
SELECT balance FROM accounts WHERE id = 1;   | SELECT balance FROM accounts WHERE id = 1;
-- получили 1000                             | -- тоже получили 1000
-- в коде считаем: 1000 - 100 = 900          | -- в коде считаем: 1000 - 200 = 800
UPDATE accounts SET balance = 900 WHERE id=1;| 
COMMIT;                                      | UPDATE accounts SET balance = 800 WHERE id=1;
                                             | COMMIT;

Списали 100 и 200, а баланс стал 800 вместо 700. Сто денег растворились, и никакой ошибки в логах нет: с точки зрения базы оба UPDATE абсолютно корректны, READ COMMITTED такое разрешает. Это, кстати, ровно тот сценарий с двумя последними билетами из начала урока.

Лечится тремя способами, в порядке от дешёвого к дорогому:

  1. Считать в базе, а не в коде. Второй UPDATE дождётся первого и посчитает от свежего значения:
UPDATE accounts SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- 0 обновлённых строк = денег не хватило, отказываем без отдельного SELECT

Проверка условия прямо в WHERE плюс количество затронутых строк заменяет и чтение, и блокировку.

  1. Пессимистичная блокировка. SELECT ... FOR UPDATE из раздела выше. Нужна, когда решение сложнее арифметики: посмотреть три таблицы, посчитать скидку, потом записать.

  2. Оптимистичная блокировка. Колонка version:

UPDATE orders SET status = 'paid', version = version + 1
WHERE id = 42 AND version = 7;
-- 0 строк = кто-то успел раньше, читаем заново и повторяем логику

Подходит для долгих пользовательских правок: форма открыта десять минут, держать блокировку всё это время нельзя.

Serialization failure: приложение обязано уметь повторять

На REPEATABLE READ и SERIALIZABLE появляется поведение, которого на READ COMMITTED не бывает: успешно выполнявшаяся транзакция падает на COMMIT.

ERROR: could not serialize access due to concurrent update
SQLSTATE 40001

Это не сбой и не баг драйвера, а прямое следствие контракта. База обещала, что результат будет таким же, как если бы транзакции выполнялись по очереди. Обнаружив, что для двух конкретных транзакций это уже невозможно, у неё остаётся один выход - отменить одну из них. Выбор невиновного здесь произвольный: упасть может и та транзакция, которая ничего не нарушала.

Отсюда вывод, который часто узнают уже на проде: включить SERIALIZABLE без retry в приложении означает сделать систему хуже. Раньше данные были слегка неточными, теперь пользователи видят ошибки на ровном месте.

Повторять нужно транзакцию целиком, с самого BEGIN, а не последний запрос: снимок данных отменён вместе с транзакцией.

func withRetry(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
    var lastErr error
    for attempt := 0; attempt < 3; attempt++ {
        tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
        if err != nil {
            return err
        }
        if err := fn(tx); err != nil {
            tx.Rollback()
            lastErr = err
        } else if err := tx.Commit(); err != nil {
            lastErr = err
        } else {
            return nil
        }

        var pqErr *pq.Error
        // 40001 - serialization_failure, 40P01 - deadlock_detected
        if !errors.As(lastErr, &pqErr) || (pqErr.Code != "40001" && pqErr.Code != "40P01") {
            return lastErr // не повторяемая ошибка, повтор не поможет
        }
        time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond)
    }
    return lastErr
}
function withRetry(PDO $pdo, callable $fn, int $attempts = 3): void
{
    for ($i = 1; $i <= $attempts; $i++) {
        try {
            $pdo->exec('BEGIN ISOLATION LEVEL SERIALIZABLE');
            $fn($pdo);
            $pdo->exec('COMMIT');
            return;
        } catch (PDOException $e) {
            $pdo->exec('ROLLBACK');
            // 40001 - serialization_failure, 40P01 - deadlock_detected
            $code = $e->errorInfo[0] ?? '';
            if (!in_array($code, ['40001', '40P01'], true) || $i === $attempts) {
                throw $e;
            }
            usleep($i * 20000);
        }
    }
}

Три условия, без которых retry принесёт больше вреда, чем пользы:

  • Попыток конечное число. Бесконечный цикл повторов при высокой конкуренции превращается в самоподдерживающуюся перегрузку базы.
  • Небольшая пауза между попытками, желательно со случайным разбросом. Иначе конфликтующие транзакции повторятся синхронно и столкнутся снова.
  • Внутри транзакции нет внешних эффектов. Отправку письма или списание в платёжном шлюзе нельзя откатить и нельзя повторить безнаказанно. Как это разводить, разбирали в уроке про транзакции.

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

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

Цена каждого уровня

«Медленно» из таблицы выше - слишком грубое слово. Уровни платят разной монетой, и знать, какой именно, полезнее, чем помнить порядок строк.

READ COMMITTED практически бесплатен. Снимок берётся на каждый запрос, читатели не ждут писателей, писатели не ждут читателей. Цена платится не производительностью, а тем, что аномалии вроде lost update закрывать придётся руками.

REPEATABLE READ удерживает один снимок на всю транзакцию. Цена двойная: пока транзакция открыта, база обязана хранить старые версии строк (это прямая дорога к раздуванию таблиц, разобранному в уроке про транзакции), а конкурирующие записи начинают падать с ошибкой 40001.

SERIALIZABLE в PostgreSQL реализован через SSI (Serializable Snapshot Isolation), и здесь стоит развеять популярный миф: он не блокирует строки и таблицы. Он отслеживает зависимости между транзакциями и отменяет ту, которая нарушила порядок. Цена:

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM orders;  -- прочитали всю таблицу
-- Теперь любая параллельная вставка в orders - потенциальный конфликт,
-- и одна из транзакций получит 40001 на COMMIT
COMMIT;

То есть чем шире диапазон данных, который транзакция прочитала, тем выше шанс отката. Плюс расходы памяти на отслеживание конфликтов и обязательный retry в коде. На практике SERIALIZABLE берут для узких критичных операций, а не для всей базы целиком.

FOR UPDATE на READ COMMITTED платит ожиданием. Пропускная способность по конкурентной строке равна 1 / длительность транзакции: если транзакция держит строку 200 мс, больше пяти операций в секунду по ней не пройдёт. Отсюда правило блокировать строки как можно позже и коммитить как можно раньше. Есть и модификаторы: FOR UPDATE NOWAIT вернёт ошибку вместо ожидания, а FOR UPDATE SKIP LOCKED пропустит занятые строки - на этом строят очереди задач в базе, когда несколько воркеров разбирают одну таблицу.

Практический выбор в 95% случаев: READ COMMITTED, арифметика на стороне базы (balance = balance - 100), FOR UPDATE в тех местах, где решение зависит от прочитанного, и retry на 40001 плюс 40P01, даже если SERIALIZABLE пока не включён - от deadlock не застрахован никто.

Если две транзакции блокируют друг друга (A ждёт B, B ждёт A), база через секунду обнаружит цикл и убьёт одну из них с ошибкой `deadlock detected` (SQLSTATE 40P01). Обработай это в приложении и повтори транзакцию. Профилактика дешевле: блокируй строки всегда в одном порядке, например по возрастанию `id`. Классический deadlock как раз рождается из перевода денег в обе стороны одновременно, где одна транзакция берёт счёт 1, потом 2, а вторая - наоборот.

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

  • Создай таблицу счётов с балансом
  • Откройи два разных соединения (два окна psql или два скрипта)
  • В первом: BEGINSELECT balance → жди (не коммитишь)
  • Во втором: UPDATE balanceCOMMIT
  • В первом: посмотри, что видишь (Dirty Read произойдёт? Нет, это READ COMMITTED)
  • Попробуй FOR UPDATE: какие строки блокируются?
  • Напиши сценарий, где Phantom Read может произойти (INSERT во время SELECT COUNT)
  • Повтори опыт с двумя терминалами на REPEATABLE READ и убедись, что второй SELECT показывает старое значение
  • Воспроизведи lost update руками: в двух окнах прочитай баланс, посчитай новое значение в голове и запиши оба
  • Перепиши тот же сценарий через balance = balance - 100 и проверь, что деньги больше не теряются
  • Получи ошибку 40001: два окна с BEGIN ISOLATION LEVEL SERIALIZABLE, оба читают таблицу целиком и оба вставляют строку
  • Устрой deadlock: две транзакции блокируют счета 1 и 2 в противоположном порядке

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