Транзакции и блокировки в Hibernate
Открыл две сессии и в обеих прочитал одного и того же автора - версия у обоих оказалась нулевой. Первая изменила имя и закоммитилась. Вторая изменила имя и попыталась закоммититься тоже: OptimisticLockException. В базе осталось значение первой, а версия стала единицей.
Так работает защита от потерянного обновления. Без неё вторая транзакция молча затёрла бы работу первой - и никто бы не заметил, потому что ошибок нет.
// Формулировки: «что такое потерянное обновление?», «чем оптимистичная блокировка отличается от пессимистичной?», «когда какую берёшь?».
Потерянное обновление
Сценарий такой. Два менеджера открыли карточку одного заказа. Первый поменял скидку и сохранил. Второй в это же время поменял адрес доставки и сохранил после первого. Его форма несла старое значение скидки, и оно перезаписало новое. Ошибок не было ни у кого, изменение первого просто исчезло.
Это и есть потерянное обновление: два процесса читают одно состояние, меняют разные вещи и записывают целиком, а побеждает тот, кто записал последним.
// Важно, что обычная транзакция от этого не защищает. Транзакция гарантирует, что операции не увидят промежуточных состояний друг друга, но две последовательные записи с точки зрения базы совершенно законны. Нужна отдельная защита.
- потерянное обновление
- второй пишет поверх первого, ошибок нет, изменение исчезло
Оптимистичная блокировка
Оптимистичная блокировка исходит из того, что конфликты редки, и ничего заранее не запирает. В таблицу добавляется колонка версии, поле помечается аннотацией @Version. При обновлении ORM дописывает в условие текущую версию: update ... where id = ? and version = ? и поднимает версию на единицу.
Если версия в базе уже изменилась, обновление затронет ноль строк - и это сигнал, что кто-то опередил. Именно так во втором замере родился OptimisticLockException: обе сессии прочитали версию 0, первая перевела её в 1, а условие второй перестало совпадать.
Обрабатывают это в приложении. Для пользовательских форм - показать «данные изменились, обновите страницу» и не затирать чужую работу. Для фоновых операций - повторить попытку на свежих данных. Плата за защиту нулевая, пока конфликты редки, поэтому оптимистичная блокировка и стоит по умолчанию.
@Version int version;
// обе сессии прочитали версию 0
// первая: commit -> ок, версия стала 1
// вторая: commit -> OptimisticLockException
// в базе: значение первой, версия 1- @Version
- колонка версии: обновление проверяет, что её никто не сдвинул
Пессимистичная блокировка и цена ожидания
Пессимистичная блокировка запирает строку в базе сразу при чтении - через select ... for update. Остальные ждут, пока владелец не завершит транзакцию. Конфликт исключён полностью, но платишь ожиданием: пока строка занята, все желающие стоят в очереди.
Берут её там, где конфликты часты и повтор дорог: списание с одного счёта, выдача последнего товара со склада, распределение номеров. Там, где столкновения редки, она только вредит.
// Два риска. Первый - взаимная блокировка: две транзакции заперли по строке и ждут чужую. Лечится единым порядком захвата и таймаутом ожидания. Второй - длина транзакции: пока строка заперта, всё приложение упирается в неё, поэтому запертую транзакцию нельзя растягивать чужими вызовами по сети. И общее правило: уровень изоляции - настройка того, насколько транзакции видят незавершённую работу друг друга, - задаётся базой, а ORM его только передаёт. У PostgreSQL по умолчанию это READ COMMITTED: видно лишь то, что уже зафиксировано.
- пессимистичная блокировка
- строка запирается при чтении, остальные ждут
- взаимная блокировка
- две транзакции держат по строке и ждут чужую
Как отвечать: «Что такое потерянное обновление и как его предотвратить?»
Это когда две транзакции прочитали одно состояние строки, поменяли в ней разные поля и записали целиком: побеждает тот, кто записал последним, а изменение первого исчезает без всяких ошибок. Обычная транзакция от этого не спасает - две последовательные записи для базы совершенно законны. Защищаюсь оптимистичной блокировкой: колонка версии и поле с @Version, тогда обновление идёт с условием по текущей версии и при расхождении затрагивает ноль строк. Я это проверял: обе сессии прочитали версию ноль, первая закоммитилась и подняла версию, вторая получила OptimisticLockException. Дальше в пользовательском сценарии показываю «данные изменились», а в фоновом повторяю на свежих данных. Пессимистичную блокировку через select for update беру только там, где конфликты часты и повтор дорог - списание со счёта, последний товар на складе, - потому что она заставляет всех остальных ждать.
Почему это сильный ответ: описан сам сценарий, показано, что транзакция сама по себе не защита, дан рабочий механизм с проверенным поведением и разведены случаи для двух видов блокировок.
На чём валят
- −Считать, что транзакция защищает от потерянного обновления. Две последовательные записи для базы законны, нужна версия или блокировка.
- −Поймать OptimisticLockException и просто повторить в цикле, не перечитав данные. Повтор со старой версией упадёт снова.
- −Ставить пессимистичную блокировку везде «для надёжности». Все конкуренты выстраиваются в очередь за строкой.
- −Держать запертую строку во время вызова чужого сервиса. Ожидание сети превращается в ожидание всей очереди.
- −Захватывать несколько строк в разном порядке в разных местах кода. Это готовая взаимная блокировка.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 16, остальные разбираются в тренажёре.
- Чем пессимистичная блокировка отличается от оптимистичной?A)Пессимистичная не блокирует строку, а лишь сверяет номер версии при попытке сохранить измененияB)Пессимистичная физически блокирует строку в БД (SELECT ... FOR UPDATE) на время транзакцииC)Пессимистичная работает только в памяти приложения, никак не затрагивая саму базу данных при этомD)Пессимистичная и оптимистичная — синонимы; выбор между ними влияет только на текст сообщения об ошибке
показать ответ и разбор
+B)Пессимистичная физически блокирует строку в БД (SELECT ... FOR UPDATE) на время транзакции// разбор: Пессимистичная блокировка ФИЗИЧЕСКИ блокирует строку(и) в БД на время транзакции (SELECT ... FOR UPDATE, через LockModeType.PESSIMISTIC_WRITE): другие транзакции, желающие изменить/заблокировать ту же строку, ЖДУТ. Она предотвращает конфликт заранее, но снижает параллелизм и рискует deadlock/таймаутами — берут для частых конфликтов и критичных операций (списание средств). Оптимистичная (@Version) конфликт не предотвращает, а ОБНАРУЖИВАЕТ при коммите, не блокируя конкурентов. Выбор: редкие конфликты и высокая конкуренция чтений → оптимистичная; частые конфликты/критичность → пессимистичная.
- Что происходит при коммите транзакции с точки зрения Hibernate?A)Все managed-сущности немедленно удаляются из памяти, а их изменения безвозвратно теряются при коммитеB)Hibernate только помечает изменения как готовые, но фактическая запись в БД произойдёт лишь при чтенииC)Контекст флашится (SQL уходит в БД), транзакция фиксируется, изменения становятся видимы другимD)Коммит откатывает все несохранённые изменения и начинает новую пустую транзакцию с чистого листа
показать ответ и разбор
+C)Контекст флашится (SQL уходит в БД), транзакция фиксируется, изменения становятся видимы другим// разбор: При коммите Hibernate сначала выполняет FLUSH: синхронизирует контекст с БД, отправляя накопленные INSERT/UPDATE/DELETE (от dirty checking) в правильном порядке. Затем ФИКСИРУЕТ транзакцию в БД — изменения становятся durable и видимыми другим транзакциям (согласно уровню изоляции). После коммита (и обычно закрытия сессии) managed-сущности становятся detached. При откате (rollback) накопленные изменения в БД отменяются, но состояние объектов в памяти автоматически не откатывается — поэтому detached-объекты после rollback могут быть «грязными». Границы транзакции определяют, когда и что уходит в БД.
- Почему две параллельные транзакции, читающие-меняющие один счёт, могут «потерять» одно обновление?A)Потому что Hibernate по умолчанию выполняет все транзакции строго последовательно, конфликтов быть не можетB)Потому что при параллельном доступе Hibernate автоматически берёт пессимистичную блокировку строкиC)Потому что кэш первого уровня одной транзакции виден другой, и они синхронизируют значения между собойD)Lost update: обе прочитали старое значение и записали своё — нужна блокировка (optimistic/pessimistic)
показать ответ и разбор
+D)Lost update: обе прочитали старое значение и записали своё — нужна блокировка (optimistic/pessimistic)// разбор: Классический lost update: транзакция A и B обе читают баланс=100, каждая прибавляет 10 и пишет 110 — второй коммит ЗАТИРАЕТ первый, одно обновление потеряно (итог 110 вместо 120). Обычные уровни изоляции (READ_COMMITTED) этого не ловят. Защита: ОПТИМИСТИЧНАЯ блокировка (@Version обнаружит, что версия изменилась, и бросит OptimisticLockException — перечитать и повторить) либо ПЕССИМИСТИЧНАЯ (SELECT ... FOR UPDATE — второй ждёт), либо атомарный UPDATE на стороне БД (SET balance = balance + 10). Понимание lost update — база корректной конкурентной работы с данными.
- Где должны проходить границы транзакции в типичном Spring+Hibernate приложении?A)На уровне сервисного метода (@Transactional), охватывая связную бизнес-операциюB)На уровне каждого отдельного SQL-запроса — по одной короткой транзакции на каждый select или updateC)На уровне всего приложения — одна долгоживущая транзакция открывается при старте и живёт до остановкиD)На уровне контроллера, чтобы транзакция включала в себя ещё и сериализацию ответа в JSON клиенту
показать ответ и разбор
+A)На уровне сервисного метода (@Transactional), охватывая связную бизнес-операцию// разбор: Транзакцию открывают на уровне СЕРВИСНОГО метода (@Transactional), чтобы она охватывала ЦЕЛУЮ бизнес-операцию: либо все её шаги применятся, либо ни одного (атомарность). Транзакция на каждый запрос ломает согласованность (перевод денег между счетами должен быть одной транзакцией). Одна вечная транзакция держала бы блокировки и раздувала контекст. Тянуть транзакцию до контроллера/сериализации (чтобы лениво догрузить связи в ответе) — антипаттерн Open-Session-In-View: лучше загрузить нужное в сервисе и вернуть DTO. Держать транзакции КОРОТКИМИ и на уровне бизнес-операции.
- Почему важно держать транзакции короткими?A)Короткие транзакции исключают взаимоблокировки, поэтому deadlock перестаёт возникатьB)Долгие транзакции держат блокировки/соединение и раздувают контекст — падает параллелизмC)Длина транзакции влияет только на читаемость кода, но на производительность базы данных не влияетD)Долгая транзакция автоматически повышает уровень изоляции до SERIALIZABLE, замедляя всё приложение
показать ответ и разбор
+B)Долгие транзакции держат блокировки/соединение и раздувают контекст — падает параллелизм// разбор: Пока транзакция открыта, она удерживает ресурсы: соединение с БД из пула, приобретённые блокировки строк (особенно при пессимистичной блокировке или записи), растущий контекст персистентности (память, dirty checking по всё большему числу сущностей). Долгая транзакция заставляет конкурентов ЖДАТЬ на тех же строках, исчерпывает пул соединений и повышает риск deadlock/таймаутов — параллелизм падает. Поэтому: не открывать транзакцию раньше нужного, не делать в ней сетевые/долгие внешние вызовы, выносить тяжёлое чтение за транзакцию, разбивать массовые операции. Короткие транзакции = здоровый параллелизм.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.