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.
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.
- podejście imperatywne
@RestController
class Controller {
@GetMapping("/endpoint")
public ResponseEntity<Void> endpoint() {
return ResponseEntity.ok().build();
}
}
- reaktywne
@RestController
class Controller {
@GetMapping("/endpoint")
Mono<Void> endpoint() {
return Mono.empty();
}
}
- korutyny
@RestController
class Controller {
@GetMapping("/endpoint")
suspend fun endpoint(): ResponseEntity<Void> {
return ResponseEntity.ok().build()
}
}
Liczba żądań | RPS | 99th pct | Średni czas odpowiedzi | Odchylenie standardowe | |
Imperatywne | 4 719 272 | 39 327 | 45 | 15 | 11 |
Reaktywne | 5 326 083 | 44 384 | 29 | 13 | 7 |
Korutyny | 4 972 563 | 41 438 | 26 | 14 | 8 |
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.
- imperatywne
@RestController
class Controller {
@GetMapping("/endpoint")
public ResponseEntity<Void> endpoint() throws InterruptedException {
Thread.sleep(200);
return ResponseEntity.ok().build();
}
}
- relatywne
@RestController
class Controller {
@GetMapping("/endpoint")
Mono<Void> endpoint() {
return Mono.empty()
.delaySubscription(Duration.ofMillis(200))
.then();
}
}
- korutyny
@RestController
class Controller {
@GetMapping("/endpoint")
suspend fun endpoint(): ResponseEntity<Void> {
coroutineScope {
delay(200)
}
return ResponseEntity.ok().build()
}
}
Liczba żądań | RPS | 99th pct | Średni czas odpowiedzi | Odchylenie standardowe | |
Imperatywne | 113 775 | 948 | 1088 | 625 | 289 |
Reaktywne | 339 739 | 2 831 | 226 | 211 | 7 |
Korutyny | 344 001 | 2 866 | 222 | 208 | 6 |
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
- imperatywne
- reaktywne
- korutyny
Liczba żądań | RPS | 99th pct | Średni czas odpowiedzi | Odchylenie standardowe | |
Imperatywne | 45 328 | 377 | 2692 | 1551 | 717 |
Reaktywne | 139 261 | 1 160 | 521 | 513 | 4 |
Korutyny | 139 293 | 1 160 | 519 | 512 | 4 |
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
Zostaw komentarz