ZBD - Zadanie 4

Autor: Karol Baryła (kb406092) Kod używany w zadaniu dostępny w repozytorium: https://github.com/Lorak-mmk/zbd-zadanie4 Raport zalecany do czytania na https://wiki.armiaprezesa.pl/books/studia-lorak_/page/zbd---zadanie-4/, ponieważ konwersja Markdown -> PDF rozwleka raport na więcej stron niż to potrzebne.

ZBD - Zadanie 4 - system wyświetlania reklam

Platforma testowa

  • CPU: AMD Ryzen 9 3900 12 rdzeni, 24 wątki, 3.1GHz
  • RAM: 64GB (2x32GB) 3200Mhz
  • SSD: 1 TB M.2 Samsung 970 PRO | PCIe 3.0 x4 | NVMe (odczyt ~3.5GB/s, czas dostępu ~0.04ms)

Opis rozwiązań

Redis

Proces 1 generuje nowe zapytanie identyfikowane poprzez UUID. Umieszcza w bazie pod kluczem request-<uuid> hashmapę postaci {'client_id': losowe uuid, 'ip': losowe ip, 'time_1': aktualny czas}. Następnie dodaje informację o tym zapytaniu do dwóch kolejek, po jednej dla procesów typu 2 i 3. Kolejki te realizowane są poprzez listę, więc proces 1 wykonuje komendę RPUSH nazwa_kolejki 'request-<uuid>'. Następnie powtarza wszystko od początku.

Procesy 2 oraz 3 używają komendy BLPOP do pobierania zapytań z kolejki. Jest to blokująca wersja instrukcji LPOP - jeśli lista z której chcemy pobrać element jest pusta, zapytanie blokuje do czasu aż coś się w niej pojawi. Gdy pojawi się nowy element, budzony jest klient oczekujący najdłużej. Według dokumentacji Redisa BLMOVE jest zalecanym sposobem implementacji kolejek - jednak nie musimy się tutaj przejmować awariami procesów, więc BLPOP jest wystarczające.

Proces 2 pobiera element z kolejki, następnie czyta z odpowiadającej hashmapy identyfikator klienta. Na podstawie identyfikatora losuje nazwę kraju i zapisuje ją do tej samej hashmapy pod kluczem 'country'. Jest to uproszczona wersja generowania dodatkowych informacji o zapytaniu - co miał robić ten proces. Po zapisaniu tych danych, wrzuca informację do osobnej kolejki, na którą czeka proces 3.

Proces 3 pobiera zapytanie z kolejki (podobnie jak proces 2). Następnie, w 50% przypadków od razu zapisuje do hashmapy zapytania informację o wyświetleniu reklamy oraz bieżący czas. W pozostałych 50% najpierw czeka aż proces 2 skończy przetwarzanie (czeka na odpowiednią kolejkę, która w sumie nie jest kolejką a bardziej.. semaforem? barierą? zmienną warunkową? chyba semafor najlepiej pasuje), następnie z szansą 50% zapisuje do bazy informację o wyświetleniu reklamy.

Postgres

Całość działa niemal identycznie jak w przypadku redisa - oczywiście uzywamy postgresa zamiast redisa, więc dane są trzymane nieco inaczej. Mamy tabelkę na zapytania (z kolumnami odpowiadającymi kluczom w hashmapie w redisie, czyli request_id, client_id, ip, country, time_1, time_2, time_3, type), oraz 2 tabelki na kolejki (kolumny to request_id i status, tzn czy już zajęte). Do tego jest ustawione wysłanie powiadomienia na odpowiednim kanale przy insercie do kolejki. Próba pobrania elementu z kolejki wygląda tak:

UPDATE queue_2
SET status='taken'
WHERE request_id IN (
    SELECT request_id
    FROM queue_2
    WHERE status='new'
    LIMIT 1
    FOR NO KEY UPDATE SKIP LOCKED
) RETURNING request_id;

Powiadomienia odbieramy natychmiastowo w procesach 2 i 3 z pomocą select, tak jak opisano w dokumentacji: https://www.psycopg.org/docs/advanced.html#asynchronous-notifications

Metodologia testowa

Opóźnienie mierzymy jako różnicę czasów zapisanych przez procesy 3 i 1. Testy przeprowadziłem w dwóch scenariuszach: bez ograniczeń zasobów, oraz z ograniczeniami zasobów poprzez odpowiednie flagi do dockera (--cpus --memory). Każdy test trwa 5 sekund. We wszystkich wykresach oś X oznacza czas wysłania zapytania (czyli czas zapisany przez proces 1), a oś Y czas odpowiedzi na zapytanie (czas 3 - czas 1). Jednostki na obu osiach to milisekundy. Wartość SLA 95% = X oznacza, że 95% zapytań zostało obsłużonych w czasie X lub szybszym.

Redis Pub/Sub

Sporo czasu poświęciłem na użycie mechanizmu pub/sub do przesyłania informacji od procesu 2 do 3 o zakończeniu przetwarzania. Mechanizm ten okazał się jednak zbyt zawodny - pojawiały się sytuacje, gdzie proces odbierający tracił komunikat - co było problematyczne, bo się wieszał i nic nie działało. Próbowałem naprawiać to na różne sposoby - nawet odbierać wiadomości w dedykowanym wątku, by robić to cały czas - bezskutecznie. Musiałem przerobić komunikację 2->3 na podobną do tej jak 1->2 i 1->3. Proces 2 wykonuje RPUSH finished-<uuid>, a 3 robi BLPOP finished-<uuid>. Powoduje to dużo porzuconych kluczy - gdy 3 nie czeka na wynik z 2, w realnej aplikacji trzebaby je jakoś sprzątać, zapewne z użyciem EXPIRE - w tym przykładzie pozwoliłem sobie to pominąć.

Testy

Redis

Zacznijmy od redisa bez ograniczeń sprzętowych. Przy odpaleniu po 1 procesie każdego typu dostajemy następujące wyniki:

Handled 19983 of 74037 results (26.99%)
SLA for Immediately send ad
95%: 2711.64 ms, 99%: 2841.50 ms, 99.9%: 2867.99 ms
SLA for Send ad after processing
95%: 2720.92 ms, 99%: 2846.51 ms, 99.9%: 2866.94 ms

Jak widać pojedynczy proces 1 generuje dane zdecydowanie za szybko, żeby pojedyncze procesy typu 2 i 3 dały radę to obsłużyć. Dwa procesy dalej nie wyrabiają, przy trzech udaje się obsłużyć wszystko, ale występują częste skoki opóźnień:

Handled 39037 of 52186 results (74.8%)
SLA for Immediately send ad
95%: 6.76 ms, 99%: 11.43 ms, 99.9%: 11.99 ms
SLA for Send ad after processing
95%: 7.04 ms, 99%: 11.65 ms, 99.9%: 12.12 ms

Przy 4 procesach jest już całkiem stabilnie:

Handled 38139 of 50821 results (75.05%)
SLA for Immediately send ad
95%: 0.13 ms, 99%: 0.16 ms, 99.9%: 0.21 ms
SLA for Send ad after processing
95%: 0.32 ms, 99%: 0.37 ms, 99.9%: 0.48 ms

Liczba zapytań waha się mniej więcej od 40000 do 55000. Czy możemy ich obsłużyć więcej? Spróbujmy zwiększyć liczbę procesów typu 1 (i odpowiednio pozostałych). Liczba generowanych zapytań nie zwiększa się liniowo wraz ze wzrostem liczby procesów 1, tylko zdecydowanie wolniej (2 procesy - ~75000, 3 procesy - ~80000), i raczej ciężko wejść dużo wyżej niż 80000 zapytań. Najsensowniejszą konfiguracją (wyznaczona metodą prób i błędów) wydaje się <4, 12, 12>, oto jej przykładowe wyniki:

Handled 61537 of 82444 results (74.64%)
SLA for Immediately send ad
95%: 0.34 ms, 99%: 0.43 ms, 99.9%: 0.62 ms
SLA for Send ad after processing
95%: 0.84 ms, 99%: 1.01 ms, 99.9%: 1.41 ms

Dalsze zwiększanie liczby procesów nie poprawia zbytnio ilości zapytań, a wpływa negatywnie na opóźnienia.

Zaproponowane w treści limity (1CPU, 512MB RAM) nie zmieniają zbyt wiele dla konfiguracji <4, 12, 12>:

Handled 61032 of 81536 results (74.85%)
SLA for Immediately send ad
95%: 0.35 ms, 99%: 0.44 ms, 99.9%: 0.67 ms
SLA for Send ad after processing
95%: 0.84 ms, 99%: 1.04 ms, 99.9%: 1.45 ms

Okazuje się, że w tym teście redis ma bardzo małe zużycie pamięci - test przechodzi poprawnie przy fladze --memory=48m, bez zmian w wynikach względem wcześniej pokazanych. W kolejnych testach używam --memory=128m, dla bezpieczeństwa.

Spróbujmy zmniejszyć dostępny czas procesora. Dla --cpu=0.75 mamy:

Handled 56982 of 76054 results (74.92%)
SLA for Immediately send ad
95%: 0.37 ms, 99%: 0.51 ms, 99.9%: 7.70 ms
SLA for Send ad after processing
95%: 0.92 ms, 99%: 1.73 ms, 99.9%: 8.32 ms

Część zapytań była obsługiwana zdecydowanie dłużej niż inne - prawdopodobnie podczas obsługi danego zapytania skończył się redisowi dostępny czas procesora. Bardzo zwiększa to SLA 99.9%. Spróbujmy nieco urealnić ten benchamark (na prawdziwym słabym sprzęcie coś takiego nie powinno zachodzić). Dokumentacja docker mówi, że --cpus="1.5" to to samo co --cpu-period="100000" and --cpu-quota="150000". Możemy zmniejszyć period (i odpowiednio quota), żeby zmniejszyć czas oczekiwania na kolejny kwant czasu. Dla --cpu-period="10000" --cpu-quota="7500" otrzymujemy:

Handled 57639 of 77134 results (74.73%)
SLA for Immediately send ad
95%: 0.37 ms, 99%: 1.08 ms, 99.9%: 1.25 ms
SLA for Send ad after processing
95%: 1.43 ms, 99%: 1.62 ms, 99.9%: 1.97 ms

Jest lepiej. Używamy 75% wątku, a obsługujemy znacznie więcej niż 75% oryginalnej ilości zapytań, i nie pogarszamy zbyt bardzo opóźnień. Oczywiście dalej widać zapytania obsługiwane dłużej, jednak różnica nie jest już tak duża. Próba przejścia do istotnie niższych wartości flag niestety pogarsza sprawę, np dla --cpu-period="2000" --cpu-quota="1500" mamy:

Handled 45475 of 60823 results (74.77%)
SLA for Immediately send ad
95%: 1.09 ms, 99%: 1.79 ms, 99.9%: 1.97 ms
SLA for Send ad after processing
95%: 2.23 ms, 99%: 2.39 ms, 99.9%: 2.97 ms

Uznajmy, że konfiguracja --cpu-period="10000" --cpu-quota="7500" była sensowna dla 75% wątku, spróbujmy ją zastosować do 50% oraz 25% wątku. Dla 50% otrzymujemy:

Handled 25795 of 34452 results (74.87%)
SLA for Immediately send ad
95%: 4.40 ms, 99%: 4.68 ms, 99.9%: 4.91 ms
SLA for Send ad after processing
95%: 5.39 ms, 99%: 5.57 ms, 99.9%: 5.88 ms

Tutaj widzimy już znaczne pogorszenie wyniku. Używamy 50% watku, a obsługujemy mniej niż 50% oryginalnej liczby zapytań, i mamy bardzo dużo opóźnionych zapytań. Zobaczmy jeszcze jak sytuacja się zmieni dla 25%:

Handled 13836 of 18511 results (74.74%)
SLA for Immediately send ad
95%: 7.92 ms, 99%: 8.13 ms, 99.9%: 14.74 ms
SLA for Send ad after processing
95%: 8.83 ms, 99%: 15.20 ms, 99.9%: 15.60 ms

Tutaj oprócz obsługiwania małej liczby zapytań, otrzymujemy koszmarne opóźnienia - co nie jest zbyt zaskakujące biorąc pod uwagę sposób symulowania ograniczeń - chociaż na prawdziwym sprzęcie nie byłoby raczej dużo lepiej.

Redis - wnioski

Moja pierwsza myśl po przeprowadzeniu tych testów, jest taka, że Redis to naprawdę świetny kawałek oprogramowania. Ma praktycznie zerowy overhead pamięciowy (testy bez problemu przechodziły poniżej 48MB użycia RAMu), i jest bardzo szybki - pojedyncza instancja odpalona na laptopie obsługuje ponad 10 tysięcy zapytań na sekundę w przeprowadzanych testach, gdzie każde zapytanie wymaga wykonania kilku komend i pracy kilku procesów. Nieźle radzi sobie też przy ograniczonych zasobach CPU - ograniczenie do jednego wątku nie wpłynęło na wydajność, a ograniczenie do 75% wątku pogorszyło wydajność mniej niż można by się spodziewać. Jest też bardzo wygodny ze strony programistycznej - używanie go z poziomu Pythona jest wygodne i prowadzi do krótkiego i czytelnego kodu, w porównaniu np. z rozwiązaniem opartym na Postgresie. Redis udostępnia też wygodne narzędzie do zaimplementowania kolejek (BLPOP ew BLMOVE).

Jedna uwaga co do testów ze zmniejszonym procesorem: można uzyskać mniejsze opóźnienia używając konfiguracji <1, 4, 4> zamiast <4, 12, 12> przy podobnej (nieco niższej) liczbie przetworzonych zapytań

Postgres

Najbardziej podstawowy test - brak ograniczeń sprzętowych, po 1 procesie każdego rodzaju:

Handled 583 of 776 results (75.13%)
SLA for Immediately send ad
95%: 16.75 ms, 99%: 19.25 ms, 99.9%: 45.52 ms
SLA for Send ad after processing
95%: 15.61 ms, 99%: 17.25 ms, 99.9%: 27.66 ms

Oj, niefajnie. Nie dość, że obsługujemy mało zapytań, to jeszcze mamy duże opóźnienia. Przewaga względem Redisa jest taka, że zdążamy obsługiwać wszystko w podobnym czasie, wykres nie leci cały czas w górę. Wygląda na to, że wszystkie procesy działają podobnie wolno. Próba zmniejszenia opóźnień poprzez zwiększenie liczby procesów 2 i 3 nie daje zbyt wiele. Dla chyba najlepszej konfiguracji (<1, 4, 4>) mamy:

Handled 560 of 754 results (74.27%)
SLA for Immediately send ad
95%: 8.41 ms, 99%: 11.00 ms, 99.9%: 17.67 ms
SLA for Send ad after processing
95%: 16.68 ms, 99%: 22.51 ms, 99.9%: 25.07 ms

Czasu na przetwarzanie danych dla procesu 2 nie ma praktycznie wcale :( Zobaczmy czy jesteśmy w stanie zrobić coś z małą ilością przetwarzanych zapytań - zwiększmy liczbę procesu 1. Niestety, niewiele to daje - nie udało mi się generować istotnie więcej niż 1200 zapytań. Odpuszczę sobie wstawianie statystyk, bo nic ciekawego tam nie ma. Póki co wygląda na to, że Postgres niezbyt nadaje się do tego zadania. Zostańmy przy konfirguracji <1, 4, 4>. Przetestujemy jeszcze ostatnią rzecz - ograniczone zasoby sprzętowe.

Pamięć zachowuje się podobnie, a nawet lepiej, jak w Redisie. Można odpalić bazę z limitem 40MB RAM i nie ma to wpływu na działanie ani zbyt dużego wpływu na wydajność. Dalej będę używał --memory=128M. Sprawdźmy jak się zachowa przy ograniczaniu CPU. Użyję od razu docelowej metody, czyli zmniejszenia też okresu schedulera do 10000 mikrosekund.

Dla 100% wątku nic się nie zmieniło. Dla 75% zwiększyły się opóźnienia (co było dosyć spodziewane):

Handled 553 of 738 results (74.93%)
SLA for Immediately send ad
95%: 8.49 ms, 99%: 13.10 ms, 99.9%: 25.55 ms
SLA for Send ad after processing
95%: 17.08 ms, 99%: 32.81 ms, 99.9%: 35.87 ms

Dla 50% zmniejszyła się też liczba przetworzonych zapytań, oraz znów nieco zwiększyły się opóźnienia (ale większość zapytań dalej było obsługiwanych sporo szybciej niż SLA 95%).

Handled 533 of 684 results (77.92%)
SLA for Immediately send ad
95%: 11.76 ms, 99%: 15.95 ms, 99.9%: 25.46 ms
SLA for Send ad after processing
95%: 22.58 ms, 99%: 42.44 ms, 99.9%: 43.87 ms

Dopiero przy 25% opóźnienia naprawdę się rozlały:

Handled 323 of 431 results (74.94%)
SLA for Immediately send ad
95%: 32.68 ms, 99%: 38.86 ms, 99.9%: 43.12 ms
Icon theme "gnome" not found.
SLA for Send ad after processing
95%: 50.63 ms, 99%: 56.30 ms, 99.9%: 56.67 ms

Co ciekawe, obsługujemy ponad 50% oryginalnej liczby zapytań mimo użycia tylko 25% jednego wątku procesora.

Postgres - wnioski

Postgres lepiej niż Redis radzi sobie ze skalowaniem zasobów sprzętowych w dół - używa mniej pamięci, i lepiej radzi sobie z małą ilością dostępnego CPU względem braku ograniczeń. Jednak jeśli chodzi o wartości opóźnień czy ilości przetworzonych zapytań, Postgres przegrywa z kretesem, przetwarzając około 100x mniej zapytań i mając około 30x gorsze opóźnienia. Zaimplementowanie w nim kolejki było też zdecydowanie trudniejsze niż w Redisie, a sam kod rozwiązania jest większy objętościowo i w mojej subiektywnej opinii mniej czytelny. Ogólnie - Redis zdaje się znacznie lepiej nadawać do postawionego zadania.