Na pewno spotkałeś się z pojęciem równoległej edycji, gdzie wielu użytkowników edytuje (lub próbuje edytować) te same w tym samym momencie.
W którą stronę pojedziesz?
Zauważ, że użytkownicy modyfikują jedną encję w tym samym momencie, patrząc na ekran. Skutkiem może być napisanie danych edycji użytkownika przez ostatnio wykonane zapytanie UPDATE.
Jak można reagować? Czy można zabezpieczyć się przed tym? Zapraszam do artykułu, w którym dowiesz się o kilku metodach:
- Pessimistic locking
- Pessimistic read
- Pessimistic write
- Pessimistic force increment
- Optimistic locking
- Inne metody
Pessimistic locking
Pessimistic locking to mechanizm wykorzystujący blokowanie rekordu na poziomie bazy danych. Zablokowany i zmodyfikowany rekord (lub rekordy) nie może być modyfikowany/usuwany przez inną transakcję/sesję.
Jak można reagować w aplikacji/systemie:
- Czekać określony czas na możliwość dostępu do danych i kontynuować (byle by użytkownik nie zanudził się oczekiwaniem)
- Rzucić/przechwycić wyjątek i wyświetlić użytkownikowi informację, iż rekord/obiekt jest aktualnie edytowany przez innego użytkownika. Przykładowe zachowanie systemu w takim wypadku:
- Uniemożliwić użytkownikowi pracę z edytowanym rekordem (nie wiadomo co się stanie – np. rekord może zostać za chwilę usunięty).
- Pozwolić na podgląd aktualnych danych.
- Pozwolić na podgląd niezatwierdzonych jeszcze danych z bazy danych (dirty read) – nie jest zalecane
Zalety pesymistycznego blokowania
- Po odblokowaniu dane mogą być przetwarzane dalej
- Zapewnienie integralności danych przez zablokowanie równoległej edycji danych
- Możliwość poinformowania użytkownika o tym, że dane są aktualnie edytowane
- Możliwość kontrolowanej blokady danych (np. wykorzystanie przy realizacji zadań, które mają być przetwarzane TYLKO na jednej z instancji serwisu bez używania bibliotek typu Quartz)
Wady pesymistycznego blokowania
- Dane blokowane są przez cały czas transakcji (jeśli jest długa to może wystąpić timeout dla oczekiwania na lock)
- W przypadku umożliwienia podglądu 'brudnych’ danych (zmodyfikowanych, ale nie zatwierdzonych) można wprowadzić użytkownika w błąd (przecież użytkownik może się wycofać z edycji; dla aktualizacji stanów magazynowych można dokonać rezerwacji w osobnej transakcji.
Poniżej przedstawię wybrane tryby pesymistycznego blokowania dla JPA z wykorzystaniem encji Card
.
@Entity
@Table(name = "CARDS")
@NoArgsConstructor
@ToString
public class Card {
@Id
@Column(name = "CARD_ID")
String cardId;
@Column(name = "CARD_UUID")
String cardUuid;
@Column(name = "CARD_OWNER_ID")
String cardOwnerId;
@Column(name = "ENABLED")
@Convert(converter = YesNoBooleanConverter.class)
Boolean enabled;
@Column(name = "COUNTRY")
@Enumerated(EnumType.STRING)
CardCountry cardCountry;
public void disable() {
enabled = false;
}
public void enable() {
enabled = true;
}
}
Zapytania SQL generowane dla poszczególnych trybów są zależne od motoru bazy danych/dialektu Hibernate. W przykładach poniżej wykorzystuję bazę Postgresql.
Sposób 1- Tryb Pessimistic Read w JPA
Tryb pessimistic read zabezpiecza przed dostępem do zmodyfikowanych, ale jeszcze niezatwierdzonych danych rekordów (commit
) w innej transakcji poprzez wstrzymanie wykonania operacji na tym rekordzie przez bazę.
Poniżej przykład kodu wykorzystującego pessimistic read
@Transactional
public void changeActivityFlag(String cardId) {
var card = entityManager.find(Card.class, cardId, LockModeType.PESSIMISTIC_READ);
if (card.enabled) {
card.disable();
} else {
card.enable();
}
}
Przy wyjściu z metody zostanie wywołany commit
Zostanie wygenerowane zapytanie typu SELECT
zależne od silnika bazy danych. Poniżej przykład dla PostgreSQL z frazą FOR SHARE
.
2022-09-14 22:11:08.448 DEBUG 26800 --- [ main] org.hibernate.SQL :
select
cardpessim0_.card_id as card_id1_0_0_,
cardpessim0_.country as country2_0_0_,
cardpessim0_.card_owner_id as card_own3_0_0_,
cardpessim0_.card_uuid as card_uui4_0_0_,
cardpessim0_.enabled as enabled5_0_0_
from
cards cardpessim0_
where
cardpessim0_.card_id=? for share
2022-09-14 22:11:08.463 TRACE 26800 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [5555]
W przypadku modyfikacji danych encji zostanie także wywołane zapytanie typu UPDATE
, które jest kluczowe dla blokowania.
Żeby łatwiej zobrazować i złapać moment w czasie, możesz tymczasowo dodać
flush
(dla wymuszenia wysłania SQL zUPDATE
),sleep
(dla ułatwienia debugowania)- log z danymi karty (żeby zobaczyć, jakie dane zostały odczytane – czy zmodyfikowane i niezatwierdzone, czy też ostatnio zatwierdzone)
@Transactional
public void changeActivityFlag(String cardId) {
var card = entityManager.find(CardPessimisticLocking.class, cardId, LockModeType.PESSIMISTIC_READ);
log.info("Dane karty " + card);
if (card.enabled) {
card.disable();
} else {
card.enable();
}
entityManager.flush();
try {
Thread.sleep(60_000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
Uruchamiając wiele instancji programu (w czasie krótszym niż sleep
) możesz zaobserwować na bazie stan blokad. Poniżej przykładowe zapytanie dla Postgresql
SELECT a.datname,
a.state,
l.relation::regclass,
l.transactionid,
l.mode,
l.GRANTED,
a.usename,
a.query,
a.query_start,
age(now(), a.query_start) AS "age",
a.pid
FROM pg_stat_activity a
JOIN pg_locks l ON l.pid = a.pid
ORDER BY a.query_start;
Rekord został zmodyfikowany.
- Po odblokowaniu rekordu z zakończeniem pierwszej transakcji przez commit) w bieżącej transakcji kolejny
SELECT ... FOR SHARE
pobierze zaktualizowany wiersz, ale następujący po nimUPDATE
rzuci wyjątek –org.postgresql.util.PSQLException: ERROR: deadlock detected
, a Hibernate zmapuje go naorg.hibernate.exception.LockAcquisitionException
- UWAGA Dla drugiej transakcji próbującej wykonać operacje na tym samym rekordzie (zmodyfikowany i zablokowany)
- Instrukcja
SELECT
bez frazyFOR SHARE
zwróci wynik bez czekania - Instrukcja
SELECT .. FOR SHARE
na zmodyfikowanym rekordzie zostanie wstrzymana (działa lock) - Instrukcja
UPDATE
bez wcześniejszego locka po odblokowaniu nadpisze wiersz (czyli stać się może coś, czego nie oczekiwałeś).
- Instrukcja
Jeżeli nie będzie modyfikacji danych, to obydwa zapytania typu SELECT .. FOR SHARE
wykonają się bez blokady/czekania – dla blokowania rekordu istotna jest modyfikacja danych.
W przypadku gdy pierwsza transakcja zakończy się jej wycofaniem (rollback
), to po zdjęciu blokady SELECT
zwróci 'stare dane’ i kolejny lock czy UPDATE
się powiedzie.
Sposób 2 – Tryb Pessimistic Write w JPA
W trybie pessimistic write rekord jest blokowany na wyłączność już na etapie wykonania zapytania SELECT
i inna transakcja nie może odczytać (z pewnym wyjątkiem, ale o tym dalej).
Jeżeli użyjesz trybu LockModeType.PESSIMISTIC_WRITE
to może zostać wygenerowane inne zapytanie – przykład dla PostgreSQL (tym razem fraza SQL FOR UPDATE
)
2022-09-14 22:16:51.250 DEBUG 24924 --- [ main] org.hibernate.SQL :
select
cardpessim0_.card_id as card_id1_0_0_,
cardpessim0_.country as country2_0_0_,
cardpessim0_.card_owner_id as card_own3_0_0_,
cardpessim0_.card_uuid as card_uui4_0_0_,
cardpessim0_.enabled as enabled5_0_0_
from
cards cardpessim0_
where
cardpessim0_.card_id=? for update
2022-09-14 22:16:51.250 TRACE 24924 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [555]
W tym przypadku drugie równoległe zapytanie (typu SELECT FOR UPDATE
dla tego samego rekordu) będzie oczekiwało na zwolnienie blokady.
Jeżeli drugie zapytanie, będzie zapytaniem typu SELECT
bez frazy FOR UPDATE,
to zostanie zwrócony od razu (bez czekania) ostatnio zatwierdzony stan rekordu.
Sposób 3 – Tryb Pessimistic Force Increment w JPA
Tryb pessimistic force increment jest połączeniem pessimistic i optimistic locking, gdzie implementacja JPA w ramach wyszukiwania encji
- Blokuje encję w trybie wyłącznym.
- Edytuje encję poprzez inkrementację wersji rekordu.
Umożliwia to tryb LockModeType.
PESSIMISTIC_FORCE_INCREMENT
. Jest on powiązany z wersjonowaniem (dodatkowa kolumna z anotacją @Version
– zobacz dalej w artykule w sekcji Optimistic Locking).
@Transactional
public void changeActivityFlag(String cardId) {
var card = entityManager.find(CardPessimisticLocking.class, cardId, LockModeType.PESSIMISTIC_FORCE_INCREMENT);
}
Już dla samego odczytu encji przez EntityManager.find
Zostaną wygenerowane dwa zapytania dla wywołania metody find
na EntityManager
.
Pierwsze dla SELECT
– przykład dla PostgreSQL (tym razem fraza SQL FOR UPDATE NOWAIT
)
2022-09-14 22:23:34.767 DEBUG 3308 --- [ main] org.hibernate.SQL :
select
cardpessim0_.card_id as card_id1_0_0_,
cardpessim0_.country as country2_0_0_,
cardpessim0_.card_owner_id as card_own3_0_0_,
cardpessim0_.card_uuid as card_uui4_0_0_,
cardpessim0_.enabled as enabled5_0_0_,
cardpessim0_.version as version6_0_0_
from
cards cardpessim0_
where
cardpessim0_.card_id=? for update
nowait
2022-09-14 22:23:34.771 TRACE 3308 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [5555]
Drugie inkrementujące wersję rekordu
2022-09-14 22:23:34.794 DEBUG 3308 --- [ main] org.hibernate.SQL :
update
cards
set
version=?
where
card_id=?
and version=?
2022-09-14 22:23:34.794 TRACE 3308 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [1] as [INTEGER] - [24]
2022-09-14 22:23:34.794 TRACE 3308 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [2] as [VARCHAR] - [5555]
2022-09-14 22:23:34.794 TRACE 3308 --- [ main] o.h.type.descriptor.sql.BasicBinder : binding parameter [3] as [INTEGER] - [23]
Jak wspomniałem wcześniej tryb ten jest bliższy mechanizmowi optimistic locking – gdyż również wykorzystuje wersjonowanie encji. Bez tego (bez atrybutu anotowanego @Version
) zostanie rzucony wyjątek.
Jeżeli w tym samym czasie baza zostanie odpytana tym samym zapytaniem (SELECT FOR UPDATE NOWAIT
) zostanie rzucony wyjątek bazodanowy
could not obtain lock on row in relation "cards"
zmapowany przez Hibernate na PessimisticLockException
.
UWAGA. Istnieje również tryb LockModeType.
OPTIMISTIC_FORCE_INCREMENT
jednak działa on inaczej. Wygenerowane zapytanie SELECT
nie zawiera frazy blokującej (FOR SHARE/UPDATE
).
Sposób 4 – Wersjonowanie w pessimistic locking
Możesz również dla pessimistic lock skorzystać z anotacji @Version
, co da dodatkowy poziom zabezpieczenia, gdyż oprócz mechanizmu bazodanowego (lock) będzie weryfikacja zgodności wersji. Chociaż czystszym rozwiązaniem wydaje się opisany wcześniej PESSIMISTIC_FORCE_INCREMENT
.
@Entity
@Table(name = "CARDS")
@NoArgsConstructor
public class CardPessimisticLocking {
@Id
@Column(name = "CARD_ID")
String cardId;
@Column(name = "CARD_UUID")
String cardUuid;
@Column(name = "CARD_OWNER_ID")
String cardOwnerId;
@Column(name = "ENABLED")
@Convert(converter = YesNoBooleanConverter.class)
Boolean enabled;
@Column(name = "COUNTRY")
@Enumerated(EnumType.STRING)
CardCountry cardCountry;
@Version
Integer version;
public void disable() {
enabled = false;
}
public void enable() {
enabled = true;
}
}
Sposób 5 – Optimistic locking
Optymistyczne blokowanie to mechanizm, który wykorzystuje numer wersji rekordu. Każda modyfikacja wiąże się z podbiciem numer wersji o jeden i użyciem we frazie WHERE warunku na stary numer wersji.
Poniżej przedstawiam przykład z wykorzystaniem anotacji @javax.persistence.Version
dla Spring lub Hibernate.
Wystarczy jedynie istnienie kolumny numerycznej w tabeli powiązanej z encją
CREATE TABLE CARDS (
CARD_ID VARCHAR(20) NOT NULL PRIMARY KEY,
CARD_UUID VARCHAR(40) NOT NULL,
CARD_OWNER_ID VARCHAR(100) NOT NULL,
ENABLED VARCHAR(1) NOT NULL,
COUNTRY VARCHAR(2) NOT NULL,
VERSION NUMERIC(22,0) NOT NULL DEFAULT 1
);
oraz anotacja nad polem z wersją w encji.
import javax.persistence.Entity;
import javax.persistence.Version;
@Entity
@Table(name = "CARDS")
@NoArgsConstructor
public class Card {
@Id
@Column(name = "CARD_ID")
String cardId;
@Column(name = "CARD_UUID")
String cardUuid;
@Column(name = "CARD_OWNER_ID")
String cardOwnerId;
@Version
Integer version;
public void disable() {
enabled = false;
}
public void enable() {
enabled = true;
}
}
Uwagi odnośnie użycia:
- Modyfikacja musi być wykonana w ramach transakcji (i oczywiście musi być zmiana danych)
- Anotacja
@Version
z pakietuorg.springframework.data.annotation
nie zadziała - atrybut dla anotacji
@Version
musi być jednym z typówint, Integer, long, Long, short, Short
Przykład logu zapytania SQL dla modyfikowanych danych
2022-09-11 21:14:12.641 DEBUG 11812 --- [ main] org.hibernate.SQL :
update
cards
set
country=?,
card_owner_id=?,
card_uuid=?,
enabled=?,
version=?
where
card_id=?
and version=?
21:14:12.642 TRACE --- [main] o.h.t.d.sql.BasicBinder : binding parameter [1] as [VARCHAR] - [PL]
21:14:12.642 TRACE --- [main] o.h.t.d.sql.BasicBinder : binding parameter [2] as [VARCHAR] - [1]
21:14:12.642 TRACE --- [main] o.h.t.d.sql.BasicBinder : binding parameter [3] as [VARCHAR] - [8861e09d]
21:14:12.642 TRACE --- [main] o.h.t.d.sql.BasicBinder : binding parameter [4] as [VARCHAR] - [N]
21:14:12.643 TRACE --- [main] o.h.t.d.sql.BasicBinder : binding parameter [5] as [INTEGER] - [5]
21:14:12.643 TRACE --- [main] o.h.t.d.sql.BasicBinder : binding parameter [6] as [VARCHAR] - [1]
21:14:12.643 TRACE --- [main] o.h.t.d.sql.BasicBinder : binding parameter [7] as [INTEGER] - [4]
W logu powyżej.
- We frazie
WHERE
pojawia się warunek zawierający numer wersji (stara) – wartość równa 4 – parametr 7 - We frazie
SET
pojawia się automatyczne inkrementacja numeru wersji (nowa wartość równa 5 – parametr 5
Jeżeli żaden wiersz w bazie danych nie zostanie zaktualizowany, to oznacza to, że wiersz został usunięty lub zaktualizowany w innej sesji. W przedstawionym powyżej przykładzie zostanie rzucony wyjątek
java.lang.IllegalStateException:
at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:798) ~[spring-boot-
...
Caused by: org.springframework.orm.ObjectOptimisticLockingFailureException: Object of class [pl.softwareskill.course.hibernate.locks.CardOptimisticLocking] with identifier [1]: optimistic locking failed; nested exception is org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [pl.softwareskill.course.hibernate.locks.CardOptimisticLocking#1]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:337) ~[spring-orm-5.2.10.RELEASE.jar:5.2.10.RELEASE]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:255) ~[spring-orm-5.2.10.RELEASE.jar:5.2.10.RELEASE]
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:538) ~[spring-orm-5.2.10.RELEASE.jar:5.2.10.RELEASE]
at
.....
at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:795) ~[spring-boot-2.3.5.RELEASE.jar:2.3.5.RELEASE]
... 3 common frames omitted
Caused by: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [pl.softwareskill.course.hibernate.locks.CardOptimisticLocking#1]
at org.hibernate.persister.entity.AbstractEntityPersister.check(AbstractEntityPersister.java:2604) ~[hibernate-core-5.4.23.Final.jar:5.4.23.Final]
at org.hibernate.persister.entity.AbstractEntityPersister.update(AbstractEntityPersister.java:3448) ~[hibernate-core-5.4.23.Final.jar:5.4.23.Final]
......
at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1362) ~[hibernate-core-5.4.23.Final.jar:5.4.23.Final]
at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:453) ~[hibernate-core-5.4.23.Final.jar:5.4.23.Final]
Można również zastosować zabezpieczenie na poziomie bazy danych w postaci triggera (update, delete), który sprawdzi zgodność starych wartości rekordu w bazie z tym co jest aktualizowane. Trigger w przypadku nieprawidłowości powinien rzucić wyjątek.
Pozwoli to zabezpieczyć przed nieprawidłowymi modyfikacjami danych z zewnątrz. Przykładem jest instrukcja SQL (np. skrypt) uruchomiona ręcznie albo inna aplikacja, która ma dostęp do tych danych. Dzięki temu nieprawidłowe aktualizacje zostaną odrzucone (będą musiały być dostosowane do mechanizmu wersjonowania aby się powiodły).
A jeżeli używasz w Hibernate/Spring grupowania modyfikacji danych (batch updates) poprzez sterowanie properties, jak np:
hibernate.jdbc.batch_size
hibernate.order_inserts
hibernate.order_updates
i chciałbyś również, aby wersjonowane encje podlegały tym regułom, to ustaw dodatkowo property hibernate.jdbc.batch_versioned_data
na true
.
Wady optymistycznego blokowania
- O modyfikacji/usunięciu danych dowiadujesz się po fakcie (nie można wykryć locka rekordu na bazie)
Zalety
- Szybsze niż pessimistic locking
Wyjątki JPA związane z pessimistic i optimistic locking
JPA definiuje kilka specjalizowanych wyjątków związanych z pessimistic i optimistic locking
PessimisticLockException
– w przypadku niemożności wykonania blokady rekordu-
LockTimeoutException
– w przypadku przekroczenia dopuszczalnego czasu oczekiwania na blokadę. Ustawia się to w konfiguracji JPA atrybutemjavax.persistence.lock.timeout
a jego wartość to liczba ms.
Powyższe wyjątki mogą posłużyć np. do poinformowania użytkownika, iż rekord jest zablokowany do edycji w innej sesji (przez innego użytkownika).
Istnieje także PersistenceException
– ale to bazowy wyjątek w JPA. Powyższe wyjątki dziedziczą z niego.
Sposób 6 – Inne sposoby zarządzania równoległą edycją
Pewną wariacją jest użycie flagi blokady edycji jako osobnej kolumny (lub kolumn) czy też tabeli, gdzie 'rezerwuje się’ chęć edycji. Wówczas nie jest konieczne utrzymywanie długiej transakcji (lub locka), ale konieczne są inne mechanizmy (czyszczenie osieroconych rezerwacji, filtrowanie jeśli to konieczne edytowanych wierszy).
Możesz się także spotkać z dodatkowym podejściem, gdzie system poza rezerwacją edycji umożliwia 'projektowanie’ i podgląd zmiany. Jest to osobny zbiór tabel (kopia), gdzie modelujesz zmianę, a na samym końcu zmiana może zostać wprowadzona (odpowiednie zmiany na tabelach w bazie) lub nawet zaplanowana do wprowadzenia.
Apache Kafka – wydajność vs. gwarancja dostarczenia wiadomości
Jak stworzyć piekielnie szybką albo maksymalnie bezpieczną wersję producenta oraz konsumenta.
Przydatne linki
- https://www.baeldung.com/jpa-optimistic-locking
- https://www.baeldung.com/jpa-pessimistic-locking
- https://www.baeldung.com/java-jpa-transaction-locks
- https://shootskill.com/database/select-for-update/
- https://www.baeldung.com/jpa-hibernate-batch-insert-update
- http://www.quartz-scheduler.org/
- https://vladmihalcea.com/hibernate-locking-patterns-how-do-pessimistic_read-and-pessimistic_write-work/
- Obraz autorstwa wirestock na Freepik
Gdybyś potrzebował jeszcze więcej:
Jesteś Java Developerem?
Przejdź na wyższy poziom wiedzy
„Droga do Seniora” 🔥💪
Jesteś Team Leaderem? Masz zespół?
Podnieś efektywność i wiedzę swojego zespołu 👌