Привет, хабр!
Сегодня я хотел бы рассказать о том, как можно реализовать оптимистичную блокировку в Hibernate если вы используете DDD, а точнее агрегаты. Если вы не знакомы с тем, что такое оптимистичная блокировка, то советую сначала почитать это.
Проблема:
Думаю, многим известно что, в целом, реализация оптимистичной блокировки в Hibernate проще некуда — всё что нужно сделать это добавить поле версии с аннотацией @Version
в вашу сущность(entity). Bот так:
@Entity public class MyEntity { @Version private Long version; }
Проблемы начинаются когда вы используете концепт агрегатов из DDD. Если коротко, агрегат — это кластерный объект, состоящий из нескольких сущностей, которые должны быть всегда констистенты и быть в рамках одного transaction boundary. Согласно многим DDD авторам (Вон Вернон, Влад Хононов) желательно, чтобы агрегаты поддерживали оптимистичную блокировку, т. к. в противном случае они могут оказаться в невалидном состоянии.
Даже если вы не используете DDD или агрегаты как понятия в своей разработке, проблема всё ещё может быть для вас актуальна, т. к. и в анемичной модели могут быть кластерные объекты, либо объекты зависящие друг от друга.
Tеперь представим что у нас есть корень агрегата (aggregate root) и в нём лист сущностей. Как мы можем добавить в него оптимистичную блокировку? Например, мы могли бы добавить поле version
только в корень. В зависимости от типа вашего маппинга это сработает когда мы добавляем или удаляем сущность в список внутри корня (напомню, что согласно правилам DDD, сущности внутри агрегата могут добавляться или обновляться только через корень). Но тут возникает проблема: при обновлении сущности, Hibernate не обновит версию корня. А что если бы мы добавили поле version и в корень и в сущность? Помогло бы это? На самом деле нет, т. к. если сущности находятся в рамках агрегата это значит (должно, по крайней мере), что они взаимосвязаны. Поэтому получается, что даже если мы обновим 2 разные сущности в рамках агрегата параллельно это может привести сам агрегат в невалидное состояние, и т. к. обновятся только версии разных сущностей, а не корня, то и OptimisticLockException
у нас не упадёт.
Наглядный пример:
Предположим, у нас есть агрегат состоящий из Ордера — корня агрегата и Майлстоунов — листа сущностей внутри этого корня, которые представляют собой шаги на пути Ордера. У каждого майлстоуна есть дата начала и дата конца. При этом эти даты не могут пересекаться между собой. Теперь представим что юзеры могут редактировать эти даты.
Допустим, сейчас наши даты такие:
Майлстоун 1: начало 2025-04-10, конец 2025-04-11
Майлстоун 2: начало 2025-04-15, конец 2025-04-16
Юзер 1 заходит и меняет конец Майлстоуна 1 на 2025-04-14, что не конфликтует с началом Майлстоуна 2. Но в то же самое время Юзер 2 заходит и меняет начало Майлстоуна 2 на 2025-04-13, что с его точки зрения тоже не конфликтует с Майлстоуном 1.
Несмотря на то, что мы нарушаем инварианты т. к. мы обновляем разные сущности, но не сам корень (и версия корня остаётся прежней) мы не увидим никаких конфликтов и приведём систему в невалидное состояние.
Как это выглядит в коде:
@Entity public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "order_id") private List<Milestone> milestones = new ArrayList<>(); @Version private Long version; // работает нормально - обновляет версию корня агрегата, т. к. мы меняем лист и Hibernate это понимает public void addMilestone(Milestone milestone) { milestones.add(milestone); } // работает нормально - обновляет версию корня агрегата, т. к. мы меняем лист и Hibernate это понимает public void removeMilestone(Integer milestoneId) { milestones.removeIf(milestone -> milestone.getId().equals(milestoneId)); } // НЕ обновляет версию корня public void updateMilestone(Integer milestoneId, LocalDate startDate, LocalDate endDate) { validatePeriod(milestoneId, startDate, endDate); milestones.stream() .filter(milestone -> milestone.getId().equals(milestoneId)) .findFirst() .ifPresent(milestone -> { milestone.setStartDate(startDate); milestone.setEndDate(endDate); }); } } @ToString @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) @FieldNameConstants @Getter // set-методы будут вызываться только из корня @Setter(value = AccessLevel.PROTECTED) public class Milestone { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String name; private LocalDate startDate; private LocalDate endDate; }
Способы решения
1. Использовать вэлью объекты
Самый простой способ, который, тем не менее, подойдёт далеко не всем — это сделать майлстоуны вэлью объектами и использовать аннотацию @ElementCollection
Выглядеть это будет вот так:
public class Order { @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "milestone", joinColumns = {@JoinColumn(name = "order_id")}) List<Milestone> milestones = new ArrayList<>(); } @Embeddable public class Milestone { ... }
Т. к. в случае с @ElementCollection
Hibernate будет относиться к майлстоунам как к части Ордера, то и при каждом обновлении майлстоуна будет обновляться и версия Ордера. С одной стороны это решает проблему, с другой есть набор минусов:
-
Т. к. класс помеченный
@ElementCollection
не может быть сущностью, то нужно переделывать Майлстоуны на вэлью объекты, что может быть невозможно в зависимости от вашего домена. -
По дефолту при изменении коллекции помеченной
@ElementCollection
Hibernate пересоздаёт всю коллекцию. Чтобы этого избежать, нужно добавлять дополнительное поле для сортировки в наш ВО, что может быть очень нежелательно, т. к. в этом случае мы изменяем домен из-за ифраструктуры. Подробнее про это можно почитать тут.
2. Увеличивать версию корня агрегата вручную
Мы добавляем ручное увеличивание версии корня при каждом изменении вложенных сущностей:
public void updateMilestone(Integer milestoneId, LocalDate startDate, LocalDate endDate) { validatePeriod(milestoneId, startDate, endDate); milestones.stream() .filter(milestone -> milestone.getId().equals(milestoneId)) .findFirst() .ifPresent(milestone -> { milestone.setStartDate(startDate); milestone.setEndDate(endDate); }); this.setVersion(this.getVersion() + 1); }
Плюсы:
-
Просто и прозрачно
Минусы:
-
Мы должны не забывать делать это мануально для всех методов обновляющих части агрегата и для всех корней в проекте, что потенциально накладно и с большой долей вероятности рано или поздно приведёт к ошибкам, т. к. будет очень легко забыть это сделать во всех местах.
-
У нас нет однозначного способа узнать изменилась ли на самом деле сущность, поэтому мы обновляем версию корня в любом случае – даже если фактического апдейта не было.
3. Использовать хуки entity lifecycle
Наконец, мы подошли к тому как можно решить нашу проблему автоматически. Здесь будет несколько возможных вариантов в зависимости от того какой вид отношений у нас используется – uni или bi-directional, а так же в зависимости от того нужны ли нам @OneToOne отношения в рамках агрегата.
Основная же идея одинаковая – мы вклиниваемся в процесс обновления сущности и в случае если она является частью агрегата мы находим корень и насильно обновляем его версию.
Мы можем это делать через entity listeners(например @PostUpdate) либо через event listeners(PostUpdateEventListener)
3.1 Bidirectional relationship
Проще всего, если мы используем bidirectional отношения между нашим корнем агрегата и сущностью. В этом случае всё что нам нужно сделать — это после обновления сущности обновить версию корня:
@Entity public class Order { ... @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY, mappedBy = "order") private List<Milestone> milestones = new ArrayList<>(); } @Entity public class Milestone { ... @ManyToOne @JoinColumn(name = "order_id", updatable = false, insertable = false) private Order order; } public class BiDirectionalPostUpdateEventListener implements PostUpdateEventListener { @Override @Transactional public void onPostUpdate(PostUpdateEvent event) { var entity = event.getEntity(); if (entity instanceof Milestone milestone) { event.getSession().lock(milestone.getOrder(), LockMode.OPTIMISTIC_FORCE_INCREMENT); } } @Override public boolean requiresPostCommitHanding(EntityPersister persister) { return false; } }
При желании ивент менеджер можно заменить на @PostUpdate:
@Entity public class Milestone { ... @PostUpdate public void update() { EntityManager entityManager = SpringContext.getBean(EntityManager.class); entityManager.lock(this.getOrder(), LockModeType.OPTIMISTIC_FORCE_INCREMENT); } }
или
public class PostUpdateEntityListener { @PostUpdate public void update(Milestone milestone) { EntityManager entityManager = SpringContext.getBean(EntityManager.class); entityManager.lock(milestone.getOrder(), LockModeType.OPTIMISTIC_FORCE_INCREMENT); } } @Entity @EntityListeners(PostUpdateEntityListener.class) public class Milestone { … }
Но т. к. event listener более гибкий и в него не нужно ничего инжектать, то я считаю его предпочтительнее. Помимо этого он более удобен в дженерик подходе, который я покажу позже.
Плюсы:
-
Простота
-
Работает для всех видов связей — @OneToMany, @OneToOne и тд
-
Bidirectional отношения лучше с точки зрения производительности в Hibernate
Минусы:
-
Если вы адепт DDD, то должно быть знаете что bidirectional связи считаются нежелательными (Эванс, Вон Вернон), т. к. вложенная сущность не должна знать о родителе.
-
Несмотря на то, что с точки зрения Hibernate bidirectional связь считается предпочтительнее, если не мониторить свои запросы, то может быть несложно ошибиться и это может привести к ненужным запросам, лишним джойнам и т. д.
3.2 Unidirectional relationship с rootId у сущности
Если же вы всё таки не хотите использовать bidirectional зависимости (как и мы на проекте в данный момент), то первое что вы можете сделать это обойтись «малой кровью». Под этим я понимаю добавление только rootId
в вашу сущность вместо полноценной Hibernate связи. После чего в том же listener-e вы можете по типу и айди корня достать его из сессии и обновить его версию.
Пример:
@Entity public class Order { ... @ManyToOne @JoinColumn(name = "order_id", updatable = false, insertable = false) private List<Milestone> milestones = new ArrayList<>(); } @Entity public class Milestone { ... private Integer orderId; } public class BiDirectionalPostUpdateEventListener implements PostUpdateEventListener { @Override @Transactional public void onPostUpdate(PostUpdateEvent event) { var entity = event.getEntity(); if (entity instanceof Milestone milestone) { Object root = event.getSession().get(Order.class, milestone.getRootId()); event.getSession().lock(root, LockMode.OPTIMISTIC_FORCE_INCREMENT); } } @Override public boolean requiresPostCommitHanding(EntityPersister persister) { return false; } }
Плюсы:
-
Относительная простота
-
Чище и правильнее с точки зрения DDD
Минусы:
-
Не работает
@OneToOne
, т. к. если делать зависимость по айди сущности в корне то в самой сущности не будет id корня. Те:
create table root( id serial primary key, member_id int references member(id) ); create table member( id serial primary key )
class Root { @OneToOne(cascade = CascadeType.ALL) @JoinColumn(name = "member_id", referencedColumnName = "id") private Member member; } class Member { // нет айди корня, т. к. связь идёт через корень }
Можно было бы сделать зависимость в другую сторону:
create table root( id serial primary key ); create table member( id serial primary key, root_id int references root(id) )
Но в данный момент в Hibernate есть баг который не поддерживает такой маппинг.
В случае если вы сами открываете сессию и закрываете её до того, как отработает post-update то у вас будет ошибка т. к. Hibernate не сможет достать из неё корень.
3.3 Unidirectional relationship
Последний вариант это если вы хотите иметь по-настоящему unidirectional relationship и не использовать даже rootId. В этом случае единственный способ как это можно сделать, который я нашёл — это найти все корни агрегата находящиеся в данный момент в сессии, рефлексией пройти по каждому из них и найти текущую сущность.
Примерный код будет выглядеть так:
public class UniDirectionalPostUpdateEventListener implements PostUpdateEventListener { @Override public void onPostUpdate(PostUpdateEvent event) { var entity = event.getEntity(); if (entity instanceof Milestone milestone) { var pc = event.getSession().getPersistenceContext(); var result = new ArrayList<Order>(); for (var entry : pc.reentrantSafeEntityEntries()) { if (entry.getKey() instanceof Order) { result.add((Order) entry.getKey()); } } var order = processAggregateMembers(result, milestone); event.getSession().lock(order, LockMode.OPTIMISTIC_FORCE_INCREMENT); } } public Order processAggregateMembers(List<Order> orders, Milestone milestone) { for (var root : orders) { // Рекурсивный метод для обработки полей объекта var result = processFieldsRecursively(milestone, root, root); if (result != null) { return result; } } throw new RuntimeException(); } private static Order processFieldsRecursively(Milestone milestone, Object currentObject, Order order) { // Получаем все поля текущего класса Field[] fields = currentObject.getClass().getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); try { Object value = field.get(currentObject); // Получаем значение поля if (value instanceof Milestone) { if (((Milestone) value).getId().equals(milestone.getId())) { return order; } Order result = processFieldsRecursively(milestone, value, order); if (result != null) { return result; } } // Если поле является списком объектов else if (value instanceof Collection) { Collection<?> collection = (Collection<?>) value; for (Object item : collection) { if (item instanceof Milestone) { if (((Milestone) item).getId().equals(milestone.getId())) { return order; } // Рекурсивно обрабатываем каждый элемент списка Order result = processFieldsRecursively(milestone, item, order); if (result != null) { return result; } } } } } catch (IllegalAccessException e) { e.printStackTrace(); } } return null; } @Override public boolean requiresPostCommitHandling(EntityPersister persister) { return false; } }
ПЫСЫ: Я пытался найти более элегантный и простой способ, но не нарыл в сурсах Hibernate способа найти родительскую сущность, когда обновляется дочерняя. Даже если дочерняя сущность выкачивается как часть коллекции родителя при обновлении она обновляется уже отдельно, поэтому доступа к родителю нет. Вполне возможно что это можно сделать и я просто плохо искал. Дайте мне знать в комментариях если нашли способ оптимальнее
Плюсы:
-
Поддерживает все возможные кейсы
Минусы:
-
Может быть неэффективен по производительности
-
Если по какой-либо причине у вас сущность шарится между агрегатами, то данный способ не будет работать
3.4 Generic решение
Данный пункт не является отдельным способом, но generic имплементацией любого из перечисленных выше пунктов. Т. к. решения для всех пунктов похожи, я приведу пример для «3.2 Unidirectional relationship с rootId у сущности» и добавлю комменты для 3.1 и 3.3.
Итак, что нам нужно для начала — это абстрактный базовый класс Entity, от которого будет наследоваться тоже абстрактный класс AggregateRoot и интерфейс AggregateMember. AggregateRoot будет указывать на то, является ли класс корнем, а AggregateMember на то, является ли класс частью агрегата (но не корнем).
@Getter @MappedSuperclass public abstract class Entity<PK extends Serializable> { @Version private Long version; public abstract PK getId(); } @MappedSuperclass public abstract class AggregateRoot<PK extends Serializable> extends Entity<PK> { } public interface AggregateMember<ROOT_PK extends Serializable, T extends AggregateRoot<ROOT_PK>> { ROOT_PK getRootId(); }
Почему для корня агрегата мы используем абстрактный класс, а не итерфейс:
Во-первых, любой агрегат это сущность, но не наоборот
Во-вторых, в этом случае можно типизировать
AggregateMember
типом id корня, что не получилось бы сделать в случае интерфейса
Далее мы используем всё тот же event listener что и в предыдущих примерах, но немного модифицируем его:
public class OptimisticLockPostUpdateListener implements PostUpdateEventListener { @Override public void onPostUpdate(PostUpdateEvent event) { var entity = event.getEntity(); if (entity instanceof AggregateMember<?, ?> aggregateMember) { Object root = event.getSession().get(resolveClass(aggregateMember), aggregateMember.getRootId()); event.getSession().lock(root, LockMode.OPTIMISTIC_FORCE_INCREMENT); } } … public static <ROOT_PK extends Serializable, T extends AggregateRoot<ROOT_PK>> Class<T> resolveClass(AggregateMember<?, ?> aggregateMember) { var genericInterfaces = aggregateMember.getClass().getGenericInterfaces(); for (Type type : genericInterfaces) { if (type instanceof ParameterizedType parameterizedType && parameterizedType.getRawType().equals(AggregateMember.class)) { var actualTypeArguments = parameterizedType.getActualTypeArguments(); for (Type actualTypeArgument : actualTypeArguments) { if (actualTypeArgument instanceof Class<?> actualClass) { if (AggregateRoot.class.isAssignableFrom(actualClass)) { return (Class<T>) actualClass; } } } } } throw new InternalServerException("Cannot resolve class"); } }
4. Сериализуемые транзакции
Хоть данное решение и не относится напрямую к DDD и Hibernate, но т. к. проблема описанная выше по факту является частным случаем асимметрии записи (write skew), то стоит упомянуть, что она может решаться сериализуемыми транзакциями. Но т. к. по-настоящему сериализуемые транзакции — это редкость и они поддерживаются не всеми базами данных, имеют большие минусы с производительностью и не относятся явно к теме данной статьи, я не буду расписывать это решение подробнее. Тем кому интересно могут, например, почитать главу 7 книги с кабанчиком либо выжимки по этой книге на хабре.
Заключение
Сегодня мы рассмотрели возможные способы реализации оптимистичных блокировок в DDD. Будем надеяться, что когда нибудь Hibernate добавит поддержку описанных выше кейсов из коробки, а пока что каждый может сам решать какой вариант использовать. Ссылка с проектом на гитхаб тут.
ссылка на оригинал статьи https://habr.com/ru/articles/858040/
Добавить комментарий