Zum Inhalt springen
Deutsch

Cluster — Überblick

Ein Cluster ist eine Gruppe von ActorSystems — typischerweise eines pro Node — die voneinander wissen. Nodes verteilen ihren Mitgliedschaftszustand per Gossip, erkennen gegenseitig Ausfälle und routen Nachrichten über das Netzwerk. Sobald ein Node beigetreten ist, kann Code auf jedem Node einem Actor auf jedem anderen Node ein tell schicken; der Cluster-Transport versteckt die Leitung.

Zwei Sichtweisen:

  • Aus Sicht der Anwendung: ein logisches Actor-System, verteilt über N Nodes. Ein ActorRef kann auf einen Actor auf einem beliebigen Node zeigen; derselbe Code, der mit einem Node funktioniert hat, funktioniert weiterhin.
  • Aus Sicht der Runtime: N unabhängige Systeme, die Gossip + Heartbeats austauschen, jedes verfolgt, wer am Leben ist, und routet Nachrichten über einen gewählten Transport.

Cluster.bootstrap bündelt die vier Schritte, die Du sonst von Hand verdrahten würdest — das ActorSystem erstellen, Seeds auflösen, Cluster.join rufen, SIGTERM/SIGINT einhängen — in einem einzigen Aufruf:

import { Cluster, ClusterBootstrapOptions } from 'actor-ts';
// Lokale Entwicklung — keine Env-Variablen, keine Seeds: Single-Node-Cluster.
const { system, cluster, shutdown } = await Cluster.bootstrap(ClusterBootstrapOptions.create('my-app'));
// Mit expliziten Overrides:
const { system, cluster } = await Cluster.bootstrap(
ClusterBootstrapOptions.create('my-app')
.withHost('0.0.0.0')
.withPort(2552)
.withSeeds(['node-a:2552', 'node-b:2552'])
.withRoles(['compute']),
);
// Sobald beigetreten:
cluster.upMembers(); // → ReadonlyArray<Member> der Up-Nodes
cluster.subscribe((evt) => { /* MemberUp, MemberDown, ... */ });

Für volle Kontrolle (eigener Dispatcher, manuelle Signal-Behandlung, eigene Discovery-Schleife) bleibt das Low-Level-Paar weiter verfügbar:

import { ActorSystem, Cluster, ClusterOptions } from 'actor-ts';
const system = ActorSystem.create('my-app');
const clusterOptions = ClusterOptions.create()
.withHost('0.0.0.0')
.withPort(2552)
.withSeeds(['node-a:2552', 'node-b:2552']);
const cluster = await Cluster.join(
system,
clusterOptions,
);

Drei Einstellungen leisten die meiste Arbeit:

EinstellungZweck
host + portDie externe Adresse dieses Nodes. Peers kontaktieren ihn hier.
seedsAdressen anderer Nodes. Beim Start kontaktiert dieser Node sie, um dem bestehenden Cluster beizutreten. Eine leere Liste = “ich bin der erste” (wird automatisch zum Leader).
rolesTags, die dieser Node trägt. Router und Shard-Regionen können danach filtern (role: 'compute' überspringt Nodes ohne dieses Tag).

Nachdem Cluster.join aufgelöst ist, befindet sich der Node im Cluster (möglicherweise aber noch ein paar Sekunden im Zustand joining, bis Konvergenz eintritt).

Cluster.join veröffentlicht die Instanz zugleich am ActorSystem — du musst das Handle also nicht durch jeden Actor durchreichen, der es braucht:

system.cluster; // Option<Cluster> — None, wenn dieses System nie beigetreten ist
this.context.cluster; // dasselbe Option, innerhalb eines Actors
this.cluster; // ausgepackt, innerhalb eines Actors — wirft, wenn es keinen gibt

Die ausgepackte Form ist die für Code, der ohnehin nur geclustert läuft. Dazu gehören jede Sharded Entity und jeder Singleton: die konstruiert das Framework, es gibt also gar keine Aufrufstelle, an der sich ein Cluster injizieren ließe:

class CartEntity extends Actor<CartMessage> {
override preStart(): void {
// No constructor argument, no closure, no options field.
this.log.info(`cart ${this.entityId} on ${this.cluster.selfAddress}`);
}
private onCheckout(): void {
if (this.cluster.isLeader()) { /* ... */ }
}
}

this.cluster.sharding und this.cluster.singleton kommen damit gleich mit — ein Actor kann also von innen heraus eine Region oder einen Singleton starten.

Greife stattdessen zu this.context.cluster, wenn der Actor auch auf einem einfachen, ungeclusterten System laufen muss: das antwortet mit None, statt zu werfen. Beide lesen bei jedem Zugriff auf system.cluster durch — ein Actor, der den Beitritt überlebt hat, oder ein System, das nach leave() erneut beigetreten ist, sieht also immer die aktuelle Instanz.

Jedes Mitglied durchläuft eine kleine Zustandsmaschine:

optional

Leader bestätigt

Failure Detector

Heartbeat kehrt zurück

cluster.leave()

Tombstone-TTL

joining

weaklyUp

up

unreachable

leaving

exiting

removed

  • joining — hat sich gerade angekündigt. Andere Peers wissen davon, aber er ist noch nicht routbar.
  • weakly-up (optional) — per Gossip sichtbar für Peers, aber der Leader hat ihn noch nicht bestätigt. Nützlich für partitionstolerante Joins; siehe Weakly-up.
  • up — vollständig im Cluster, routbar. Das ist der Normalzustand.
  • unreachable — der Failure Detector hat den Peer als nicht heartbeatend markiert. Offiziell weiterhin Mitglied, aber das Routing umgeht ihn. Transient; springt zurück auf reachable, wenn Heartbeats wieder ankommen.
  • leaving / exiting — das Mitglied verlässt den Cluster geordnet (per cluster.leave()).
  • removed — formal aus dem Cluster entfernt. Bleibt für eine TTL (Standard 24 h) als Tombstone erhalten, damit veralteter Gossip von einem langsamen Peer ihn nicht versehentlich wiederbelebt.

Der Event-Stream des Clusters legt jeden Übergang offen — abonniere MemberUp / MemberRemoved / MemberUnreachable etc. und reagiere.

cluster.subscribe(listener) beginnt damit, die bereits bestehende Mitgliedschaft erneut abzuspielen — ein Listener, der sich zehn Minuten nach dem Start anhängt, erfährt so trotzdem die Welt, der er beitritt, statt auf die nächste Änderung zu warten. Zwei Formen, pro Subscription wählbar:

import { CurrentClusterState } from 'actor-ts';
// 'events' (der Standard) — die Mitgliedschaft als die Events, die sie
// gebaut haben: MemberJoined pro Member, dann das Status-Event, das dieses
// Member erreicht hat, dann LeaderChanged. Der Handler des Live-Streams
// deckt das Replay mit ab.
cluster.subscribe((evt) => { /* … */ });
// 'snapshot' — ein einziges CurrentClusterState, unabhängig von der
// Cluster-Größe.
cluster.subscribe(
(evt) => {
if (evt instanceof CurrentClusterState) {
console.log(`${evt.members.length} members, ${evt.unreachable.length} unreachable`);
}
},
{ replayMode: 'snapshot' },
);

Wähle 'snapshot', wenn der Listener wissen will, wie der Stand ist, statt nachzuvollziehen, wie es dazu kam: das ist ein Callback statt eines pro Member, und es markiert das Ende des Replays — was die Event-Form nicht kann. unreachable ist eine Teilmenge von members und keine Menge daneben, und CurrentClusterState wird nie auf dem Event-Stream veröffentlicht: es beschreibt den Startpunkt genau eines Subscribers, nicht etwas, das dem Cluster widerfahren ist.

Das Replay entspricht immer getMembers(): Tombstones bleiben außen vor, und jedes Member wird in dem Status angekündigt, den es tatsächlich hat — ein unreachable-Peer wird als unreachable abgespielt, nicht als frischer Join.

cluster.leader() liefert das niedrigst-adressierte up-Member — dasjenige, dessen host:port zuerst sortiert. Es ist ausdrücklich nicht das älteste Member: Adress- und Join-Reihenfolge haben nichts miteinander zu tun. Eine Node, die zuletzt joint, führt sofort, wenn ihre Adresse am niedrigsten sortiert — und übernimmt alles, was der Leader hostet (einen Cluster-Singleton, die Shard-Allokation).

Das ist Absicht. Die eine Eigenschaft, die der Leader haben muss, ist, dass jede Node dieselbe benennt, und die Adress-Ordnung liefert das aus Gossip, den jede Node ohnehin hat — es muss keine monoton wachsende Join-Sequenz über die Leitung reisen. Der Preis: „wer führt“ entscheidet die Adressierung, nicht die Laufzeit — stabil über den Neustart desselben Pods, nicht stabil über eine Neuadressierung.

Gossip ist der Mechanismus, mit dem sich Mitglieder darauf einigen, wer im Cluster ist. Alle gossipIntervalMs (Standard 1000 ms) wählt jedes Mitglied einen zufälligen erreichbaren Peer und tauscht seine Sicht auf den Cluster aus. Über ein paar Runden konvergieren alle Peers auf denselben Zustand — ohne zentralen Koordinator.

Das Protokoll trägt:

  • Mitgliederliste — Adresse, Status, Rollen und Versionsvektor jedes Mitglieds.
  • Erreichbarkeitsbeobachtungen — “ich habe von X länger nichts gehört.”

Zwei Peers, die Gossip austauschen, mergen ihre Tabellen, indem sie die höhere Version für jedes Mitglied wählen. Genau das lässt Konvergenz ohne leader-gewählten Koordinator funktionieren: jedes parallele Update wird schließlich von jedem Peer gesehen.

Für den tiefen Einstieg siehe Joining und Seeds.

Das Framework verwendet einen einfachen, deterministischen Failure Detector auf Basis von abgelaufenen Zeitschwellen. Jedes Mitglied verfolgt pro Peer einen Zuletzt-gesehen-Zeitstempel — jede Nachricht zählt als Heartbeat; bleibt ein Peer länger als eine Schwelle still, markiert ihn der Detector.

  • unreachableAfterMs — sobald ein Peer diese Zeit lang still war, wird das Mitglied als unreachable markiert.
  • downAfterMs — hält die Stille diese Zeit lang an, wird das Mitglied heruntergefahren (Split-Brain-Auflösung).

Schlichte abgelaufene Zeitgrenzen, kein Tracking statistischer Varianz — ausreichend für Cluster auf LAN-Skala. Siehe Failure Detector für Tuning und die WAN-Vorbehalte.

Wenn das Netzwerk partitioniert wird, können zwei Hälften des Clusters beide weiterlaufen und gleichzeitig den Kontakt zueinander verlieren. Ohne Eingriff laufen beide Seiten weiter und akzeptieren widersprüchliche Schreiboperationen — das klassische Split-Brain-Problem.

actor-ts liefert mehrere Downing-Strategien mit:

StrategieWas sie tut
KeepMajorityDie Seite mit mehr Nodes gewinnt; die kleinere Seite fährt sich selbst herunter.
KeepOldestDie Seite mit dem niedrigst-adressierten Mitglied gewinnt — „ältestes“ meint Adress-, nicht Join-Reihenfolge.
KeepRefereeDie Sicht eines bestimmten Schiedsrichter-Nodes gewinnt.
StaticQuorumDie Seite, die eine konfigurierte Quorum-Größe erreicht, gewinnt; eine Seite darunter fährt sich selbst herunter.
LeaseMajorityDie Mehrheitsseite gewinnt, aber nur solange sie eine Koordinations-Lease hält.

Siehe Downing-Strategien für die vollständige Liste. Wähle bewusst — der Standard (keine Downing-Strategie) erfordert manuelles Eingreifen während einer Partition.

Sobald zwei Nodes denselben Cluster teilen, funktioniert ref.tell(message) auf einen Foreign-Node-Ref einfach so:

const remote = await system.actorSelection(
'actor-ts://my-app@10.0.0.5:2552/user/api/sessions/user-42',
).resolveOne();
remote.tell({ kind: 'whatever' });
// → serialisiert, über den Transport gesendet, in die Mailbox des fremden Actors zugestellt

Der Cluster-Transport serialisiert die Nachricht (standardmäßig JSON), nimmt den Routing-Pfad mit, und der Transport des Empfängers stellt sie zu. replyTo-Refs serialisieren sauber — der empfangende Node hängt einen remote-routbaren Handle an, sodass Antworten über denselben Transport zurückfließen.

Siehe Refs über Nodes hinweg für die Details des Wire-Formats.

Das Cluster-Modul ist das Fundament; alles Interessante an verteilten Actor-Systemen kommt von den Extensions, die darauf aufbauen:

ExtensionWas sie hinzufügt
ShardingEin Actor pro “Entity-Key”, verteilt über Nodes, mit automatischem Rebalancing bei Mitgliedschaftsänderungen.
SingletonEin Actor clusterweit. Wird woanders neu gespawnt, falls der Host-Node geht.
DistributedPubSubThemenbasierte Fan-out-Verteilung über den Cluster.
DistributedDataCRDT-basierter gemeinsamer Zustand mit eventueller Konsistenz.
Cluster-RouterRoutet Nachrichten über Cluster-Up-Mitglieder an einem bekannten Pfad.
ReceptionistService-Registry — Actors registrieren sich, andere suchen sie per Key.

Du aktivierst diese nicht standardmäßig; du greifst zu ihnen, wenn du sie brauchst. Diese Seite behandelt das Fundament, das sie alle teilen — sobald du Mitgliedschaft, Gossip und Failure Detection verstehst, folgen die Extensions.

Ein “Cluster” aus einem Node ist gültig. Cluster.join ohne Seeds (oder mit unerreichbaren Seeds) aufzurufen, gibt dir einen Singleton-Cluster — der lokale Node befördert sich selbst zum Leader, wird up, und jede Extension, die vom Cluster abhängt (Sharding, Singleton, PubSub), funktioniert, als wäre sie in einem größeren Cluster.

Das heißt: Cluster-Code kann mit einem einzelnen Node entwickelt und getestet werden; du brauchst kein Docker-Compose-Setup zum Loslegen. Füge später mehr Nodes hinzu, indem du ihnen Seeds gibst, die auf den ersten zeigen.

Drei Hauptmotivationen:

  1. Scale-out: mehr Actors, als der Speicher oder die CPU eines einzelnen Nodes verkraften. Sharding verteilt sie.
  2. Fehlertoleranz: wenn ein Node abstürzt, wandert die Arbeit auf einen anderen. Singleton und Sharding übernehmen den Failover.
  3. Geografische Verteilung: Actors nah an ihren Nutzern oder Daten, koordiniert mit dem Rest des Clusters über das WAN.

Für eine Single-Process-App brauchst du das Cluster-Modul nicht. Für eine Multi-Process-App, in der jeder Prozess unabhängigen Zustand hält, ebenfalls nicht — nutze einfach Prozessgrenzen. Greife zu, wenn du wirklich gemeinsamen logischen Zustand über mehrere Maschinen brauchst.

Die Cluster API-Referenz deckt die Join/Leave/Subscribe-Oberfläche ab.