Транзакции и изоляция
Что это такое
Транзакция группирует инструкции так, что применяются либо все они, либо ни одна, а другие сеансы видят либо всю работу целиком, либо ничего. Сколько именно незавершённой работы соседней транзакции может увидеть одна транзакция, решает уровень изоляции. Благодаря этим двум свойствам приложение переводит деньги с одного счёта на другой и ни разу не показывает состояние, в котором денег нет ни на одном из них.
Почему это важно
Ошибки конкурентности — самые интересные из тех, что встречаются в приложениях с базой: два запроса читают одну строку, оба решают продолжить и оба пишут. По отдельности каждый запрос верен, а вместе они дают неверный результат. Инструменты для рассуждения об этом — транзакции и уровни изоляции; неверно выбранный уровень даёт систему, которая проходит все тесты с одним пользователем и теряет деньги с двумя. От уровня зависит и то, сколько возникает блокировок, поэтому изоляция — решение не только о корректности, но и о производительности.
Ключевые идеи
Транзакция — всё или ничего, и это включает падение
Все инструкции транзакции применяются в момент её фиксации. Если раньше этого момента процесс упадёт или соединение оборвётся, работа откатывается: движок записывает достаточно информации, чтобы её отменить. Многошаговое изменение становится безопасным именно поэтому — иначе сбой на середине оставил бы данные несогласованными, и приложению пришлось бы их чинить.
begin
debit account A
credit account B
commit -> both or neither
Уровни изоляции обменивают корректность на конкурентность
Чем строже уровень, тем больше аномалий он предотвращает и тем меньше параллелизма допускает. Практических аномалий три: чтение видит значение, которое позже откатили; повторное чтение внутри одной транзакции возвращает другие значения; набор строк между двумя чтениями меняется. Какие из них приложение может вытерпеть — это и превращает уровень из значения по умолчанию в осознанное решение.
READ UNCOMMITTED -> видит незафиксированную работу (редко уместно)
READ COMMITTED -> только зафиксированную работу (обычный уровень по умолчанию)
REPEATABLE READ -> стабильные чтения внутри транзакции (может блокировать или прерывать чаще)
SERIALIZABLE -> как будто транзакции шли по очереди
Потерянные обновления — та аномалия, которая кусает код приложения
Две транзакции читают значение, вычисляют новое и записывают обратно — вторая перезаписывает первую, и одно обновление теряется. Лекарств три: читать и писать одной инструкцией, заблокировать строку или обнаружить конфликт при фиксации. В коде приложений это самая частая ошибка конкурентности: на тесте с одним пользователем такой шаблон выглядит безобидно.
Блокировки — это механизм, и его цена — соперничество за ресурс
Большинство движков обеспечивают изоляцию двумя способами: удерживают блокировки или обнаруживают конфликты и прерывают одну из сторон. И то и другое при конкурентности даёт ожидание либо повторные попытки. Поэтому транзакции должны быть короткими и не включать работу, которую можно сделать вне них, — сетевой вызов, ожидание пользователя, долгое вычисление. Удерживать блокировку во время сетевого вызова — надёжный способ построить систему, которая встаёт в тупик под нагрузкой.
Взаимные блокировки — проблема протокола, и у неё есть ответ на уровне протокола
Если каждая из двух транзакций держит то, что нужно другой, они будут ждать вечно — поэтому движок находит цикл и прерывает одну из них. Значит, редкие сбои нужно заранее предусмотреть и повторять попытку. Обращение к таблицам и строкам в одном и том же порядке по всей кодовой базе исключает возможность цикла и делает взаимные блокировки гораздо более редкими.
Атомарной инструкции часто достаточно
Явная транзакция нужна не всегда: если изменение выражается одной инструкцией, которая читает и пишет за одну операцию, движок и так считает её атомарной. Однако для изменений, действительно охватывающих несколько инструкций, без транзакции не обойтись. Границы таких транзакций держите настолько узкими, насколько позволяет корректность.
Практика
Продемонстрируйте потерянное обновление
Пусть два сеанса прочитают один и тот же счётчик, прибавят к нему единицу и запишут результат обратно; посмотрите, что получится.
Готово, когда: итоговое значение больше начального на единицу, а не на две, и вы можете описать два способа это предотвратить.
Вынесите сетевой вызов из транзакции
Возьмите функцию, которая открывает транзакцию, записывает строку, а затем вызывает внешний сервис, и перестройте её так, чтобы вызов шёл до или после транзакции.
Готово, когда: во время ожидания сети ни одна блокировка не удерживается, и поведение при сбое внешнего вызова описано явно.
Частые ошибки
- Открытая транзакция поверх сетевого вызова — блокировки удерживаются всё это время, а дальше начинаются соперничество или тупик; исправить: держите транзакции короткими, а вызов вынесите наружу.
- Чтение и запись одного значения двумя инструкциями — при конкурентности одно обновление теряется; исправить: объедините их, заблокируйте строку или обнаружьте конфликт.
- Уверенность, что уровня изоляции по умолчанию хватит всегда — не всякий шаблон «прочитал и записал» безопасен на этом уровне; исправить: определите аномалию, которая важна именно вам, и выбирайте осознанно.
- Отсутствие повторной попытки для прерванной транзакции — тупик доходит до пользователя как ошибка; исправить: повторяйте транзакцию ограниченное число раз.
- Оборачивание всего в одну длинную транзакцию — конкурентность падает, и один медленный шаг блокирует систему; исправить: где возможно, обходитесь атомарной инструкцией, а остальное ограничьте.
Что читать дальше
- Индексы и планы запросов (05-backend-data/07-sql-relational-databases/03-indexes-and-query-plans) — блокировки и выбор индекса связаны: сканирование блокирует больше, чем поиск.
- SQL и реляционные базы данных (05-backend-data/07-sql-relational-databases) — движок, чьими гарантиями всё это является.
- Конкурентность и асинхронное программирование (01-cs-fundamentals/05-concurrency-async-programming) — общие понятия гонок, блокировок и повторных попыток.