Логотип Workflow

Article

Updated at:

Isolation Levels

Stage 9. Уровни изоляции: что происходит при параллельных транзакциях

Уровень изоляции определяет, как одна транзакция взаимодействует с другими транзакциями, которые выполняются одновременно. В этой статье “транзакция A” и “транзакция B” означают две отдельные операции, запущенные разными запросами, воркерами или пользователями. Они могут обращаться к одним и тем же строкам, пока обе еще не завершились.

Без изоляции база может работать быстро, но некорректно. При слишком строгой изоляции база безопаснее, но чаще блокирует операции или просит приложение повторить транзакцию. Задача разработчика — выбрать уровень под цену ошибки в конкретном бизнес-сценарии.

Isolation levels and lost update

Lost update простыми словами

Lost update, или потерянное обновление, — это ситуация, когда одна транзакция незаметно перезаписывает результат другой.

Пример с балансом счета:

  1. Транзакция A читает balance = 100.
  2. Транзакция B тоже читает balance = 100.
  3. Транзакция A вычитает 10 и записывает 90.
  4. Транзакция B вычитает 20 из своей старой копии и записывает 80.

Итоговый баланс становится 80, хотя должен быть 70, если применились оба списания. Транзакция B не хотела отменять транзакцию A. Она просто работала со старым значением и записала результат позже.

Более безопасный вариант — заблокировать строку, пока первая транзакция ее меняет:

BEGIN;
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

UPDATE accounts
SET balance = balance - 10
WHERE id = 1;
COMMIT;

FOR UPDATE блокирует выбранную строку для других writer-операций до конца транзакции. Другая транзакция, которая хочет изменить тот же счет, должна ждать, завершиться ошибкой или повториться позже — точное поведение зависит от СУБД и настроек таймаута.

Для простого изменения баланса часто еще лучше использовать атомарный UPDATE без предварительного чтения в приложение:

UPDATE accounts
SET balance = balance - 10
WHERE id = 1 AND balance >= 10;

После этого приложение проверяет, сколько строк обновилось. Если результат 0, значит денег не хватило или условие не прошло.

Типичные аномалии чтения и записи

Изоляция нужна не только для lost update. Она также определяет, что транзакция может читать, пока другие транзакции меняют данные.

ПроблемаЧто происходитПочему это важно
Dirty read / грязное чтениеЧитаются данные, которые другая транзакция еще не закоммитилаРешение может быть принято по данным, которые потом исчезнут
Non-repeatable read / неповторяемое чтениеОдна и та же строка внутри транзакции возвращает разные значенияОтчет или проверка могут использовать несогласованные данные
Phantom read / фантомное чтениеОдин и тот же запрос возвращает другой набор строкСчетчики, квоты и проверки диапазонов могут стать неверными
Lost update / потерянное обновлениеДва writer-а используют одно старое значение, и один результат перезаписывает другойДеньги, остатки или счетчики становятся неправильными

Грязные чтения обычно запрещены в популярных реляционных базах при нормальных настройках. Неповторяемые чтения и фантомы сильнее зависят от выбранного уровня изоляции и конкретной реализации СУБД.

Основные уровни изоляции

УровеньПрактический смыслГде обычно используют
Read CommittedКаждый запрос видит только закоммиченные данные, но следующий запрос может увидеть более свежий commitОбычный CRUD, списки, простые обновления
Repeatable ReadЧтение внутри транзакции стабильнее, часто строится на одном снимке данныхРасчеты, которым нужен стабильный набор входных данных
SerializableБаза старается сделать результат таким, будто транзакции выполнялись по очередиКритичные деньги, остатки, квоты, бронирования

Read Committed часто является уровнем по умолчанию. Он не дает читать незакоммиченные данные, но не обещает, что два SELECT внутри одной транзакции вернут одно и то же значение. Если другая транзакция закоммитилась между двумя чтениями, второй запрос может увидеть новое значение.

BEGIN;
SELECT amount FROM orders WHERE id = 10; -- вернуло 100
-- параллельно другая транзакция обновила amount до 150 и закоммитила
SELECT amount FROM orders WHERE id = 10; -- на Read Committed может вернуть 150
COMMIT;

Repeatable Read дает более стабильный вид данных внутри транзакции. Он полезен, когда одна операция принимает несколько решений на основании одного набора данных. Детали отличаются между СУБД, но смысл общий: пока транзакция идет, чтение не должно неожиданно “переезжать” на новые значения.

Serializable — самый строгий уровень. База защищает корректность так, как будто транзакции выполнялись последовательно. Это не значит, что база буквально запускает только одну транзакцию за раз. Она может выполнять их параллельно, обнаружить опасный конфликт и отменить одну транзакцию. Приложение после этого должно безопасно повторить отмененную операцию.

Блокировки, версии и повторы

Базы обычно обеспечивают изоляцию через блокировки, версии строк или оба механизма сразу. Блокировка временно запрещает конфликтующий доступ. Версионирование позволяет читателям видеть стабильную старую версию строки, пока writer готовит новую зафиксированную версию.

Более строгая изоляция может давать больше ожиданий, deadlock-ов или serialization conflict-ов. Это не всегда ошибка. Иногда база правильно отказывается выдавать небезопасный результат. Поэтому сервис должен понимать, какие операции можно повторять.

Практическая стратегия:

  • используйте Read Committed для обычных списков, карточек и простых CRUD-сценариев;
  • для денег, остатков и счетчиков используйте блокировки строк или атомарные условные UPDATE;
  • для операций, где неверный результат дороже повтора, выбирайте более строгую изоляцию;
  • добавляйте ограниченные retry при serialization conflict;
  • держите транзакции короткими, чтобы блокировки жили меньше.

Частые ошибки:

  1. Надеяться, что дефолтный уровень изоляции подходит всем бизнес-операциям.
  2. Читать значение, считать новое в приложении и записывать обратно без блокировки или атомарного обновления.
  3. Использовать Serializable без retry-логики.
  4. Держать транзакцию открытой во время вызова внешнего API.
  5. Тестировать только один запрос за раз и не замечать баги конкурентности.

Уровень изоляции — это часть бизнес-корректности, а не только настройка базы. Правильный выбор зависит от того, что может сломаться, если два пользователя одновременно трогают одни и те же данные.

Практика перед следующим уроком

Попробуйте руками повторить примеры из статьи на маленьком наборе данных из 5-10 строк. Это важный шаг: когда вы сами запускаете SQL и видите конкретный результат таблицей, материал перестает быть абстрактным. Сначала выполните запрос без оптимизаций, затем внесите одно изменение и посмотрите, как меняется результат или план выполнения. Если что-то не сходится, зафиксируйте вопрос и проверьте: корректны ли фильтры, правильно ли выбраны поля, не потерялись ли строки из-за условий соединения, не появился ли NULL там, где вы его не ожидали.

Мини-проверка понимания:

  1. Могу объяснить тему урока одним простым предложением.
  2. Могу написать базовый SQL-пример без подсказки.
  3. Могу прочитать результат запроса и объяснить каждую колонку.
  4. Могу назвать минимум одну частую ошибку и как ее избежать.

Для критичных сценариев добавляйте хотя бы один конкурентный интеграционный тест на каждый инвариант: баланс, остаток, квоту, слот бронирования. Так решение по изоляции становится проверенным поведением, а не предположением.

Дополнительный сценарий: повтор транзакции при конфликте

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

Практический шаблон:

  1. Выполнить транзакцию.
  2. При конфликте подождать короткий backoff.
  3. Повторить до 2-3 раз.

Такой подход особенно важен для операций, где корректность важнее единичного роста задержки.