Ostatnio zastanawiałem się jakie konsekwencje może nieść za sobą przypadkowe usunięcie adnotacji @Transactional z wybranej metody. Do tych refleksji zmusił mnie niedawno napotkany błąd w teście integracyjnym w pracy. Zawziąłem się więc i sprawdziłem to na bardzo prostym przypadku. Chcę teraz podzielić się z Tobą rezultatem tego eksperymentu.

Prosty przypadek

Stwórzmy projekt w oparciu o Spring Boot 3 oraz Java 17. Podane wersje nie mają tutaj większego znaczenia. Niezbędne jest za to, przygotowanie podstawowych klocków na potrzeby naszego studium przypadku.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RuntimeExceptionWIthId extends RuntimeException {

  private final Long id;

  public RuntimeExceptionWIthId(Long id) {
    System.out.println("created exception with id: " + id);
    this.id = id;
  }

  public Long getId() {
    return id;
  }

}

Powyższy wyjątek ma dużą wartość dla naszego doświadczenia. Przechowamy w nim id naszej encji, aby sprawdzić czy czegoś przypadkiem nie zapisaliśmy w bazie danych pomimo wystąpienia wyjątkowej sytuacji. Więcej sensu złapie ten zabieg w późniejszej części artykułu.

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
47
48
49
50
51
52
53
54
55
56
@Entity
public class ExampleEntity {

  @Id
  @GeneratedValue
  private Long id;

  private String firstField;

  private String secondField;

  public ExampleEntity() {}

  public Long getId() {
    return id;
  }

  public String getFirstField() {
    return firstField;
  }

  public void setFirstField(String firstField) {
    this.firstField = firstField;
  }

  public void setFirstFieldWithRuntimeException(
      Long id, 
      String firstField) {
    this.firstField = firstField;
    throw new RuntimeExceptionWIthId(id);
  }

  public String getSecondField() {
    return secondField;
  }

  public void setSecondField(String secondField) {
    this.secondField = secondField;
  }

  public void setSecondFieldWithRuntimeException(
      Long id,
      String secondField) {
    this.secondField = secondField;
    throw new RuntimeExceptionWIthId(id);
  }

  @Override
  public String toString() {
    return "ExampleEntity{" +
        "id=" + id +
        ", firstField='" + firstField + '\'' +
        ", secondField='" + secondField + '\'' +
        '}';
  }
}

Przygotujmy też naprawdę trywialną encję. Będzie ona posiadać tylko id oraz dwa pola. W tym miejscu należy zwrócić uwagę na dwie metody - setFirstFieldWithRuntimeException oraz setSecondFieldWithRuntimeException. Ich zadaniem jest ustawienie wartości dla pola, a następnie rzucenie wcześniej przygotowanego wyjątku.

W celu uzupełnienia całości, stwórzmy jeszcze repozytorium wykorzystając CrudRepository oraz kilka dostępnych operacji na encji w klasie ExampleEntityOperations. Są to proste akcje do aktualizacji pól oraz zapisu encji.

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
@Component
public class ExampleEntityOperations {

  private final ExampleEntityRepository exampleEntityRepository;

  public ExampleEntityOperations(
      ExampleEntityRepository exampleEntityRepository
  ) {
    this.exampleEntityRepository = exampleEntityRepository;
  }

  public Long create() {
    return exampleEntityRepository.save(
        new ExampleEntity()).getId();
  }
  
  public void updateFirstField(Long id, String field) {
    exampleEntityRepository.findById(id)
        .ifPresent(exampleEntity -> exampleEntity
            .setFirstField(field));
  }
  
  public void updateSecondField(Long id, String field) {
    exampleEntityRepository.findById(id)
        .ifPresent(exampleEntity -> exampleEntity
            .setSecondField(field));
  }
  
  public void updateSecondFieldWithException(Long id, String field) {
    exampleEntityRepository.findById(id)
        .ifPresent(exampleEntity -> exampleEntity
            .setSecondFieldWithRuntimeException(id, field));
  }

}

Jeszcze jedna rzecz. Istotne jest ustawienie następujących właściwości w application.properties w celach diagnostycznych. Dzięki nim podejrzymy jakie zapytania lecą do bazy danych.

1
2
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true

Tak przygotowani możemy iść dalej. Zaczniemy od najprostszego przypadku.

I. przypadek - bez wyjątku

Nie będziemy się na razie skupiać na żadnych przypadkach brzegowych (brak wyjątków). Zweryfikujemy co się stanie, gdy zapomnimy dodać adnotację @Transactional na metodą tworzącą instancję klasy encji i aktualizującą jej dwa pól.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Long withoutTransactional() {
  return useCase();
}

@Transactional
public Long withTransactional() {
  return useCase();
}

private Long useCase() {
  Long id = useCases.create();
  useCases.updateFirstField(id, "first");
  useCases.updateSecondField(id, "second");
  return id;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void execute() {
  System.out.println("-> WITHOUT TRANSACTION <-");
  Long withoutTransactionalId = withoutExceptionUseCases
      .withoutTransactional();
  System.out.println("=== VERIFICATION ===");
  exampleEntityRepository.findById(withoutTransactionalId)
      .map(ExampleEntity::toString)
      .ifPresent(System.out::println);

  System.out.println("====");

  System.out.println("-> WITH TRANSACTION <-");
  Long withTransactionalId = withoutExceptionUseCases
      .withTransactional();
  System.out.println("=== VERIFICATION ===");
  exampleEntityRepository.findById(withTransactionalId)
      .map(ExampleEntity::toString)
      .ifPresent(System.out::println);
}

Uruchommy aplikację i sprawdzimy ile zapytań pójdzie do bazy danych dla obydwu przypadków. Dla opcji bez transakcji widzimy w logach, że wysłany został INSERT oraz dwa SELECTy. W sumie to nic dziwnego. Przecież właściwie takie operacje zleciliśmy naszemu repozytorium do wykonania. Powstaje pytanie - dlaczego nie skorzystaliśmy więc z metody save po użyciu każdego z setterów?

Szperając w dokumentacji Springa, dowiemy się, że jeśli w ramach metody oznaczonej przez @Transactional pobierzemy obiekt przez repozytorium (np. użycie find) to będzie on przypisany do persistance context. Wtedy Spring zrobi za nas całą robotę. Będzie sobie odnotowywał zmiany jakie dokonaliśmy na encji (oznaczy ją jako “brudną”), aby na koniec wykonania metody automatycznie wysłać zmiany do bazy bez fizycznego skorzystania z save.

Właśnie tak prezentuje się scenariusz dla drugiej opcji. W nim finalnie skończymy z wpisem ExampleEntity{id=2053, firstField='first', secondField='second'} w bazie danych. Co lepsze, uderzymy do niej tylko z jednym INSERTem oraz UPDATEm. Osiągamy to bez wykonania żadnych zbędnych SELECTów, pomimo korzystania z metody find np. w updateFirstField. Taki efekt zawdzięczamy mechanizmom dirty checking i cache Hibernate, które optymalizują nam zapytania (więcej o tym można przeczytać na blogu Marcina Mroczkowskiego).

II. przypadek - z wyjątkiem

Pierwszy przypadek mamy za sobą. Sprawdźmy jak ma się sytuacja dla drugiego. Na starcie warto przytoczyć zdanie, które znajdziemy w dokumentacji.

If no custom rollback rules are configured in this annotation, the transaction will roll back on RuntimeException and Error but not on checked exceptions.

Właśnie z tego powodu utworzona przez nas klasa RuntimeExceptionWIthId dziedziczy po RuntimeException. W ten sposób sprawdzimy jak zachowa się rollback przy wystąpieniu unchecked exception. Dobra, przejdźmy do sedna. Stwórzmy sobie kod, który nam zweryfikuje zachowanie metod z, i bez, @Transactional przy wystąpieniu wyjątku.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Long withoutTransactional() {
  return useCase();
}

@Transactional
public Long withTransactional() {
  return useCase();
}

private Long useCase() {
  Long id = useCases.create();
  useCases.updateFirstField(id, "first");
  useCases.updateSecondFieldWithException(id, "second");
  return id;
}
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
public void execute() {
  System.out.println("-> WITHOUT TRANSACTION <-");
  try {
    withExceptionUseCases.withoutTransactional();
  } catch (RuntimeExceptionWIthId exception) {
    System.out.println("=== VERIFICATION ===");
    Long id = exception.getId();
    if (id == null) {
      System.out.println("no saved item");
    } else {
      exampleEntityRepository.findById(id)
          .map(ExampleEntity::toString)
          .ifPresent(System.out::println);
    }
  }

  System.out.println("====");

  System.out.println("-> WITH TRANSACTION <-");
  try {
    withExceptionUseCases.withTransactional();
  } catch (RuntimeExceptionWIthId exception) {
    System.out.println("=== VERIFICATION ===");
    Long id = exception.getId();
    if (id == null) {
      System.out.println("no saved item");
    } else {
      exampleEntityRepository.findById(id)
          .map(ExampleEntity::toString)
          .ifPresent(System.out::println);
    }
  }
}

Wywołajmy metodę execute. Dla pierwszego wywołania ujrzymy coś naprawdę niepokojącego. Wynik działania beztransakcyjnej metody jest identyczny jak w pierwszym przypadku. Pomimo wystąpienia sytuacji wyjątkowej powstał wpis w bazie danych! Jeśli byłaby to istotna funkcja biznesowa to nasz proces byłby w nieprawidłowym/niedokończonym stanie. Jest to niedopuszczalne. W rozwiązaniu tego problemu pomoże nam adnotacja @Transactional. Przypadek z jej wykorzystaniem nie utworzył nawet jednego zapytania. Konsekwencją tego jest brak jakiegokolwiek zapisu w bazie danych. Jedyny ślad jaki zobaczymy to odnotowany wyjątek.

III. przypadek - try-catch w transakcji

Przeanalizujmy teraz kwestię wykorzystania bloku try-catch. Sprawdźmy jak jego umiejscowienie może wpłynąć na działanie naszego programu, który komunikuje się w danej funkcji biznesowej z bazą danych. Na początku weźmy na tapet przypadek, w którym wyjątek unchecked będzie łapany w metodzie wykonującej zapytania do bazy danych.

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
public Long withoutTransactionalWithTry() {
  try {
    return useCase();
  } catch (RuntimeExceptionWIthId exception) {
    return exception.getId();
  }
}

@Transactional
public Long withTransactionalWithTry() {
  try {
    return useCase();
  } catch (RuntimeExceptionWIthId exception) {
    return exception.getId();
  }
}

private Long useCase() {
  ExampleEntity exampleEntity = exampleEntityRepository.save(
      new ExampleEntity());
  Long id = exampleEntity.getId();

  exampleEntity.setFirstFieldWithRuntimeException(id, "first");
  exampleEntity.setSecondField("second");

  return id;
}
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
public void executeWithoutTry() {
  System.out.println("-> WITHOUT TRANSACTION <-");
  Long firstId = interestingUseCases.withoutTransactionalWithTry();
  System.out.println("=== VERIFICATION ===");
  if (firstId == null) {
    System.out.println("no saved item");
  } else {
    exampleEntityRepository.findById(firstId)
        .map(ExampleEntity::toString)
        .ifPresent(System.out::println);
  }

  System.out.println("====");

  System.out.println("-> WITH TRANSACTION <-");
  Long secondId = interestingUseCases.withTransactionalWithTry();
  System.out.println("=== VERIFICATION ===");
  if (secondId == null) {
    System.out.println("no saved item");
  } else {
    exampleEntityRepository.findById(secondId)
        .map(ExampleEntity::toString)
        .ifPresent(System.out::println);
  }
}

Zmieniliśmy trochę kod z II przypadku. Teraz wyjątek powstaje zaraz po ustawieniu pierwszego pola.

Metoda bez @Transactional stworzy nam wpis w bazie danych i tyle. Pominie aktualizację pola pomimo tego, że zostało ono zmienione wcześniej niż pojawienie się wyjątku. Powodem tego jest to co widzieliśmy już wcześniej. Nie skorzystaliśmy z metody save

Drugie rozwiązanie również utworzy wpis w bazie danych. Jednak ustawi nam wartość dla pierwszego pola. Jest to spowodowane tym, że wyjątek został złapany jeszcze we wewnątrz metody oznaczonej jako @Transactional. Z punktu widzenia transakcji Springa nie poleciał po prostu żaden wyjątek. Dlatego wypuści nasze operacje na encji w świat do bazy danych.

IV. przypadek - try-catch poza transakcją

Sytuacja jest zgoła odmienna, gdy try-catch znajdzie się poziom wyżej.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Long withoutTransactionalWithoutTry() {
  return useCase();
}

@Transactional
public Long withTransactionalWithoutTry() {
  return useCase();
}

private Long useCase() {
  ExampleEntity exampleEntity = exampleEntityRepository.save(
      new ExampleEntity());
  Long id = exampleEntity.getId();

  exampleEntity.setFirstFieldWithRuntimeException(id, "first");
  exampleEntity.setSecondField("second");

  return id;
}
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
public void executeWithTry() {
  System.out.println("-> WITHOUT TRANSACTION <-");
  try {
    interestingUseCases.withoutTransactionalWithoutTry();
  } catch (RuntimeExceptionWIthId exception) {
    System.out.println("=== VERIFICATION ===");
    Long id = exception.getId();
    if (id == null) {
      System.out.println("no saved item");
    } else {
      exampleEntityRepository.findById(id)
          .map(ExampleEntity::toString)
          .ifPresent(System.out::println);
    }
  }

  System.out.println("====");

  System.out.println("-> WITH TRANSACTION <-");
  try {
    interestingUseCases.withTransactionalWithoutTry();
  } catch (RuntimeExceptionWIthId exception) {
    System.out.println("=== VERIFICATION ===");
    Long id = exception.getId();
    if (id == null) {
      System.out.println("no saved item");
    } else {
      exampleEntityRepository.findById(id)
          .map(ExampleEntity::toString)
          .ifPresent(System.out::println);
    }
  }
}

Opcja z brakiem adnotacji zachowa się identycznie jak jej poprzedniczka. Powstanie wpis w bazie danych z polami ustawionymi na null. Natomiast @Transactional wycofa wszystkie zmiany. Wyjątek nie został obsłużony wewnątrz, więc Spring go zobaczy i wycofa wszystkich wykonane zmian.

Podsumowanie

Wychodzi na to, że @Transactional jest naprawdę potrzebny w wielu przypadkach. Pomaga nam tworzyć spójne, zoptymalizowane paczki danych, które zapisujemy do bazy danych. Jednak nadużycie tej adnotacji również niesie za sobą wiele negatywnych konsekwencji, o których tutaj nie wspomniałem. Ogólnie transakcje w Springu są naprawdę ciężkim, a przez co, ciekawym zagadnieniem. Niestety często są traktowane po macoszemu. Warto więc zwracać uwagę na takie aspekty i szerzyć wiedzę o tym co się dzieje w naszej aplikacji wykorzystującej dany framework. Jak widać, nawet najmniejsza zmiana w kodzie, jak usunięcie adnotacji, może narobić nam biedy. Dlatego jeszcze raz uczulam na powyższe przypadki. Warto sprawdzić na spokojnie dany przypadek na boku niż puścić go bez przemyślenia na produkcję…