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: