Zum Inhalt springen
Deutsch

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.

ClusterRouter auf node-A

/user/worker

auf node-A

/user/worker

auf node-B

/user/worker

auf node-C

eines pro Up-Mitglied mit role=compute

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.

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/worker
system.spawn(Worker, 'worker');
// 2. Jeder Node kann einen Cluster-Router bauen, der diese Worker anspricht
const 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 geroutet
router.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.

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;
};
FeldErforderlichWas
clusterJaDer Cluster — für Mitgliedschaftsverfolgung + den Wire-Transport.
routerTypeJaEine der fünf Strategien.
routeePathJaDer Pfad, an dem der Routee-Actor auf jedem Node lebt (typisch /user/<actorName>).
roleNeinWenn gesetzt, sind nur Mitglieder mit dieser Rolle Routees.
extractKeyWenn routerType: 'consistent-hashing'Extrahiert den Routing-Key aus einer Nachricht.
mailboxDepthRefreshMsNein (Default 200)Nur smallest-mailbox — wie oft die gecachten Tiefen aufgefrischt werden.
mailboxDepthStaleAfterMsNein (Default 1000)Nur smallest-mailbox — wie lange eine gecachte Tiefe zählt. 0 schaltet den Verfall aus.
StrategieWas 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.

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.

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:

node-B

node-A

alle mailboxDepthRefreshMs

liest die Warteschlange

Meldung

synchron gelesen,

pro Nachricht

ClusterRouter

smallest-mailbox

gecachte Tiefen

pro Node

Mailbox-Tiefen-Agent

/user/worker

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.

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.

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.

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.

router.tell({ payload: 'a' });
// → wenn kein Up-Mitglied `role` matched, wird die Nachricht mit einem Warn-Log fallengelassen

Wichtig: 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.

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.

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.

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.

Die ClusterRouter API-Referenz deckt die vollständigen Optionen ab.