Транзакции и уровни изоляции в базах данных простыми словами
Код проходит ревью, тесты зелёные, на стейджинге всё работает — а на проде под нагрузкой два пользователя почти одновременно списывают деньги с одного счёта, и баланс в итоге не сходится. Никто не трогал сумму напрямую в базе, никакого «бага» в привычном смысле нет — просто два запроса выполнялись параллельно и один незаметно затёр результат другого. Это не редкий крайний случай, а обычное следствие того, как транзакции ведут себя по умолчанию, если не задумываться об уровне изоляции.
Что реально даёт транзакция
Транзакция — это способ сказать базе данных «выполни эти несколько операций как одну неделимую единицу». За этим стоит акроним ACID, и на практике разработчику обычно важны не все четыре буквы одинаково:
- Atomicity (атомарность) — все операции внутри транзакции выполнятся полностью, либо ни одна. Если на середине перевода денег между счетами процесс упадёт, деньги не «зависнут» списанными с одного счёта и не зачисленными на другой.
- Consistency (согласованность) — транзакция переводит базу из одного корректного состояния в другое, не нарушая ограничения вроде внешних ключей или уникальности.
- Isolation (изоляция) — параллельно выполняющиеся транзакции не должны видеть промежуточные, ещё не зафиксированные результаты друг друга.
- Durability (устойчивость) — то, что подтверждено коммитом, переживёт падение сервера сразу после этого.
Atomicity и durability почти всегда работают так, как от них интуитивно ожидают. А вот isolation — это не бинарный переключатель «включено или выключено», а шкала компромиссов между корректностью и производительностью, и именно она чаще всего становится источником трудноуловимых багов.
Что идёт не так без строгой изоляции
Когда несколько транзакций читают и пишут одни и те же строки одновременно, возможны несколько классических аномалий:
- Грязное чтение (dirty read) — транзакция видит данные, которые другая транзакция ещё не закоммитила и может откатить. Прочитанное значение может оказаться никогда не существовавшим в базе по-настоящему.
- Неповторяющееся чтение (non-repeatable read) — если внутри одной транзакции дважды прочитать одну и ту же строку, между чтениями другая транзакция успевает её изменить и закоммитить, и результаты не совпадают.
- Фантомное чтение (phantom read) — повторный запрос с тем же условием WHERE возвращает другой набор строк, потому что кто-то успел вставить или удалить подходящую запись.
- Потерянное обновление (lost update) — классический случай счёта из начала статьи: две транзакции читают одно значение, каждая независимо прибавляет к нему что-то своё и сохраняет результат, и одно из изменений бесследно исчезает под другим.
Ни одна из этих ситуаций не вызовет ошибку — код отработает без исключений и вернёт вроде бы валидные данные. Именно поэтому такие баги почти никогда не ловятся на code review: их не видно в самом коде, они возникают только на пересечении времени выполнения двух параллельных запросов.
Четыре уровня изоляции
Стандарт SQL описывает четыре уровня изоляции, каждый следующий строже предыдущего и стоит дороже по производительности из-за дополнительных блокировок или версионирования данных:
READ UNCOMMITTED -- допускает грязное чтение
READ COMMITTED -- запрещает грязное чтение
REPEATABLE READ -- запрещает ещё и неповторяющееся чтение
SERIALIZABLE -- запрещает всё, включая фантомы
Уровень задаётся на транзакцию или на сессию, например так:
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- операции внутри транзакции
COMMIT;
READ UNCOMMITTED на практике почти нигде не используется — выигрыш в скорости не окупает риск читать данные, которые могут исчезнуть после отката. SERIALIZABLE формально гарантирует, что результат параллельных транзакций будет таким же, как если бы они выполнялись строго по очереди, но платит за это либо блокировками, либо отказами транзакций с ошибкой сериализации, которые приложению нужно уметь перезапускать.
Уровень изоляции — это не абстрактная настройка для админов баз данных, а прямое решение о том, какими багами вы готовы рискнуть ради скорости. Дефолтный уровень СУБД — это чужое решение этого компромисса, принятое не для вашей конкретной задачи.
Что стоит по умолчанию и почему это важно проверить
Разные СУБД выбирают разные значения по умолчанию, и это регулярно становится сюрпризом при переезде между базами. PostgreSQL по умолчанию использует READ COMMITTED — сравнительно мягкий уровень, где каждый отдельный запрос внутри транзакции видит свежий снимок закоммиченных данных. MySQL с движком InnoDB по умолчанию использует более строгий REPEATABLE READ. Ни один из уровней по умолчанию не спасает от потерянного обновления в общем случае — для денежных операций и счётчиков это обычно решают либо явной блокировкой строки (SELECT ... FOR UPDATE), либо оптимистичной блокировкой через версию строки, а не только повышением уровня изоляции.
Как выбирать уровень на практике
Разумный подход — не ставить SERIALIZABLE везде «на всякий случай» и не оставлять дефолт бездумно:
- Для обычного чтения списков и отчётов почти всегда достаточно
READ COMMITTED— цена более строгих уровней не оправдана. - Для операций «прочитать, изменить, сохранить» над одной и той же строкой — балансы, счётчики, остатки на складе — используйте явную блокировку строки или optimistic locking с проверкой версии, а не полагайтесь только на уровень изоляции.
SERIALIZABLEоставляйте для случаев, где действительно нужна гарантия «как будто транзакции шли по очереди», и закладывайте в код повторную попытку транзакции при ошибке сериализации — это ожидаемое поведение этого уровня, а не исключительная ситуация.
Итог
Изоляция транзакций — это не деталь, которую можно один раз настроить и забыть. Дефолтный уровень изоляции СУБД — это компромисс, выбранный производителем базы данных, а не решение под вашу конкретную задачу с деньгами, остатками или счётчиками. Прежде чем гнаться за более строгим уровнем, стоит понять, какая именно аномалия угрожает конкретной операции — и часто оказывается, что точечная блокировка одной строки решает проблему надёжнее и дешевле, чем повышение уровня изоляции для всего приложения.
← Все статьи