Уровни изоляции транзакций: что именно вы теряете на каждой ступеньке вниз
Уровень изоляции транзакций — не галочка в конфиге, которую выставляют один раз и забывают, а явный компромисс: сколько чужих недоделанных изменений транзакция готова видеть ради того, чтобы не блокировать соседей. Большинство разработчиков вообще не трогают значение по умолчанию — и зря, потому что дефолт у разных СУБД разный, а цена ошибки в обе стороны реальная: слишком слабая изоляция даёт трудноуловимые баги, слишком строгая — очередь из заблокированных транзакций там, где её могло не быть.
Содержание
- Зачем нужны уровни изоляции
- Три аномалии, без которых лестница не имеет смысла
- Read Uncommitted и Read Committed: нижние две ступени
- Repeatable Read: замораживаем снимок на всю транзакцию
- Serializable: верхняя ступень и её настоящая цена
- Почему строгость почти всегда означает меньше параллелизма
- Как выбирать уровень изоляции под задачу
Зачем нужны уровни изоляции
Стандарт SQL-92 описывает четыре уровня изоляции — Read Uncommitted, Read Committed, Repeatable Read, Serializable — как ответы на один вопрос: насколько транзакция может быть уверена, что данные, которые она читает, не изменятся у неё под ногами до конца её собственной работы.
Полная последовательность выполнения транзакций — просто и безопасно, но убивает параллелизм: сотня активных соединений выстраивается в очередь вместо одновременной работы. Полное отсутствие изоляции — противоположность: максимальный параллелизм ценой того, что решение может быть построено на данных, которых через секунду не будет, потому что вносившая их транзакция откатится. Четыре уровня стандарта — это фиксированные точки на этой шкале, а не непрерывный слайдер: нельзя выбрать «немного строже Read Committed, но не так строго, как Repeatable Read».
Важная оговорка: разные СУБД реализуют одинаково названные уровни по-разному. PostgreSQL строит Repeatable Read на едином MVCC-снапшоте и физически исключает фантомные чтения на этом уровне — сильнее, чем требует стандарт. MySQL/InnoDB защищается от фантомов иначе — блокировками следующего ключа. Прежде чем полагаться на конкретное поведение уровня, стоит свериться с документацией именно вашей СУБД и версии.
Три аномалии, без которых лестница не имеет смысла
Грязное чтение (dirty read). Транзакция A изменила строку, но ещё не сделала COMMIT. Транзакция B читает это ещё не зафиксированное значение и принимает на его основе решение. Дальше A делает ROLLBACK — изменения отменяются, а решение B уже принято на данных, которых в подтверждённом состоянии базы никогда не существовало.
Неповторяемое чтение (non-repeatable read). Транзакция A дважды в рамках одной транзакции читает одну и ту же строку. Между чтениями транзакция B меняет эту строку и коммитится. Второе чтение A видит уже другое значение — хотя сама A ничего не делала между своими двумя SELECT. Это ломает логику вида «прочитал остаток → посчитал → сверил ещё раз».
Фантомное чтение (phantom read). Транзакция A выполняет запрос по диапазону, например SELECT COUNT(*) FROM orders WHERE status = 'pending'. B добавляет новую подходящую строку и коммитится. A повторяет тот же запрос и получает другое количество строк, хотя ни одна из ранее прочитанных строк не менялась. Отличие от неповторяемого чтения: там менялась конкретная строка, здесь меняется состав набора строк под условием — защититься от этого технически сложнее, потому что нужно заблокировать ещё не существующие строки.
Отдельно стоит упомянуть потерянное обновление (lost update): две транзакции читают одно значение, каждая независимо вычисляет новое и пишет — итог выглядит так, будто одного из обновлений не было вовсе. Стандарт не включает её в таблицу уровней, но именно от неё чаще всего страдают счётчики и остатки на складе, если код читает значение, меняет в приложении и пишет обратно вместо атомарного UPDATE items SET stock = stock - 1.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверRead Uncommitted и Read Committed: нижние две ступени
Read Uncommitted — самый слабый уровень: допускает все три аномалии. На практике он работает не так, как звучит в теории: PostgreSQL принимает эту команду, но фактически исполняет как Read Committed — грязного чтения в PostgreSQL нет вообще, ни на каком уровне, потому что MVCC устроен так, что читающая транзакция в принципе не видит незакоммиченные версии строк. MySQL/InnoDB, наоборот, реализует Read Uncommitted буквально. Из-за этого расхождения полагаться на Read Uncommitted в межплатформенном коде опасно — область его применения на практике узкая: примерные агрегаты для дашбордов, где сотая доля процента расхождения не критична.
Read Committed закрывает грязное чтение полностью — незакоммиченные изменения других транзакций не видны никогда. Неповторяемое чтение и фантомы по-прежнему возможны. Это уровень по умолчанию в PostgreSQL, Oracle и SQL Server (в MySQL/InnoDB по умолчанию — Repeatable Read). Причина популярности: каждый отдельный оператор видит свежий снапшот на момент своего запуска, а не на момент начала транзакции, — читатели почти не блокируют писателей и наоборот.
Плата — классическая ловушка: приложение читает баланс, показывает форму подтверждения, пользователь думает, подтверждает — а баланс уже мог измениться. Практическое правило: любую последовательность «прочитать → решить → записать» на Read Committed нужно защищать либо SELECT ... FOR UPDATE, либо условием прямо в UPDATE (UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100), либо optimistic locking через версию строки. Полагаться, что между чтением и записью ничего не изменится, — на этом уровне не гарантия, а надежда.
Repeatable Read: замораживаем снимок на всю транзакцию
Repeatable Read закрывает и грязное, и неповторяемое чтение: повторное чтение той же строки в рамках транзакции всегда вернёт то же значение, даже если другая транзакция успела изменить и закоммитить эту строку. По стандарту фантомы на этом уровне всё ещё разрешены.
В PostgreSQL снапшот MVCC фиксируется на момент первого запроса транзакции и физически исключает фантомы — новая строка, добавленная параллельной транзакцией, просто не попадает в снимок. На практике PostgreSQL на Repeatable Read близок к Serializable для чтения, разница проявляется при конкурентной записи. Цена снимка — риск ошибки сериализации: если транзакция на Repeatable Read пытается изменить строку, которую параллельно уже изменила и закоммитила другая транзакция, PostgreSQL вернёт could not serialize access due to concurrent update, и транзакцию придётся повторить целиком. Приложение обязано быть готово перезапускать такие транзакции — иначе часть операций будет просто падать под нагрузкой.
MySQL/InnoDB на Repeatable Read (это дефолт движка) защищается от фантомов блокировками следующего ключа (next-key locks) — они блокируют не только существующие строки, но и промежутки между ними, чтобы туда нельзя было вставить строку, подходящую под уже выполненный диапазонный запрос. Это расширяет зону блокировки: конкурентные вставки в тот же диапазон индекса могут начать ждать друг друга там, где на первый взгляд конфликта нет.
Serializable: верхняя ступень и её настоящая цена
Serializable гарантирует, что результат параллельных транзакций эквивалентен какому-то последовательному порядку их выполнения одну за другой. Ни грязных, ни неповторяемых, ни фантомных чтений.
Классический путь к этому — блокировочный (two-phase locking): транзакция держит блокировки на всё, что читает и пишет, до самого конца. Это гарантирует сериализуемость ценой заметно возросшего числа блокировок и роста вероятности дедлоков при высокой конкуренции за одни и те же строки.
PostgreSQL с версии 9.1 использует другой подход — Serializable Snapshot Isolation (SSI). Транзакции выполняются оптимистично поверх снапшотов, как на Repeatable Read, а база в фоне отслеживает опасные паттерны зависимостей между читающими и пишущими транзакциями. При обнаружении такого паттерна одна из вовлечённых транзакций получает ошибку сериализации и откатывается — даже если конкретный конфликт данных фактически не произошёл: это плата за отказ от предварительных блокировок, возможны ложные срабатывания. Практическое следствие: код на Serializable обязан оборачивать транзакции в цикл повторных попыток — это не опциональная защита, а обязательное условие корректной работы уровня.
Почему строгость почти всегда означает меньше параллелизма
Связь между силой гарантий и стоимостью не случайна. Чтобы гарантировать транзакции, что прочитанные данные не изменятся, база либо физически запрещает их изменение (блокировка), либо держит достаточно версий данных для согласованного снимка каждой транзакции (MVCC), либо задним числом обнаруживает нарушения и откатывает виновника (оптимистичные схемы вроде SSI). Все три пути не бесплатны.
Блокировки — самый прямой способ и самый дорогой по параллелизму: заблокированная строка недоступна для изменения другими до завершения транзакции. Чем дольше живёт транзакция и чем больше строк она трогает, тем выше шанс, что кто-то упрётся в её блокировку, — а в худшем случае две транзакции заблокируют друг друга во встречном порядке и получат дедлок, который база разрешит принудительным откатом одной из них.
MVCC снижает конфликты между чтением и записью — читатели не блокируют писателей и наоборот, — но платит ростом числа версий строк, которые нужно хранить, пока хоть одна активная транзакция может их видеть. Долгоживущая транзакция на Repeatable Read или Serializable удерживает старые версии строк от очистки автоочисткой (vacuum в PostgreSQL, purge в InnoDB) — таблица разбухает физически, хотя формально никто никого не блокирует напрямую. Это одна из самых частых практических граблей: разработчик открывает транзакцию, делает в ней долгий внешний вызов и не закрывает вовремя.
Оптимистичные схемы вроде SSI не блокируют заранее вообще, но платят откатами и повторными попытками — перекладывают стоимость координации с ожидания на конфликт-и-повтор. При низкой конкуренции за одни и те же данные это дёшево, при высокой — частота откатов растёт, и часть транзакций выполняется впустую. Не существует уровня, который был бы «просто лучше» остальных: каждая ступень вверх покупает предсказуемость данных ценой ожидания, памяти на версии или повторных попыток.
Как выбирать уровень изоляции под задачу
Решение почти никогда не требует перебора всех четырёх уровней — обычно есть Read Committed по умолчанию, и вопрос в том, есть ли конкретный сценарий, который его гарантий не покрывает.
Оставайтесь на Read Committed, если операции короткие, а логика «прочитать → решить → записать» укладывается в один SQL-оператор (UPDATE с условием в WHERE) либо явно защищена SELECT ... FOR UPDATE. Это большинство CRUD-нагрузки: создание заказов, обновление профилей, атомарные счётчики.
Переходите на Repeatable Read, когда транзакция должна работать с согласованным снимком данных на всём своём протяжении — генерация отчёта, считающего несколько агрегатов по разным таблицам, или сверка, где повторное чтение той же строки не должно давать другой результат. Готовьтесь обрабатывать ошибки сериализации при конкурентной записи — это retry-логика вокруг транзакции.
Переходите на Serializable, когда цена аномалии выше цены отказов и повторов — типичный пример: финансовые операции, где два одновременных перевода в сумме могут превысить остаток на счету, потому что каждый видит баланс до вычета другого (аномалия write skew, которую не ловят даже Repeatable Read и обычные проверки в WHERE). Serializable через SSI в PostgreSQL не требует ручных блокировок, но требует дисциплины в retry и, как правило, ограничения длительности транзакций.
Не стоит поднимать уровень изоляции глобально ради одного проблемного места — он задаётся на транзакцию (BEGIN; SET TRANSACTION ISOLATION LEVEL ...), и логичнее повысить его точечно для критичного участка, оставив остальную нагрузку на более дешёвом Read Committed. Также стоит проверить, не решается ли проблема без изменения уровня изоляции вообще — через атомарный UPDATE, SELECT ... FOR UPDATE или уникальные ограничения на уровне схемы. Если вы арендуете сервер под базу данных, при тюнинге стоит сразу продумать, какие транзакции требуют повышенной изоляции — это влияет и на память под версии строк, и на скорость автоочистки. Подробнее о конфликтах блокировок и о том, кто кого ждёт в базе под нагрузкой, — в материале про блокировки в базе данных и про то, как рождаются дедлоки. Если долгие транзакции на Repeatable Read или Serializable раздувают таблицы, механику этого эффекта со стороны автоочистки разбирает статья почему база растёт при удалении.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли поменять уровень изоляции для одного запроса, а не для всей транзакции?
Нет, уровень изоляции задаётся на уровне транзакции целиком и действует до её конца. Если нужна разная строгость для разных операций, их стоит разносить по разным транзакциям.
Какой уровень изоляции используется по умолчанию, если я ничего не настраивал?
PostgreSQL, Oracle и SQL Server по умолчанию используют Read Committed, MySQL/InnoDB — Repeatable Read. Перед миграцией между базами или версиями стоит явно проверить и, если нужно, зафиксировать уровень изоляции в коде, а не полагаться на дефолт.
Repeatable Read в PostgreSQL правда защищает от фантомов лучше, чем требует стандарт?
Да — за счёт реализации через единый MVCC-снапшот на всю транзакцию PostgreSQL исключает фантомные чтения на уровне Repeatable Read, хотя стандарт формально их допускает. Это особенность конкретной реализации, а не гарантия, которую можно ожидать от любой СУБД с таким же названием уровня.
Serializable — всегда самый безопасный выбор, если сомневаешься?
Он безопасен в смысле корректности данных, но требует, чтобы приложение перехватывало ошибки сериализации и повторяло транзакцию — без retry-логики Serializable будет просто ронять часть операций под нагрузкой. Если код к этому не готов, более предсказуемым выбором часто оказывается Repeatable Read с явной обработкой конфликтов там, где они реально возможны.
Что такое write skew и почему от него не спасает даже Repeatable Read?
Это ситуация, когда две транзакции читают разные, но связанные по бизнес-логике строки, каждая по отдельности не нарушает ограничения, а их совместный результат нарушает. Read Committed и Repeatable Read такую аномалию не ловят, потому что формально ни одна строка не читалась дважды с разным результатом — нужен именно Serializable.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →