Статьи

JPA в случае асинхронной обработки

Несколько лет назад в мире Java было почти очевидно, что для каждого проекта «корпоративного» класса необходим JPA для связи с базой данных. JPA является прекрасным примером « дырявой абстракции », описанной Джоэлем Спольски. Великий и легкий в начале, но трудно настраиваемый и ограничивающий в конце. Взламывание и работа непосредственно с кешами, сбросами и нативными запросами — ежедневная рутина для многих разработчиков бэкэнда, вовлеченных в уровень доступа к данным. Для написания специальной книги «JPA для хакеров» достаточно проблем и обходных путей, но в этой статье я остановлюсь только на параллельной обработке сущностей.

Давайте предположим ситуацию: у нас есть объект Person, который в каком-то бизнес-процессе обновляется каким-то сервисом.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@Entity
public class Person {
  
    @Id
    @GeneratedValue
    private Long id;
  
    private String uuid = UUID.randomUUID().toString();
  
    private String firstName;
  
    private String lastName;
  
    // getters and setters
  
}

Чтобы игнорировать любую сложность домена, мы говорим об обновлении имени и фамилии человека. Мы можем представить, что код выглядит так:

1
2
firstNameUpdater.update(personUuid, "Jerry");
lastNameUpdater.update(personUuid, "Newman");

Через некоторое время бизнес решил, что обновление обоих элементов занимает слишком много времени, поэтому сокращение продолжительности становится первоочередной задачей. Конечно, есть много разных способов сделать это, но давайте предположим, что в данном конкретном случае решение этой проблемы решит нашу боль. Это кажется простым делом — просто нужно аннотировать наши методы обслуживания с помощью @Async из Spring и вуаля — проблема решена. В самом деле? У нас есть две возможные проблемы в зависимости от использования оптимистического механизма блокировки.

  • С оптимистической блокировкой почти наверняка мы получим OptimisticLockException от одного из методов обновления — тот, который финиширует вторым. И это лучше, чем вообще не использовать оптимистическую блокировку.
  • Без контроля версий все обновления будут завершены без каких-либо исключений, но после загрузки обновленного объекта из базы данных мы обнаружим только одно изменение. Почему это случилось? Оба метода обновляли разные поля! Почему вторая транзакция перезаписала другое обновление? Из-за дырявой абстракции 🙂

Мы знаем, что Hibernate отслеживает изменения (называемые грязной проверкой), сделанные в наших объектах. Но для сокращения времени, необходимого для компиляции запроса, по умолчанию он включает в запрос на обновление все поля, а не только те, которые были изменены. Выглядит странно? К счастью, мы можем настроить Hibernate для работы по-другому и генерировать запросы на обновление на основе фактически измененных значений. Это можно включить с помощью аннотации @DynamicUpdate . Это можно рассматривать как обходной путь для проблемы частичного обновления, но вы должны помнить, что это компромисс. Теперь каждое обновление этой сущности занимает больше времени, чем было раньше.

Теперь вернемся к ситуации с оптимистической блокировкой. Если честно — то, что мы хотим сделать, как правило, противоположно идее такой блокировки, которая предполагает, что, вероятно, не будет одновременной модификации объекта, и когда возникает такая ситуация, возникает исключение. Теперь мы определенно хотим одновременную модификацию! В качестве быстрого обходного пути мы можем исключить эти два поля ( firstName и lastName ) из механизма блокировки. Это может быть достигнуто с помощью @OptimisticLock (исключено = true), добавленного в каждое поле. Теперь обновление имен не приведет к увеличению версии — оно останется неизменным, что, конечно, может стать источником многих неприятных и трудных для поиска проблем согласованности.
Последнее, но не менее важное решение — это изменение спина. Чтобы использовать его, мы должны обернуть логику обновления в цикл, который обновляется во время транзакции, когда происходит OptimisticLock. Это работает тем лучше, чем меньше потоков вовлечено в процесс. Исходный код со всеми этими решениями можно найти на моем GitHub в репозитории jpa-async-examples . Просто исследуй коммиты.

Подождите — все еще нет правильного решения? На самом деле нет. Именно благодаря использованию JPA мы закрыты для простых решений проблемы одновременной модификации. Конечно, мы можем переделать наше приложение, чтобы ввести некоторые подходы, основанные на событиях, но все же у нас есть JPA выше. Если мы используем Domain Driven Design, мы пытаемся закрыть всю совокупность, используя блокировку OPTIMISTIC_FORCE_INCREMENT , просто чтобы быть уверенным, что изменение составной сущности или добавление элемента в коллекцию обновит всю совокупность, поскольку это должно защитить инварианты. Так почему бы не использовать какой-либо инструмент прямого доступа, например, JOOQ или JdbcTemplate ? Идея замечательная, но, к сожалению, не будет работать одновременно с JPA. Любая модификация, выполненная JOOQ , не будет автоматически распространяться на JPA, что означает, что сессия или кеши могут содержать устаревшие значения.

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

1
update person set first_name = "Jerry" where uuid = ?;

С абстракцией JPA это становится действительно сложной задачей, которая требует действительно глубокого понимания поведения Hibernate, а также внутренних функций реализации. Подводя итог, на мой взгляд, JPA не придерживается «реактивного» подхода. Он был создан для решения некоторых проблем, но в настоящее время мы навязываем разные проблемы, и во многих приложениях постоянство не входит в их число.

Ссылка: JPA в случае асинхронной обработки от нашего партнера JCG Якуба Кубринского в блоге Java (B) Log .