Zum Inhalt springen
Deutsch

Sharding — Überblick

Cluster-Sharding ist die Antwort des Frameworks auf “ich habe ein paar Millionen Entities, jede braucht ihren eigenen Actor, verteilt über N Nodes.” Beispiele: Per-User-Sessions, Per-IoT-Device-Controller, Per-Order-Koordinatoren, Per-Game-Room-Actors.

Der Nutzer gibt dem Framework eine Möglichkeit, eine Entity-ID aus jeder Nachricht zu extrahieren; das Framework hasht die ID auf einen Shard; der Koordinator entscheidet, welcher Node diesen Shard hostet; die lokale Region des Nodes spawnt den Entity-Actor bei Bedarf. Wenn Nodes kommen und gehen, rebalanciert der Koordinator die Shards über die neue Topologie.

Cluster aus 3 Nodes

node-1 Region

node-2 Region

node-3 Region

Shards 1-33

Shards 34-66

Shards 67-100

Entities für Keys,

die in diese Shards hashen

Entities für Keys,

die in diese Shards hashen

Entities für Keys,

die in diese Shards hashen

Jede Entity ist ein Actor, auf einem Node, zu einer Zeit — genauso wie ein Singleton, aber skaliert auf N Entities. Failover passiert automatisch: wenn ein Node geht, wandern seine Shards woandershin und die Entities werden dort neu gespawnt.

import { Actor, Cluster } from 'actor-ts';
type CartCmd =
| { entityId: string; kind: 'add'; sku: string }
| { entityId: string; kind: 'view'; replyTo: ActorRef<Cart> };
class CartActor extends Actor<CartCmd> {
private items: string[] = [];
override onReceive(cmd: CartCmd): void {
if (cmd.kind === 'add') this.items.push(cmd.sku);
if (cmd.kind === 'view') cmd.replyTo.tell({ items: this.items });
}
}
// Setup — einzeiliger Cluster- + Sharding-Einstieg:
const { system, cluster } = await Cluster.bootstrap({ name: 'my-app' });
const cartRegion = cluster.sharding.start('cart', CartActor, {
extractEntityId: (msg: CartCmd) => msg.entityId,
});
// Verwendung — `tell` an die Region, mit Entity-ID in der Nachricht:
cartRegion.tell({ entityId: 'user-42', kind: 'add', sku: 'book-1' });
cartRegion.tell({ entityId: 'user-42', kind: 'view', replyTo: ... });
// ^^^^^^^^^^
// Gleiche ID → gleicher Entity-Actor, jedes Mal, unabhängig vom Node.

cluster.sharding.start() akzeptiert drei Aufruf-Formen — nimm die, die zur Entity passt:

// 1. Klassen-Kurzform (am häufigsten):
cluster.sharding.start('cart', CartActor, { extractEntityId: (m: CartCmd) => m.entityId });
// 2. Factory-Kurzform — wenn die Entity Konstruktor-Argumente braucht:
cluster.sharding.start('cart', () => new CartActor(deps), {
extractEntityId: (m: CartCmd) => m.entityId,
});
// 3. Vollform — explizite Props + alle Settings:
cluster.sharding.start<CartCmd>({
typeName: 'cart',
entityProps: Props.create(() => new CartActor()),
extractEntityId: (m) => m.entityId,
numShards: 16,
role: 'cart-host',
});

cluster.sharding ist eine memoisierte Fassade — wiederholte Zugriffe liefern dieselbe ClusterSharding-Instanz. Die explizite Form ClusterSharding.get(system, cluster) funktioniert weiter und liefert dasselbe Objekt; greif dazu nur, wenn Du die Klasse außerhalb eines Cluster-Handles brauchst.

Aus Sicht des Aufrufers ist die cartRegion ein einzelner ActorRef. Hinter den Kulissen:

  1. Die Region berechnet aus entityId einen Shard (Default: Hash auf einen von 100 Shards).
  2. Sie fragt den Koordinator “wem gehört dieser Shard?”
  3. Wenn der Besitzer dieser Node ist, spawnt sie die Entity (falls noch nicht vorhanden) und leitet die Nachricht weiter.
  4. Wenn der Besitzer ein anderer Node ist, leitet sie über den Cluster-Transport an dessen Region, die dasselbe macht.
ActorRolle
RegionEine pro Node. Routet Nachrichten an den Besitzer des richtigen Shards, hostet lokale Entities.
KoordinatorEiner pro Cluster (Singleton, auf dem Leader). Entscheidet, welcher Node welchen Shard besitzt. Behandelt Rebalancing bei Mitgliedschaftsänderungen.
EntityEine pro entityId. Hosted auf dem Node, dem aktuell der Shard gehört, in den die ID hasht.

Jede Ebene hat eine eigene Seite im Cluster-Abschnitt — siehe ShardRegion, die Allokationsstrategie und Rebalance für die Mechanik.

ShardingSettings<TMsg> — die Felder, die du am häufigsten anfasst:

interface ShardingSettings<TMsg> {
typeName: string;
entityProps: Props<TMsg>;
extractEntityId: (message: TMsg) => string;
extractEntityMessage?: (message: TMsg) => unknown;
numShards?: number; // Default 100
role?: string; // auf Nodes mit dieser Rolle beschränken
proxy?: boolean; // nur Routing, keine lokalen Entities
rememberEntities?: boolean; // Entities bei Failover neu spawnen (#)
passivationIdleMs?: number; // idle Entities auto-stoppen
maxEntities?: number; // LRU-Cap pro Node
}
FeldWas es steuert
typeNameEin String, der diesen sharded Typ identifiziert. Verschiedene Typen können im selben Cluster koexistieren (cart, session, order).
extractEntityId(msg)Zieh die Entity-ID aus einer Nachricht. Das ist der Key, der zu einem Shard gehasht wird.
extractEntityMessage(msg)(Optional) Wenn der Nachrichten-Envelope Routing-Info plus eine Payload enthält, entfernt das den Envelope, bevor die Entity ihn sieht. Default ist die Nachricht unverändert.
numShardsIn wie viele Shards der Entity-Raum aufgeteilt wird. 100 reichen für die meisten Cluster; setze auf 1000 für sehr große Cluster (>50 Nodes).
roleNur Mitglieder mit dieser Rolle hosten Shards dieses Typs. Nützlich, um compute-schwere Entities auf dedizierten Nodes zu platzieren.
proxyDieser Node leitet Nachrichten an die Region weiter, hostet aber nie lokale Entities. Wird für Client-Nodes in einem asymmetrischen Cluster verwendet.
rememberEntitiesPersistiere die Menge der aktiven Entity-IDs. Nach einem Koordinator-Failover (oder vollem Cluster-Neustart) werden diese IDs eager gespawnt, sodass Nachrichten nicht die gesamte Flotte neu erstellen müssen.
passivationIdleMsStoppt eine Entity nach so viel Leerlaufzeit. Befreit Speicher; die nächste Nachricht für dieselbe ID erstellt die Entity neu.
maxEntitiesPer-Node-Cap. Wird er überschritten, wird die LRU-Entity passiviert.

Die Defaults sind sinnvoll für kleine Cluster. Für Produktion willst du meistens rememberEntities: true und ein passivationIdleMs passend zu deinem Verkehrsmuster.

import { Passivate, Actor } from 'actor-ts';
class CartActor extends Actor<CartCmd | Passivate> {
override onReceive(msg): void {
if (msg instanceof Passivate) {
// Optional Shutdown-Arbeit erledigen, dann das Passivate-Ack senden.
this.context.parent.forEach(p => p.tell({ kind: 'passivate-ack' }));
return;
}
// ...
}
}

Wenn passivationIdleMs konfiguriert ist (oder maxEntities erreicht wird), schickt die Region Passivate an die Entity. Die Entity quittiert; die Region stoppt sie und sorgt dafür, dass für dieselbe ID gepufferte Nachrichten an die nächste Inkarnation drainen, wenn sie spawnt.

Wenn du voll manuelle Kontrolle willst, schicke dem Parent selbst eine { kind: 'passivate' }-Nachricht.

Wenn sich die Cluster-Topologie ändert (ein Node tritt bei oder geht), führt der Koordinator einen Rebalance-Lauf durch:

  1. Berechne die neue Shard-zu-Node-Zuordnung aus der aktiven Allokationsstrategie (Default: Hash modulo Regionen).
  2. Sage für jeden bewegten Shard der alten Region, sie soll den Shard übergeben.
  3. Die alte Region stoppt ihre Entities (die ihren State persistieren können), sagt dem Koordinator “Handoff abgeschlossen” und hört auf, für diesen Shard zu routen.
  4. Die neue Region spawnt Entities für diesen Shard bei Bedarf, wenn Nachrichten ankommen.

Der Handoff ist nicht sofort fertig — gepufferte Nachrichten warten auf das “Handoff abgeschlossen”-Signal, bevor sie an den neuen Besitzer weitergeleitet werden. Das verhindert, dass Nachrichten an einer halb-verschobenen Entity vorbeisausen.

Siehe Rebalance für das vollständige Protokoll.

Sharded Entities unterliegen denselben Neustart-Semantiken wie jeder andere Actor — wenn eine Entity zu einem neuen Node wandert, startet die neue Instanz mit weißer Weste.

Für State, der überleben soll:

  • PersistentActor — die Entity persistiert Events in einem Journal; auf einem frischen Node spielt sie das Journal beim Start ab. Siehe PersistentActor.
  • DurableStateActor — einfacher: persistiere den aktuellen State-Snapshot; restore beim Neustart. Siehe DurableState.
  • DistributedData — für State, der von jedem Node lesbar sein soll (nicht nur vom aktuellen Host der Entity), nutze ein CRDT im DD-Replicator stattdessen.

Ohne eine dieser Lösungen gibt dir Sharding Platzierung und Routing, aber keine Durability.

Drei gute Anwendungen:

  1. Per-User-/Per-Tenant-State, der zu viel für einen Node ist, aber nicht von überall gleichzeitig lesbar sein muss.
  2. Per-Entity-Workflows — Sagas, Order-Processing, lange laufende Koordinatoren — die von Per-Key-Serialisierung profitieren.
  3. Hotspots, die Keys folgen — eine Streaming-Pipeline, in der die Events jedes Users in Reihenfolge auf einem einzelnen Actor verarbeitet werden sollen.

Die ClusterSharding API-Referenz deckt die vollständige Oberfläche ab.