Przejdź do głównej zawartości

Spring Data - save vs saveAndFlush

Cześć, dzisiaj będzie znowu trochę o warstwie persystencji. Czasami kodzie aplikacji korzystającej ze Spring Data można napotkać użycia metody repozytorium save, a czasami saveAndFlush, a z kolei innym razem brak jakiejkolwiek z nich podczas zapisu obiektu. Wszystkie trzy metody mają swoje zastosowanie choć nieco się różnią.

Teoria

W teorii, różnica jest prosta. Metody save oraz saveAndFlush dodają obiekt do kontekstu persystencji danej sesji i zwracają obiekt zarządzany. Ponadto druga z nich wymusza wymusza wykonanie nagranych przez ORM akcji na bazie danych przez co dane zostają przesłane do silnika bazy. Może się to okazać przydatne w przypadku gdy w ramach jednej transakcji chcemy jeszcze wykonać kolejne zapytania, które mają być świadome wprowadzonych wcześniej zmian. Natychmiastowa synchronizacja może się również przydać gdy wykorzystujemy poziom izolacji READ_UNCOMMITTED.

Czasami jednak w kodzie nie ma żadnej z nich. Wtedy możliwe jest wykonywanie tylko zapytań UPDATE i tylko na obiektach zarządzanych przez ORM (czyli zapisanych przez jedną z opisanych wyżej metod lub pobranych za pomocą ORM - np. metodami find*). Dzieje się to w momencie commit-a transakcji. Tak więc w przypadku zapisu nowych obiektów (zapytania INSERT) należy użyć najpierw jednej z metod save*.

Praktyka

Warto sprawdzić czy opisane wyżej zachowanie jest zgodne z rzeczywistością. W tym celu posłuży oczywiście kod testowy. Poniżej przykładowa encja User.

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;
}
I klasa testowa (pomijam już definicję repozytorium - po prostu rozszerzenie interfejsu JpaRepository):
@DataJpaTest
@TestPropertySource(locations = "classpath:application-it.properties")
class UserRepositoryIT {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private EntityManager entityManager;

    @Test
    void testUserRepository() {
        SessionImpl session = entityManager.unwrap(SessionImpl.class);
        ActionQueue actionQueue = session.getActionQueue();
        System.out.println("Insertions to sync: " + actionQueue.numberOfInsertions());
        User user = User.builder().name("mario").build();
        User savedUser = userRepository.save(user);
        System.out.println("Saved");
        System.out.println("Insertions to sync: " + actionQueue.numberOfInsertions());
        long count = userRepository.count();
        System.out.println("Insertions to sync: " + actionQueue.numberOfInsertions());
        assertEquals(0L, count);
    }

    @Test
    void testUserRepository2() {
        SessionImpl session = entityManager.unwrap(SessionImpl.class);
        ActionQueue actionQueue = session.getActionQueue();
        System.out.println("Insertions to sync: " + actionQueue.numberOfInsertions());
        User user = User.builder().name("mario").build();
        User savedUser = userRepository.saveAndFlush(user);
        System.out.println("Saved");
        System.out.println("Insertions to sync: " + actionQueue.numberOfInsertions());
        long count = userRepository.count();
        System.out.println("Insertions to sync: " + actionQueue.numberOfInsertions());
        assertEquals(1L, count);
        // savedUser.setName("mario2");
        // TestTransaction.flagForCommit();
    }
}
Pierwszy test wykorzystuje metodę save, a drugi saveAndFlush. Oczekiwany wynik wykonania funkcji na bazie danych w pierwszym wypadku to 0 jako, że nowy wiersz nie powinien zostać jeszcze zapisany po stronie bazy danych. W przypadku drugiego testu oczekiwana wartość to 1.

Niestety, pierwszy test zakończył się niepowodzeniem (a raczej powodzeniem bo wykrył problem 😎)! Wyjście obu testów niczym się nie różni:

Insertions to sync: 0
Hibernate: insert into users (name) values (?)
Saved
Insertions to sync: 0
2020-03-15 17:27:27.854  INFO 7000 --- [           main] o.h.h.i.QueryTranslatorFactoryInitiator  : HHH000397: Using ASTQueryTranslatorFactory
Hibernate: select count(*) as col_0_0_ from users user0_
Insertions to sync: 0

Flush mode

Problem tkwi w domyślnym zachowaniu Hibernate. JPA możliwość określenia trybu zarządzania flushami oferując 2 tryby (AUTO oraz COMMIT). Hibernate oczywiście robi to po swojemu oferując 4 tryby (MANUAL, COMMIT, AUTO, ALWAYS), które zdefiniowane są inaczej (szczególnie zwrócić należy uwagę w różnicy pomiędzy trybami AUTO). Domyślnym trybem w Hibernate jest tryb AUTO. W jego przypadku nie wiemy dokładnie kiedy wystąpi flush. To Hibernate decyduje kiedy go wykonać wybierając najbardziej sprzyjający moment, który zapewni, że dane będą aktualne. Jedną z alternatyw jest tryb COMMIT. Pozwala on odłożyć moment operacji flush do chwili gdy nastąpi commit transakcji, chyba, że został on wymuszony ręcznie. Tryb możemy zmienić za pomocą properties-a:
spring.jpa.properties.org.hibernate.flushMode=COMMIT
Wyjście pierwszego testu:
Insertions to sync: 0
Saved
Insertions to sync: 1
2020-03-15 17:31:31.921  INFO 13088 --- [           main] o.h.h.i.QueryTranslatorFactoryInitiator  : HHH000397: Using ASTQueryTranslatorFactory
Hibernate: select count(*) as col_0_0_ from users user0_
Insertions to sync: 1

Bonus

Aby przekonać się, że nie zawsze konieczne jest używanie metod save* zapraszam do przeprowadzenia testu odkomentowując linie 37 i 38 z kodu testowego. Domyślnie testy oznaczone adnotacją @DataJpaTest są wykonywane wewnątrz transakcji, która kończy się rollbackiem przez co update nie zostanie wykonany. W linii 38 zmieniamy to zachowanie oznaczając transakcję jako przeznaczoną do zacommitowania.

Wartość zwracana

Jako, że działanie metody save opiera się na wywołaniu metod EntityManager-a (persist lub merge) to warto zwrócić uwagę, że obiekt przez nią zwracany może nie być tym samym obiektem co podany jako parametr wywolania (to samo dotyczy saveAndFlush. Wynika to ze specyfiki metody merge.

Koniec

Mam nadzieję, że wyjaśniłem dość jasno podstawową różnicę między sposobami zapisu encji przy pomocy Spring Data. Zachęcam do poczytania więcej o trybach flushowania. Np tu: https://dzone.com/articles/dark-side-hibernate-auto-flush

Komentarze

Popularne posty z tego bloga

Pułapki logowania w Javie

Czy spotkałeś kiedyś się z kodem takim jak poniższy? if (logger.isDebugEnabled()) { logger.debug("Request: " + requestObject); } Ja tak. Jakiś czas temu napotkałem na mnóstwo takich fragmentów w kodzie, który analizowałem. Zastanowiło mnie po co została wykorzystana tutaj instrukcja warunkowa - a jako, że if-y uwazam za zło konieczne postanowiłem to zbadać. Czy nie do tego właśnie służą biblioteki do logowania i udostępniane przez nie metody jak debug żeby właśnie sterowane konfiguracją decydowały co zalogować a co nie? Co więcej, okazało się, że instrukcję warunkową wprowadził ktoś w ramach większej akcji. I jak się okazuje, prawdopodobnie znacznie zwiększył dzieki temu wydajność aplikacji. W dzisiejszym wpisie postaram się wyjaśnić jak korzystać z loggera aby nie zaszkodziło to wydajności. Po co if-y? Na początek wyjaśnić trzeba czemu ten if tak na prawdę służy. Korzystamy z biblioteki do logowania i spodziewamy się, że wykorzystujac wybraną metodę, zostanie

Sneaky throws, czyli checked i unchecked exception w jednym!

Często podczas pisania kodu Java korzystam z wyrażeń lambda wprowadzonych wraz z Java 8. Często prowadzi to też do pewnych nowych problemów i zmusza do szukania rozwiązań. Przyjrzyjmy się jednemu z nich. Checked exception w wyrażeniu lambda Często zdarza się, że metoda wywoływana wewnątrz lambdy rzuca wyjątek. Nie ma sprawy gdy wyjątek jest obiektem klasy będącej podklasą RuntimeException . Wtedy po prostu się nim nie przejmujemy. Wyjątek jest przekazywany w górę stosu wywołań. Problem zaczyna się gdy kompilator zmusza nas do obsługi wyjątku. Jak zwykle mamy 2 wyjścia: obsłużyć wyjątek za pomocą bloku try-catch , bądź zadeklarować przekazanie wyjątku dalej za pomocą słowa kluczowego throws . O ile wiemy co zrobić po złapaniu wyjątku to wszystko gra. Co natomiast gdy chcemy wybrać drugą opcję? Większość interfejsów stosowanych jako typy parametrów, często przekazywanych jako lambdy jak np: Function , Consumer czy Supplier nie deklarują, że mogą rzucić wyjątek. W takim wypadku druga