Zum Inhalt springen
Deutsch

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.

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 messageIndex auf 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.

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.

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.

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.

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.

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=42 landet 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=42 ist, 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.

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.

  • 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.