Wyślij zapytanie Dołącz do Sii

Budując aplikację, należy pamiętać o tym, by była ona wydajna, niezawodna oraz odporna na obciążenie. Żeby to uzyskać, stosuje się skalowanie wertykalne (dodawanie RAM oraz CPU) i horyzontalne (dodawanie większej liczby instancji w połączeniu z Load Balancerem). Oczekujemy, że aplikacja będzie odpowiadała z możliwie niską latencją (czasem odpowiedzi). Wydajność stron oraz przetwarzania żądań wpływa znacząco na odczucia użytkownika.

Według badań Google’a z 2018, prawie 80% osób robiących zakupy online, które spotkały się z problemem wolnego ładowania strony, zadeklarowało, że nie wróci już na zakupy na daną stronę. Blisko połowa klientów oczekuje, że strona załaduje się w ciągu 2 sekund lub krócej. Jeśli dana witryna nie otworzy się w ciągu 3 sekund, 53% użytkowników ją opuści.

To pokazuje, jak istotne jest dbanie o szybkość działania aplikacji niezależenie od obciążenia. Dla użytkownika nie ma znaczenia, że trwa akurat np. okres świąteczny, więc jest wzmożony ruch na platformach zakupowych. Klient zawsze oczekuje, że strony oraz przetwarzanie żądań będzie realizowane możliwie szybko.

Tworząc aplikację wykorzystującą framework Spring, mamy do wyboru kilka API, które odpowiadają na powyższe potrzeby.

Podejście imperatywne – klasyczne

Budując Springową aplikację umożliwiającą komunikację REST-ową, najczęściej sięgamy po rozwiązanie, jakim jest użycie webowego starteru wraz z wbudowanym kontenerem servletów Tomcat.

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Każde żądanie HTTP przychodzące do aplikacji jest w tym przypadku przypisywane do jednego wątku z puli. Tomcat domyślnie posiada takich wątków 200.

Gdy pula tych wątków zostanie wykorzystana, kolejne żądania HTTP zostają zakolejkowane i oczekują na zwolnienie się wątku. Możemy obliczyć przybliżoną wartość obciążenia (tzw. Throughput) liczoną w żądaniach na sekundę RPS (Requests Per Second), jaką jest w stanie przyjąć aplikacja, stosując wzór:

throughput = liczba wątków / średni czas obsługi żądania

W idealnej sytuacji, mając do dyspozycji 200 wątków oraz czas obsługi każdego z nich wynoszący 500ms, serwer jest w stanie obsłużyć: 200req/500ms = 400RPS.

Zostawiając domyślną pulę wątków na poziomie 200 oraz chcąc obsłużyć większe obciążenie, należy albo skrócić czas obsługi pojedynczego żądania albo dołożyć kolejną instancję serwisu. 

Podejście reaktywne

Wraz z projektem Reactor możliwe jest pisanie kodu nieblokującego:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Domyślnym kontenerem servletów jest w tym przypadku Netty. Używa on jednego wątku na serwer oraz liczbę wątków równą liczbie rdzeni komputera (CPU Cores) na obsługę żądań. W przypadku 4-rdzeniowego komputera będą to 4 wątki.

Podejście to umożliwia asynchroniczne oraz nieblokujące przetwarzania żądań. Przy takim założeniu podczas wywołania operacji wejścia/wyjścia, np. wywołania bazy danych, wątek nie czeka na odpowiedź, tylko od razu wraca do puli wątków i jest gotowy do przyjęcia kolejnego zdarzenia. Następnie, dzięki mechanizmowi callbacków, aplikacja zostaje poinformowana o rezultacie zapytania.

Przewagą podejścia reaktywnego jest więc optymalne wykorzystanie działania wątków bez czasu oczekiwania na odpowiedź, jak to ma miejsce w przypadku klasycznego podejścia.

Korutyny

Kolejną opcją napisanie nieblokującej aplikacji jest wykorzystanie języka Kotlin wraz z korutynami. W odróżnieniu od projektu Reactor, możliwe jest pisanie kodu w sposób bardzo zbliżony do podejścia klasycznego, co znacząco poprawia czytelność kodu oraz łatwość testowania, wykorzystując zalety kodu nieblokującego.

Korutyny często nazywane są również lekkimi wątkami. Spowodowane jest to tym, że ich utworzenie jest proste oraz wymaga niewielkich zasobów. W przypadku tworzeniu nowego wątku zajmuje on około 1 MB pamięci. Korutyna potrzebuje jedynie kilkadziesiąt bajtów pamięci. Jeden wątek może tworzyć wiele korutyn.        

W tym przypadku również pobieramy zależność webfluxową, więc ponownie domyślnym kontenerem servletów będzie Netty.

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Poza klasycznymi zależnościami kotlinowymi, potrzebujemy również bibliotek do obsługi korutyn.

<dependency>
   <groupId>org.jetbrains.kotlinx</groupId>
   <artifactId>kotlinx-coroutines-core</artifactId>
</dependency>
<dependency>
   <groupId>org.jetbrains.kotlinx</groupId>
   <artifactId>kotlinx-coroutines-reactor</artifactId>
</dependency>

Testy obciążeniowe

Do przeprowadzenia testów obciążeniowych zostało wykorzystane narzędzie Gatling umożliwiające symulowanie produkcyjnego ruchu poprzez wywoływanie wielu zapytań równolegle przez określony czas.

Testy zostały przeprowadzone na 6-rdzeniowym Intel i7 oraz 32GB RAM, dysk SSD. W ramach testów porównano czasy odpowiedzi aplikacji zbudowanej według trzech przedstawionych wyżej podejść.

public class CustomerRequestsSimulation extends Simulation {

    HttpProtocolBuilder httpProtocol = http
            .baseUrl("http://localhost:8080")
            .acceptHeader("application/json")
            .userAgentHeader("Gatling/Performance Test");


    ScenarioBuilder scn = CoreDsl.scenario("Load Test Scenario")
            .forever()
            .on(exec(http("create-customer-request")
                    .get("/endpoint")
            ));

    public CustomerRequestsSimulation() {
        setUp(scn.injectClosed(
                        incrementConcurrentUsers(256)
                                .times(8)
                                .eachLevelLasting(Duration.ofSeconds(20))
                                .separatedByRampsLasting(Duration.ofSeconds(10))
                                .startingFrom(0)
                )
        ).maxDuration(Duration.ofMinutes(2))
                .protocols(httpProtocol);
    }
}

W tym scenariuszu trwającym 2 minuty 256 użytkowników będzie dodawanych w ciągu 10 sekund. Pomiędzy kolejnymi inkrementacjami jest 20 sekund przerwy.

Aktywni użytkownicy w trakcie symulacji
Ryc. 1 Aktywni użytkownicy w trakcie symulacji

Bez opóźnień

W tym przykładzie aplikacje nie będą posiadały żadnych opóźnień ani logiki wewnątrz kontrolera. Będą więc próbować odpowiadać na żądania tak szybko, jak tylko jest to możliwe.

  1. podejście imperatywne
@RestController
class Controller {

    @GetMapping("/endpoint")
    public ResponseEntity<Void> endpoint() {
        return ResponseEntity.ok().build();
    }
}
Ryc. 2 Zakresy czasu reakcji
Ryc. 2 Zakresy czasu reakcji
Rozkład czasu odpowiedzi
Ryc. 3 Rozkład czasu odpowiedzi
  1. reaktywne
@RestController
class Controller {

    @GetMapping("/endpoint")
    Mono<Void> endpoint() {
        return Mono.empty();
    }
}
Zakresy czasu odpowiedzi
Ryc. 4 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 5 Rozkład czasu odpowiedzi
  1. korutyny
@RestController
class Controller {

    @GetMapping("/endpoint")
    suspend fun endpoint(): ResponseEntity<Void> {
        return ResponseEntity.ok().build()
    }
}

Zakresy czasu odpowiedzi
Ryc. 6 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 7 Rozkład czasu odpowiedzi
 Liczba żądańRPS99th pctŚredni czas odpowiedziOdchylenie standardowe
Imperatywne4 719 27239 327451511
Reaktywne5 326 08344 38429137
Korutyny4 972 56341 43826148
Tab. 1 Zestawienie wyników testów bez opóźnień dla 3 podejść

W tym przypadku nie widać znaczących różnic pomiędzy poszczególnymi technologiami. Klasyczne podejście jest wolniejsze, ale są to na tyle małe wartości, że nie mają znaczącego wpływu na użytkowanie aplikacji.

Ciekawiej zaczyna się robić, gdy dodamy opóźnienia.

Opóźnienie 200 ms

W tym przykładzie do aplikacji zostały dodane opóźnienia 200 ms, które symulują odpytywanie bazy danych lub innego serwisu.

  1. imperatywne
@RestController
class Controller {

    @GetMapping("/endpoint")
    public ResponseEntity<Void> endpoint() throws InterruptedException {
        Thread.sleep(200);
        return ResponseEntity.ok().build();
    }
}
Zakresy czasu odpowiedzi
Ryc. 8 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 9 Rozkład czasu odpowiedzi
  1. relatywne
@RestController
class Controller {

    @GetMapping("/endpoint")
    Mono<Void> endpoint() {
        return Mono.empty()
                .delaySubscription(Duration.ofMillis(200))
                .then();
    }
}
Zakresy czasu odpowiedzi
Ryc. 10 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 11 Rozkład czasu odpowiedzi
  1. korutyny
@RestController
class Controller {

    @GetMapping("/endpoint")
    suspend fun endpoint(): ResponseEntity<Void> {
        coroutineScope {
            delay(200)
        }
        return ResponseEntity.ok().build()
    }
}
Zakresy czasu odpowiedzi
Ryc. 12 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 13 Rozkład czasu odpowiedzi
 Liczba żądańRPS99th pctŚredni czas odpowiedziOdchylenie standardowe
Imperatywne113 7759481088625289
Reaktywne339 7392 8312262117
Korutyny344 0012 8662222086
Tab. 2 Zestawienie wyników testów opóźnień 200 ms dla 3 podejść

Już w przypadku niewielkiego opóźnienia w aplikacji zaczyna być wyraźnie widoczna różnica w przepustowości serwisów na korzyść podejścia reaktywnego oraz z użyciem korutyn. W przypadku podejścia klasycznego przy opóźnieniu wynoszącym 200 ms średni czas odpowiedzi wynosił nieco ponad 600 ms, a 99% żądań miało czas latencji wynoszący poniżej sekundy.

Opóźnienie 500 ms

  1. imperatywne
Zakresy czasu odpowiedzi
Ryc. 14 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 15 Rozkład czasu odpowiedzi
  1. reaktywne
Zakresy czasu odpowiedzi
Ryc. 16 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 17 Rozkład czasu odpowiedzi
  1. korutyny
Zakresy czasu odpowiedzi
Ryc. 18 Zakresy czasu odpowiedzi
Rozkład czasu odpowiedzi
Ryc. 19 Rozkład czasu odpowiedzi
 Liczba żądańRPS99th pctŚredni czas odpowiedziOdchylenie standardowe
Imperatywne45 32837726921551717
Reaktywne139 2611 1605215134
Korutyny139 2931 1605195124
Tab. 3 Zestawienie wyników testów opóźnień 500 ms dla 3 podejść

W przypadku opóźnienia 500 ms widać znaczne problemy ze sprawną obsługą żądań przez serwis napisany imperatywnie. Jeśli chodzi o podejście reaktywne oraz z użyciem korutyn czas obsługi żądania jest średnio o 13 ms dłuższy niż zaprogramowane opóźnienie.

Podsumowanie

Im większe symulowane opóźnienie, tym większa przepustowość w podejściu reaktywnym oraz z wykorzystaniem korutyn w stosunku do podejścia klasycznego. Nie oznacza to jednak, że trzeba wszystkie aktualne serwisy przepisać z wykorzystaniem tych technologii, jak również, że wszystkie nowe serwisy muszą być napisane w ten sposób.

Pisanie takiego kodu jest trudniejsze w przetestowaniu, trudniejsze w debuggowaniu oraz często mniej czytelne niż w przypadku podejścia imperatywnego.

Należy więc dobrać odpowiednią technologię do przypadku użycia. Gdy więc pada pytanie, czy lepiej użyć podejścia imperatywnego, reaktywnego czy korutyn – odpowiedź brzmi… to zależy 😉

***

A jeśli interesuje Cię, co testować: wydajność czy szybkość zdaniem naszych ekspertów z CC Testing, zachęcamy do zapoznania z ich artykułem: Testowanie wydajności czy szybkości? Google Lighthouse w praktyce

5/5 ( głosy: 5)
Ocena:
5/5 ( głosy: 5)
Autor
Avatar
Marek Bojdys

Pracuje w Sii jako Software Engineer. Głównie zajmuje się programowaniem w Javie oraz Kotlinie. Prywatnie jest fanem escape roomów oraz podróży.

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Może Cię również zainteresować

Pokaż więcej artykułów

Bądź na bieżąco

Zasubskrybuj naszego bloga i otrzymuj informacje o najnowszych wpisach.

Otrzymaj ofertę

Jeśli chcesz dowiedzieć się więcej na temat oferty Sii, skontaktuj się z nami.

Wyślij zapytanie Wyślij zapytanie

Natalia Competency Center Director

Get an offer

Dołącz do Sii

Znajdź idealną pracę – zapoznaj się z naszą ofertą rekrutacyjną i aplikuj.

Aplikuj Aplikuj

Paweł Process Owner

Join Sii

ZATWIERDŹ

This content is available only in one language version.
You will be redirected to home page.

Are you sure you want to leave this page?