Mailbox-Sizing
Die Default-Mailbox eines Actors ist unbounded. Auf dem Weg hinein wird nichts verworfen, und die Queue wächst, bis der Actor sie abarbeitet. Eine zu begrenzen ist eine Entscheidung pro Actor, denn es ist eine Entscheidung, Nachrichten zu verlieren — du triffst sie dort, wo du sagen kannst, welche verzichtbar sind.
Diese Seite ist der Entscheidungs-Guide für Produktions-Mailbox-Sizing.
Default-Verhalten
Abschnitt betitelt „Default-Verhalten“const ref = system.spawnAnonymous(Worker);// ↑ unbounded FIFO-Mailbox — auf dem Weg hinein wird nie etwas verworfenUnbounded ist der ehrliche Default: ein Actor-Framework kann nicht
wissen, welche deiner Nachrichten entbehrlich ist — und zwischen v0.10
und v0.15 hat dieses geraten. Es begrenzte jede Mailbox auf 10 000 mit
drop-head, ein zurückfallender Actor verlor also still seine
älteste wartende Nachricht. Für einen Sensorwert ist das richtig,
für ein Terminated-Signal, eine Delivery-Bestätigung oder ein
WebSocket-close falsch. Alle drei liefen durch dieselbe Queue, und
aus jedem wurde ein gemeldeter Defekt.
Einer der drei ist inzwischen endgültig vom Tisch: ein Terminated der
Death Watch kommt über eine Spur an,
die keine Overflow-Policy abwerfen und kein Throttle verwerfen kann.
Eine Schranke, die du setzt, kostet den Actor also seinen Backlog, nie
die Tode, auf die er wartet. Die anderen zwei sind
Anwendungsnachrichten, und dort gilt der Satz oben unverändert.
Die Obergrenze, die dieser Handel erkaufte, gab es ohnehin nicht: nur die User-Queue war begrenzt. System-Nachrichten waren nie gedeckelt, der Prozess konnte den Heap also trotzdem ausschöpfen.
Was du stattdessen bekommst, ist Wachstum, von dem du erfährst:
- Ein Actor, dessen Queue 10 000 Nachrichten erreicht, loggt eine Warnung — und erneut bei jeder Verdopplung: 20 000, 40 000 und so weiter. Das läuft immer, ohne Metrik-Stack.
- Mit aktivierten Metriken meldet
actor_mailbox_size{class, path}die Tiefe jeder Mailbox ab derselben Marke.
Der Fehlermodus, den eine unbegrenzte Queue weiterhin erreichen kann — erschöpfter Heap, lange GC-Pausen, ein Producer, der nie erfährt, dass es ein Problem gibt — kündigt sich also lange vorher an. Achte auf die Warnung; begrenze die Actors, die sie produzieren.
Tiefe ist kein Durchsatzproblem. Beide Queues in einer Mailbox sind Ring-Buffer: die nächste Nachricht zu entnehmen rückt einen Index vor, statt den Backlog dahinter neu zu indizieren — eine Queue von einer Million kostet pro Nachricht dasselbe wie eine von zehn. Was eine tiefe Queue sehr wohl kostet, ist Speicher und Latenz: jede Nachricht darin wird gehalten, und die hinterste wartet auf alles, was vor ihr liegt. Das sind die Gründe zu begrenzen, und keiner davon wird durch eine schnellere Queue behoben.
Wann einen Bound einführen
Abschnitt betitelt „Wann einen Bound einführen“Drei Muster, in denen eine unbegrenzte Queue die falsche Antwort ist. In jedem sind Kapazität und Policy bewusste Entscheidungen:
1. Producer/Consumer-Mismatch vorab bekannt
Abschnitt betitelt „1. Producer/Consumer-Mismatch vorab bekannt“import { ActorOptions } from 'actor-ts';
// Langsamer Consumer: schreibt 10/sec auf Disk; Producer pusht 1000/secconst writerOptions = ActorOptions.create() .withMailbox(() => new BoundedMailbox({ capacity: 1_000, overflow: 'reject', }));
const slowWriter = system.spawnAnonymous(SlowWriter, writerOptions);Bounde auf den Worst-Case-akzeptablen Puffer. reject propagiert
Backpressure zum Sender — er sieht MailboxFullError und passt
sich an (Retry, Drop, Alert).
2. Telemetrie-artige Actors (veraltete Daten sind falsch)
Abschnitt betitelt „2. Telemetrie-artige Actors (veraltete Daten sind falsch)“import { ActorOptions } from 'actor-ts';
const telemetryOptions = ActorOptions.create() .withMailbox(() => new BoundedMailbox({ capacity: 5_000, overflow: 'drop-head', }));
const telemetry = system.spawnAnonymous(MetricsAggregator, telemetryOptions);Für Metriken, Sensorwerte, Status-Pings — frischer ist besser.
drop-head verwirft die älteste wartende Nachricht, wenn neue
ankommen, und hält die Queue mit aktuellen Daten gefüllt.
3. Kritische Actors nahe an Limits
Abschnitt betitelt „3. Kritische Actors nahe an Limits“import { ActorOptions } from 'actor-ts';
const authOptions = ActorOptions.create() .withMailbox(() => new BoundedMailbox({ capacity: 10_000, overflow: 'drop-new', }));
const auth = system.spawnAnonymous(AuthActor, authOptions);drop-new verwirft eingehende Nachrichten, wenn voll — bewahrt
bereits eingereihte Arbeit. Richtig, wenn “die Queue, die ich habe,
ist die Arbeit, die mich interessiert” gilt — teilweiser Denial of
Service ist besser, als gar nichts zu verarbeiten.
Kapazität wählen
Abschnitt betitelt „Kapazität wählen“Drei Faktoren:
- Worst-Case-Burst-Größe — wieviele Nachrichten im Worst-Case-Fenster ankommen, bevor der Consumer drainen kann.
- Speicher pro Nachricht —
capacity × bytes_per_messagebegrenzt die Speicherkosten. - Latenz-Budget —
capacity / drain_ratebegrenzt die Worst-Case-Latenz, die eine Nachricht vor der Verarbeitung wartet.
Für einen Worker, der 100 msg/sec verarbeitet und Bursts bis zu 1000 msg in 1 Sekunde erwartet:
capacity = 1000 # Worst-Case-BurstWorst-Case-Latenz = 1000 / 100 = 10s # bei voller QueueWenn 10 Sekunden Queue okay sind, ist Kapazität 1000 fein. Wenn
nicht, Kapazität reduzieren oder akzeptieren, dass Producer
MailboxFullError sehen.
Die Metriken lesen
Abschnitt betitelt „Die Metriken lesen“Stock-Metriken (Stock-Metriken) exponieren die Mailbox-Tiefe:
actor_mailbox_size{class="Worker", path="..."}actor_mailbox_depth_bucketactor_mailbox_wait_seconds_bucketactor_mailbox_dropped_total{class="Worker", reason="drop-head"}Beobachte:
actor_mailbox_size— die Tiefe jeder Mailbox ab 10 000 wartenden Nachrichten. Eine Serie entsteht erst, wenn ein Actor diese Marke überschreitet — ihr Vorhandensein ist also das Signal; auf einem gesunden System ist die Metrik leer. Eine geleerte Mailbox verliert ihre Serie, statt auf ihrem letzten Ausschlag stehen zu bleiben — alarmiere also auf der Existenz der Serie, nicht auf einem Übergang nach 0.actor_mailbox_depth— dieselbe Größe als Verteilung, und sie deckt den Bereich ab, in dem das Gauge stumm ist. Das ist die Metrik, aus der du eine Kapazität ableitest: Einecapacityist eine Wette auf den schlimmsten Ausschlag, und das p99 des Histogramms ist die Messung dieses Ausschlags. Seine letzte Bucket-Grenze ist die Schwelle von 10 000 des Gauges, beide decken also alles dazwischen ab — das Histogramm sagt dir, wie tief, das Gauge sagt dir, wer. Es hat keine Labels und kostet damit dasselbe, ob du zehn Actors betreibst oder zehntausend geshardete Entities.actor_mailbox_wait_seconds— wie lange Nachrichten tatsächlich auf ihre Zustellung gewartet haben. Das ist das frühere der beiden Rückstau-Signale und das, auf das du alarmieren solltest: die Tiefe hat eine Schwelle von 10 000 Nachrichten, bevor sie überhaupt etwas meldet, während die Wartezeit zu steigen beginnt, sobald ein Actor nicht mehr mitkommt. Sie hat keine Labels, sagt dir also dass das System zurückfällt, nicht welcher Actor — kombiniere sie, sobald sie anschlägt, mit der Tiefe, die denpathträgt.actor_mailbox_dropped_total— ungleich Null mitdrop-head/drop-newist bei den Actors beabsichtigt, die du begrenzt hast; bei jedem anderen sollte sie gar nicht auftauchen. Aggregiert pro Klasse, nicht pro Actor — die beiden Metriken unterscheiden sich hier bewusst: eine Tiefen-Serie existiert nur für einen Actor, der bereits in Schwierigkeiten steckt, eine Drop-Serie dagegen für jeden Actor, der seine Arbeit tut. Für Drop-Zahlen pro Instanz nimm ein eigenesonDrop; es bekommt neben dem Grund auch den verworfenen Envelope, kann also sagen was verloren ging und nicht nur wie viel.MailboxFullError-Rate beim Sender — taucht meist als Supervisor-Restarts des sendenden Actors auf.
Dieselbe 10 000er-Schwelle erzeugt eine Log-Warnung, wiederholt bei jeder Verdopplung, unabhängig davon ob Metriken aktiv sind. Auf diese Zeile alarmierst du, wenn du keinen Metrik-Stack betreibst.
Behalten, was du verworfen hast
Abschnitt betitelt „Behalten, was du verworfen hast“Ein Zähler sagt, wie viel abgeworfen wurde. Er sagt nicht, was: ein
verworfener Telemetrie-Messwert und ein verworfenes Command mit offenem
Callback sind dasselbe Inkrement. deadLetterDrops schließt diese Lücke,
indem es jeden verworfenen Envelope an die
Dead Letters leitet, wo er seine Nutzlast
und seinen Sender behält:
import { ActorOptions, BoundedMailbox, BoundedMailboxOptions } from 'actor-ts';
const commandMailbox = BoundedMailboxOptions.create() .withCapacity(1_000) .withOverflow('drop-new') .withDeadLetterDrops(true);
const commandOptions = ActorOptions.create() .withMailbox(() => new BoundedMailbox(commandMailbox));PriorityMailboxOptions hat denselben Schalter, und dort lohnt er sich
öfter: Eine Nachricht, die diese Mailbox abwirft, hat deine
Prioritätsfunktion als letzte eingestuft — und nur die Nutzlast kann
bestätigen, dass das richtig war.
Standardmäßig aus, und das ist keine Zaghaftigkeit. Ein Drop passiert auf dem Stack des Senders, und ein Dead Letter ist eine dauerhafte Erfassung mit anschließender synchroner Veröffentlichung an jeden Event-Stream-Abonnenten — eine dauerhaft eingeschaltete Variante würde Load Shedding also genau in dem Moment in Arbeit pro Nachricht verwandeln, in dem die Schranke die Dinge billiger machen soll. Als Faustregel: Schalte ihn dort ein, wo du den Verlust hinterher erklären müsstest, und lass ihn für den Feuerwehrschlauch aus, den du bewusst begrenzt hast.
Zwei Dinge ändert er nicht. Er erreicht nur Mailboxen, die du selbst
konstruierst — withMailboxCapacity hat keine Tür dorthin — und der Letter
trägt Nachricht, Sender und Empfänger, dieselben drei Felder, die jeder
andere Verlustpfad im Framework festhält. Der MDC-Kontext und der
Tracing-Span, die auf dem Envelope mitreisen, gehören nicht dazu.
Die drei Overflow-Policies im Vergleich
Abschnitt betitelt „Die drei Overflow-Policies im Vergleich“| Policy | Wann |
|---|---|
reject | Backpressure schlägt zum Sender durch. Der Sender muss handeln. |
drop-head | Telemetrie / Metriken — Neueste gewinnt. |
drop-new | Kritische Arbeit — Eingereihtes behalten, Eingehendes droppen. |
Wähle nach der richtigen Antwort auf Overflow:
- “Sender soll retryen / alerten” →
reject. - “Veraltete Daten sind falsch” →
drop-head. - “Eingereihte Arbeit ist wertvoll” →
drop-new.
Es gibt kein “Bestes” — kontextabhängig.
Priority-Mailboxes
Abschnitt betitelt „Priority-Mailboxes“Für Actors mit gemischter Dringlichkeit:
import { ActorOptions, PriorityMailbox } from 'actor-ts';
const workerOptions = ActorOptions.create<Message>() .withMailbox(() => new PriorityMailbox<Message>({ priorityFor: (m) => m.kind === 'urgent' ? 0 : 5, }));
const worker = system.spawnAnonymous(Worker, workerOptions);Niedrigere Zahlen = höhere Priorität. System-Nachrichten übertrumpfen immer.
Nutze für:
- HTTP-Antworten (dringend) vs Batch-Jobs (aufschiebbar).
- Health-Pings vs Bulk-Metriken.
Priorität und ein Bound
Abschnitt betitelt „Priorität und ein Bound“Ordnung und eine Obergrenze sind kein Entweder-oder. withMailbox und
withMailboxCapacity lassen sich nicht kombinieren, die Capacity liegt
also auf den Options der Mailbox selbst:
import { ActorOptions, PriorityMailbox, PriorityMailboxOptions } from 'actor-ts';
const triageOptions = PriorityMailboxOptions.create<Message>() .withPriorityFor((m) => m.kind === 'urgent' ? 0 : 5) .withCapacity(10_000) .withOverflow('drop-lowest-priority');
const workerOptions = ActorOptions.create<Message>() .withMailbox(() => new PriorityMailbox<Message>(triageOptions));Die Policy-Menge ist 'drop-lowest-priority' / 'drop-new' /
'reject', per Default reject. drop-head fehlt mit Absicht: der
Kopf einer Priority-Queue ist die Nachricht, die du als wichtigste
bezeichnet hast. drop-lowest-priority wirft stattdessen vom anderen
Ende ab — das ist die Variante von “Last abwerfen”, für die eine
Priority-Mailbox überhaupt existiert: die dringende Schicht überlebt,
die Bulk-Schicht zahlt.
Drops landen wie alle anderen in actor_mailbox_dropped_total, dasselbe
Alerting gilt also weiter.
Eine Form, auf die du beim Dimensionieren achten solltest: eine
Nachricht, deren priorityFor nicht auswertbar ist, wird zuletzt
rangiert — auf einer vollen Mailbox ist sie damit diejenige, die
abgeworfen wird. Das ist die richtige Antwort, bedeutet aber, dass sich
ein defekter Priority-Callback hier als steigendes drop-new zeigt und
nicht als Fehler — verdrahte onPriorityError, wenn du beides
unterscheiden willst.
Siehe Mailboxes für die volle PriorityMailbox-Oberfläche.
Mailbox + Backpressure-Design
Abschnitt betitelt „Mailbox + Backpressure-Design“producer → reject Backpressure → Sender verlangsamt sichproducer → drop-head → Producer macht weiter; Reader sieht das Neuesteproducer → drop-new → Producer macht weiter; Reader verarbeitet das ÄltesteBounded Mailboxes sind eine Ebene in einer Backpressure-Story. Für Ende-zu-Ende-Backpressure (das vorgelagerte System wird langsamer) kombinierst du:
- Bounded Mailbox am Actor.
- Sender-Retry-Logik.
- Upstream-Rate-Limiting (HTTP 429, Broker-Pushback).
Die Mailbox erzwingt die lokale Grenze; der Rest ist dein Protokoll-Design.
Wohin als nächstes
Abschnitt betitelt „Wohin als nächstes“- Mailboxes — die konzeptuelle Referenz.
- Stock-Metriken — die Mailbox-Depth- + Drop-Metriken.
- Dispatcher-Tuning — der komplementäre Knopf.
- Backoff-Supervisor — für Stash-on-Fail mit bounded Puffer.
