Замечательная аннотация Version в JPA

Введение


Итак, начнем! Что же означает аннотация Version в JPA?

Если коротко, то она отвечает за блокировки в JPA. Данная аннотация решает одну из проблем, которые могут возникнуть в результате параллельного выполнения транзакций.

Какие же могут возникнуть проблемы?


  1. Потерянные обновления могут возникнуть в ситуациях, когда две транзакции, выполняющиеся параллельно, пытаются обновить одни и те же данные.
  2. Грязные чтения возникают, когда транзакция видит еще не сохранённые изменения, сделанные другой транзакцией. В таких случая может возникнуть проблема из-за отката второй транзакции, но при этом данные уже были прочитаны первой.
  3. Неповторяемые чтения возникают, когда первая транзакция получила данные, а вторая транзакция внесла в них изменение и успешно закоммитила их, до окончания первой транзакции. Иначе говоря, когда в рамках одной транзакции один и тот же запрос на получение, например всей таблицы, возвращает разные результаты.
  4. Фантомное чтение — проблема похожая на неповторяемые чтения, за тем исключением, что возвращается разное количество строк.

Коротко о их решениях


  1. READ UNCOMMITED — решается с помощью аннотации Version в JPA(об этом как раз и статья)
  2. READ COMMITED — позволяет читать только закоммиченные изменения
  3. REPEATABLE READ — тут немного посложнее. Наша транзакция «не видит» изменения данных, которые были ею ранее прочитаны, а другие транзакции не могут изменять тех данных, что попали в нашу транзакцию.
  4. SERIALIZABLE — последовательное выполнение транзакций

Каждый последующий пункт покрывает все предыдущие, иначе говоря может заменить решения, указанные ранее. Таким образом SERIALIZABLE имеет самый высокий уровень изолированности, а READ UNCOMMITED — самый низкий.

Version


Version решает проблему с потерянными обновлениями. Как именно, сейчас и посмотрим.

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

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

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

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

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

LockModeType или как выставить блокировку


Блокировку можно выставить через вызов метода look у EntityManager.

entityManager.lock(myObject, LockModeType.OPTIMISTIC);

LockModeType задает стратегию блокирования.

LockModeType бывает 6 видов(2 из которых относятся к оптимистичным, а 3 к пессимистичным):

  1. NONE — отсутствие блокировки
  2. OPTIMISTIC
  3. OPTIMISTIC_FORCE_INCREMENT
  4. PESSIMISTIC_READ
  5. PESSIMISTIC_WRITE
  6. PESSIMISTIC_FORCE_INCREMENT

Создадим нашу Entity
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;

@EntityListeners(OperationListenerForMyEntity.class)
@Entity
public class MyEntity{
    @Version
    private long version;

    @Id
    @GeneratedValue
    @Getter
    @Setter
    private Integer id;

    @Getter
    @Setter
    private String value;
  
    @Override
    public String toString() {
        return "MyEntity{" +
                "id=" + id +
                ", version=" + version +
                ", value='" + value + '\'' +
                '}';
    }
}


Создадим класс, где будут реализованы все Callback методы
import javax.persistence.*;

public class OperationListenerForMyEntity {
    @PostLoad
    public void postLoad(MyEntity obj) {
        System.out.println("Loaded operation: " + obj);
    }

    @PrePersist
    public void prePersist(MyEntity obj) {
        System.out.println("Pre-Persistiting operation: " + obj);
    }

    @PostPersist
    public void postPersist(MyEntity obj) {
        System.out.println("Post-Persist operation: " + obj);
    }

    @PreRemove
    public void preRemove(MyEntity obj) {
        System.out.println("Pre-Removing operation: " + obj);
    }

    @PostRemove
    public void postRemove(MyEntity obj) {
        System.out.println("Post-Remove operation: " + obj);
    }

    @PreUpdate
    public void preUpdate(MyEntity obj) {
        System.out.println("Pre-Updating operation: " + obj);
    }

    @PostUpdate
    public void postUpdate(MyEntity obj) {
        System.out.println("Post-Update operation: " + obj);
    }
}


Main.java
import javax.persistence.*;
import java.util.concurrent.*;

// В этом классе создаем несколько потоков и смотрим, что будет происходить.
public class Main {
// Создаем фабрику, т.к. создание EntityManagerFactory дело дорогое, обычно делается это один раз.
    private static EntityManagerFactory entityManagerFactory =
            Persistence.createEntityManagerFactory("ru.easyjava.data.jpa.hibernate");

    public static void main(String[] args) {
// Создаем 10 потоков(можно и больше, но в таком случае будет сложно разобраться).
        ExecutorService es = Executors.newFixedThreadPool(10);
        try {
// Метод persistFill() нужен для авто-заполнения таблицы.
           persistFill();
            for(int i=0; i<10; i++){
                int finalI = i;
                es.execute(() -> {
// Лучше сначала запустить без метода updateEntity(finalI) так, чтоб java создала сущность в базе и заполнила ее. Но так как java - очень умная, она сама запоминает последний сгенерированный id, даже если вы решили полностью очистить таблицу, id новой строки будет таким, как будто вы не чистили базу данных(может возникнуть ситуация, в которой вы запускаете метод persistFill(), а id в бд у вас начинаются с 500).
                    updateEntity(finalI);
                });
            }
            es.shutdown();
            try {
                es.awaitTermination(10, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } finally {
            entityManagerFactory.close();
        }
    }

// Метод для получения объекта из базы и изменения его.
    private static void updateEntity(int index) {
// Создаем EntityManager для того, чтобы можно было вызывать методы, управления жизненным циклом сущности.
        EntityManager em = entityManagerFactory.createEntityManager();

        MyEntity myEntity = null;
        try {
            em.getTransaction().begin();
// Получаем объект из базы данных по индексу 1.
            myEntity = em.find(MyEntity.class, 1);
// Вызываем этот sout, чтобы определить каким по очереди был "вытянут" объект.
            System.out.println("load = "+index);
// Эту строчку мы и будем изменять (а именно LockModeType.*).
            em.lock(myEntity, LockModeType.OPTIMISTIC);
// Изменяем поле Value, таким образом, чтобы понимать транзакция из какого потока изменила его.
            myEntity.setValue("WoW_" + index);
            em.getTransaction().commit();
            em.close();
            System.out.println("--Greeter updated : " + myEntity +" __--__ "+ index);
        }catch(RollbackException ex){
            System.out.println("ГРУСТЬ, ПЕЧАЛЬ=" + myEntity);
        }
    }

    public static void persistFill() {
        MyEntity myEntity  = new MyEntity();
        myEntity.setValue("JPA");
        EntityManager em = entityManagerFactory.createEntityManager();
        em.getTransaction().begin();
        em.persist(myEntity);
        em.getTransaction().commit();
        em.close();
    }
}


Первый запуск с закомментированным методом updateEntity
Pre-Persistiting operation: MyEntity{id=null, version=0, value='JPA'}
Post-Persist operation: MyEntity{id=531, version=0, value='JPA'}
Все ожидаемо. Меняем id в методе find и идем дальше. 


LockModeType.OPTIMISTIC

Это оптимистическая блокировка, ну это и так логично. Как я писал выше, происходит сравнения значение поля version, если оно отличается, то бросается ошибка. Проверим это.

Результаты:
Loaded operation: MyEntity{id=531, version=0, value='JPA'}
load = 3
Loaded operation: MyEntity{id=531, version=0, value='JPA'}
load = 2
Pre-Updating operation: MyEntity{id=531, version=0, value='WoW_2'}
Pre-Updating operation: MyEntity{id=531, version=0, value='WoW_3'}
Loaded operation: MyEntity{id=531, version=0, value='JPA'}
load = 9
Pre-Updating operation: MyEntity{id=531, version=0, value='WoW_9'}
Loaded operation: MyEntity{id=531, version=0, value='JPA'}
load = 1
Pre-Updating operation: MyEntity{id=531, version=0, value='WoW_1'}
Post-Update operation: MyEntity{id=531, version=1, value='WoW_1'}
--Greeter updated : MyEntity{id=531, version=1, value='WoW_1'} __--__ 1
ГРУСТЬ, ПЕЧАЛЬ=MyEntity{id=531, version=0, value='WoW_2'}
ГРУСТЬ, ПЕЧАЛЬ=MyEntity{id=531, version=0, value='WoW_3'}
Loaded operation: MyEntity{id=531, version=1, value='WoW_1'}
load = 4
Pre-Updating operation: MyEntity{id=531, version=1, value='WoW_4'}
Post-Update operation: MyEntity{id=531, version=2, value='WoW_4'}
--Greeter updated : MyEntity{id=531, version=2, value='WoW_4'} __--__ 4
ГРУСТЬ, ПЕЧАЛЬ=MyEntity{id=531, version=0, value='WoW_9'}
Loaded operation: MyEntity{id=531, version=2, value='WoW_4'}
load = 0
Pre-Updating operation: MyEntity{id=531, version=2, value='WoW_0'}
Post-Update operation: MyEntity{id=531, version=3, value='WoW_0'}
--Greeter updated : MyEntity{id=531, version=3, value='WoW_0'} __--__ 0
Loaded operation: MyEntity{id=531, version=3, value='WoW_0'}
load = 6
Pre-Updating operation: MyEntity{id=531, version=3, value='WoW_6'}
Post-Update operation: MyEntity{id=531, version=4, value='WoW_6'}
Loaded operation: MyEntity{id=531, version=4, value='WoW_6'}
load = 5
Pre-Updating operation: MyEntity{id=531, version=4, value='WoW_5'}
Post-Update operation: MyEntity{id=531, version=5, value='WoW_5'}
--Greeter updated : MyEntity{id=531, version=4, value='WoW_6'} __--__ 6
--Greeter updated : MyEntity{id=531, version=5, value='WoW_5'} __--__ 5
Loaded operation: MyEntity{id=531, version=5, value='WoW_5'}
load = 7
Pre-Updating operation: MyEntity{id=531, version=5, value='WoW_7'}
Post-Update operation: MyEntity{id=531, version=6, value='WoW_7'}
Loaded operation: MyEntity{id=531, version=5, value='WoW_5'}
load = 8
Pre-Updating operation: MyEntity{id=531, version=5, value='WoW_8'}
--Greeter updated : MyEntity{id=531, version=6, value='WoW_7'} __--__ 7
ГРУСТЬ, ПЕЧАЛЬ=MyEntity{id=531, version=5, value='WoW_8'}


Наблюдения: Как видно из результатов первыми начали загружаться потоки 3, 2, 9 и 1, для них были вызваны методы Pre-Update callback. Первый поток, где вызвался метод Post-Update был 1, как видно из результатов там уже было изменено(увеличилось на 1) поле помеченное аннотацией Version. Соответственно все оставшиеся потоки 2, 3, 9 выбросили исключение. И так далее. Результат выполнения value = WoW_7, version = 6. Действительно, последний Post-Update был у потока 7 с версией = 6.

LockModeType.OPTIMISTIC_FORCE_INCREMENT

Работает по тому же алгоритму, что и LockModeType.OPTIMISTIC за тем исключением, что после commit значение поле Version принудительно увеличивается на 1. В итоге окончательно после каждого коммита поле увеличится на 2(увеличение, которое можно увидеть в Post-Update + принудительное увеличение). Вопрос. Зачем? Если после коммита мы хотим еще «поколдовать» над этими же данными, и нам не нужны сторонние транзакции, которые могут ворваться между первым коммитом и закрытием нашей транзакции.

Важно! Если данные попытаться изменить на те же самые, то в таком случае методы Pre-Update и Post-Update не вызовутся. Может произойти обрушение всех транзакций. Например, у нас параллельно считали данные несколько транзакций, но поскольку на вызовы методов pre и post (update) нужно время, то та транзакция, которая пытается изменить данные(на те же), сразу же выполнится. Это приведет к ошибке остальных транзакций.

LockModeType.PESSIMISTIC_READ, LockModeType.PESSIMISTIC_WRITE и LockModeType.PESSIMISTIC_FORCE_INCREMENT

Так как работа оставшихся видов блокировок выглядит похожим образом, поэтому напишу о всех сразу и рассмотрю результат только по PESSIMISTIC_READ.

LockModeType.PESSIMISTIC_READ — пессимистичная блокировка на чтение.
LockModeType.PESSIMISTIC_WRITE — пессимистичная блокировка на запись (и чтение).
LockModeType.PESSIMISTIC_FORCE_INCREMENT — пессимистичная блокировка на запись (и чтение) с принудительным увеличением поля Version.

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

Результат по LockModeType.PESSIMISTIC_READ(представлен не целиком):
load = 0
Pre-Updating operation: MyEntity{id=549, version=5, value='WoW_0'}
Post-Update operation: MyEntity{id=549, version=6, value='WoW_0'}
Loaded operation: MyEntity{id=549, version=6, value='WoW_0'}
load = 8
Pre-Updating operation: MyEntity{id=549, version=6, value='WoW_8'}
Loaded operation: MyEntity{id=549, version=6, value='WoW_0'}
load = 4
Pre-Updating operation: MyEntity{id=549, version=6, value='WoW_4'}
...
ERROR: ОШИБКА: обнаружена взаимоблокировка
  Подробности: Процесс 22760 ожидает в режиме ExclusiveLock блокировку "кортеж (0,66) отношения 287733 базы данных 271341"; заблокирован процессом 20876.
Процесс 20876 ожидает в режиме ShareLock блокировку "транзакция 8812"; заблокирован процессом 22760.


Как результат, потоки 4 и 8 заблокировали друг друга, что привело к не разрешимому конфликту. До этого потоку 0 никто не мешал выполняться. Аналогичная ситуация со всеми потоками до 0.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 4

    +3

    Давайте разбираться, дабы не ввести добрых людей в заблуждение сим опусом.


    Сначала отделим мясо от костей. JPA работает поверх RDBMS леера, и практически все сказанное относится к последнему, и к JPA не имеет никакого отношения. Транзакционность и изолированность данных обеспечивается в первую очередь самой базой данных, а не JPA, которая играет роль лишь обертки ввиде меппера данных и генератора SQL. Теперь:


    READ UNCOMMITED — решается с помощью аннотации Version в JPA(об этом как раз и статья)

    Это еруйня. Потерянные обновления не возникают в модели RDBMS уже на самом низком уровне READ UNCOMMITED. JPA Version здесь абсолютно ни при чем. К тому же JDBC и в большинство RDBMS по дефолту работают на уровне READ COMMITED.


    REPEATABLE READ — при повторном чтении получаются те же данные, что и в начале транзакции. Однако возможна пропажа/добавление рядов от другой транзакции при SELECT… WHERE ..., когда данные удовлетворяют критерию WHERE. Плюс другие транзакции все-же могут изменять прочитанные данные (но не измененные).


    SERIALIZABLE — последовательное выполнение транзакций

    Симуляция последовательного выполнения с возможностью спонтанного отката ввиду нарушения блокировки. И тем не менее, некоторые вендоры, которые используют схему MVCC, понижают SERIALIZABLE уровень до т.н. "SNAPSHOT" isolation, в котором возможен неприятный феномен типа write-skew.


    Теперь вернемся к JPA.


    Version была придумана во-первых для того, чтобы можно было отслеживать изменения в Extended Persistence Context, в котором unit-of-work живет дольше одной транзакции, и где Version имплементирует классическую схему MVCC.


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

    Разница во-первых в том, что пессимистичные блокировки проверяют соответствие на момент получения блокировки, и как правило для этого исполользуют средства RDBMS (SELECT FOR UPDATE) а оптимистичные — делают все проверки при коммите при помощи сравнения данных или маркеров изменений типа Version. А то, что вы сказали — уже следствие.


    В оптимистичных блокировках при коммите в базу данных производится сравнивание значения поля, помеченного как version, на момент получения данных и на данный момент.

    Тем не менее поле Version не обязательно. При его отсутствии многие JPA делают полное/частичное сравнение данных полей объектов.


    LockModeType.OPTIMISTIC_FORCE_INCREMENT… Вопрос. Зачем? Если после коммита мы хотим еще «поколдовать» над этими же данными, и нам не нужны сторонние транзакции

    Вообще не для этого. Это аналог WRITE LOCK для optimistic locking. Гарантирует, что прочитанная Entity не была изменена другой транзакцией до самого коммита, даже если данная транзакция ничего не меняла. Используется для каскадного трекинга изменений, когда поля самой Entity не меняются, но зато меняются ее дочерние entities. При LockModeType.OPTIMISTIC поле Version меняется и проверяется только, если entity была изменена.

      0
      Спасибо за комментарий!
      JPA работает поверх RDBMS леера, и практически все сказанное относится к последнему, и к JPA не имеет никакого отношения. Транзакционность и изолированность данных обеспечивается в первую очередь самой базой данных, а не JPA, которая играет роль лишь обертки ввиде меппера данных и генератора SQL.
      Я и не говорил, что JPA обеспечивает транзакционность и изолированность данных.
      JPA — это спецификация, ниже которой находятся провайдер ORM(Hibernate например) -> JDBC -> JDBC-драйвер -> База данных. Хотя в эту цепочку можно добавить и Spring Data.

      Касаемо REPEATABLE READ, SERIALIZABLE вы расписали более подробно, хотя в статье я пометил их как «Коротко о...»

      Разница во-первых в том, что пессимистичные блокировки проверяют соответствие на момент получения блокировки, и как правило для этого исполользуют средства RDBMS (SELECT FOR UPDATE) а оптимистичные — делают все проверки при коммите при помощи сравнения данных или маркеров изменений типа Version. А то, что вы сказали — уже следствие.
      Да, действительно это следствие. Но о том, как происходит проверка я писал позже, поэтому это скорее стилистический косяк, нежели технический.
      При его отсутствии многие JPA делают полное/частичное сравнение данных полей объектов.
      Что? «Многие...» А можно примеры? Вы наверно имели ввиду «многие реализации этого стандарта», но в таком случае большинство Вашего негодования связано с тем, что читая JPA Вы представляете себе некую ORM.
      При LockModeType.OPTIMISTIC поле Version меняется и проверяется только, если entity была изменена.

      Хм… но как наша транзакция узнает, что entity была изменена другой транзакцией без сравнения полей Version. А если вы имели ввиду те транзакции, которые не меняли данных, то да вы правы. Хотя, это вроде как и так ясно. Если сильно все упростить, то Version — это версия данных.

      Вообще не для этого. Это аналог WRITE LOCK для optimistic locking. Гарантирует, что прочитанная Entity не была изменена другой транзакцией до самого коммита, даже если данная транзакция ничего не меняла. Используется для каскадного трекинга изменений, когда поля самой Entity не меняются, но зато меняются ее дочерние entities.
      Да, тут согласен, но тем не менее это ни как не исключает мой вариант. К слову, я бы тут добавил «когда изменение вносятся в другую сущность, связанную с исходной».

      Подводя итоги, я не понимаю причину Вашего негодования. Да, есть стилистически плохие решения и в некоторых местах стоило расписать по подробней(сказывается отсутствие опыта в написании статей), но с технической стороны все верно.
        0

        Негодование не поэтому. Впечатление от посыла вашей статьи — это "используйте Version и будет вам сериализуемое счастье", тогда для статьи требуется более глубокий анализ: расписать общий принцип работы локов и типичные нюансы испльзования Optimistic Locking в реальных программах.


        Например, задайтесь вопросом, если у вас в Entity есть отношение @OneToMany, и вы добавляете/удаляете дочернюю сущность (пусть все сущности имеют Version), будет ли гарантия корректного параллельного выполнения? Поменяется ли Version для родительской сущности при удалении дочерней? Сразу скажу, что в JPA с каскадным локингом не все так просто, и многие вендоры добавляют свой функционал для обеспечения оного.
        https://www.eclipse.org/eclipselink/documentation/2.5/jpa/extensions/a_optimisticlocking.htm#BCGIACHD


        Что? «Многие...» А можно примеры? Вы наверно имели ввиду «многие реализации этого стандарта»

        Вот пример в самой популярной ORM: http://docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#locking-optimistic-versionless


        Хм… но как наша транзакция узнает, что entity была изменена другой транзакцией без сравнения полей Version

        А никак. LockModeType.OPTIMISTIC после чтения уже ничего не проверяет. Проверка и инкремент делается только, если entity была изменена текущей транзакцией. Если нужен более строгий контроль, используйте LockModeType.OPTIMISTIC_FORCE_INCREMENT — он всегда после чтения инкрементирует Version и делает в конце проверку, даже если entity не была изменена в текущей транзакции.


        Если сильно все упростить, то Version — это версия данных

        Не совсем. См. выше про LockModeType.OPTIMISTIC_FORCE_INCREMENT, там это тупо счетчик чтений.

          0
          C каскадным локингом не сталкивался, поэтому спасибо, что упомянули об этом.
          Вот пример в самой популярной ORM: docs.jboss.org/hibernate/orm/5.4/userguide/html_single/Hibernate_User_Guide.html#locking-optimistic-versionless
          Касаемо этой части. Я имел виду, что разновидностей JPA НЕТ, и быть не может. Это же стандарт. А вот реализации да, тот же хибер отличный пример.
          А никак. LockModeType.OPTIMISTIC после чтения уже ничего не проверяет. Проверка и инкремент делается только, если entity была изменена текущей транзакцией.
          Ну с этим я согласился еще в предыдущем комментарии.
          Если нужен более строгий контроль, используйте LockModeType.OPTIMISTIC_FORCE_INCREMENT — он всегда после чтения инкрементирует Version и делает в конце проверку, даже если entity не была изменена в текущей транзакции.
          Да, тут вы правы. Но это, вроде как, понятно из моих слов в статье:
          значение поле Version принудительно увеличивается на 1.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое