Analiza wydajności i konfiguracja — potężne narzędzie do sprawdzania zgodności wydajności dla klientów.
Analiza wydajności może być stosowana do identyfikacji wąskich gardeł w programie, stosując podejście naukowe do testowania eksperymentów konfiguracyjnych. Artykuł ten określa ogólną strategię analizy wydajności i konfiguracji, używając jako przykładu serwera internetowego na Go.
Go jest tu szczególnie odpowiedni, ponieważ posiada narzędzia do profilowania. pprof w standardowej bibliotece.

Strategia
Stwórzmy syntetyczną listę dla naszej analizy strukturalnej. Postaramy się użyć danych do podejmowania decyzji, zamiast wprowadzać zmiany oparte na intuicji czy domysłach. W tym celu zrobimy to w taki sposób:
- Określamy granice optymalizacji (wymagania);
- Obliczamy obciążenie transakcyjne dla systemu;
- Przeprowadzamy test (tworzymy dane);
- Obserwujemy;
- Analizujemy — czy wszystkie wymagania są spełnione?
- Konfigurujemy naukowo, tworzymy hipotezę;
- Przeprowadzamy eksperyment, aby sprawdzić tę hipotezę.

Architektura prostego serwera HTTP
W tym artykule użyjemy małego serwera HTTP napisanego w Golang. Cały kod z tego artykułu można znaleźć .
Analizowana aplikacja — serwer HTTP, który zapytuje Postgresql przy każdym żądaniu. Dodatkowo jest Prometheus, node_exporter i Grafana do zbierania i wyświetlania metryk aplikacji i systemu.

Dla uproszczenia zakładamy, że w przypadku poziomego skalowania (i uproszczenia obliczeń) każdy serwis i baza danych są wdrażane razem:

Określamy cele
Na tym etapie określamy cel. Co próbujemy przeanalizować? Jak dowiemy się, kiedy kończyć? W tym artykule załóżmy, że mamy klientów i że nasza usługa będzie obsługiwać 10 000 żądań na sekundę.
W dokładnie omówiono metody wyboru i modelowania. Postąpmy w podobny sposób, budując modele:
- Opóźnienie: 99% żądań powinno być realizowanych w czasie krótszym niż 60 ms;
- Koszt: usługa powinna zużywać minimalną kwotę pieniędzy, która wydaje się rozsądna. W tym celu maksymalizujemy przepustowość;
- Planowanie pojemności: wymaga zrozumienia i dokumentowania, ile instancji aplikacji będzie potrzebne do uruchomienia, w tym ogólna funkcja skalowania, a także ile instancji będzie koniecznych do spełnienia początkowych wymagań dotyczących obciążenia i zapewnienia .
Opóźnienie może wymagać optymalizacji w uzupełnieniu do analizy, ale przepustowość musi być wyraźnie przeanalizowana. W procesie SRE wymaganie dotyczące opóźnienia pochodzi od klienta lub biznesu, reprezentowanego przez właściciela produktu. Nasza usługa będzie spełniać to zobowiązanie od samego początku, bez żadnych ustawień!
Konfiguracja środowiska testowego
Dzięki środowisku testowemu będziemy mogli wytworzyć dawkowane obciążenie dla naszego systemu. Będą zbierane dane dotyczące wydajności usługi internetowej.
Obciążenie transakcyjne
To środowisko wykorzystuje do generowania dostosowanej częstotliwości żądań HTTP, aż do momentu zatrzymania:
$ make load-test LOAD_TEST_RATE=50
echo "POST http://localhost:8080" | vegeta attack -body tests/fixtures/age_no_match.json -rate=50 -duration=0 | tee results.bin | vegeta reportMonitorowanie
W trakcie wykonania będzie stosowane obciążenie transakcyjne. Oprócz metryk aplikacji (liczba żądań, opóźnienia odpowiedzi) i systemu operacyjnego (pamięć, CPU, IOPS) uruchomione zostanie profilowanie aplikacji, aby zrozumieć, gdzie pojawiają się problemy, a także jak wykorzystywany jest czas procesora.
Profilowanie
Profilowanie to rodzaj pomiaru, który pozwala zobaczyć, gdzie przepływa czas procesora podczas działania aplikacji. Umożliwia określenie, gdzie dokładnie i ile czasu procesora jest używane:

Te dane można wykorzystać podczas analizy, aby uzyskać wgląd w niepotrzebnie zmarnowany czas procesora i wykonywaną niepotrzebną pracę. Go (pprof) może generować profile i wizualizować je w formie flame graph, korzystając z zestawu standardowych narzędzi. Przedstawię ich wykorzystanie i wskazówki dotyczące konfiguracji nieco niżej w artykule.
Wykonanie, monitorowanie, analiza.
Przeprowadzimy eksperyment. Będziemy wykonywać, obserwować i analizować, aż osiągniemy satysfakcjonującą wydajność. Wybierzemy losowo niską wartość obciążenia, aby zastosować ją do uzyskania wyników pierwszych obserwacji. Na każdym kolejnym kroku będziemy zwiększać obciążenie według pewnego współczynnika skalowania, wybranego z pewnym rozrzutem. Każde uruchomienie testu obciążeniowego odbywa się z regulacją liczby żądań: make load-test LOAD_TEST_RATE=X.
50 żądań na sekundę

Zauważ dwa górne wykresy. Górny lewy pokazuje, że nasza aplikacja przetwarza 50 żądań na sekundę (według jej oceny), a górny prawy — czas trwania każdego żądania. Oba parametry pomagają nam monitorować i analizować: czy mieszczemy się w naszych granicach wydajności, czy nie. Czerwona linia na wykresie Opóźnienie żądania HTTP pokazuje SLO na poziomie 60ms. Z linii widać, że jesteśmy znacznie poniżej naszego maksymalnego czasu odpowiedzi.
Przyjrzyjmy się z perspektywy kosztów:
10000 żądań na sekundę / na 50 żądań na serwer = 200 serwerów + 1
Wciąż możemy poprawić ten wskaźnik.
500 żądań na sekundę
Bardziej interesujące rzeczy zaczynają się dziać, gdy obciążenie wynosi 500 żądań na sekundę:

Znów na lewym górnym wykresie widać, że aplikacja rejestruje normalne obciążenie. Jeśli tak nie jest, to oznacza, że występuje problem z serwerem, na którym uruchomiona jest aplikacja. Wykres z opóźnieniem odpowiedzi znajduje się na górze po prawej, pokazuje, że 500 żądań na sekundę doprowadziło do opóźnienia odpowiedzi w granicach 25-40ms. 99 percentyl nadal świetnie mieści się w wybranym wcześniej SLO na poziomie 60ms.
Z punktu widzenia kosztów:
10000 żądań na sekundę / na 500 żądań na serwer = 20 serwerów + 1
Wciąż można poprawić.
1000 żądań na sekundę

Świetne uruchomienie! Aplikacja pokazuje, że przetworzyła 1000 żądań na sekundę, jednak granica opóźnienia została naruszona z punktu widzenia SLO. Widać to po linii p99 na górnym prawym wykresie. Mimo że linia p100 jest znacznie wyższa, rzeczywiste opóźnienia przekraczają maksimum 60ms. Przeanalizujmy profilowanie, aby dowiedzieć się, co naprawdę robi aplikacja.
Profilowanie
W celu profilowania ustawiamy obciążenie na 1000 żądań na sekundę, a następnie używamy pprof do zbierania danych, aby dowiedzieć się, gdzie aplikacja traci czas procesora. Można to zrobić, aktywując punkt końcowy HTTP. pprof, po czym podczas obciążenia zapisać wyniki za pomocą curl:
$ curl http://localhost:8080/debug/pprof/profile?seconds=29 > cpu.1000_reqs_sec_no_optimizations.profWyniki mogą być wyświetlane następująco:
$ go tool pprof -http=:12345 cpu.1000_reqs_sec_no_optimizations.prof
Wykres pokazuje, gdzie i ile procesora zużywa aplikacja. Z opisu od :
Na osi X - wypełnienie profilu stosu, posortowane alfabetycznie (to nie czas), oś Y pokazuje głębokość stosu, licząc od zera w [top]. Każdy prostokąt to kadr stosu. Im szerszy kadr - tym częściej występuje w stosach. To, co na górze - działa na CPU, a poniżej - elementy potomne. Kolory zazwyczaj nic nie oznaczają, a są po prostu wybierane losowo do różnicowania kadrów.
Analiza - hipoteza
Dla dostosowania skupimy się na próbie znalezienia niepotrzebnych wydatków czasu procesora. Będziemy szukać największych źródeł niepotrzebnych wydatków i je usuwać. Uwzględniając, że profilowanie bardzo dokładnie ujawnia, gdzie aplikacja trwoni czas procesora, może być konieczne powtórzenie tego kilka razy oraz modyfikacja kodu źródłowego aplikacji, ponowne uruchamianie testów i obserwowanie, jak wydajność zbliża się do zamierzonej.
Zgodnie z zaleceniami Brendan Gregg będziemy czytać wykres od góry do dołu. Każdy wiersz przedstawia kadr stosu (wywołanie funkcji). Pierwszy wiersz to punkt wejścia do programu, rodzic wszystkich innych wywołań (innymi słowy, wszystkie inne wywołania będą miały go w swoim stosie). Następny wiersz już się różni:
![]()
Kiedy najeżdżasz kursorem na nazwę funkcji na wykresie - wyświetli się całkowity czas, przez jaki znajdowała się w stosie podczas debugowania. Funkcja HTTPServe spędziła tam 65% czasu, inne funkcje runtime, runtime.mcall, mstart i gc, zajmowały pozostały czas. Interesujący fakt: 5% całkowitego czasu poświęcono na zapytania do DNS:

Adresy, które program szuka, należą do Postgresql. Klikamy na FindByAge:

Interesujące, program pokazuje, że w zasadzie istnieją trzy główne źródła, które wprowadzają opóźnienia: otwieranie i zamykanie połączeń, zapytania o dane i łączenie z bazą. Na wykresie widać, że zapytania do DNS, otwieranie i zamykanie połączeń zajmują około 13% całkowitego czasu wykonania.
Hipoteza: Wykorzystanie połączeń za pomocą puli powinno skrócić czas jednego zapytania HTTP, umożliwiając wyższą przepustowość i niższe opóźnienia..
Konfiguracja aplikacji — eksperyment.
Aktualizujemy kod źródłowy, próbując zredukować połączenie z Postgresql przy każdym zapytaniu. Pierwsza opcja — użycie. na poziomie aplikacji. W tym eksperymencie skonfigurujemy. db, err := sql.Open("postgres", dbConnectionString) db.SetMaxOpenConns(8)if err != nil { return nil, err }
Wykonanie, monitorowanie, analiza.Po ponownym uruchomieniu testu z 1000 zapytań na sekundę widać, że p99 opóźnień wróciło do normy ze SLO 60ms!
Jak wygląda koszt?
10000 zapytań na sekundę / na 1000 zapytań na serwer = 10 serwerów + 1.
Zróbmy to jeszcze lepiej!
2000 zapytań na sekundę.
Podwojenie obciążenia pokazuje to samo, w lewym górnym wykresie widać, że aplikacja radzi sobie z 2000 zapytań na sekundę, p100 poniżej 60ms, p99 spełnia SLO.

10000 zapytań na sekundę / na 2000 zapytań na serwer = 5 serwerów + 1.
Z punktu widzenia kosztów:
3000 zapytań na sekundę.
Tutaj aplikacja może przetworzyć 3000 zapytań z opóźnieniem p99 poniżej 60ms. SLO nie jest naruszane, a koszt przyjęty tak:

10000 zapytań na sekundę / na 3000 zapytań na serwer = 4 serwery + 1.
(autor zaokrąglił w górę, Spróbujmy jeszcze raz przeanalizować. przyp. tłumacza)
Zbieramy i wyświetlamy wyniki debugowania aplikacji przy 3000 zapytaniach na sekundę:
Analiza - hipoteza
Nadal 6% czasu spędza się na ustanawianiu połączeń. Konfiguracja puli poprawiła wydajność, ale wciąż widać, że aplikacja kontynuuje proces tworzenia nowych połączeń z bazą.

Połączenia, mimo istnienia puli, nadal są odrzucane i usuwane, więc aplikacja musi je ponownie ustanawiać. Ustalenie liczby oczekujących połączeń równych rozmiarowi puli powinno pomóc w opóźnieniu poprzez minimalizację czasu, jaki aplikacja spędza na tworzeniu połączeń.
Hipoteza: Spróbujmy ustawić..
Konfiguracja aplikacji — eksperyment.
MaxIdleConns. db, err := sql.Open("postgres", dbConnectionString) db.SetMaxOpenConns(8) db.SetMaxIdleConns(8) if err != nil { return nil, err } ):
p99 poniżej 60ms z znacząco niższym p100!Po ponownym uruchomieniu testu z 1000 zapytań na sekundę widać, że p99 opóźnień wróciło do normy ze SLO 60ms!
Tutaj aplikacja może przetworzyć 3000 zapytań z opóźnieniem p99 poniżej 60ms. SLO nie jest naruszane, a koszt przyjęty tak:

Sprawdzanie flame graph pokazuje, że ustanawianie połączenia jest już niezauważalne! Sprawdźmy szczegóły.

pg(*conn).query. — także nie zauważamy ustanawiania połączenia tutaj. — tutaj również nie zauważamy nawiązania połączenia.

Podsumowanie
Analiza wydajności jest kluczowa dla zrozumienia, czy oczekiwania klientów i wymagania niefunkcjonalne są spełnione. Analiza poprzez porównanie obserwacji z oczekiwaniami klientów może pomóc określić, co jest akceptowalne, a co nie. Go oferuje efektywne narzędzia wbudowane w standardową bibliotekę, które umożliwiają łatwą i dostępną analizę.
Źródło: habr.com
