Real-time dispatching přes SignalR: architektura pro flotily
Jak postavit real-time dispatching pro flotilu přes SignalR — aukční model, 4 huby, oddělení push od live syncu, jedna source of truth a 12 background jobů na timeouty.
Rok 2024, projekt Carivio. Flotila taxi, desítky řidičů online ve špičce, dispečeři chtějí vidět polohu v reálném čase a zákazníci chtějí vědět, kde je jejich vůz. Zdánlivě jednoduchá věc — přidat SignalR. Ve skutečnosti je real-time, který nezahltí síť duplicitními eventy a nespadne při reconnectu, otázka disciplíny v architektuře, ne jen instalace NuGet balíčku.
Tady je, jak jsme to postavili a kde jsou háčky.
Čtyři huby, čtyři domény
První rozhodnutí: jeden hub nebo víc? Jeden hub je lákavý — jednodušší routing, jeden connection endpoint. Jenže pak do jednoho kanálu tečou zprávy s úplně různou prioritou: systémová notifikace (nová jízda — kritické), chatová zpráva (řidič píše dispečerovi — méně kritické), aktualizace polohy (každé 3 sekundy — high frequency). Míchat to dohromady znamená, že zprávy o poloze zahltí log a kritické notifikace se těžko trasují.
Rozdělili jsme to do 4 hubů podle domény:
- NotificationsHub — systémové zprávy, nové objednávky, stav nabídky
- ChatHub — komunikace řidič ↔ dispečer v rámci jízdy
- RidesHub — přechody stavu objednávky (přijato, nastoupen, ukončeno)
- DriversHub — poloha řidičů, online/offline přechody
Každý hub má své skupiny (SignalR groups) — řidič se přihlásí do skupiny podle své jízdy nebo oblasti. Broadcast jde jen tam, kam má.
Aukční dispatching: jak to funguje
Sekvenční model dispatchingu — nabídni jednomu řidiči, čekej 30 sekund, pak zkus dalšího — je pomalý a neférový. Řidiči blíž dispečince mají výhodu jen proto, že jsou první v pořadí.
Aukční model funguje jinak:
- Zákazník objedná jízdu.
- Systém identifikuje způsobilé řidiče v okruhu (vzdálenost, online stav, kapacita).
- Nabídka se broadcastne všem zároveň přes NotificationsHub.
- Řidiči reagují — každý pošle bid (zájem o jízdu, případně s cenou).
- Systém vybere nejlepší nabídku podle váženého skóre (vzdálenost, hodnocení, čas reakce).
- Výsledek jde zpátky přes SignalR: vítěz dostane potvrzení, ostatní dostanou „obsazeno".
Celý cyklus trvá typicky 10–20 sekund. Bez real-time by to nešlo dělat rozumně.
Kde je háček: po rozeslání nabídky musí systém zvládnout případ, kdy dva řidiči pošlou bid ve stejné vteřině. Přijetí musí být idempotentní — jeden vítěz, ne race condition. Řešíme to optimistickým zamknutím na úrovni DB záznamu jízdy při výběru vítěze.
Push vs. SignalR: neplést, nemíchat
Tady se schová nejčastější chyba v real-time dispatchingu. Řidič má mobilní appku. Appka může být na popředí (aktivní session) nebo na pozadí (přišla push notifikace, uživatel ještě neotevřel). Jsou to dva různé stavy s různými kanály:
Push notifikace (FCM/APNs): dorazí k řidiči i když aplikace spí. Slouží jako wakeup — říká „otevři appku, máš nabídku". Nečekáme, že z pushů budeme přímo řídit stav.
SignalR: živý kanál pro aktivní session. Aktualizuje UI v reálném čase, přenáší polohu, stavy jízdy, chatové zprávy.
Když se to sloučí — pošleme push s payloadem a zároveň SignalR zprávu se stejným contentem — řidičova appka zpracuje tu samou nabídku dvakrát. Jednou z push handleru, jednou z SignalR handleru. To se projeví jako duplicitní záznam v UI nebo záhadné dvojité zpracování na serveru.
Pravidlo, které jsme zavedli: push je jen wakeup, bez business payloadu. Payload přijde přes SignalR po reconnectu. Appka se po příchodu push notifikace připojí k hubu a stáhne aktuální stav.
Jedna source of truth
Řidič má tři místa, kde se může „vidět" jeho stav: lokální cache v appce, SignalR connection stav na serveru a DB záznam. Když se tato tři místa rozejdou, nastává klasický problém: dispečer vidí řidiče jako online, appka ho ukazuje jako offline, DB říká „poslední ping před 4 minutami".
Pravidlo: jediná source of truth pro kritické stavy je DB. SignalR connection state a lokální cache v appce jsou repliky — mohou zaostávat, ale musí konvergovat.
Konkrétně:
- Online/offline stav řidiče se zapíše do DB při každém heartbeatu (každé 2 minuty). Heartbeat jde přes SignalR, ale zapíše se do DB.
- Pending nabídky jízdy existují v DB. SignalR je jen transport pro real-time notifikaci.
- Aktivní jízda — stav v DB, SignalR přenáší přechody, ale po reconnectu appka vždy stáhne aktuální stav z REST endpointu, ne ze SignalR zpráv.
To znamená, že reconnect je bezpečný: appka se může odpojit a znovu připojit kdykoli a dostane konzistentní stav.
Reconnect a timeout: 12 background jobů
Real-time systém bez timeout logiky přestane fungovat ve chvíli, kdy řidič ztratí signál na 3 minuty. Nebo když server se restartuje. Nebo když řidič zavře appku bez odhlášení.
Spravujeme 12 Quartz background jobů, každý s konkrétní odpovědností:
- HeartbeatTimeoutJob — označí řidiče offline, pokud nepřišel heartbeat déle než 5 minut
- OfferExpirationJob — expiruje nevyzvednuté nabídky jízdy po N sekundách
- RideTimeoutJob — eskaluje jízdy, kde řidič nepřijel do X minut od přijetí
- ChatCleanupJob — archivuje staré konverzace
- ... a další pro cleanup, reconciliation a monitoring
Quartz dává persistenci — job se nezapomene ani po restartu serveru. To je klíčové: bez persistence se timeout joby ztratí při deployi a nabídky budou viset otevřené navždy.
Reconnect handling na klientovi: appka se pokusí reconnect s exponenciálním backoffem (1s, 2s, 4s, max 30s). Po úspěšném reconnectu zavolá REST endpoint pro aktuální stav a přihlásí se znovu do příslušných SignalR skupin. Zprávy, které přišly za výpadku, dohledá z DB — SignalR není persistent message queue a na to ho nepoužíváme.
Co se naučit z toho dřív než začnete
Real-time dispatching pro flotilu není „přidat SignalR a broadcastnout". Klíčová rozhodnutí, která ovlivní debug na měsíce dopředu:
Rozdělte push od live syncu hned od začátku. Sloučit je snadno, rozdělit bolí. Duplicitní eventy se projeví v produkci, kde je nejtěžší je trasovat.
Jedna source of truth v DB, vždy. SignalR connection stav je pomíjivý. Při reconnectu, restartu nebo nové instanci serveru zmizí. DB ne.
Timeout joby jsou core, ne nice-to-have. Flotila 50 řidičů za den vygeneruje desítky situací, kde řidič ztratí konektivitu, zavře appku nebo nezareaguje. Bez jobů se z toho hromadí zombie záznamy.
Aukční model vyžaduje idempotenci při výběru vítěze. Dva bidy ve stejnou vteřinu jsou normální provoz, ne edge case. Ošetřete to od začátku.
Tuhle architekturu jsme postavili v Carivio. Pokud řešíte real-time dispatching pro flotilu, napište nám — rádi projdeme váš návrh.
FAQ
Proč oddělovat push notifikace od SignalR?
Push a SignalR mají různé role. Push je wakeup — dostane se k řidiči i když aplikace spí. SignalR je live sync pro aktivní session. Když se to sloučí do jedné vrstvy, vznikají duplicitní eventy: řidič dostane notifikaci přes push a zároveň přes SignalR, zpracuje ji dvakrát. To se projeví jako záhadný bug, kde jedna objednávka vypadá jako dvě.
Co je aukční model dispatchingu a kdy ho použít?
Nabídka jízdy se rozešle skupině řidičů zároveň. Každý může reagovat. Systém vybere nejlepší nabídku a výsledek broadcastne ostatním. Nevyhrávší dostanou „obsazeno". Oproti sekvenčnímu modelu je rychlejší a spravedlivější, ale vyžaduje timeout logiku a deduplikaci přijetí.
Kolik SignalR hubů je dost a jak je rozdělit?
Není pevné pravidlo. Doporučuji dělit podle domény, ne podle technické vrstvy. U dispatchingu flotily vychází přirozeně 4 oblasti: notifikace, chat, jízdy a řidiči. Jeden hub pro vše zdánlivě zjednodušuje kód, ale smíchává zprávy s různou prioritou — a debug se z toho stane noční můra.