W dzisiejszych czasach duża liczba firm zatrudniająca deweloperów Java wykorzystuje w swoich szeregach Springa z Hibernate. Ja sam na ten moment pracuję w tych dwóch technologiach i jak każdy napotykam ciekawe problemy z nimi związane. Jeden z nich dotyczył tytułowej adnotacji @Transactional, która przyprawiła niejedną osobę o brak włosów i nieprzespane noce. Przejdźmy zatem do krótkiego opisu, który objaśni w czym pomaga nam ten znacznik.

Krótki opis adnotacji @Transactional

Pracując z bazami danych na pewno musiałaś albo musiałeś zapoznać się z pojęciem transakcyjności. To dzięki niej możemy być pewni, że zapisywane dane znajdą się w bazie w spójnym stanie. Jeśli nasza funkcja biznesowa dotyka pod spodem 3 różnych tabel to przez uruchomienie transakcji, gdy jeden z zapisów się nie powiedzie, pozostałe zostaną wycofane. Tutaj na scenę przychodzi właśnie adnotacja @Transactional, którą tak ochoczo umieszczamy nad metodą wykonującą przedstawione zadanie.

Oczywiście, aby ten mechanizm zadziałał trzeba spełnić kilka warunków. Metoda oznaczona przez @Transactional musi (oczywiście przy ustawieniach domyślnych):

  • znajdować się w klasie, która jest beanem Springa
  • być publiczna
  • zostać wywołana z poziomu innej klasy

Istnieje również możliwość umieszczenia adnotacji @Transactional nad nazwą klasy. W ten sposób wszystkie obecne w tej klasie metody (spełniające powyższe warunki) będą wykonywane w transakcji.

Jak w sumie działa adnotacja @Transactional? Gdy wywołujemy metodę oznaczoną tą adnotacją to do gry wchodzi magia Springa. Wykorzystywane są do tego celu wzorzec projektowy Proxy oraz programowanie aspektowe. Dzięki temu Spring może zarządzać transakcją w niewidoczny dla nas sposób.

Propagacja transakcji, czyli sedno problemu

Skoro już jesteśmy po bardzo skróconym wstępie dotyczącym transakcji zarządzanych przez Springa to możemy przejść do sedna tego artykułu. Załóżmy, że nasza aplikacja wyciąga pewne dane z bazy danych do wyświetlenia ich w interfejsie graficznym użytkownika. Jednak przy okazji chcielibyśmy, aby zapisywała również informację o tym ile razy były one pobrane. Czyli mamy tutaj do czynienia z sytuacją, w której w jednym momencie chcemy sięgnąć po dane, a przy okazji podbić dany licznik o jeden.

Z tego powodu pierwszą część przydałoby się mieć jako tylko do odczytu, a drugą mogącą modyfikować otrzymane dane. Jednak na początku przyjrzyjmy się w sytuacji, w której używamy tylko domyślnych ustawień adnotacji.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.devcezz.transactional.propagation.dto.ShopListDto;

@Service
public class ShopListService {

  private final ShopListRepository shopListRepository;
  private final CounterService counterService;

  public ShopListService(ShopListRepository shopListRepository,
               CounterService counterService) {
    this.shopListRepository = shopListRepository;
    this.counterService = counterService;
  }

  ...

  @Transactional
  public ShopListDto getShopList(Long shopListId) {
    ShopList shopList = shopListRepository.findById(shopListId)
        .orElseThrow(() -> new IllegalArgumentException("shop list not found: " + shopListId));

    counterService.incrementCounterFor(shopListId);

    return ShopListMapper.INSTANCE.map(shopList);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CounterService {

  private final ShopListCounterRepository shopListCounterRepository;

  public CounterService(ShopListCounterRepository shopListCounterRepository) {
    this.shopListCounterRepository = shopListCounterRepository;
  }

  ...

  @Transactional
  public void incrementCounterFor(Long shopListId) {
    shopListCounterRepository.findByShopListId(shopListId)
        .ifPresentOrElse(
            ShopListCounter::increment,
            () -> shopListCounterRepository.save(new ShopListCounter(shopListId))
        );
  }
}

Wywołując metodę getShopList chcemy pobrać wybraną listę zakupów. W jej ciele standardowo korzystamy ze służącego do tego celu repozytorium i przy okazji wykorzystujemy serwis podbijający licznik. Obydwie metody są oznaczone przez adnotację @Transactional. W tej sytuacji transakcja rozpoczęta przez getShopList będzie kontynuowana w metodzie incrementCounterFor. Tak właśnie zachowuje się domyślna propagacja REQUIRED - założy nową transakcję jeśli nie ma żadnej lub będzie kontynuować istniejącą.

Przypadek transakcji z ustawieniem domyślnej propagacji `REQUIRED`
Przypadek transakcji z ustawieniem domyślnej propagacji REQUIRED

Zweryfikujmy czy wszystko działa zgodnie z założeniami. Napiszemy test weryfikujący to wymaganie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package pl.devcezz.transactional.propagation;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import pl.devcezz.transactional.propagation.dto.ShopListDto;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Rollback
public class ShopListIntTest {

  @Autowired
  private ShopListService shopListService;

  @Autowired
  private CounterService counterService;

  @Test
  void shouldGetShopListAndIncrementCounter() {
    //given
    Long shopListId = shopListService.createShopList();
    shopListService.addItem(shopListId, "headphones", 3);

    //when
    ShopListDto shopList = shopListService.getShopList(shopListId);

    //then
    assertThat(shopList.items().size()).isEqualTo(1);
    assertThat(shopList.items()).allSatisfy(product -> {
      assertThat(product.name()).isEqualTo("headphones");
      assertThat(product.quantity()).isEqualTo(3);
    });

    //when
    shopListService.getShopList(shopListId);

    //and
    Integer counter = counterService.getCounter(shopListId);

    //then
    assertThat(counter).isEqualTo(2);
  }
}

Test jest zielony. Jednak zastanówmy się co by się stało, gdyby ktoś zmodyfikował uzyskane dane w getShopList. Jeśli pobralibyśmy jakąś encję w transakcji i zmodyfikowali jedno z jej pól to mechanizm dirty checkingu zapisałoby tą informację w bazie. Jest to dla nas działanie niepożądane. Z tego powodu musimy użyć atrybut adnotacji @Transactional o nazwie readonly i ustawić go na true dla metody getShopList. Dodam, że według tego artykułu takie podejście potrafi zaoszczędzić sporo pamięci przy odczycie.

Transakcja w trybie do odczytu

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.devcezz.transactional.propagation.dto.ShopListDto;

@Service
public class ShopListService {

  private final ShopListRepository shopListRepository;
  private final CounterService counterService;

  public ShopListService(ShopListRepository shopListRepository,
               CounterService counterService) {
    this.shopListRepository = shopListRepository;
    this.counterService = counterService;
  }

  ...

  @Transactional(readonly = true)
  public ShopListDto getShopList(Long shopListId) {
    ShopList shopList = shopListRepository.findById(shopListId)
        .orElseThrow(() -> new IllegalArgumentException("shop list not found: " + shopListId));

    counterService.incrementCounterFor(shopListId);

    return ShopListMapper.INSTANCE.map(shopList);
  }
}

Przypadek transakcji z ustawieniem tylko do odczytu
Przypadek transakcji z ustawieniem tylko do odczytu

W tym momencie jeśli uruchomimy nasz wcześniej utworzony test to on nie przejdzie. Pojawi się wyjątek mówiący, że nie można znaleźć danego licznika w bazie danych przy próbie jego pobrania. Jak sama nazwa wskazuje cała transakcja wejściowa jest w stanie tylko do odczytu, a propagacja REQUIRED w incrementCounterFor ją po prostu kontynuuje. Nie ma, więc żadnego commita do bazy danych zapisującego dane. Wychodzi na to, że weszliśmy z deszczu pod rynnę.

Wybawienie w postaci REQUIRED_NEW

Jednak wczytując się w dokumentację Springa możemy znaleźć różne poziomy propagacji transakcji, a wśród nich ujrzymy REQUIRED_NEW.

Create a new transaction, and suspend the current transaction if one exists.

To zdanie mam nadzieję, że dobrze obrazuje następująca grafika.

Przypadek transakcji z ustawieniem `REQUIRED_NEW`
Przypadek transakcji z ustawieniem REQUIRED_NEW

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CounterService {

  private final ShopListCounterRepository shopListCounterRepository;

  public CounterService(ShopListCounterRepository shopListCounterRepository) {
    this.shopListCounterRepository = shopListCounterRepository;
  }

  ...

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void incrementCounterFor(Long shopListId) {
    shopListCounterRepository.findByShopListId(shopListId)
        .ifPresentOrElse(
            ShopListCounter::increment,
            () -> shopListCounterRepository.save(new ShopListCounter(shopListId))
        );
  }
}

Uruchamiając test dostaniemy zielony pasek. Osiągnęliśmy to co chcieliśmy, jednak na pewno kosztem wydajności. Trzeba sobie zdawać z tego sprawę. Jeśli wymagane będzie się skupienie się na jej poprawieniu to wtedy wiadomo, od którego miejsca można zacząć.

Jedno z możliwych podejść

Zwróć uwagę, że jeśli wywołamy teraz metodę incrementCounterFor z poziomu innej transakcji to i tak uruchomią nam się dwie pomimo tego, że pierwsza z nich niekoniecznie będzie w trybie readonly. Pomysłem na poprawę tego jest stworzenie klasy pomocniczej, która opakowuje dane wywołanie w transakcję. Zobaczmy jak to może wyglądać pamiętając o tym, że musi to być bean Springa.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class TransactionHelper {

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void withRequiredNew(Runnable task) {
    task.run();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.devcezz.transactional.propagation.dto.ShopListDto;

@Service
public class ShopListService {

  private final ShopListRepository shopListRepository;
  private final CounterService counterService;
  private final TransactionHelper transactionHelper;

  public ShopListService(ShopListRepository shopListRepository,
               CounterService counterService,
               TransactionHelper transactionHelper) {
    this.shopListRepository = shopListRepository;
    this.counterService = counterService;
    this.transactionHelper = transactionHelper;
  }

  ...

  @Transactional(readOnly = true)
  public ShopListDto getShopList(Long shopListId) {
    ShopList shopList = shopListRepository.findById(shopListId)
        .orElseThrow(() -> new IllegalArgumentException("shop list not found: " + shopListId));

    transactionHelper.withRequiredNew(
        () -> counterService.incrementCounterFor(shopListId)
    );

    return ShopListMapper.INSTANCE.map(shopList);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CounterService {

  private final ShopListCounterRepository shopListCounterRepository;

  public CounterService(ShopListCounterRepository shopListCounterRepository) {
    this.shopListCounterRepository = shopListCounterRepository;
  }

  ...

  @Transactional
  public void incrementCounterFor(Long shopListId) {
    shopListCounterRepository.findByShopListId(shopListId)
        .ifPresentOrElse(
            ShopListCounter::increment,
            () -> shopListCounterRepository.save(new ShopListCounter(shopListId))
        );
  }
}

Stworzyliśmy komponent TransactionHelper z jedną metodą przyjmującą jako parametr interfejs Runnable, którą oznaczyliśmy adnotacją @Transactional z propagacją REQUIRED_NEW. Następnie wstrzyknęliśmy tą klasę do serwisu ShopListService i opakowaliśmy wywołanie metody incrementCounterFor przez nowopowstałą metodę withRequiredNew. Uruchamiając testy zobaczymy, że wszystko działa jak należy. Zaproponowane rozwiązanie jest jednym z możliwych, ale być może masz jakiś inny pomysł na rozwiązanie tej sytuacja?

Uwaga na @Transacional w testach

Podczas testów integracyjnych chcemy, aby każdy przypadek był niezależny od innego. W tym celu po każdym teście wykonujemy rollback przez wykorzystanie adnotacji @Rollback. Istnieje również możliwość wykorzystania @Transactional do tego celu. Jednak warto sprawdzić co się stanie w jednej z powyższych sytuacji, gdy getShopList jest tylko do odczytu, a incrementCounterFor ma domyślną propagację REQUIRED.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CounterService {

  private final ShopListCounterRepository shopListCounterRepository;

  public CounterService(ShopListCounterRepository shopListCounterRepository) {
    this.shopListCounterRepository = shopListCounterRepository;
  }

  ...

  @Transactional
  public void incrementCounterFor(Long shopListId) {
    shopListCounterRepository.findByShopListId(shopListId)
        .ifPresentOrElse(
            ShopListCounter::increment,
            () -> shopListCounterRepository.save(new ShopListCounter(shopListId))
        );
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package pl.devcezz.transactional.propagation;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.devcezz.transactional.propagation.dto.ShopListDto;

@Service
public class ShopListService {

  private final ShopListRepository shopListRepository;
  private final CounterService counterService;
  private final TransactionHelper transactionHelper;

  public ShopListService(ShopListRepository shopListRepository,
               CounterService counterService,
               TransactionHelper transactionHelper) {
    this.shopListRepository = shopListRepository;
    this.counterService = counterService;
    this.transactionHelper = transactionHelper;
  }

  ...

  @Transactional(readOnly = true)
  public ShopListDto getShopList(Long shopListId) {
    ShopList shopList = shopListRepository.findById(shopListId)
        .orElseThrow(() -> new IllegalArgumentException("shop list not found: " + shopListId));

    counterService.incrementCounterFor(shopListId);

    return ShopListMapper.INSTANCE.map(shopList);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package pl.devcezz.transactional.propagation;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import pl.devcezz.transactional.propagation.dto.ShopListDto;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Transactional
public class ShopListIntTest {

  @Autowired
  private ShopListService shopListService;

  @Autowired
  private CounterService counterService;

  @Test
  void shouldGetShopListAndIncrementCounter() {
    ...
  }
}

Wcześniej taki stan aplikacji powodował, że nasz test nie przechodził. Jednak teraz jak najbardziej jest zielony, czyli mamy tutaj do czynienia z false positive. Jeśli ten kod uruchomilibyśmy na produkcji to licznik nigdy by się nawet nie zapisał do bazy danych. Stało się tak, dlatego że test utworzył transakcję, a getShopList wraz incrementCounterFor mają domyślą propagację, które tylko ją potrzymały. W ten sposób licznik mógł się podbić, a test przeszedł.

Podsumowanie

Jeśli chcesz dowiedzieć się więcej na temat różnych typów propagacji transakcji to zapraszam w to miejsce. Bartek przechodzi po każdym dostępnym rodzaju i wyjaśnia go w skondensowanej formie. Mam nadzieję, że ten artykuł chociaż trochę przybliżył Ci problem jaki można napotkać podczas korzystania z adnotacji @Transactional.