Praktycznie wszystkie nowoczesne produkty programowe składają się z kilku usług. Często długi czas odpowiedzi między kanałami między usługami staje się źródłem problemów z wydajnością. Standardowym rozwiązaniem takich problemów jest pakowanie kilku międzyserwisowych żądań w jeden pakiet, co nazywa się przetwarzaniem wsadowym (batching).
Jeśli korzystasz z przetwarzania wsadowego, możesz nie być zadowolony z jego wyniku pod względem wydajności lub zrozumiałości kodu. Ta metoda nie jest tak prosta dla wywołującego, jak może się wydawać. Dla różnych celów i w różnych sytuacjach rozwiązania mogą się znacznie różnić. Na konkretnych przykładach pokażę zalety i wady kilku podejść.
Projekt demonstracyjny
Aby zilustrować, rozważmy przykład jednej z usług w aplikacji, nad którą obecnie pracuję.
Wyjaśnienie wyboru platformy dla przykładówProblem złej wydajności jest dość powszechny i nie dotyczy konkretnych języków ani platform. W tym artykule do demonstrowania zadań i rozwiązań będą używane przykłady kodu w Spring + Kotlin. Kotlin jest równie zrozumiały (lub niezrozumiały) dla programistów Java i C#, ponadto kod jest bardziej zwarty i zrozumiały niż w Java. Aby ułatwić zrozumienie dla czystych programistów Java, będę unikać magii Kotlin i używać tylko białej (w duchu Lombok). Będzie kilka metod rozszerzeń, ale w rzeczywistości są one znane wszystkim programistom Java jako metody statyczne, więc to będzie małym cukierkiem, który nie zepsuje smaku potrawy.
Istnieje usługa zatwierdzania dokumentów. Ktoś tworzy dokument i przedstawia go do dyskusji, w trakcie której wprowadzane są poprawki, a ostatecznie dokument zostaje zatwierdzony. Sama usługa zatwierdzania nic nie wie o dokumentach: to po prostu czat zatwierdzających z niewielkimi dodatkowymi funkcjami, które tutaj nie będziemy omawiać.
Tak więc, są pokoje czatowe (odpowiadające dokumentom) z wcześniej określonym zestawem uczestników w każdym z nich. Jak w zwykłych czatach, wiadomości zawierają tekst i pliki i mogą być odpowiedziami (reply) oraz przekazami (forward):
dane klasa WiadomośćCzatu(
// nullable так как появляется только после persist
val id: Długi? = null,
/** Ссылка на автора */
val autor: ReferencjaUżytkownika,
/** Сообщение */
val wiadomość: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: Lista<ReferencjaPliku>? = null,
/** Если является ответом, то здесь будет оригинал */
val odpowiedzDo: WiadomośćCzatu? = null,
/** Если является пересылкой, то здесь будет оригинал */
val przekażZ: WiadomośćCzatu? = null
)
Linki do pliku i użytkownika to linki do innych domeny. U nas to żyje w ten sposób:
typ ReferencjaPliku = Długi
typ ReferencjaUżytkownika = Długi
Dane o użytkownikach są przechowywane w Keycloak i uzyskiwane za pomocą REST. To samo dotyczy plików: pliki i metainformacje o nich znajdują się w oddzielnej usłudze przechowywania plików.
Wszystkie wywołania tych usług są ciężkimi zapytaniami. Oznacza to, że koszty transportu tych zapytań są znacznie większe niż czas ich przetwarzania przez zewnętrzną usługę. Na naszych testowych stanowiskach typowy czas wywołania takich usług wynosi 100 ms, więc w dalszej części będziemy używać tych danych.
Musimy stworzyć prosty kontroler REST, aby uzyskać ostatnie N wiadomości ze wszystkimi niezbędnymi informacjami. Zakładamy, że w frontendzie model wiadomości jest prawie taki sam i trzeba przesłać wszystkie dane. Różnica w modelu dla frontendu polega na tym, że plik i użytkownik muszą być przedstawieni w nieco odszyfrowanej postaci, aby stały się linkami:
/** В таком виде отдаются ссылки на сущности для фронта */
dane klasa InterfejsReferencyjny(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val nazwa: String
)
dane klasa InterfejsWiadomościCzatowej(
val id: Długi,
/** Ссылка на автора */
val autor: InterfejsReferencyjny,
/** Сообщение */
val wiadomość: String,
/** Ссылки на аттачи */
val files: Lista<InterfejsReferencyjny>,
/** Если являтся ответом, то здесь будет оригинал */
val odpowiedzDo: InterfejsWiadomościCzatowej? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val przekażZ: InterfejsWiadomościCzatowej? = null
)
Musimy zrealizować następujące:
interfejs ChatRestApi {
fun getLast(n: Int): Lista<InterfejsWiadomościCzatowej>
}
Postfix UI oznacza modelki DTO dla frontendu, czyli to, co musimy zwrócić za pomocą REST.
Może się wydawać zaskakujące, że nie przekazujemy żadnego identyfikatora czatu, a nawet w modelu ChatMessage/ChatMessageUI go nie ma. Zrobiłem to celowo, aby nie zaśmiecać kodu przykładami (czaty są izolowane, więc możemy założyć, że mamy ich tylko jeden).
Filozoficzna dygresjaZarówno w klasie ChatMessageUI, jak i w metodzie ChatRestApi.getLast używany jest typ danych List, podczas gdy w rzeczywistości jest to uporządkowany Set. W JDK jest z tym słabo, dlatego deklarowanie porządku elementów na poziomie interfejsu (zachowanie porządku podczas dodawania i pobierania) nie będzie możliwe. Ogólną praktyką stało się używanie List w tych przypadkach, gdy potrzebny jest uporządkowany Set (jest jeszcze LinkedHashSet, ale to nie jest interfejs).
Ważne ograniczenie: będziemy uważać, że długie łańcuchy odpowiedzi lub przesyłania nie występują. To znaczy, że są, ale ich długość nie przekracza trzech wiadomości. W frontendzie łańcuch wiadomości musi być przekazywany w całości.
Aby uzyskać dane z zewnętrznych usług, istnieją następujące API:
interfejs RepozytoriumWiadomościCzatowych {
fun znajdźOstatni(n: Int): Lista<WiadomośćCzatu>
}
dane klasa NagłówekPlikuZdalnego(
val id: ReferencjaPliku,
val nazwa: String
)
interfejs ApiPlikuZdalnego {
fun pobierzNagłówekWedługId(id: ReferencjaPliku): NagłówekPlikuZdalnego
fun pobierzNagłówkiWedługId(id: Set<ReferencjaPliku>: Set<NagłówekPlikuZdalnego>
fun pobierzNagłówkiWedługId(id: Lista<ReferencjaPliku>: Lista<NagłówekPlikuZdalnego>
fun pobierzNagłówkiWedługCzatu(): Lista<NagłówekPlikuZdalnego>
}
dane klasa UżytkownikZdalny(
val id: ReferencjaUżytkownika,
val nazwa: String
)
interfejs ApiUżytkownikaZdalnego {
fun pobierzUżytkownikaWedługId(id: ReferencjaUżytkownika): UżytkownikZdalny
fun pobierzUżytkownikówWedługId(id: Set<ReferencjaUżytkownika>: Set<UżytkownikZdalny>
fun pobierzUżytkownikówWedługId(id: Lista<ReferencjaUżytkownika>: Lista<UżytkownikZdalny>
}
Widać, że w zewnętrznych usługach pierwotnie przewidziano przetwarzanie wsadowe, zarówno w obu wariantach: przez Set (bez zachowania porządku elementów, z unikalnymi kluczami) i przez List (mogą być duplikaty — porządek jest zachowany).
Proste implementacje
Naiwna implementacja
Pierwsza naiwnie zrealizowana wersja naszego kontrolera REST wyglądałaby w większości przypadków mniej więcej tak:
class ChatRestController(
prywatna val messageRepository: RepozytoriumWiadomościCzatowych,
prywatna val userRepository: ApiUżytkownikaZdalnego,
prywatna val fileRepository: ApiPlikuZdalnego
) : ChatRestApi {
nadpisać fun getLast(n: Int) =
messageRepository.findLast(n)
.map { toFrontModel.prywatna fun() }
ChatMessageUI( WiadomośćCzatu.prywatna fun(): InterfejsWiadomościCzatowej =
id = id ?:
IllegalStateException( throw musi być zachowany""$this author = userRepository.getUserById(author).),
toFrontReferencemessage = message,(),
files = files?.
letfileRepository.getHeadsByIds( { files ->
listafiles)
.map { toFrontModel.message = message,() }
} ?: forwardFrom = forwardFrom?.(),
replyTo = replyTo?.prywatna fun(),
}prywatna fun()
)
}
Wszystko jest maksymalnie jasne, co jest dużym plusem.
Używamy przetwarzania wsadowego i pobieramy dane zewnętrznego serwisu w pakietach. Ale jak wygląda wydajność?
Dla każdej wiadomości wykonamy jedno wywołanie UserRemoteApi, aby uzyskać dane dotyczące pola author, oraz jedno wywołanie FileRemoteApi, aby uzyskać wszystkie załączone pliki. Wydaje się, że to wszystko. Załóżmy, że pola forwardFrom i replyTo dla ChatMessage są uzyskiwane w sposób, który nie wymaga dodatkowych wywołań. Ale transformacja do ChatMessageUI doprowadzi do rekurencji, co oznacza, że wskaźniki liczników wywołań mogą znacznie wzrosnąć. Jak wcześniej zauważyliśmy, zakładamy, że nie ma dużych zagnieżdżeń, a łańcuch ogranicza się do trzech wiadomości.
W rezultacie otrzymamy od dwóch do sześciu wywołań zewnętrznych serwisów na jedną wiadomość i jedno wywołanie JPA dla całego pakietu wiadomości. Całkowita liczba wywołań będzie się wahać od 2*N+1 do 6*N+1. Ile to w rzeczywistych jednostkach? Załóżmy, że do wyświetlenia strony potrzebne są 20 wiadomości. Aby je zdobyć, potrzebne będzie od 4 s do 10 s. Straszne! Chcielibyśmy zmieścić się w 500 ms. A ponieważ w frontendzie marzyliśmy o bezszwowym przewijaniu, wymagania dotyczące wydajności tego endpointu można podwoić.
Zalety:
- Kod jest krótki i samodokumentujący się (marzenie wsparcia).
- Kod jest prosty, więc możliwości strzału w nogę są niemal żadne.
- Przetwarzanie wsadowe nie wygląda na coś obcego i organicznie wpisuje się w logikę.
- Zmiany logiki będą wprowadzać łatwo i będą lokalne.
Minus:
Okropna wydajność związana z tym, że pakiety są bardzo małe.
Takie podejście dość często można zobaczyć w prostych serwisach lub prototypach. Jeśli ważna jest szybkość wprowadzania zmian, nie ma sensu komplikować systemu. Jednocześnie w przypadku naszego bardzo prostego serwisu wydajność jest okropna, więc zakres zastosowania takiego podejścia jest bardzo wąski.
Naiwne przetwarzanie równoległe
Można uruchomić przetwarzanie wszystkich wiadomości równolegle - to pozwoli wyeliminować liniowy wzrost czasu w zależności od liczby wiadomości. To nie jest szczególnie dobra droga, ponieważ prowadzi do dużego szczytu obciążenia na zewnętrzny serwis.
Wdrożenie przetwarzania równoległego jest bardzo proste:
nadpisać fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { toFrontModel.prywatna fun() }
.collect(toList())
Korzystając z przetwarzania równoległego wiadomości, uzyskamy 300–700 ms w idealnych warunkach, co jest znacznie lepsze niż w przypadku naiwnych implementacji, ale wciąż niezbyt szybkie.
Przy takim podejściu zapytania do userRepository i fileRepository będą wykonywane synchronicznie, co nie jest zbyt efektywne. Aby to naprawić, będziemy musieli znacząco zmienić logikę wywołań. Na przykład, za pomocą CompletionStage (znanego również jako CompletableFuture):
ChatMessageUI( WiadomośćCzatu.prywatna fun(): InterfejsWiadomościCzatowej =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).message = message,()
}.thenCombine(
files?.fileRepository.getHeadsByIds( {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).map { toFrontModel.message = message,() }
}
} ?: CompletableFuture.completedFuture(forwardFrom = forwardFrom?.())
) { author, files ->
id = id ?:
IllegalStateException( throw musi być zachowany""$this author = userRepository.getUserById(author).),
author = author,
files = files?.
files = files,
replyTo = replyTo?.prywatna fun(),
}prywatna fun()
)
}.get()!!
Widać, że początkowo prosty kod mapowania stał się mniej zrozumiały. Wynika to z faktu, że musieliśmy oddzielić wywołania zewnętrznych usług od miejsca użycia wyników. To samo w sobie nie jest złe. Jednak kombinacja wywołań wygląda dość nieładnie i przypomina typowe reaktywne „nudle”.
Jeśli użyjemy korutyn, wszystko będzie wyglądać lepiej:
ChatMessageUI( WiadomośćCzatu.prywatna fun(): InterfejsWiadomościCzatowej =
join(
{ userRepository.getUserById(author).message = message,() },
{ files?.fileRepository.getHeadsByIds( { fileRepository.getHeadsByIds(files)
.map { toFrontModel.message = message,() } } ?: forwardFrom = forwardFrom?.() }
).fileRepository.getHeadsByIds( { (autor, pliki) ->
id = id ?:
IllegalStateException( throw musi być zachowany""$this author = userRepository.getUserById(author).),
autor = autor,
files = files?.
pliki = pliki,
replyTo = replyTo?.prywatna fun(),
}prywatna fun()
)
}
Gdzie:
fun <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll((196 tysięcy linii kodu, 11 autorów, 7 komiterów, 11 otwartych problemów); { a() }, (196 tysięcy linii kodu, 11 autorów, 7 komiterów, 11 otwartych problemów); { b() })
}.fileRepository.getHeadsByIds( {
toFrontModel[0] as A to toFrontModel[1] as B
}
Teoretycznie, korzystając z takiego przetwarzania równoległego, uzyskamy 200–400 ms, co już jest bliskie naszym oczekiwaniom.
Niestety, tak dobre rozdzielenie nie występuje w rzeczywistości, a koszt jest dość wysoki: przy jednoczesnej pracy kilku użytkowników na usługi pojawi się fala zapytań, które i tak nie będą przetwarzane równolegle, więc wrócimy do naszych smutnych 4 s.
Mój wynik przy użyciu takiego serwisu to 1300–1700 ms na przetwarzanie 20 wiadomości. To szybciej niż w pierwszej implementacji, ale wciąż nie rozwiązuje problemu.
Alternatywne zastosowanie zapytań równoległychCo jeśli w zewnętrznych serwisach nie przewidziano przetwarzania pakietowego? Na przykład, można ukryć brak implementacji przetwarzania pakietowego w metodach interfejsów:
interfejs ApiUżytkownikaZdalnego {
fun pobierzUżytkownikaWedługId(id: ReferencjaUżytkownika): UżytkownikZdalny
fun pobierzUżytkownikówWedługId(id: Set<ReferencjaUżytkownika>: Set<UżytkownikZdalny> =
id.parallelStream()
.map { getUserById(toFrontModel) }.collect(toSet())
fun pobierzUżytkownikówWedługId(id: Lista<ReferencjaUżytkownika>: Lista<UżytkownikZdalny> =
id.parallelStream()
.map { getUserById(toFrontModel) }.collect(toList())
}
Ma to sens, jeśli istnieje nadzieja na pojawienie się przetwarzania pakietowego w kolejnych wersjach.
Zalety:
- Łatwe wdrożenie przetwarzania równoległego wiadomości.
- Dobra skalowalność.
Wady:
- Konieczność oddzielenia pozyskiwania danych od ich przetwarzania podczas równoległego przetwarzania zapytań do różnych usług.
- Zwiększona obciążenie dla zewnętrznych usług.
Widać, że zakres zastosowania jest podobny do naiwnego podejścia. Użycie metody równoległych zapytań ma sens, jeśli chcesz znacznie zwiększyć wydajność swojego serwisu kosztem wyzyskiwania obcych zasobów. W naszym przykładzie wydajność wzrosła o 2,5 razy, ale to zdecydowanie za mało.
Odtwarzanie
Można zastosować buforowanie w stylu JPA dla zewnętrznych serwisów, czyli w ramach sesji przechowywać otrzymane obiekty, aby nie pobierać ich ponownie (w tym podczas przetwarzania wsadowego). Można stworzyć takie bufory samodzielnie, można wykorzystać Spring z jego @Cacheable, a także zawsze można ręcznie zastosować gotowy bufor, taki jak EhCache.
Ogólny problem polega na tym, że buforowanie ma sens tylko wtedy, gdy występują trafienia. W naszym przypadku bardzo prawdopodobne są trafienia w polu author (załóżmy, 50%), a trafień w plikach nie będzie w ogóle. Ten sposób przyniesie pewne poprawy, ale nie zmieni radykalnie wydajności (a potrzebujemy przełomu).
Bufory między sesjami (długie) wymagają złożonej logiki unieważniania. Ogólnie, im później dojdziesz do sytuacji, w której będziesz musiał rozwiązywać problemy wydajnościowe za pomocą buforów między sesjami, tym lepiej.
Zalety:
- Wdrożenie buforowania bez zmiany kodu.
- Przyrost wydajności wielokrotnie (w niektórych przypadkach).
Wady:
- Możliwość spadku wydajności przy niewłaściwym użyciu.
- Duże obciążenie pamięci, szczególnie z długimi buforami.
- Złożone unieważnianie, błędy w którym prowadzą do trudnych do odtworzenia problemów w czasie działania.
Bardzo często bufory są używane tylko po to, aby szybko załatać problemy projektowe. To nie oznacza, że nie należy ich używać. Jednak zawsze warto podchodzić do nich z ostrożnością i najpierw ocenić uzyskany przyrost wydajności, a dopiero potem podejmować decyzje.
W naszym przykładzie z buforów uzyskamy przyrost wydajności na poziomie około 25%. Jednocześnie negatywnych aspektów buforów jest dość dużo, więc osobiście bym ich tutaj nie stosował.
Podsumowanie
Zatem rozpatrzyliśmy naiwną implementację serwisu wykorzystującego przetwarzanie wsadowe i kilka prostych metod na jej przyspieszenie.
Główną zaletą wszystkich tych metod jest prostota, z której wynika wiele korzystnych konsekwencji.
Wspólnym problemem tych metod jest słaba wydajność, związana przede wszystkim z rozmiarem pakietów. Jeśli te rozwiązania nie są dla Ciebie odpowiednie, warto rozważyć bardziej radykalne metody.
Są dwa główne kierunki, w których można szukać rozwiązań:
- asynchroniczna praca z danymi (wymaga zmiany paradygmatu, dlatego w tym artykule nie jest omawiana);
- zwiększenie rozmiaru paczek przy zachowaniu synchronizacji.
Zwiększenie rozmiaru paczek pozwoli znacząco zmniejszyć liczbę zewnętrznych wywołań, przy jednoczesnym zachowaniu kodu w trybie synchronizowanym. Temat ten zostanie omówiony w następnej części artykułu.
Źródło: habr.com
