WIADOMOŚCI

Benchmarking protokołów RPC

Published on:15 / February / 2016

Platforma Infobip nie jest jednym wielkim monolitem. Jako typowy przykład infrastruktury mikrousługowej składa się ona z wielu drobnych usług stanowiących różne elementy platformy. Każda usługa ma jeden, dobrze zdefiniowany cel, i uwzględnia tylko zagadnienia umożliwiające jej możliwie jak najlepsze wykonanie zadania.

Posiadanie wielu drobnych usług ułatwia zarządzanie całą platformą. Każdą usługę można w dowolnym momencie zaktualizować bez zakłócania pracy pozostałych usług. Serwisy mogą być (i są) pisane w różnych językach programowania, które najlepiej nadają się do rozwiązywanego problemu. Części platformy obsługujące duże natężenie ruchu mogą być skalowane niezależnie od części obsługujących niewielki ruch. Są to zalety architektury mikrousługowej, które sprawiają, że rozwój platformy Infobip jest wspaniałym doświadczeniem.

Jednak żadna usługa nie funkcjonuje w próżni. Ponieważ usługi odpowiadają jedynie za jedną niewielką część, te z nich, które ze sobą współpracują, muszą mieć jakiś sposób komunikacji. Na przykład uruchomieniem kampanii SMS-owej na naszym Infobip Portal będzie się zajmować usługa odpowiedzialna za kampanie. Jeśli kampania obejmuje kontakty lub grupy klientów, zostaną one pozyskane z innej usługi, która zarządza kontaktami. Jeszcze inne usługi zajmują się tworzeniem harmonogramów wiadomości, ich trasowaniem, rozliczaniem oraz samym dostarczaniem do subskrybentów mobilnych.

Wybory, wybory

Nasze usługi komunikują się ze sobą poprzez różne protokoły zdalnego wywołania procedury (RPC). Na przestrzeni lat wypróbowaliśmy wiele takich protokołów, ale obecnie wykorzystujemy jedynie trzy.

  • Stary dobry HTTP/1.1. Posiada możliwość wdrożenia wielu klientów i serwerów w praktycznie dowolnym języku programowania, co sprawia, że dobrze sprawdza się w obsłudze naszej wielojęzycznej platformy. Zwykle za pośrednictwem tego protokołu wysyłamy zwykły JSON, co ułatwia debugowanie usług, bo potrzebna jest tylko przeglądarka sieciowa lub klient REST.
  • Nasz drugi protokół to też HTTP, ale tym razem wysyłające serializowane obiekty Java w obie strony. Ten protokół był domyślny, gdy dwie usługi napisane w języku programowania Java komunikowały się ze sobą, ponieważ mogą one natywnie odczytywać i zapisywać tego rodzaju dane.
  • MML (Machine-to-Machine-Language) to nasz własny protokół opracowany pod kątem dużej przepustowości. Wykazuje podobieństwa do HTTP/2, który jeszcze nie istniał, gdy zaczęliśmy pracę w MML. Wykorzystuje jedno stałe połączenie TCP pomiędzy klientem a serwerem, umożliwia klientom zachowanie równowagi obciążenia pomiędzy serwerami, a w razie konieczności przejmuje funkcję w trakcie awarii. MML wykorzystuje typowany JSON jako ładunek – wygląda on jak zwykły JSON, ale każdy obiekt jest otagowany typem Java. Z tego względu MML wykorzystuje się tylko pomiędzy usługami Java.

Posiadanie trzech różnych sposobów komunikacji nieco utrudnia dokonanie wyboru. Każdy protokół ma swoje zalety i wady dotyczące wydajności, debugowalności i interoperacyjności z usługami w języku innym niż Java. MML uważany jest za najbardziej wydajny, ale ogranicza się do komunikacji pomiędzy dwiema usługami Java. Jeśli w grę wchodzą inne języki programowania, do wyboru pozostaje tylko jedna opcja, czyli zwykły JSON w HTTP.

Posiadanie trzech rozwiązań tego samego problemu jest dalekie od ideału, Wszystkie działania zmierzające do usprawnienia komunikacji między usługami trzeba wykonywać trzy razy dla każdego protokołu, z których każdy ma swoje dziwactwa, co utrudnia wdrożenie tych samych funkcji w każdym protokole. Idealnie byłoby mieć tylko jeden protokół – taki, który odznacza się wydajnością i łatwością debugowania oraz może być używany z różnymi językami programowania. Chciałoby się zjeść ciastko i mieć ciastko.

Posiadanie trzech rozwiązań tego samego problemu jest dalekie od ideału, Wszystkie działania zmierzające do usprawnienia komunikacji między usługami trzeba wykonywać trzy razy dla każdego protokołu, z których każdy ma swoje dziwactwa, co utrudnia wdrożenie tych samych funkcji w każdym protokole. Idealnie byłoby mieć tylko jeden protokół – taki, który odznacza się wydajnością i łatwością debugowania oraz może być używany z różnymi językami programowania. Chciałoby się zjeść ciastko i mieć ciastko.

Ponadto potrzebny był nam benchmark. Łatwo jest napisać benchmark, który wykona kilka wywołań w pętli i zmierzy przepustowość. Jeszcze łatwiej przy takim benchmarku popełnić błędy, tak że nie będzie on mierzył tego, co nas interesuje, albo jego wyniki będą zniekształcone, a przez to bezużyteczne.

Pisanie (dobrego) benchmarku

Dwa z trzech naszych protokołów ograniczają się do języka programowania Java, więc sensowne jest napisanie całego benchmarku w tym języku. Jednak napisanie benchmarków, które mogą bez problemów działać w wirtualnej maszynie Java (JVM) jest trudne. Trzeba pamiętać o tylu szczegółach:

  • Wyprofilowanie i optymalizacja wygenerowanego kodu zajmuje JVM trochę czasu. Nie ma sensu dokonywać pomiaru, zanim JVM nie zakończy optymalizacji.
  • JVM wyprofiluje i zoptymalizuje kod zgodnie z wzorcem rzeczywistego wykorzystania. Jeśli benchmarkujemy dwa protokoły jeden po drugim, drugi z nich może uzyskać gorsze wyniki, bo JVM wyspecjalizowała już kod pod kątem pierwszego. Aby temu zapobiec, każdy benchmark powinien być uruchomiony w nowym, czystym procesie.
  • W przypadku benchmarków wielowątkowych należy zachować szczególną ostrożność, aby wszystkie wątki rozpoczynały i kończył pomiar w tym samym czasie.
  • A to nawet nie połowa wszystkich potencjalnych problemów. Na szczęście istnieją narzędzia, które mogą rozwiązać dla nas te problemy. Jednym z nich jest Java Microbenchmark Harness (JMH). Narzędzie to zostało napisane przez programistów JVM. Możemy spokojnie założyć, że wiedzą, co robią.

Pisanie benchmarków w JMH jest łatwe Musimy tylko napisać kod, który chcemy poddać pomiarom, a JMH zajmie się wszystkim innym – uruchomieniem kodu w pętli w celu pomiaru przepustowości, prowadzeniem oddzielnych procesów dla każdego benchmarku, zapewnieniem JVM odpowiedniego czasu na „rozgrzewkę” i odpowiednią optymalizację kodu oraz innymi uciążliwymi (ale ważnymi) szczegółami.

 

Szkielet naszego benchmarku wygląda mniej więcej tak:

public class ProtocolBenchmark {
 
    @Param({"http_json", "http_java", "mml"})
    private String protocol;
 
    @Param({"0", "10", "100"})
    private int payloadSize;
 
    @Setup
    public void setup() {
        // Uruchomienie serwera i klientów
    }
 
    @Benchmark
    @Threads(1)
    public Object threads_1() {
        // Wykonaj wywołanie RPC
        // Zostanie wywołane tylko z jednego wątku (klienta)
    }
 
    @Benchmark
    @Threads(32)
    public Object threads_32() {
        // Wykonaj wywołanie RPC
        // Zostanie wywołane równolegle z 32 wątków (klientów)
    }

}

Narzędzie JHM jest dość elastyczne i może być dostosowywane do różnych scenariuszy. Najbardziej przydatnymi opcjami dla naszego benchmarku były parametry i liczba wątków. Parametry umożliwiły nam ponowne wykorzystanie tego samego benchmarku dla różnych scenariuszy. Nie ma różnicy w logice benchmarku przy pomiarze różnych protokołów lub wykorzystaniu różnej wielkości ładunków, więc dzięki parametrom możemy uniknąć kopiowania i wklejania tego samego kodu. Z kolei zmienna liczba jednocześnie uruchamianych wątków pozwala nam mierzyć, jak protokół radzi sobie z rosnącą liczbą równoległych klientów.

Uruchamianie benchmarku

Pora uruchomić nasz mały benchmark! Przeprowadziliśmydwie odrębne próby w różnych warunkach.

Podczas pierwszej próby serwer i klienci działali na tej samej maszynie, komunikując się przez interfejs localhosta. Wykorzystaliśmy ten scenariusz do pomiaru czystego obciążenia protokołu przy braku czynników zewnętrznych wpływających na pomiary.

Podczas drugiej próby umieściliśmy protokoły w bardziej realistycznych warunkach. Serwer działał na innej maszynie niż klienci, a komunikacja odbywała się przez rzeczywistą sieć. W przypadku protokołów HTTP cały ruch trasowany był przez HAProxy, ponieważ właśnie takie rozwiązanie wykorzystujemy w środowisku produkcyjnym. Ponieważ MML obsługuje równoważenie obciążenia po stronie klienta, klient może łączyć się z samym serwerem bez żadnych pośredników.

Benchmark uruchamiał określoną liczbę klientów, zaś każdy klient wielokrotnie wykonywał zdalne wywołanie o określonej wielkości ładunku, a następnie czekał aż serwer powtórzy ten sam ładunek. Ładunkiem była po prostu lista obiektów, a wielkością – liczba obiektów na liście.

Liczba klientów i wielkość ładunku została określona w parametrach, aby uwzględnić wzorce komunikacji typowe dla naszych usług. Dokonaliśmy pomiarów z 1, 32, 64 i 128 równoległymi klientami oraz z wielkością ładunku od 0 (pusta lista) do 10 i 100 elementów.

Wyniki

Po przeprowadzeniu próby JHM wygeneruje dane tabularyczne z pomiarami dla każdej kombinacji wskazanych parametrów. Przedstawiliśmy te wyniki w formie prostych wykresów słupkowych, które ułatwiają porównanie.

Początkowo podczas pomiaru komunikacji przez localhosta otrzymujemy coś takiego:

Engineering

Przy małej i średniej wielkości ładunków zdecydowanie wygrywa protokół MML, niezależnie od liczby równoległych klientów. To się jednak zmieniło, gdy przeszliśmy do większych wielkości – wtedy protokoły oparte na HTTP niemal dorównywały MML pod względem przepustowości. Ciekawe jest też to, że używanie serializowanych obiektów Java jest wolniejsze niż używanie JSON. Wygląda na to, że używana przez nas biblioteka JSON, Jackson, jest lepiej zoptymalizowana dla naszego zastosowania.

W drugiej próbie, przeprowadzonej w rzeczywistej sieci, wyniki wyglądają następująco:

Engineering

MML nadal prowadzi w przypadku małych i średnich ładunków, ale jego przewaga jest mniejsza. A w przypadku większych ładunków sytuacja się odwróciła. Przesłanie zwykłego JSON w HTTP jest znacznie bardziej wydajne niż MML. Prawdopodobną przyczyną źródłową takiego wyniku jest typowany JSON – te wszystkie dodatkowe tagi znacząco zwiększają liczbę bajtów, którą trzeba przesłać przewodowo, na czym cierpi przepustowość.

Podsumowanie

Czego więc dowiedzieliśmy się z tego eksperymentu? Nasz własny protokół, choć świetnie się sprawdza przy małych lub średnich ładunkach, nie jest już tak dobrym wyborem w przypadku usług wysyłających duże ilości żądań i odpowiedzi. Z drugiej strony, JSON w HTTP odznacza się przyzwoitą wydajnością i może być wykorzystywany przez usługi napisane w dowolnym języku, co stanowi zdecydowany plus. Na koniec, serializacja Java w HTTP uzyskała słabsze wyniki we wszystkich testowanych scenariuszach, więc z tego protokołu zdecydowanie zrezygnujemy.

Ostatecznie nie udało nam się wyłonić zdecydowanego zwycięzcy. Chcielibyśmy przyjrzeć się optymalizacji JSON w HTTP – lub może opracować prototyp rozwiązania opartego na HTTP/2 i zobaczyć, jak się sprawdzi. Przed nami jeszcze wiele pracy. Obserwujcie nas!

By Hrvoje Ban, Software Engineer