Real-time dispatching przez SignalR: architektura dla flot
Jak zbudować real-time dispatching dla floty przez SignalR — model aukcyjny, 4 huby, rozdzielenie push od live syncu, jedna source of truth i 12 background jobów na timeouty.
Rok 2024, projekt Carivio. Flota taxi, dziesiątki kierowców online w szczycie, dyspozytorzy chcą widzieć pozycję w czasie rzeczywistym, a klienci chcą wiedzieć, gdzie jest ich pojazd. Pozornie prosta sprawa — dodać SignalR. W rzeczywistości real-time, który nie zaleje sieci duplikatowymi eventami i nie padnie przy reconnect, to kwestia dyscypliny w architekturze, nie tylko zainstalowania pakietu NuGet.
Oto jak to zbudowałem i gdzie są pułapki.
Cztery huby, cztery domeny
Pierwsze pytanie: jeden hub czy więcej? Jeden hub kusi — prostszy routing, jeden endpoint połączenia. Ale wtedy do jednego kanału płyną komunikaty o zupełnie różnym priorytecie: notyfikacja systemowa (nowy przejazd — krytyczna), wiadomość czatu (kierowca pisze do dyspozytora — mniej krytyczna), aktualizacja pozycji (co 3 sekundy — high frequency). Mieszanie tego razem oznacza, że komunikaty o pozycji zalewają log, a krytyczne notyfikacje trudno śledzić.
Podzieliłem to na 4 huby według domeny:
- NotificationsHub — komunikaty systemowe, nowe zamówienia, stan oferty
- ChatHub — komunikacja kierowca ↔ dyspozytor w ramach przejazdu
- RidesHub — przejścia stanu zamówienia (przyjęte, wsiadanie, zakończone)
- DriversHub — pozycja kierowców, przejścia online/offline
Każdy hub ma swoje grupy (SignalR groups) — kierowca zapisuje się do grupy według swojego przejazdu lub obszaru. Broadcast idzie tylko tam, gdzie powinien.
Aukcyjny dispatching: jak to działa
Model sekwencyjny dispatchingu — zaproponuj jednemu kierowcy, czekaj 30 sekund, potem spróbuj następnego — jest powolny i niesprawiedliwy. Kierowcy bliżej dyspozytorni mają przewagę tylko dlatego, że są pierwsi w kolejce.
Model aukcyjny działa inaczej:
- Klient zamawia przejazd.
- System identyfikuje uprawnionych kierowców w zasięgu (odległość, stan online, pojemność).
- Oferta zostaje broadcastowana do wszystkich jednocześnie przez NotificationsHub.
- Kierowcy reagują — każdy wysyła bid (zainteresowanie przejazdem, ewentualnie z ceną).
- System wybiera najlepszą ofertę według ważonego score'a (odległość, ocena, czas reakcji).
- Wynik wraca przez SignalR: zwycięzca dostaje potwierdzenie, pozostali dostają „zajęte".
Cały cykl trwa zwykle 10–20 sekund. Bez real-time nie dałoby się tego sensownie zrobić.
Gdzie jest haczyk: po rozesłaniu oferty system musi obsłużyć przypadek, gdy dwóch kierowców wyśle bid w tej samej sekundzie. Przyjęcie musi być idempotentne — jeden zwycięzca, bez race condition. Rozwiązuję to optymistycznym lockiem na poziomie rekordu DB przy wyborze zwycięzcy.
Push vs. SignalR: nie mylić, nie mieszać
Tu chowa się najczęstszy błąd w real-time dispatchingu. Kierowca ma mobilną aplikację. Aplikacja może być na pierwszym planie (aktywna sesja) albo w tle (przyszła push notyfikacja, użytkownik jeszcze nie otworzył). To dwa różne stany z różnymi kanałami:
Push notyfikacje (FCM/APNs): docierają do kierowcy nawet gdy aplikacja śpi. Służą jako wakeup — mówią „otwórz aplikację, masz ofertę". Nie oczekuję, że z pushów będę bezpośrednio zarządzać stanem.
SignalR: żywy kanał dla aktywnej sesji. Aktualizuje UI w czasie rzeczywistym, przesyła pozycję, stany przejazdu, wiadomości czatu.
Gdy to się połączy — wyślemy push z payloadem i jednocześnie wiadomość SignalR z tą samą treścią — aplikacja kierowcy przetworzy tę samą ofertę dwa razy. Raz z push handlera, raz z SignalR handlera. Objawia się to jako duplikat w UI lub tajemnicze podwójne przetwarzanie po stronie serwera.
Reguła, którą wdrożyłem: push to tylko wakeup, bez business payloadu. Payload przychodzi przez SignalR po reconnect. Aplikacja po otrzymaniu push notyfikacji łączy się z hubem i pobiera aktualny stan.
Jedna source of truth
Kierowca ma trzy miejsca, gdzie można „widzieć" jego stan: lokalna cache w aplikacji, stan połączenia SignalR na serwerze i rekord w DB. Gdy te trzy się rozejdą, pojawia się klasyczny problem: dyspozytor widzi kierowcę jako online, aplikacja pokazuje go jako offline, DB mówi „ostatni ping 4 minuty temu".
Reguła: jedyna source of truth dla krytycznych stanów to DB. Stan połączenia SignalR i lokalna cache w aplikacji to repliki — mogą pozostawać w tyle, ale muszą konwergować.
Konkretnie:
- Stan online/offline kierowcy zapisuje się do DB przy każdym heartbeacie (co 2 minuty). Heartbeat idzie przez SignalR, ale zapisuje się do DB.
- Oczekujące oferty przejazdów istnieją w DB. SignalR to tylko transport dla real-time notyfikacji.
- Aktywny przejazd — stan w DB, SignalR przesyła przejścia, ale po reconnect aplikacja zawsze pobiera aktualny stan z endpointu REST, nie z wiadomości SignalR.
To oznacza, że reconnect jest bezpieczny: aplikacja może się rozłączyć i ponownie połączyć w dowolnym momencie i dostanie spójny stan.
Reconnect i timeout: 12 background jobów
System real-time bez logiki timeoutu przestaje działać w momencie, gdy kierowca traci sygnał na 3 minuty. Albo gdy serwer się restartuje. Albo gdy kierowca zamknie aplikację bez wylogowania.
Zarządzam 12 background jobami Quartz, każdy z konkretną odpowiedzialnością:
- HeartbeatTimeoutJob — oznacza kierowcę jako offline, jeśli heartbeat nie przyszedł przez ponad 5 minut
- OfferExpirationJob — wygasza niepodebrane oferty przejazdu po N sekundach
- RideTimeoutJob — eskaluje przejazdy, gdzie kierowca nie dotarł w ciągu X minut od przyjęcia
- ChatCleanupJob — archiwizuje stare konwersacje
- ... i kolejne do cleanup, reconciliacji i monitoringu
Quartz daje persystencję — job nie ginie po restarcie serwera. To kluczowe: bez persystencji joby timeoutu giną przy deployu i oferty zostają otwarte na zawsze.
Obsługa reconnect po stronie klienta: aplikacja próbuje reconnect z exponential backoff (1s, 2s, 4s, max 30s). Po udanym reconnect wywołuje endpoint REST po aktualny stan i zapisuje się ponownie do odpowiednich grup SignalR. Wiadomości, które przyszły podczas przerwy, odczytuje z DB — SignalR nie jest persistent message queue i nie używam go w ten sposób.
Co warto wiedzieć zanim zaczniesz
Real-time dispatching dla floty to nie „dodać SignalR i broadcastować". Kluczowe decyzje, które wpłyną na debug przez następne miesiące:
Oddziel push od live syncu od razu. Połączyć łatwo, rozdzielić boli. Duplikatowe eventy ujawnią się w produkcji, gdzie najtrudniej je śledzić.
Jedna source of truth w DB, zawsze. Stan połączenia SignalR jest ulotny. Przy reconnect, restarcie lub nowej instancji serwera znika. DB nie.
Joby timeoutu to core, nie nice-to-have. Flota 50 kierowców dziennie generuje dziesiątki sytuacji, gdzie kierowca traci łączność, zamyka aplikację lub nie reaguje. Bez jobów narastają zombie rekordy.
Model aukcyjny wymaga idempotencji przy wyborze zwycięzcy. Dwa bidy w tej samej sekundzie to normalny ruch, nie edge case. Zadbaj o to od początku.
Tę architekturę zbudowałem w Carivio. Jeśli rozwiązujesz real-time dispatching dla floty, napisz do nas — chętnie przejrzymy twój projekt.
FAQ
Dlaczego oddzielać push notyfikacje od SignalR?
Push i SignalR pełnią różne role. Push to wakeup — dotrze do kierowcy nawet gdy aplikacja śpi. SignalR to live sync dla aktywnej sesji. Gdy się to połączy w jedną warstwę, powstają duplikatowe eventy: kierowca dostaje notyfikację przez push i jednocześnie przez SignalR, przetwarza ją dwa razy. Objawia się to jako tajemniczy bug, gdzie jedno zamówienie wygląda jak dwa.
Czym jest model aukcyjny dispatchingu i kiedy go używać?
Oferta przejazdu zostaje rozesłana do grupy kierowców jednocześnie. Każdy może zareagować. System wybiera najlepszą ofertę i broadcastuje wynik do pozostałych. Przegrywający dostają „zajęte". W porównaniu do modelu sekwencyjnego jest szybszy i sprawiedliwszy, ale wymaga logiki timeoutu i deduplikacji przyjęcia.
Ile hubów SignalR wystarczy i jak je podzielić?
Nie ma stałej reguły. Polecam dzielić według domeny, nie według warstwy technicznej. Przy dispatchingu floty wychodzą naturalnie 4 obszary: notyfikacje, czat, przejazdy i kierowcy. Jeden hub dla wszystkiego pozornie upraszcza kod, ale miesza komunikaty o różnym priorytecie — a debug staje się koszmarem.