Статьи

JPA, асинхронная обработка и «утечка абстракций»

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

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

@Entity
public class Person {

    @Id
    @GeneratedValue
    private Long id;

    private String uuid = UUID.randomUUID().toString();

    private String firstName;

    private String lastName;

    // getters and setters

}

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

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 выше. Если мы используем управляемый доменом дизайн, мы пытаемся закрыть весь агрегат, используя OPTIMISTIC_FORCE_INCREMENTблокировка, просто чтобы быть уверенным, что изменение составных объектов или добавление элементов в коллекцию обновит весь агрегат, поскольку это должно защитить инварианты. Так почему бы просто не использовать какой-либо инструмент прямого доступа? JOOQ или JdbcTemplate , например? Идея замечательная, но, к сожалению, она не будет работать одновременно с JPA. Любая модификация, выполненная JOOQ , не будет автоматически распространяться на JPA, что означает, что сессии или кэши могут содержать устаревшие значения.

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

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

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