Załóżmy, że zaprojektowaliśmy aplikację do umieszczania postów z możliwością ich komentowania. Oczywiście naszym wybranym stackiem technologicznym jest Spring oraz Hibernate. Na początku aplikacja cieszyła się bardzo małą popularnością, jednak w pewnym momencie zaliczyła znaczny wzrost liczby użytkowników. Niestety wraz z tą dobrą wiadomością zaczęły również przybywać liczne zgłoszenia błędów. Co ciekawe dotyczyły one w głównej mierze tego samego aspektu, a mianowicie czasami nie można było edytować wybranych postów. Po zajrzeniu w logi aplikacji naszym oczom ukazał się niekiedy występujący wyjątek OptimisticLockException, który mógł być skutkiem wzrostu popularności. Należy, więc zadać sobie następujące pytanie: “jaka dokładnie sytuacja mogła dopuścić do takiego błędu?”.

Okazało się, że powstały problem dotyczy postów cieszących się dużym zainteresowaniem. Załóżmy, że jeden z przykładowych postów wzbudza pewne kontrowersje przez co wielu użytkowników co chwila odbywa z nim interakcję poprzez np. dodawanie komentarzy. Gdy właściciel postu spostrzeże w końcu jak duże poruszenie wywołała jego notka zdarza się, że zechce zmienić jego treść. Niestety przez sporą ilość komentujących nie może tego dokonać, ponieważ co chwila dostaje komunikat, aby “spróbować ponownie później - błąd wewnętrzny systemu”. Po tej pobieżnej analizie możliwego scenariusza przyszła pora, aby w końcu zajrzeć do kodu aplikacji.

Zaglądamy do kodu

Skoro problem dotyczy edycji postu warto na początku zacząć od encji reprezentującej ten koncept. Nosi ona nazwę Post i w jej ramach istnieją pola reprezentujące liczniki dodanych komentarzy, polubień oraz udostępnień. Dodatkowo użyte zostało blokowanie optymistyczne, więc z tego powodu widnieje pole version, którego wartość przedstawia aktualną wersję danego postu.

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
57
58
59
60
61
62
63
64
65
66
package pl.devcezz.optimisticlock;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Version;

@Entity
public class Post {

  @Id
  @GeneratedValue
  private Long id;

  private String content;

  //... other fields

  private Long numberOfComments;

  private Long numberOfLikes;

  private Long numberOfShares;

  @Version
  private Long version;

  public void changeContent(String changedContent) {
    content = changedContent;
  }

  public void increaseNumberOfComments() {
    numberOfComments++;
  }

  public void decreaseNumberOfComments() {
    if (numberOfComments < 0) {
      throw new IllegalStateException("number of comments cannot be negative");
    }
    numberOfComments--;
  }

  public void increaseNumberOfLikes() {
    numberOfLikes++;
  }

  public void decreaseNumberOfLikes() {
    if (numberOfLikes < 0) {
      throw new IllegalStateException("number of likes cannot be negative");
    }
    numberOfLikes--;
  }

  public void increaseNumberOfShares() {
    numberOfShares++;
  }

  public void decreaseNumberOfShares() {
    if (numberOfShares < 0) {
      throw new IllegalStateException("number of shares cannot be negative");
    }
    numberOfShares--;
  }

  //... other methods, getters & setters
}

Taki przydział wydaje się logiczny, ponieważ gdy przeglądamy popularne portale społecznościowe zawsze pod danym postem istnieją liczniki wskazujące na ilość komentarzy, polubień czy udostępnień. Zagłębmy się zatem dalej w kod i sprawdźmy, kiedy zmienia się licznik komentarzy, czyli kiedy wywoływana jest metoda increaseNumberOfComments.

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.optimisticlock;

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

@Service
public class CommentService {

  private final PostRepository postRepository;
  private final CommentRepository commentRepository;

  public CommentService(PostRepository postRepository, CommentRepository commentRepository) {
    this.postRepository = postRepository;
    this.commentRepository = commentRepository;
  }

  @Transactional
  public void addComment(CommentDto dto) {
    postRepository.findById(dto.postId())
        .ifPresentOrElse(
            Post::increaseNumberOfComments,
            () -> { throw new IllegalArgumentException("post not found: " + dto.postId()); }
        );

    Comment comment = new Comment(dto.author(), dto.content(), dto.postId());
    commentRepository.save(comment);
  }

  //... other methods
}

Jest dokładnie tylko jedno takie miejsce w serwisie CommentService. Na samym początku wyszukiwany jest dany post i jeśli zostanie znaleziony to zwiększany jest jego licznik komentarzy. Następnie powstaje nowy obiekt encji Comment, który jest zapisywany do bazy danych. Wychodzi na to, że przy dodawaniu komentarza dotykamy dwóch klas mapowanych na tabele - Post oraz Comment. Zweryfikujmy jeszcze, w którym miejscu używana jest metoda do zmiany treści postu - changeContent.

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.optimisticlock;

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

@Service
public class PostService {

  private final PostRepository postRepository;

  public PostService(PostRepository postRepository) {
    this.postRepository = postRepository;
  }

  @Transactional
  public void editPost(Long postId, String content) {
    postRepository.findById(postId)
        .ifPresentOrElse(
            post -> post.changeContent(content),
            () -> { throw new IllegalArgumentException("post not found: " + postId); }
        );
  }

  //... other methods
}

Jest to dokładnie w klasie PostService i jej metodzie editPost. Bogatsi w tą wiedzę możemy przejść do do analizy i łączenia poznanych faktów. Warto zastanowić się jeszcze raz nad prawdopodobnym scenariuszem powstania błędu, ale tym razem pod kątem technicznym.

Techniczna analiza sytuacji

Na pewno trzeba podkreślić ponownie moment, w którym dzieje się wyżej opisana sytuacja. Gdy post zostanie opublikowany i zyska bardzo szybko na popularności to ilość jego komentarzy rośnie w zawrotnym tempie. Autor postu w tym momencie próbuje go z edytować i niestety ta akcja nie powodzi się. Przeanalizujmy co wtedy dzieje się w tle, czyli we wnętrzu aplikacji.

Wykorzystując blokowanie optymistyczne wraz z encją bazodanową zapisywany jest jej aktualny numer wersji. Oznacza to, że gdy dokonamy jakiejkolwiek aktualizacji rekordu reprezentującego daną encję to licznik wersji zawsze się podbije. Jednak musi zostać spełniony jeden warunek - dane wysłane na serwer muszą posiadać wersję równą tej znajdującej się w bazie danych.

W naszej sytuacji, gdy autor tekstu chce edytować swój post to musi najpierw pobrać go z serwera, zmienić jego treść i ponownie wysłać do zapisania. Natomiast jeśli osoba trzecia doda komentarz zaraz po pobraniu danych przez autora to zwiększy się licznik komentarzy znajdujący się w encji Post, a także numer wersji ulegnie zwiększeniu o jeden. Gdy autor spróbuje zapisać swoją zmianę to otrzyma błąd wewnętrzny, ponieważ pobrana przez niego wersja będzie niższa od tej znajdującej się aktualnie w bazie. Mam nadzieję, że lepiej zobrazuje to następująca grafika.

Animacja przedstawiająca problem edycji posta
Animacja przedstawiająca problem edycji posta

Możliwe rozwiązanie

Widać, że problemem jest sztuczne podbijanie wersji encji Post, gdy tak naprawdę główny aktorem przy dodawaniu komentarza jest encja Comment. Może nie tyle sztuczne, ale bardziej niekonieczne. Dobrym tropem jest myśl, aby wydzielić odpowiedzialność przechowywania liczników do jakiegoś innego tworu. Post niekoniecznie musi wiedzieć o tym, że ktoś dodał do niego komentarz czy go udostępnił. Może on żyć bez tej informacji w bazie danych, natomiast na potrzeby prezentacji możemy dodać do niego dane na temat liczby interakcji z nim.

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
package pl.devcezz.optimisticlock;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
public class PostMeters {

  private static final Long ZERO = 0L;

  @GeneratedValue
  @Id
  private Long id;

  private Long postId;

  private Long numberOfComments = ZERO;

  private Long numberOfLikes = ZERO;

  private Long numberOfShares = ZERO;

  public PostMeters() {
  }

  public PostMeters(Long postId) {
    this.postId = postId;
  }

  //... getters & setters
}
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
package pl.devcezz.optimisticlock;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Version;

@Entity
public class Post {

  @Id
  @GeneratedValue
  private Long id;

  private String content;

  //... other fields

  @Version
  private Long version;

  static Post of(PostDto dto) {
    Post post = new Post();
    post.content = dto.content();
    //... others assignments

    return post;
  }

  public void changeContent(String changedContent) {
    content = changedContent;
  }

  //... other methods

  public Long getPostId() {
    return id;
  }

  //... getters & setters
}

Zaproponowanym rozwiązaniem może być stworzenie klasy PostMeters przechowującej wszystkie liczniki danego postu, do którego mamy odwołanie przez id. W ten sposób znacznie odchudziła się pierwotna encja Post, która teraz nic nie wie o swoich licznikach. Na pewno da się zauważyć, że w nowym tworze nie ma metod do zmiany wybranego licznika. Stało się tak z ważnego powodu. Gdybyśmy za pomocą repozytorium pobrali daną encję, a potem wywołali jego metodę do zwiększenia licznika to w między czasie ktoś mógłby też ją pobrać i zmienić ten sam licznik. W ten sposób mogłoby dojść do rozsynchronizowania danych, ponieważ encja PostMeters nie ma żadnego mechanizmu blokowania.

Z tego powodu najlepszym rozwiązaniem będzie stworzenie w repozytorium natywnych zapytań SQL zmieniających wybrany licznik. W ten sposób zabezpieczymy się na możliwą utratę spójności 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
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
package pl.devcezz.optimisticlock;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface PostMetersRepository extends JpaRepository<PostMeters, Long> {

  Optional<PostMeters> findByPostId(Long postId);

  @Modifying
  @Query(
      value = "UPDATE post_meters SET number_of_comments = number_of_comments + 1 WHERE post_id = :#{postId}",
      nativeQuery = true
  )
  void increaseNumberOfComments(Long postId);

  @Modifying
  @Query(
      value = "UPDATE post_meters SET number_of_comments = number_of_comments - 1 WHERE post_id = :#{postId}",
      nativeQuery = true
  )
  void decreaseNumberOfComments(Long postId);

  @Modifying
  @Query(
      value = "UPDATE post_meters SET number_of_likes = number_of_likes + 1 WHERE post_id = :#{postId}",
      nativeQuery = true
  )
  void increaseNumberOfLikes(Long postId);

  @Modifying
  @Query(
      value = "UPDATE post_meters SET number_of_likes = number_of_likes - 1 WHERE post_id = :#{postId}",
      nativeQuery = true
  )
  void decreaseNumberOfLikes(Long postId);

  @Modifying
  @Query(
      value = "UPDATE post_meters SET number_of_shares = number_of_shares + 1 WHERE post_id = :#{postId}",
      nativeQuery = true
  )
  void increaseNumberOfShares(Long postId);

  @Modifying
  @Query(
      value = "UPDATE post_meters SET number_of_shares = number_of_shares - 1 WHERE post_id = :#{postId}",
      nativeQuery = true
  )
  void decreaseNumberOfShares(Long postId);
}

Podpięcie nowego kodu

Spójrzmy teraz jak wyglądają serwisy z podpiętym, nowopowstałym kodem. CommentService zamiast z repozytorium postów korzysta z PostMetersRepository i w praktycznie ten sam sposób zwiększa licznik dodanych komentarzy podczas dodawania jednego z nich.

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.optimisticlock;

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

@Service
public class CommentService {

  private final PostMetersRepository postMetersRepository;
  private final CommentRepository commentRepository;

  public CommentService(PostMetersRepository postMetersRepository, CommentRepository commentRepository) {
    this.postMetersRepository = postMetersRepository;
    this.commentRepository = commentRepository;
  }

  @Transactional
  public void addComment(CommentDto dto) {
    postMetersRepository.findByPostId(dto.postId())
        .ifPresentOrElse(
            PostMeters::increaseNumberOfComments,
            () -> { throw new IllegalArgumentException("post meters not found for post id: " + dto.postId()); }
        );

    Comment comment = new Comment(dto.author(), dto.content(), dto.postId());
    commentRepository.save(comment);
  }

  //... other methods
}

Natomiast w PostService podczas edycji treści postu nic się tak naprawdę nie zmieni. Dopiero przy dodawaniu nowego postu będziemy musieli pamiętać, aby również utworzyć dla niego nowy, wyzerowany licznik. Taka sama sytuacja będzie miała miejsce przy usuwaniu postu, ale pominiemy to w prezentowany poniżej kodzie.

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.optimisticlock;

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

@Service
public class PostService {

  private final PostRepository postRepository;
  private final PostMetersRepository postMetersRepository;

  public PostService(PostRepository postRepository, PostMetersRepository postMetersRepository) {
    this.postRepository = postRepository;
    this.postMetersRepository = postMetersRepository;
  }

  @Transactional
  public void addPost(PostDto dto) {
    Post post = postRepository.save(Post.of(dto));
    postMetersRepository.save(new PostMeters(post.getPostId()));
  }
  
  @Transactional
  public void editPost(Long postId, String content) {
    postRepository.findById(postId)
        .ifPresentOrElse(
            post -> post.changeContent(content),
            () -> { throw new IllegalArgumentException("post not found: " + postId); }
        );
  }

  //... other methods
}

Kluczowym miejscem do zmiany jest również warstwa prezentacyjna. Teraz przy pobieraniu danych na temat postu musimy pobrać informacje z dwóch encji. Może to wyglądać w następujący sposób.

1
2
3
4
5
6
7
8
9
10
@Transactional(readOnly = true)
public PostDto findPost(Long postId) {
  Post post = postRepository.findById(postId)
      .orElseThrow(() -> new IllegalArgumentException("post not found: " + postId));

  PostMeters postMeters = postMetersRepository.findById(postId)
      .orElseThrow(() -> new IllegalArgumentException("post meters not found for post: " + postId));

  return PostDto.from(post, postMeters);
}

Na pewno to rozwiązanie będzie wolniejsze, ponieważ będą musiały zostać wykonane dwa zapytania do bazy danych. Jeżeli jednak w naszym wypadku wydajność nie jest kluczowa w tym miejscu to nie mamy czym się kompletnie przejmować.

Podsumowanie

Problem opisany powyżej zdarzył mi się ostatnio w pracy, ale oczywiście był osadzony w innym kontekście biznesowym. Udało mi się go rozwiązać bez największych problemów dzięki wiedzy zdobytej z kursu LegacyFighter, do którego zakupu serdecznie zachęcam. Nauczyłem się również, aby szerzej patrzeć na napotkane problemy, które przyjdzie mi rozwiązywać. Oczywiście będzie to wymagało ogromnej kreatywności oraz wiedzy.

Cały kod znajdziesz tutaj: https://github.com/cezarysanecki/code-from-blog