Routing-Strategien
Eine Routing-Strategie ist eine Funktion: gegeben die Routee-Liste und etwas Zustand, gib zurück, welche(r) Routee(s) die nächste Nachricht empfangen sollen.
type RoutingStrategy = ( routees: ReadonlyArray<ActorRef>, state: { readonly messageIndex: number },) => Iterable<ActorRef>;Gib eine Ref für Single-Target-Routing zurück, mehrere für Fan-Out
oder nichts zum Verwerfen. Der lokale Router liefert vier
Implementierungen aus, plus einen custom-Slot; der Cluster-Router
hat zu jeder der vier ein Gegenstück und fügt Consistent-Hashing
hinzu.
Round-Robin
Abschnitt betitelt „Round-Robin“Router.roundRobin(4, routee);Zykelt durch die Routee-Liste — Nachricht 1 → Routee 1, Nachricht 2 → Routee 2, …, Nachricht 5 → Routee 1 wieder. Implementierung:
function roundRobinStrategy(): RoutingStrategy { return (routees, state) => { if (routees.length === 0) return []; return [routees[state.messageIndex % routees.length]]; };}Schafft:
- Gleichmäßige Verteilung nach Nachrichten-Count (nicht nach Nachrichten-Kosten).
- Deterministisch und inspizierbar — ein Debugger sieht genau, welcher Routee jede Nachricht bekommen hat.
- Re-Routing bei Resize: wenn ein Routee verschwindet oder ein
neuer auftaucht, landet derselbe
messageIndexauf einem anderen Routee.
Schafft nicht:
- Load-Balancing nach Arbeitskosten. Nachricht 100 könnte ein schwerer Job sein; der Router weiß es nicht. Wenn ein Routee per Zufall alle teuren Jobs bekommt, fällt er zurück.
- Eine “Bleibe beim selben Routee für verwandte Nachrichten”-Garantie liefern. Verwende dafür Consistent-Hashing.
Richtiger Default für homogene Workloads — die Nachrichten-Verarbeitungszeiten sind grob gleich über Nachrichten.
Router.random(4, routee);function randomStrategy(): RoutingStrategy { return (routees) => { if (routees.length === 0) return []; return [routees[Math.floor(Math.random() * routees.length)]]; };}Wählt einen Routee gleichverteilt zufällig.
Schafft:
- Gleiche statistische Verteilung wie Round-Robin auf lange Sicht, aber kein geteilter Zustand — nützlich in zustandslosen / rein funktionalen Setups.
- Resilienter gegen “synchronisierte” Sender. Wenn zwei Aufrufer mit ihren eigenen Indizes kooperieren, kann Round-Robin dieselben Routees hämmern; Random nicht.
Schafft nicht:
- Dir deterministisches Verhalten in Tests geben. Injiziere einen seedbaren RNG und schreibe eine eigene Strategie, wenn Reproduzierbarkeit wichtig ist.
Richtige Wahl, wenn Zustandslosigkeit wichtiger als Vorhersagbarkeit ist.
Broadcast
Abschnitt betitelt „Broadcast“Router.broadcast(4, routee);function broadcastStrategy(): RoutingStrategy { return (routees) => routees; // jeder Routee}Sendet jede Nachricht an jeden Routee. Der Pool läuft im Gleichschritt — nützlich für Fan-Out-Formen:
- Cache-Invalidierung: jeder Routee hält einen Cache; wenn sich ein Key ändert, sagt der Broadcast es allen.
- Periodischer Refresh: jeder Routee liest die Config neu, wenn
eine
Refresh-Nachricht ankommt. - Heartbeat: jeder Routee checkt bei einem Tick ein.
Schafft:
- N-Wege-Fan-Out mit N-Wege-Arbeit. Jede Nachricht wird N-mal verarbeitet. Gesamtdurchsatz ist N × Per-Routee-Durchsatz, aber jeder Routee sieht die volle Nachrichten-Last.
Schafft nicht:
- Arbeit parallelisieren — jeder Routee tut dieselbe Arbeit. Das ist Fan-Out, nicht Load-Balancing.
- Sinn für Request/Response — jeder Routee antwortet, der Aufrufer sieht N Antworten.
Wenn du Broadcast für manche Nachrichten willst, aber Routing für
andere, halte den Router non-Broadcast und wickele gelegentliche
Nachrichten in Broadcast<T> — siehe
Router.
Smallest-Mailbox
Abschnitt betitelt „Smallest-Mailbox“Router.smallestMailbox(4, routee);function smallestMailboxStrategy(): RoutingStrategy { return (routees, state) => { if (routees.length === 0) return []; // Start the scan on a different routee each message, so an // all-equal pool rotates instead of pinning routee 1. const start = state.messageIndex % routees.length; let shallowest: ActorRef | null = null; let shallowestDepth = 0; for (let offset = 0; offset < routees.length; offset++) { const routee = routees[(start + offset) % routees.length]; // `null` means "do not route here at all" — a stopped routee. // A routee whose depth is merely unreadable comes back as 0. const depth = routableDepthOf(routee); if (depth === null) continue; if (shallowest === null || depth < shallowestDepth) { shallowest = routee; shallowestDepth = depth; } } return [shallowest ?? routees[start]]; };}Liest, wie viele Nachrichten bei jedem Routee anstehen, und wählt den flachsten. Das ist die einzige eingebaute Strategie, die darauf reagiert, was die Routees gerade tatsächlich tun.
Kann:
- Nach Rückstau balancieren statt nach Zügen. Round-Robin gibt einem Routee seinen nächsten 1-aus-N, egal ob er noch am letzten arbeitet. Smallest-Mailbox wählt einen zurückgefallenen Routee nicht mehr und nimmt ihn wieder auf, sobald er aufgeholt hat — genau das Workload-aware-Load-Balancing, das Round-Robin nicht kann.
- Sich nach einem Stall selbst korrigieren. Ein Routee, der an einem langsamen Downstream-Call hängt, leert seine Queue nicht, seine Tiefe bleibt also hoch und er bekommt einfach keinen Traffic mehr. Kein Timer, kein Health-Check.
- Gleichstände rotieren. Durchweg gleiche Tiefen — ein leerlaufender Pool oder ein gesättigter — fallen auf die Round-Robin-Reihenfolge zurück, statt auf den ersten Routee einzuprügeln.
- Einen gestoppten Routee übergehen. Ein terminierter Routee wird gar nicht erst gemessen, sondern übersprungen — warum dieser Fall sich nicht der Zahl überlassen lässt, steht unten unter Was als Tiefe zählt.
Kann nicht:
- Gratis sein. Eine Tiefenabfrage pro Routee pro Nachricht: ein
Modulo bei Round-Robin gegen einen
O(N)-Scan hier. Bei homogenen Workloads ist das reiner Overhead. - Kosten kennen, nur Anzahl. Zehn triviale Nachrichten sehen tiefer aus als eine riesige. Queue-Länge ist ein Näherungswert für Last, keine Messung.
- Backpressure ausüben. Die Nachricht geht an den flachsten
Routee, wie tief der auch sei; eine Strategie, die das Routen
verweigert, würde Backpressure erfinden, die der Aufrufer nie
konfiguriert hat. Auf dem unbounded Default gibt es kein „passt
nicht mehr”, die Tiefe bleibt also ein echter Messwert des
Rückstaus, egal wie weit der Pool zurückfällt. Begrenzt du die
Routees, gleichen sich die Tiefen an, sobald alle an ihrer Kapazität
stehen — dann übernimmt die Rotation, und die Overflow-Policy
(
drop-head/drop-new/reject) entscheidet über die Nachricht. - Eine entfernte Mailbox live lesen. Die Tiefe ist In-Process-Zustand. Die Cluster-Variante unten holt sie über Nodes hinweg, aber nur als gecachte Meldung.
Richtige Wahl, wenn die Kosten pro Nachricht stark schwanken — gemischte Job-Größen, Routees, die etwas Langsames aufrufen.
Was als Tiefe zählt
Abschnitt betitelt „Was als Tiefe zählt“Zwei Arten von Routee haben keine brauchbare Queue-Länge, und sie werden bewusst gegensätzlich behandelt:
- Ein gestoppter Routee wird nie gewählt. Seine Mailbox sieht nicht bloß leer aus — sie ist dauerhaft leer, weil eine terminierte Zelle nicht mehr einreiht, sondern direkt an die Dead Letters zustellt. Als bloße Zahl gelesen würde das den toten Routee zum attraktivsten Mitglied des Pools machen, und zwar so lange, bis der Router den Tod bemerkt und ihn aus dem Pool nimmt — jede in der Zwischenzeit geroutete Nachricht ist verloren. Der Router erfährt vom Tod über eine gewöhnliche Nachricht, unter Last ist dieses Fenster also so tief wie seine eigene Queue. Deshalb wird die Terminierung vor der Tiefe geprüft, und ein gestoppter Routee fällt ganz aus dem Scan heraus.
- Ein Routee mit unlesbarer Tiefe zählt als leer. Nur ein lokal gehosteter Actor hat eine Mailbox, in die dieser Prozess hineinsehen kann; eine entfernte oder sonstwie fremde Ref ist nicht messbar. Die Strategie nimmt an, dass sie keinen Rückstau hat, statt sie zu überspringen: Überspringen würde sie aushungern, solange irgendein lokaler Routee Rückstau hat — in einem gemischten Pool also dauerhaft. Der Kompromiss läuft stattdessen in die andere Richtung: Solange die lokalen Routees beschäftigt sind, sieht ein unlesbarer am flachsten aus und bekommt den Traffic. Das ist ein Balance-Fehler, und er korrigiert sich; das Aushungern tat das nicht. Mehrere unlesbare Routees stehen bei null gleichauf und rotieren untereinander.
Sind alle Routees gestoppt, fällt der Scan auf die Rotation zurück
und routet trotzdem in eine tote Zelle. Die Nachricht ist so oder so
verloren, und als DeadLetter verloren zu gehen ist immerhin
beobachtbar — gar keinen Routee zurückzugeben würde sie spurlos
verschlucken.
Die Cluster-Variante
Abschnitt betitelt „Die Cluster-Variante“import { ClusterRouter, ClusterRouterOptions } from 'actor-ts';
const clusterRouterOptions = ClusterRouterOptions.create() .withCluster(cluster) .withRouterType('smallest-mailbox') .withRouteePath('/user/worker');ClusterRouter.factory( clusterRouterOptions,);ClusterRouter hat dieselbe Strategie — erreicht auf dem einzigen
Weg, auf dem das geht. Die Mailbox eines entfernten Routees ist von
hier aus nicht lesbar, und pro Nachricht zu fragen würde die gesamte
Router-Mailbox hinter einen Netzwerk-Roundtrip parken. Also wird die
Tiefe jedes Nodes gecacht und auf einem Hintergrund-Tick
aufgefrischt; die Routing-Entscheidung liest den Cache synchron.
Zwei Konsequenzen, die man mitnehmen sollte:
- Die Tiefe kann bis zu ein Refresh-Intervall alt sein, ein Node kann also genau dann gewählt werden, wenn er gerade vollgelaufen ist. Der nächste Tick korrigiert das.
- Ein Node, der nichts gemeldet hat, wird übersprungen statt als untätig angenommen; ein Cache ohne jeden Eintrag fällt auf die Round-Robin-Reihenfolge zurück.
Cluster-Router behandelt das Refresh-Intervall, die Verfallsgrenze und die eine Zeile Setup, die ein Node braucht, der Routees, aber keinen Router hostet.
Consistent-Hashing (nur Cluster)
Abschnitt betitelt „Consistent-Hashing (nur Cluster)“import { ClusterRouter } from 'actor-ts';
ClusterRouter.factory({ cluster, routerType: 'consistent-hashing', routeePath: '/user/worker', extractKey: (message) => message.userId,});Berechnet einen Hash von extractKey(message) und wählt den Routee,
dessen eigener Hash am nächsten ist (Rendezvous-Hashing). Gleicher
Key → gleicher Routee, deterministisch, über den Cluster hinweg.
Schafft:
- Stickiness. Ein langer Stream von Nachrichten getagged mit
userId=42landet immer beim selben Routee. Der Routee kann Per-Key-Zustand halten (Cache, in-Progress-Session) ohne Koordinator. - Topologie-stabil. Einen Routee hinzuzufügen oder zu
entfernen, mischt nur die Keys neu, deren nächster Hash sich
geändert hat — ein Anteil proportional zu
1/N, nicht alle.
Schafft nicht:
- Perfekt unter schiefen Key-Workloads balancieren. Wenn 80 % des
Traffics
userId=42ist, trägt dieser eine Routee 80 % der Last. Schiefe Keys brauchen einen anderen Ansatz — siehe Sharding für das schwerere Per-Key-Actor-Pattern. - An einen festen Routee pinnen. Topologie-Änderungen mischen manche Keys; für harte Garantien verwende einen Singleton oder eine sharded Entity.
Richtige Wahl für session-affines Routing in Cluster-Setups, bei denen der Key-Space einigermaßen uniform ist.
Eigene Strategien
Abschnitt betitelt „Eigene Strategien“import { Router, type RoutingStrategy } from 'actor-ts';
// Route immer für die ersten 100 Nachrichten an den ersten Routee// (einen Cache aufwärmen, bevor die Last verteilt wird), dann Round-Robin.const warmupStrategy: RoutingStrategy = (routees, state) => { if (routees.length === 0) return []; if (state.messageIndex < 100) return [routees[0]]; return [routees[state.messageIndex % routees.length]];};
system.spawnAnonymous(Router.custom(4, Worker, warmupStrategy));Alles, was RoutingStrategy erfüllt, funktioniert. Der
Zustands-Slot bekommt den monotonen Message-Index — das ist der
einzige Zustand, den der lokale Router hält. Für Strategien, die
mehr Zustand brauchen (einen Hash-Ring, eine Per-Routee-Gewichtstabelle,
eine Latenzschätzung über ein gleitendes Fenster), closeover eigenen
Zustand in der Funktion:
// Weighted round-robin: routee 1 has twice the capacity of the rest.function weightedStrategy(weights: readonly number[]): RoutingStrategy { const slots: number[] = []; weights.forEach((weight, index) => { for (let i = 0; i < weight; i++) slots.push(index); }); return (routees, state) => { if (routees.length === 0) return []; return [routees[slots[state.messageIndex % slots.length] % routees.length]]; };}Eine eigene Strategie sieht die Routee-Refs und den Message-Index —
mehr nicht. Alles Weitere muss sie selbst mitbringen. Beachte, was
sie nicht erreichen kann: Die Mailbox-Tiefe ist runtime-interner
Zustand und liegt bewusst nicht auf ActorRef — eine eigene
last-bewusste Strategie bräuchte also jeden Routee, der seine Last an
einen gemeinsamen Gauge zurückmeldet. Wenn es dir um die kürzeste
Queue geht, nimm das eingebaute Router.smallestMailbox: Es liest die
Tiefe von innerhalb des Frameworks, wo dieser Zustand legitim lebt.
Was nicht implementiert ist
Abschnitt betitelt „Was nicht implementiert ist“Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Router — die Factories, die jede Strategie in eine spawn-fertige Actor-Factory wickeln.
- Pool vs Group — wie sich diese Strategien verhalten, wenn sie auf einen festen Pool vs eine dynamische Gruppe von Routees angewendet werden.
- Cluster-Router — wo Consistent-Hashing lebt.
- Sharding — die schwerere Alternative, wenn Keys echte Per-Key-Actors brauchen.
