Budujemy DropReady: Adaptive Polling vs WebSockets - dlaczego wybraliśmy inaczej (Tydzień 1)

Opublikowano 21 lipca 2026

Budujemy DropReady: Adaptive Polling vs WebSockets - dlaczego wybraliśmy inaczej (Tydzień 1)

W poprzednim wpisie wspomnieliśmy że DropReady nie używa WebSocketów. Kilka osób zapytało dlaczego. Ten wpis to pełna, techniczna odpowiedź.

Zacznijmy od uczciwego przeglądu obu technologii, bo WebSocket to świetna technologia — po prostu nie do tego przypadku użycia.

Jak działa WebSocket i kiedy ma sens

WebSocket to protokół który ustanawia stałe, dwukierunkowe połączenie między przeglądarką a serwerem. Zamiast klasycznego cyklu HTTP (request, response, koniec połączenia), WebSocket utrzymuje otwarty kanał przez cały czas sesji.

To jest absolutnie właściwy wybór gdy potrzebujesz komunikacji w czasie rzeczywistym z serwera do klienta: live chat, powiadomienia push, multiplayer games, live trading, collaborative editing. Wszędzie tam gdzie serwer musi aktywnie "pchać" dane do klienta bez czekania na jego żądanie.

Problem zaczyna się gdy zaczynasz liczyć zasoby przy dużej skali.

Dlaczego WebSocket jest złym wyborem dla virtual waiting room

Każde otwarte połączenie WebSocket to otwarty deskryptor pliku na serwerze. Domyślny limit deskryptorów plików per proces na Linuksie wynosi 1024, choć w praktyce administratorzy podnoszą go do 65536 lub więcej. Mimo to, przy 100 000 jednoczesnych użytkownikach w kolejce, zarządzanie 100 000 otwartymi połączeniami wymaga precyzyjnego tuningu systemu operacyjnego zanim w ogóle zaczniesz pisać kod aplikacji.

To jednak nie jest największy problem. WebSocket jest stateful. Każde połączenie ma stan: który użytkownik, jaka pozycja w kolejce, kiedy ostatnio wysłano aktualizację. Gdy chcesz skalować poziomo i dodać drugi serwer, musisz zsynchronizować ten stan między nodami. Potrzebujesz sticky sessions albo zewnętrznego store'a stanów. Architektura natychmiast komplikuje się nielinearnie.

I jest jeszcze jeden problem który pojawia się w realnym świecie: mobilne połączenia. Użytkownik jedzie pociągiem, wjeżdża w tunel, traci zasięg na 30 sekund. Połączenie WebSocket się zrywa. Co teraz? Czy użytkownik traci swoją pozycję w kolejce? Czy serwer wie że połączenie zerwało się nieoczekiwanie czy celowo? Ile czasu czekasz zanim uznasz slot za zwolniony? To są pytania na które musisz odpowiedzieć w kodzie i każda odpowiedź dodaje złożoności.

Dla virtual waiting room - gdzie użytkownik i tak czeka i aktualizacja pozycji raz na kilka sekund jest absolutnie wystarczająca — WebSocket to armata na muchę, z całym bagażem złożoności który ze sobą niesie.

Thundering Herd i dlaczego naiwny polling też nie wystarczy

Skoro WebSocket odpada, pierwszą myślą jest klasyczny polling: przeglądarka wysyła żądanie HTTP co X sekund i pyta "jaka jest moja pozycja?".

Problem polega na tym że przy dużej liczbie użytkowników, naiwny polling z identycznym interwałem prowadzi do synchronizacji timerów. Wyobraź sobie 50 000 użytkowników którzy wszyscy zaczęli pollować w tym samym momencie i mają identyczny 3-sekundowy interwał. Co 3 sekundy serwer dostaje spike 50 000 requestów naraz, po czym przez 2.9 sekundy nic, i znowu spike. Zamiast równomiernego obciążenia masz piłę która destabilizuje serwer.

Co gorsza, gdy serwer na chwilę niedostępny i wraca do działania, wszyscy klienci którzy czekali na reconnect próbują się połączyć jednocześnie. To jest klasyczny Thundering Herd problem.

Adaptive Polling z Jitterem - jak to mitygujemy

Nasze rozwiązanie łączy trzy mechanizmy:

Adaptive backoff - interwał pollingu nie jest stały. Gdy użytkownik jest daleko w kolejce (pozycja > 100), polluje rzadziej, np. co 10 sekund. Gdy zbliża się do frontu (pozycja < 10), polluje częściej, np. co 2 sekundy. Aktualizacja pozycji jest proporcjonalna do tego jak pilna jest ta informacja dla użytkownika.

Jitter - do każdego interwału pollingu dodajemy losowe przesunięcie czasowe. Użytkownik który miał pollować co 5 sekund, faktycznie polluje co 4.2 albo 5.8 sekundy. Przy dużej liczbie użytkowników Jitter znacząco łagodzi problem synchronizacji timerów i pomaga równomierniej rozłożyć ruch w czasie. Nie eliminuje Thundering Herd całkowicie — to wymagałoby dodatkowych mechanizmów jak circuit breaker czy rate limiting po stronie serwera — ale drastycznie zmniejsza amplitudę spikeów.

Stateless server - każde żądanie pollingu jest kompletnie niezależne. Serwer nie przechowuje żadnego stanu per połączenie. Klient wysyła swój token kolejki, serwer sprawdza pozycję w KeyDB i odpowiada. Połączenie zamykane natychmiast. Można dodać dowolną liczbę nodów za load balancerem bez żadnej synchronizacji stanu między nimi.

Implementacja w Go z fasthttp - diabeł tkwi w szczegółach

Go był naturalnym wyborem dla tego przypadku. Goroutines są znacznie tańsze niż wątki systemowe, co pozwala na obsługę tysięcy jednoczesnych żądań przy minimalnym zużyciu pamięci. fasthttp zamiast standardowej biblioteki net/http daje dodatkowy boost wydajności przez agresywne reużywanie połączeń i buforów.

Przy implementacji wysokowydajnych endpointów w fasthttp warto zwrócić uwagę na trzy rzeczy których większość tutoriali nie pokazuje.

Po pierwsze, standardowy json.Marshal z pakietu encoding/json bazuje na refleksji i przy każdym wywołaniu alokuje nową pamięć na stercie (heap). Przy dziesiątkach tysięcy zapytań na sekundę, GC w Go "udławi" się zbierając te śmieci. Przy tak prostych, przewidywalnych payloadach jak odpowiedź polling endpointu, znacznie lepiej jest pisać bezpośrednio do bufora requestu przez fmt.Fprintf.

Po drugie, każde zapytanie do KeyDB musi mieć sztywny timeout. Bez context.WithTimeout, gdy KeyDB złapie chwilową zadyszkę (np. podczas robienia snapshotu na dysk przez BGSAVE), tysiące goroutyn zablokują się w oczekiwaniu na odpowiedź i serwer padnie z OOM mimo że kod aplikacji jest poprawny.

Po trzecie, odpowiedź na błąd bazy danych nie może być HTTP 500. Gdy frontend Vue dostanie 500-tkę, prawdopodobnie wpadnie w panikę i zacznie ponawiar requesty bez opóźnienia - w ten sposób przy awarii KeyDB sami dobijamy własny serwer lawiną requestów. Zamiast błędu, zwracamy HTTP 200 z wydłużonym next_poll_interval - to jest Circuit Breaker który wymusza na klientach zwolnienie podczas awarii.

func handlePoll(ctx *fasthttp.RequestCtx) {
    token := string(ctx.QueryArgs().Peek("token"))
    if len(token) < 32 { // Minimalna walidacja przed uderzeniem w DB
        ctx.SetStatusCode(fasthttp.StatusBadRequest)
        return
    }

    // Ochrona KeyDB: sztywny timeout na zapytanie
    c, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
    defer cancel()

    position, err := queue.GetPosition(c, token)
    if err != nil {
        // Circuit Breaker: zamiast rzucać 500, każemy klientom zwolnić
        ctx.SetContentType("application/json")
        ctx.SetBodyString(`{"status":"waiting","next_poll_interval":30000}`)
        return
    }

    if position == 0 {
        ctx.SetContentType("application/json")
        ctx.SetBodyString(`{"status":"admitted"}`)
        return
    }

    estimatedWait := calculateEstimatedWait(position)
    nextPollInterval := calculateAdaptiveInterval(position)

    // Zero-Allocation Response
    ctx.SetContentType("application/json")
    fmt.Fprintf(ctx, `{"status":"waiting","position":%d,"estimated_wait":%d,"next_poll_interval":%d}`,
        position, estimatedWait, nextPollInterval)
}

func calculateAdaptiveInterval(position int) int {
    base := 5000
    if position < 10 {
        base = 2000
    } else if position > 100 {
        base = 10000
    }
    jitter := rand.Intn(base/5) - base/10
    return base + jitter
}

Zwróćcie uwagę na brak json.Marshal - przy takiej skali refleksja w Go jest zbyt droga, więc bezpośrednio formatujemy odpowiedź, unikając alokacji na stercie. Dodatkowo, przy jakimkolwiek błędzie bazy danych (timeout), nie zwracamy HTTP 500, tylko wydłużony next_poll_interval — tzw. Circuit Breaker który wymusza na tłumie zwolnienie odpytywania i chroni infrastrukturę przed kaskadową awarią.

Co dalej

Następny wpis techniczny będzie o Early Intercept i weryfikacji tokenów Ed25519. Jak przechwytujemy ruch zanim dotrze do serwera klienta i dlaczego kryptografia asymetryczna jest tutaj właściwym narzędziem.

Masz pytania o wybory architektoniczne DropReady? Napisz w komentarzu lub odezwij się bezpośrednio.

FAQ

Czym Adaptive Polling różni się od WebSocket w kontekście virtual waiting room?

WebSocket utrzymuje stałe, otwarte połączenie przez cały czas oczekiwania użytkownika, co przy dużej skali wymaga zarządzania tysiącami otwartych deskryptorów plików i synchronizacji stanu między nodami. Adaptive Polling wysyła krótkie, bezstanowe żądania HTTP co kilka sekund i natychmiast zamyka połączenie. Serwer nie przechowuje żadnego stanu per użytkownik, co czyni go trywialnie skalowalnym poziomo.

Dlaczego Jitter nie eliminuje Thundering Herd całkowicie?

Jitter rozkłada timery klientów losowo, co drastycznie zmniejsza amplitudę spikeów przy normalnym działaniu. Jednak przy nagłym restarcie serwera lub powrocie po awarii, wszyscy klienci którzy mieli połączenie zerwane próbują się reconnectować w podobnym czasie. Pełna ochrona wymaga dodatkowych mechanizmów: exponential backoff po stronie klienta przy błędach połączenia oraz rate limiting i circuit breaker po stronie serwera.

Co to jest Zero-Allocation Response i dlaczego ma znaczenie?

Standardowy json.Marshal alokuje nową pamięć na stercie przy każdym wywołaniu. Przy dziesiątkach tysięcy requestów na sekundę, Garbage Collector Go musi te alokacje zbierać, co powoduje krótkie pauzy (GC stop-the-world) i zużywa CPU. Pisząc odpowiedź bezpośrednio przez fmt.Fprintf do bufora fasthttp, unikamy tych alokacji i pozwalamy GC skupić się na faktycznie potrzebnych operacjach.

Co się dzieje z użytkownikiem w kolejce gdy KeyDB jest niedostępne?

Zamiast zwracać HTTP 500 (co spowodowałoby lawinę ponowień bez opóźnienia), serwer zwraca HTTP 200 z wydłużonym next_poll_interval wynoszącym 30 sekund. Frontend traktuje to jak normalną odpowiedź i czeka 30 sekund przed kolejnym żądaniem. Pozycja użytkownika w kolejce jest zachowana w KeyDB dopóki dane nie wygasną. Gdy KeyDB wraca do działania, użytkownicy stopniowo wznawiają polling w rozłożonym czasie.

Michał Sobczak
Michał Sobczak https://msobczak.pl

Obszar działania

Start O nas Audyty Blog Kontakt
PL / EN
Zacznij Projekt