Оптимистичная и пессимистичная блокировка в JPA

Оптимистичная и пессимистичная блокировка в JPA

1 марта 2022 г.

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


В качестве примера рассмотрим базу данных авиакомпаний. В таблице Flights хранится информация о рейсах, а в таблице Tickets — информация о забронированных билетах. У каждого рейса есть своя пропускная способность, которая хранится в столбце «flights.capacity». Наше приложение должно контролировать количество проданных билетов и не должно позволять покупать билет на полностью заполненный рейс. Для этого при бронировании билета нам необходимо получить из базы данных вместимость рейса и количество проданных билетов, и если на рейсе есть свободные места, продать билет, в противном случае сообщить пользователю, что места закончились. Если каждый запрос пользователя обрабатывается в отдельном потоке, может возникнуть несогласованность данных. Предположим, что на рейсе есть одно свободное место и два пользователя бронируют билеты одновременно. В этом случае два потока одновременно считывают количество проданных билетов из базы данных, проверяют, что еще осталось место, и продают билет клиенту. Во избежание таких коллизий применяются блокировки.


Одновременные изменения без блокировки


Мы будем использовать Spring Data JPA и Spring Boot. Давайте создадим сущности, репозитории и другие классы:


```java


@Сущность


@Table(имя = "рейсы")


открытый класс Flight {


@Идентификатор


@GeneratedValue (стратегия = GenerationType.IDENTITY)


частный длинный идентификатор;


частный строковый номер;


частное LocalDateTime отправлениеВремя;


частная целочисленная емкость;


@OneToMany(mappedBy = "рейс")


частные билеты Set;


// геттеры и сеттеры


public void addTicket (Билет) {


ticket.setFlight(этот);


получить билеты (). добавить (билет);


```java


открытый интерфейс FlightRepository расширяет CrudRepository { }


```java


@Сущность


@Table(имя = "билеты")


Билет общественного класса {


@Идентификатор


@GeneratedValue (стратегия = GenerationType.IDENTITY)


частный длинный идентификатор;


@ManyToOne (выборка = FetchType.LAZY)


@JoinColumn (имя = "flight_id")


полет на частном рейсе;


частная строка firstName;


частная строка фамилия;


// геттеры и сеттеры


```java


открытый интерфейс TicketRepository расширяет CrudRepository { }


DbService выполняет транзакционные изменения:


```java


@Оказание услуг


открытый класс DbService {


приватный финал FlightRepository FlightRepository;


частный окончательный TicketRepository ticketRepository;


public DbService (FlightRepository FlightRepository, TicketRepository ticketRepository) {


this.flightRepository = FlightRepository;


this.ticketRepository = ticketRepository;


@транзакционный


public void changeFlight1() выдает исключение {


// код первого потока


@транзакционный


public void changeFlight2() выдает исключение {


// код второго потока


Класс приложения:


```java


импортировать org.apache.commons.lang3.function.FailableRunnable;


@SpringBootApplication


открытый класс JpaLockApplication реализует CommandLineRunner {


@Ресурс


частный DbService dbService;


public static void main(String[] args) {


SpringApplication.run(JpaLockApplication.class, аргументы);


@Override


public void run(String... args) {


ExecutorService executor = Executors.newFixedThreadPool(2);


executor.execute(safeRunnable(dbService::changeFlight1));


executor.execute(safeRunnable(dbService::changeFlight2));


исполнитель.shutdown();


private Runnable safeRunnable (FailableRunnable runnable) {


возврат () -> {


пытаться {


работающий.выполнить();


} поймать (Исключение e) {


e.printStackTrace();


Мы будем использовать это состояние базы данных при каждом последующем запуске приложения.


таблица "рейсы":


| идентификатор | номер | отправление_время | мощность |


| 1 | ФЛТ123 | 2022-04-01 09:00:00+03 | 2 |


| 2 | FLT234 | 2022-04-10 10:30:00+03 | 50 |


Таблица билетов:


| идентификатор | ид_рейса | имя_имя | фамилия |


| 1 | 1 | Пол | Ли |


Напишем код, имитирующий одновременную покупку билетов без блокировки.


```java


@Оказание услуг


открытый класс DbService {


// автопроводка


private void saveNewTicket(String firstName, String lastName, Flight Flight) выдает исключение {


если (flight.getCapacity() <= Flight.getTickets().size()) {


бросить новое исключение ExceededCapacityException();


вар билет = новый билет ();


ticket.setFirstName(firstName);


ticket.setLastName(фамилия);


Flight.addTicket (билет);


ticketRepository.save(билет);


@транзакционный


public void changeFlight1() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


saveNewTicket("Роберт", "Смит", рейс);


Thread.sleep(1_000);


@транзакционный


public void changeFlight2() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


saveNewTicket("Кейт", "Браун", рейс);


Thread.sleep(1_000);


```java


открытый класс ExceededCapacityException расширяет исключение {}


Вызов Thread.sleep(1_000); гарантирует, что транзакции, запущенные обоими потоками, будут перекрываться во времени. Результат выполнения этого примера в базе данных:


| идентификатор | ид_рейса | имя_имя | фамилия |


| 1 | 1 | Пол | Ли |


| 2 | 1 | Кейт | Браун |


| 3 | 1 | Роберт | Смит |


Как видите, было забронировано три билета, хотя вместимость рейса FLT123 — два пассажира.


Оптимистическая блокировка


Теперь посмотрим, как работает оптимистичная блокировка. Начнем с более простого примера - одновременной смены мощности рейса. Чтобы использовать оптимистическую блокировку, к классу сущностей необходимо добавить постоянное свойство с аннотацией @Version. Это свойство может иметь тип int, Integer, short, Short, long, Long, или java.sql.Timestamp. Свойство Version управляется поставщиком постоянства, вам не нужно изменять его значение вручную. Если сущность изменена, номер версии увеличивается на 1 (или обновляется временная метка, если поле с аннотацией @Version имеет тип java.sql.Timestamp). И если исходная версия не совпадает с версией в базе данных при сохранении сущности, выбрасывается исключение.


Добавьте свойство version к объекту Flight


```java


@Сущность


@Table(имя = "рейсы")


открытый класс Flight {


@Идентификатор


@GeneratedValue (стратегия = GenerationType.IDENTITY)


частный длинный идентификатор;


частный строковый номер;


частное LocalDateTime отправлениеВремя;


частная целочисленная емкость;


@OneToMany(mappedBy = "рейс")


частные билеты Set;


@Версия


частная длинная версия;


// геттеры и сеттеры


public void addTicket (Билет) {


ticket.setFlight(этот);


получить билеты (). добавить (билет);


Добавьте столбец «версия» в таблицу «полеты».


| идентификатор | имя | отправление_время | мощность | версия |


| 1 | ФЛТ123 | 2022-04-01 09:00:00+03 | 2 | 0 |


| 2 | FLT234 | 2022-04-10 10:30:00+03 | 50 | 0 |


Теперь меняем пропускную способность в обоих потоках:


```java


@Оказание услуг


открытый класс DbService {


// автопроводка


@транзакционный


public void changeFlight1() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


полет.setCapacity(10);


Thread.sleep(1_000);


@транзакционный


public void changeFlight2() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


полет.setCapacity(20);


Thread.sleep(1_000);


Теперь при выполнении нашего приложения мы получим исключение


org.springframework.orm.ObjectOptimisticLockingFailureException: пакетное обновление вернуло неожиданное количество строк из обновления [0]; фактическое количество строк: 0; ожидается: 1; выполненный оператор: обновить набор рейсов, вместимость =?, отправление_время =?, номер =?, версия =? где идентификатор =? а версия=?


Таким образом, в нашем примере один поток сохранил изменения, а другой поток не смог сохранить изменения, потому что в базе данных уже есть изменения. Благодаря этому предотвращаются одновременные изменения одного и того же рейса. В сообщении об исключении мы видим, что столбцы «id» и «версия» используются в предложении «where».


Имейте в виду, что номер версии не меняется при изменении коллекций @OneToMany и @ManyToMany с помощью атрибута mappedBy. Давайте восстановим исходный код DbService и проверим его:


```java


@Оказание услуг


открытый класс DbService {


// автопроводка


private void saveNewTicket(String firstName, String lastName, Flight Flight) выдает исключение {


если (flight.getCapacity() <= Flight.getTickets().size()) {


бросить новое исключение ExceededCapacityException();


вар билет = новый билет ();


ticket.setFirstName(firstName);


ticket.setLastName(фамилия);


Flight.addTicket (билет);


ticketRepository.save(билет);


@транзакционный


public void changeFlight1() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


saveNewTicket("Роберт", "Смит", рейс);


Thread.sleep(1_000);


@транзакционный


public void changeFlight2() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


saveNewTicket("Кейт", "Браун", рейс);


Thread.sleep(1_000);


Приложение будет запущено успешно, и результат в таблице «tickets» будет следующим:


| идентификатор | ид_рейса | имя_имя | фамилия |


| 1 | 1 | Пол | Ли |


| 2 | 1 | Роберт | Смит |


| 3 | 1 | Кейт | Браун |


Опять же, количество билетов превышает вместимость рейса.


JPA позволяет принудительно увеличивать номер версии при загрузке объекта с помощью аннотации @Lock со значением OPTIMISTIC_FORCE_INCREMENT. Давайте добавим метод findWithLockingById в класс FlightRepository. В Spring Data JPA любой текст между find и By может быть добавлен к имени метода, и если он не содержит ключевых слов, таких как Distinct, текст является описательным, и метод выполняется как обычныйнайти…По…:


```java


открытый интерфейс FlightRepository расширяет CrudRepository {


@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)


Необязательный findWithLockingById (длинный идентификатор);


Используйте метод findWithLockingById в DbService


```java


@Оказание услуг


открытый класс DbService {


// автопроводка


private void saveNewTicket(String firstName, String lastName, Flight Flight) выдает исключение {


@транзакционный


public void changeFlight1() выдает исключение {


var Flight = FlightRepository.findWithLockingById(1L).get();


saveNewTicket("Роберт", "Смит", рейс);


Thread.sleep(1_000);


@транзакционный


public void changeFlight2() выдает исключение {


var Flight = FlightRepository.findWithLockingById(1L).get();


saveNewTicket("Кейт", "Браун", рейс);


Thread.sleep(1_000);


Когда приложение запускается, один из двух потоков генерирует исключение ObjectOptimisticLockingFailureException. Состояние таблицы tickets


| идентификатор | ид_рейса | имя_имя | фамилия |


| 1 | 1 | Пол | Ли |


| 2 | 1 | Роберт | Смит |


Мы видим, что на этот раз в базу данных был сохранен только один Билет.


Если невозможно добавить новый столбец в таблицу, но есть необходимость использовать оптимистическую блокировку, можно применить Hibernate-аннотации OptimisticLocking и DynamicUpdate. Значение типа в аннотации OptimisticLocking может принимать следующие значения:


  • ALL - выполнить блокировку по всем полям

  • DIRTY - выполнять блокировку только на основе измененных полей полей

  • ВЕРСИЯ - выполнить блокировку, используя специальный столбец версии

  • NONE - не выполнять блокировку

Мы попробуем оптимистичный тип блокировки «ГРЯЗНОЙ» в примере с изменяющейся грузоподъемностью.


```java


@Сущность


@Table(имя = "рейсы")


@OptimisticLocking(тип = OptimisticLockType.DIRTY)


@Динамическое обновление


открытый класс Flight {


@Идентификатор


@GeneratedValue (стратегия = GenerationType.IDENTITY)


частный длинный идентификатор;


частный строковый номер;


частное LocalDateTime отправлениеВремя;


частная целочисленная емкость;


@OneToMany(mappedBy = "рейс")


частные билеты Set;


// геттеры и сеттеры


public void addTicket (Билет) {


ticket.setFlight(этот);


получить билеты (). добавить (билет);


```java


@Оказание услуг


открытый класс DbService {


// автопроводка


@транзакционный


public void changeFlight1() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


полет.setCapacity(10);


Thread.sleep(1_000);


@транзакционный


public void changeFlight2() выдает исключение {


var Flight = FlightRepository.findById(1L).get();


полет.setCapacity(20);


Thread.sleep(1_000);


Будет выброшено исключение


org.springframework.orm.ObjectOptimisticLockingFailureException: пакетное обновление вернуло неожиданное количество строк из обновления [0]; фактическое количество строк: 0; ожидается: 1; заявление выполнено: обновить набор рейсов, вместимость =? где идентификатор =? а емкость=?


Теперь столбцы «id» и «cpacity» используются в предложении «where». Если вы измените тип блокировки на «ВСЕ», будет выдано такое исключение.


org.springframework.orm.ObjectOptimisticLockingFailureException: пакетное обновление вернуло неожиданное количество строк из обновления [0]; фактическое количество строк: 0; ожидается: 1; заявление выполнено: обновить набор рейсов, вместимость =? где идентификатор =? а емкость=? и отправление_время=? и число =?


Теперь все столбцы используются в предложении where.


Пессимистическая блокировка


При пессимистической блокировке строки таблицы блокируются на уровне базы данных. Давайте изменим тип блокировки метода FlightRepository#findWithLockingById на PESSIMISTIC_WRITE.


```java


открытый интерфейс FlightRepository расширяет CrudRepository {


@Lock(LockModeType.PESSIMISTIC_WRITE)


Необязательный findWithLockingById (длинный идентификатор);


и перезапустите пример бронирования билетов. Один из потоков выдаст исключение ExceededCapacityException, и в таблице будет только два билета.


| идентификатор | ид_рейса | имя_имя | фамилия |


| 1 | 1 | Пол | Ли |


| 2 | 1 | Кейт | Браун |


Теперь поток, первым загрузивший рейс, имеет монопольный доступ к строке в таблице Flights, поэтому второй поток приостанавливает свою работу до снятия блокировки. После того, как первый поток зафиксирует транзакцию и снимет блокировку, второй поток получит монопольный доступ к строке, но в этот момент пропускная способность уже будет исчерпана, потому что изменения, сделанные первым потоком, попадут в базу данных. В результате будет выдано управляемое исключение ExceededCapacityException.


В JPA существует три типа пессимистической блокировки:


  • PESSIMISTIC_READ - получить общую блокировку, и заблокированный объект не может быть изменен до фиксации транзакции.

  • PESSIMISTIC_WRITE - получить эксклюзивную блокировку, и заблокированный объект может быть изменен.

  • PESSIMISTIC_FORCE_INCREMENT - получить эксклюзивную блокировку и обновить столбец версии, заблокированный объект можно изменить

Если несколько потоков блокируют одну и ту же строку в базе данных, получение блокировки может занять много времени. Вы можете установить тайм-аут для получения блокировки:


```java


открытый интерфейс FlightRepository расширяет CrudRepository {


@Lock(LockModeType.PESSIMISTIC_WRITE)


@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})


Необязательный findWithLockingById (длинный идентификатор);


Если тайм-аут истекает, будет выброшено исключение CannotAcquireLockException. Важно отметить, что не все поставщики сохраняемости поддерживают подсказку javax.persistence.lock.timeout. Например, поставщик постоянства Oracle поддерживает эту подсказку, а не для PostgreSQL, MS SQL Server, MySQL и H2.


Теперь рассмотрим тупиковую ситуацию.


```java


@Оказание услуг


открытый класс DbService {


// автопроводка


private void fetchAndChangeFlight(long FlightId) выдает Exception {


var Flight = FlightRepository.findWithLockingById(flightId).get();


Flight.setCapacity(flight.getCapacity() + 1);


Thread.sleep(1_000);


@транзакционный


public void changeFlight1() выдает исключение {


fetchAndChangeFlight(1L);


fetchAndChangeFlight(2L);


Thread.sleep(1_000);


@транзакционный


public void changeFlight2() выдает исключение {


fetchAndChangeFlight(2L);


fetchAndChangeFlight(1L);


Thread.sleep(1_000);


Мы получим следующую трассировку стека из одного из потоков


org.springframework.dao.CannotAcquireLockException: не удалось извлечь ResultSet; SQL [н/д]; вложенным исключением является org.hibernate.exception.LockAcquisitionException: не удалось извлечь ResultSet


Вызвано: org.postgresql.util.PSQLException: ОШИБКА: обнаружена взаимоблокировка


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


Заключение


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



Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE