Cluster-Router
Der lokale Router erstellt seine Routees als
eigene Kinder — ein fester Pool auf einem Node. Der
ClusterRouter ist anders: seine Routees sind Actors anderer
Nodes an einem bekannten Pfad, und die Menge der Routees ändert
sich mit der Cluster-Mitgliedschaft.
Jedes Up-Mitglied mit der Rolle compute (konfigurierbar) hat
einen Worker an /user/worker; der Router routet eingehende
Nachrichten gemäß einer Strategie über sie. Füge einen Node hinzu,
der Router nimmt ihn auf; entferne einen Node, der Router hört auf,
ihm zu senden — ohne Neustart.
Siehe Pool vs. Group für die Unterscheidung zwischen lokalen Pools und Cluster-Groups.
Ein minimales Beispiel
Abschnitt betitelt „Ein minimales Beispiel“import { ActorSystem, Cluster, ClusterOptions, ClusterRouter, ClusterRouterOptions, Actor } from 'actor-ts';
class Worker extends Actor<{ payload: string }> { override onReceive(message: { payload: string }): void { this.log.info(`gearbeitet an ${message.payload}`); }}
const system = ActorSystem.create('my-app');const clusterOptions = ClusterOptions.create() .withHost(host) .withPort(port) .withSeeds(seeds) .withRoles(['compute']);const cluster = await Cluster.join( system, clusterOptions,);
// 1. Jeder Node spawnt seinen eigenen Worker an /user/workersystem.spawn(Worker, 'worker');
// 2. Jeder Node kann einen Cluster-Router bauen, der diese Worker ansprichtconst clusterRouterOptions = ClusterRouterOptions.create() .withCluster(cluster) .withRouterType('round-robin') .withRouteePath('/user/worker') .withRole('compute');const router = system.spawn( ClusterRouter.factory( clusterRouterOptions, ), 'compute-router',);
// 3. Sage dem Router etwas — die Nachricht wird an den Worker eines Nodes geroutetrouter.tell({ payload: 'work-1' });Das Muster: jeder Node deployed die Routee-Actors lokal; einer
(oder mehrere) Nodes spawnen einen ClusterRouter, der auf sie
zielt. Die Strategie des Routers entscheidet, welcher Worker die
einzelne Nachricht bekommt.
Konfiguration
Abschnitt betitelt „Konfiguration“type ClusterRouterOptions<TMessage> = { cluster: Cluster; routerType: 'round-robin' | 'random' | 'consistent-hashing' | 'smallest-mailbox' | 'broadcast'; routeePath: string; role?: string; extractKey?: (message: TMessage) => string; mailboxDepthRefreshMs?: number; mailboxDepthStaleAfterMs?: number;};| Feld | Erforderlich | Was |
|---|---|---|
cluster | Ja | Der Cluster — für Mitgliedschaftsverfolgung + den Wire-Transport. |
routerType | Ja | Eine der fünf Strategien. |
routeePath | Ja | Der Pfad, an dem der Routee-Actor auf jedem Node lebt (typisch /user/<actorName>). |
role | Nein | Wenn gesetzt, sind nur Mitglieder mit dieser Rolle Routees. |
extractKey | Wenn routerType: 'consistent-hashing' | Extrahiert den Routing-Key aus einer Nachricht. |
mailboxDepthRefreshMs | Nein (Default 200) | Nur smallest-mailbox — wie oft die gecachten Tiefen aufgefrischt werden. |
mailboxDepthStaleAfterMs | Nein (Default 1000) | Nur smallest-mailbox — wie lange eine gecachte Tiefe zählt. 0 schaltet den Verfall aus. |
Die fünf Strategien
Abschnitt betitelt „Die fünf Strategien“| Strategie | Was sie tut |
|---|---|
'round-robin' | Ein Routee pro Nachricht, zyklisch. |
'random' | Ein Routee pro Nachricht, gleichverteilt zufällig. |
'consistent-hashing' | Pinnt gleiche extractKey an gleichen Routee via Rendezvous-Hashing. |
'smallest-mailbox' | Ein Routee pro Nachricht — der Node mit der kürzesten gemeldeten Warteschlange. |
'broadcast' | Schickt an jeden Routee. |
Die ersten vier sind 1-von-N-Routing; Broadcast ist Fan-out. Siehe Strategien für die Auswahlhilfe — gleiche Trade-offs gelten, nur eben über Cluster-Nodes statt über Pool-Mitglieder verteilt.
Consistent-Hashing
Abschnitt betitelt „Consistent-Hashing“const clusterRouterOptions = ClusterRouterOptions.create() .withCluster(cluster) .withRouterType('consistent-hashing') .withRouteePath('/user/cache') .withExtractKey((message) => message.userId);ClusterRouter.factory( clusterRouterOptions,);Erforderlich für 'consistent-hashing'. Die Funktion zieht einen
String-Key aus jeder Nachricht; der Router pinnt Nachrichten mit
demselben Key per Rendezvous-Hashing an denselben Node.
Nützlich, wenn jeder Routee per-Key-Zustand hält: einen Cache für die Daten dieses Users, eine Session, einen laufenden Workflow. Topologieänderungen verschieben einen Bruchteil der Keys (proportional zur Hinzufügung/Entfernung), nicht alle.
Wenn extractKey immer denselben Wert zurückgibt, geht jede
Nachricht an denselben Routee (de facto ein Singleton). Stelle
sicher, dass er über deine tatsächliche Workload variiert.
Smallest-Mailbox
Abschnitt betitelt „Smallest-Mailbox“const clusterRouterOptions = ClusterRouterOptions.create() .withCluster(cluster) .withRouterType('smallest-mailbox') .withRouteePath('/user/worker') .withRole('compute');ClusterRouter.factory( clusterRouterOptions,);Routet jede Nachricht an den Node, dessen Routee zuletzt die
kürzeste Warteschlange gemeldet hat — das Cluster-Gegenstück zum
lokalen Router.smallestMailbox. Der
Grund, danach zu greifen, ist derselbe: wenn die Kosten pro
Nachricht stark schwanken, teilt Round-Robin einem Node seinen
nächsten Zug auch dann zu, wenn er noch hinterherhängt — diese
Strategie nicht.
Sie fragt nie im Routing-Pfad. Ein Router routet synchron; eine Abfrage pro Nachricht würde die gesamte Router-Mailbox hinter einen Netzwerk-Roundtrip parken und alles dahinter umordnen. Stattdessen wird die Tiefe jedes Nodes gecacht und auf einem Hintergrund-Tick aufgefrischt:
Der Routee ist ein ganz normaler Actor von dir und erfährt nie, dass er gemessen wird: ein kleiner Framework-Agent auf jedem Node antwortet für ihn und liest die Warteschlangentiefe von innen aus der Runtime.
Was das bringt und was es kostet:
- Die Routing-Entscheidung bleibt so billig wie bei Round-Robin
— ein Scan über eine lokale Map, kein
await. - Das Bild hinkt um bis zu ein Refresh-Intervall hinterher. Ein Node kann genau dann gewählt werden, wenn er gerade vollgelaufen ist. Das ist eine schlechtere Entscheidung als die der lokalen Strategie, nie eine falsche: die Nachricht wird zugestellt, und der nächste Tick korrigiert das Bild.
- Ein Node ohne Antwort wird übersprungen, nicht als untätig angenommen — der schweigende Node könnte gerade der sein, der kämpft. Hat noch kein Node geantwortet, fällt der Router auf die Round-Robin-Reihenfolge zurück: ein kalter Cache ist ein degradierter Router und nie eine verlorene Nachricht.
Nodes mit Routees, aber ohne Router
Abschnitt betitelt „Nodes mit Routees, aber ohne Router“Ein smallest-mailbox-Router startet den Agenten auf seinem
eigenen Node, was den Einzelnode-Fall und das übliche homogene
Deployment abdeckt (jeder Node fährt Router und Routees). Auf
einem Node, der Routees, aber keinen Router hostet, startest du ihn
selbst:
import { ClusterMailboxDepthAgent } from 'actor-ts';
const stopServingDepths = ClusterMailboxDepthAgent.serve(cluster);Vergisst du es, geht nichts kaputt: dieser Node meldet dann schlicht nie etwas, wird also übersprungen, solange andere Nodes antworten — und wenn keiner antwortet, gilt die Round-Robin-Reihenfolge.
Die beiden Stellschrauben
Abschnitt betitelt „Die beiden Stellschrauben“mailboxDepthRefreshMs ist der Nachlauf des Router-Bilds und
zugleich die Rate, mit der es ein kleines Envelope pro Routee
kostet. mailboxDepthStaleAfterMs ist, wie lange eine Meldung ohne
Nachfolger gilt — 0 behält Meldungen für immer, was nur sinnvoll
ist, wenn dir eine alte Zahl lieber ist als die Rotation. Ein
Fenster, das kürzer ist als das Refresh-Intervall, das es
nachfüllt, wird bei der Konstruktion abgelehnt: jede Meldung
verfiele, bevor ihr Nachfolger ankommt, und der Cache bliebe
dauerhaft kalt.
Routee-Discovery
Abschnitt betitelt „Routee-Discovery“Bei jeder Gossip-Runde leitet der Router seine Routee-Menge aus den Up-Mitgliedern des Clusters neu ab. Auslöser eines Neuaufbaus:
MemberUp— ein neues Up-Mitglied mit passender Rolle. Hinzufügen.MemberRemoved— ein entferntes Mitglied. Rauswerfen.
Die Menge ist deterministisch geordnet (per Adresse), sodass Round-Robin-Counter über Neuaufbauten hinweg vernünftig bleiben.
Wenn die Routee-Menge leer ist
Abschnitt betitelt „Wenn die Routee-Menge leer ist“router.tell({ payload: 'a' });// → wenn kein Up-Mitglied `role` matched, wird die Nachricht mit einem Warn-Log fallengelassenWichtig: eine leere Routee-Menge bedeutet, dass Nachrichten in Dead Letters fallen. Das Framework puffert nicht, während es auf Routees wartet — das würde stillschweigend unbegrenzt wachsen.
Für “fang erst an zu bedienen, wenn der Pool mindestens N Routees
hat”, abonniere MemberUp und gate die Anfragebehandlung an einem
Zähler.
Rollenfilterung
Abschnitt betitelt „Rollenfilterung“const clusterRouterOptions = ClusterRouterOptions.create() .withCluster(cluster) .withRole('compute');ClusterRouter.factory( clusterRouterOptions // ...);Nur Up-Mitglieder mit der Rolle compute sind Kandidaten.
Nützlich für asymmetrische Cluster:
- Nodes mit
compute-Rolle erledigen schwere Arbeit. - Nodes mit
gateway-Rolle behandeln HTTP-Traffic. - Nodes mit
coordinator-Rolle hosten Singletons.
Die Rolle wird zur Cluster.join-Zeit pro Node deklariert. Das
role-Feld des Routers filtert; ohne es ist jedes Up-Mitglied
ein Kandidat.
Self-Routing
Abschnitt betitelt „Self-Routing“Wenn der lokale Node ein Kandidat ist (die Rolle passt), kann der Router auf einen Worker auf demselben Node routen. Der Transport behandelt Loopback gleich wie jede Cross-Node-Zustellung — über den Local-Loopback-Pfad des Transports.
Das bedeutet, dass die Lastverteilung des Routers symmetrisch ist — keine Bevorzugung lokaler Routees, kein Penalty. Round-Robin nimmt dich wie jeden anderen Node in den Zyklus auf.
Stoppen
Abschnitt betitelt „Stoppen“router.stop();// oder: router.tell(PoisonPill.instance);Stoppt den Router-Actor. Routees bleiben unberührt — sie sind auf anderen Nodes; sie laufen weiter. Das ist das Group-Router-Modell: der Router besitzt das Routing, nicht die Routees selbst.
Zum Vergleich: ein lokaler Pool-Router stoppt seine Routees kaskadierend beim Stoppen. Siehe Pool vs. Group.
Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- Routing-Überblick — das größere Routing-Bild.
- Pool vs. Group — der
konzeptionelle Unterschied zum lokalen
Router. - Strategien — Round-Robin / Random / Smallest-Mailbox / Consistent-Hashing im Detail.
- Sharding-Überblick — für per-Key-Actors mit stärkeren Platzierungsgarantien.
- Cluster-Überblick — die Mitgliedschaft, die der Router liest.
Die ClusterRouter API-Referenz
deckt die vollständigen Optionen ab.
