Równoległy zapis do bazy – 6 sprawdzonych sposobów na rozwiązanie problemu

softwareskill.pl 2 lat temu

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.

Równoległa edycja danych

W którą stronę pojedziesz?

W którą stronę pojedziesz?

Zauważ, iż 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:

  1. Pessimistic locking
    1. Pessimistic read
    2. Pessimistic write
    3. Pessimistic force increment
  2. Optimistic locking
  3. 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, iż 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 najważniejsze dla blokowania.

Żeby łatwiej zobrazować i złapać moment w czasie, możesz tymczasowo dodać

  • flush (dla wymuszenia wysłania SQL z UPDATE),
  • 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 nim UPDATE rzuci wyjątek – org.postgresql.util.PSQLException: ERROR: deadlock detected, a Hibernate zmapuje go na org.hibernate.exception.LockAcquisitionException
  • UWAGA Dla drugiej transakcji próbującej wykonać operacje na tym samym rekordzie (zmodyfikowany i zablokowany)
    • Instrukcja SELECT bez frazy FOR 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ś).

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.

Pessimistic lock

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.

Optimistic lock

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 pakietu org.springframework.data.annotation nie zadziała
  • atrybut dla anotacji @Version musi być jednym z typów int, 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, iż 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 manualnie 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 o ile 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 atrybutem javax.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żeli 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 choćby zaplanowana do wprowadzenia.

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

Dołączam

Dziękujemy!

Wysłaliśmy Ci mail powitalny, w którym znajdziesz link do aktywacji newslettera. Do usłyszenia!

Apache Kafka – wydajność vs. gwarancja dostarczenia wiadomości

Jak stworzyć piekielnie szybką albo maksymalnie bezpieczną wersję producenta oraz konsumenta.

Pobierz ebooka

Psst… Interesujący artykuł?

Jeżeli podoba Ci się ten artykuł i chcesz takich więcej – dołącz do newslettera. Nie ominą Cię materiały tego typu.

Dołączam

Dziękujemy!

Wysłaliśmy Ci mail powitalny, w którym znajdziesz link do aktywacji newslettera. Do usłyszenia!

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

Podoba Ci się ten artykuł? Weź więcej.

Jeżeli uważasz ten materiał za wartościowy i chcesz więcej treści tego typu – nie przegap ich i otrzymuj je prosto na swoją skrzynkę. Nawiążmy kontakt.

Dołączam

Dziękujemy!

Wysłaliśmy Ci mail powitalny, w którym znajdziesz link do aktywacji newslettera. Do usłyszenia!

Gdybyś potrzebował jeszcze więcej:

Jesteś Java Developerem?

Przejdź na wyższy poziom wiedzy
„Droga do Seniora” 🔥💪

Chcę więcej wiedzy

Jesteś Team Leaderem? Masz zespół?

Podnieś efektywność i wiedzę swojego zespołu 👌

Sprawdź
Idź do oryginalnego materiału