Retries, die Sie nicht schreiben müssen: Ownership-Churn im Zaris-Client überstehen
Hier eine Tatsache über jeden partitionierten Key/Value-Store, die die glänzenden Diagramme gern auslassen: Der Node, der Ihren Schlüssel besitzt, wird wechseln. Ein Node fällt aus, und seine Partitionen gehen per Failover auf ihre Replikate über. Sie skalieren aus, und Partitionen wandern auf das neue Mitglied. Ein bevorzugter Primary kehrt zurück, und der Besitz fällt an ihn zurück. Jedes dieser Ereignisse bedeutet: Der Node, mit dem Ihr Client vor einer Millisekunde noch zufrieden sprach, ist jetzt für einen Teil der Schlüssel der falsche.
Die naive Client-Erfahrung in diesem Moment ist hässlich: ein Verbindungsabbruch, ein generischer „Serverfehler" oder — schlimmer — ein Schreibvorgang, der auf einem Node landet, der nicht mehr der aktive Owner ist, und stillschweigend im Nichts verschwindet. Der Zaris-.NET-Client ist so gebaut, dass nichts davon Ihren Code erreicht. Ein Ownership-Wechsel wird zu einem typisierten Status, der Client routet um zum neuen Owner, und der Retry ist sowohl durch ein Versuchsbudget als auch durch eine Wanduhr-Deadline begrenzt. Im Normalfall dauert Ihr PutAsync einfach ein paar Millisekunden länger und liefert Ok. Dieser Beitrag beschreibt die Mechanik dahinter — und ebenso wichtig: die Fälle, die sie bewusst nicht überdeckt.
Der Server sagt die Wahrheit per Status, nicht per Exception
Erreicht eine Anfrage einen Node, der sie nicht bedienen kann, legt dieser nicht einfach auf und wirft auch keinen vagen Fehler. Er antwortet mit einem konkreten KvStatus, der genau sagt, warum er der falsche Ort ist:
Moved— dieser Node besitzt das Segment überhaupt nicht. Ihre Routing-Tabelle ist veraltet; der Schlüssel liegt woanders.OwnershipChanging— dieser Node ist gerade jetzt nicht der aktive Owner, aber der Besitz an diesem Segment befindet sich aktiv im Übergang (etwa bei einem Failback des bevorzugten Primary). Das ist kein beständiger Routing-Fehler, sondern eine Übergabe, die unter Ihnen stattfindet.ReplicaWriteRejected— dieser Node besitzt das Segment, ist aber das Replikat, nicht der aktive Primary. Ihr gecachter aktiver Owner ist nach einem Failover veraltet, und ein Schreibvorgang darf hier nicht akzeptiert werden.Migrating— das Segment wird gerade zu einem neuen Owner migriert; Schreibvorgänge werden kurz zurückgehalten.Unavailable— die Partition hat in diesem Augenblick keinen erreichbaren Owner.
Diese Status wurden nicht für den Client erfunden — sie entstehen auf dem Datenpfad. Die Entscheidung zwischen Owner und Nicht-Owner ist zum Beispiel eine einzige Verzweigung auf dem Server: Eine Nicht-Owner-Anfrage während einer aktiven Übergabe erhält OwnershipChanging, ein gewöhnlicher Treffer auf veraltetes Routing erhält Moved.
// server-side, Clustron.Zaris.Core.ExternalStoreService
private KvStatus OwnershipFailureStatus(ushort segmentId)
=> _topology is not null && _topology.IsSegmentOwnershipTransitioning(segmentId)
? KvStatus.OwnershipChanging // handoff in progress: refresh and retry
: KvStatus.Moved; // settled, stale routing entry
Beides auseinanderzuhalten ist wichtig: Eine vorübergehende Übergabe soll in Ihren Logs nicht wie ein Routing-Fehler aussehen, und der Client kann auf beides angemessen reagieren. Der Sinn des gesamten Status-Vokabulars ist, dass „falscher Node" eine erstklassige, maschinenlesbare Antwort ist und kein Fehler, den Sie aus einer Exception-Meldung per Mustervergleich herausfischen müssen.
Das Retry-Gehirn: was vorübergehend ist und was eine echte Antwort
Die gesamte Routing-Intelligenz sitzt im Client an einer einzigen Stelle — ClientRetryHelper —, den die Doku im Code zu Recht „das Retry-/Backoff-Gehirn des Clients" nennt. Seine wichtigste Entscheidung ist nicht, wie wiederholt wird, sondern ob überhaupt. Zwei Prädikate ziehen diese Linie:
// Retry only these — they mean "transient; the same request can succeed shortly."
private static bool ShouldRetry(KvStatus status) => status switch
{
KvStatus.Moved => true,
KvStatus.ReplicaWriteRejected => true,
KvStatus.Unavailable => true,
KvStatus.Migrating => true,
KvStatus.OwnershipChanging => true,
_ => false
};
// Of those, these three also mean "you reached the wrong node — refresh routing first."
private static bool RequiresMapRefresh(KvStatus status)
=> status == KvStatus.Moved
|| status == KvStatus.ReplicaWriteRejected
|| status == KvStatus.OwnershipChanging;
Achten Sie darauf, was in ShouldRetry fehlt. NotFound ist nicht wiederholbar — der Schlüssel ist wirklich nicht da, und erneutes Fragen zaubert ihn nicht herbei. Auch Conflict ist nicht wiederholbar, und das ist die entscheidende Unterscheidung. Wie im CAS-Beitrag beschrieben, ist ein Conflict aus einem IfMatchVersion-Schreibvorgang eine echte Antwort: Jemand anderes hat zuerst geschrieben, und Ihr Wert war veraltet. Der Client muss das direkt an Sie zurückgeben, damit Ihre Compare-and-Swap-Schleife neu lesen und neu berechnen kann. Würde das Retry-Gehirn Conflict „hilfreich" wiederholen, würde es die optimistische Nebenläufigkeit stillschweigend aushebeln.
Die Regel des Clients ist also scharf: Vorübergehende Routing- und Verfügbarkeitsfehler wiederholen; nie ein aussagekräftiges Ergebnis wiederholen. Ein Failover zu verbergen ist Sache des Stores. Ein Versionskonflikt oder ein fehlender Schlüssel ist Sache Ihrer Anwendung.
Retry reicht nicht — man muss umrouten
Ein Moved gegen denselben veralteten Node zu wiederholen, würde ewig nur ein weiteres Moved liefern. Deshalb lösen die drei „falscher Node"-Status vor dem nächsten Versuch zusätzlich über einen onMoved-Callback eine Aktualisierung der Cluster-Map aus. Die Retry-Schleife, auf ihr Rückgrat reduziert, lautet:
result = await operation().ConfigureAwait(false);
if (result.IsSuccess || !ShouldRetry(result.Status))
return result; // success, or a real answer — hand it back
if (RequiresMapRefresh(result.Status) && onMoved != null)
await onMoved(cancellationToken); // re-fetch ownership, THEN retry elsewhere
Denselben Ausnahmepfad gibt es für die Fehler auf Transportebene, die Ownership-Churn ebenfalls auslöst — PartitionUnavailableException, NodeUnavailableException, TransportException, SocketException, TimeoutException. Auch sie lösen vor dem Retry die Aktualisierung der Map aus, denn ein Socket-Fehler zu einem soeben gestorbenen Node ist selbst ein Signal, dass sich das Routing geändert hat. Eine wirklich nicht wiederholbare Exception wird dagegen sofort erneut geworfen — die Schleife verschluckt nie, was sie nicht versteht.
Das Ergebnis: Der zweite Versuch geht nicht zurück zu dem Node, der „nicht ich" gesagt hat — er geht zu dem, den die aktualisierte Map nun als aktiven Owner nennt. Erst Retry plus Reroute lässt die Operation gelingen; Retry allein würde nur im Kreis laufen.
Zwei Grenzen, nicht eine — der Refresh-Storm, den wir beheben mussten
Eine Retry-Schleife braucht eine Abbruchbedingung, und der subtile Fehler besteht darin, nur eine zu wählen. Der Zaris-Client begrenzt jede Operation sowohl durch ein Versuchsbudget als auch durch eine Wanduhr-Deadline, und die Schleifenbedingung bricht ab, sobald eine von beiden greift:
for (int attempt = 1;
attempt <= maxAttempts && (!deadline.HasValue || DateTime.UtcNow < deadline.Value);
attempt++)
Dieses && steht dort wegen eines echten Vorfalls. Eine frühere Version verwendete die Deadline anstelle von maxAttempts, sobald ein Timeout gesetzt war. Ein Aufrufer, der maxAttempts: 3 mit dem Standard-Operations-Timeout von 60 Sekunden anforderte, wiederholte deshalb nicht dreimal, sondern volle 60 Sekunden lang und löste bei jedem Versuch eine Aktualisierung der Cluster-Map aus. Unter Node-Churn, wo viele Operationen gleichzeitig scheitern und aktualisieren, wurde so aus einer einzelnen fehlschlagenden Operation ein Dutzend Refreshes, alle hinter dem Refresh-Lock serialisiert. Der Sendepfad verhungerte, und der Durchsatz brach Richtung null ein. Die Korrektur: beide Grenzen einhalten — ein paar Versuche und eine harte Deadline, je nachdem, was zuerst eintritt.
Die Defaults sind bewusst bescheiden — maxAttempts: 3, backoffMillis: 100 —, wobei die Operations-Deadline aus ZARIS_REQUEST_TIMEOUT aufgelöst wird (standardmäßig 60 Sekunden; mit infinite deaktivierbar). Das Versuchsbudget hält einen vorübergehenden Aussetzer billig; die Deadline verhindert, dass ein echter Ausfall Ihren Aufruf ewig hängen lässt.
Backoff, der die Deadline respektiert — und weiß, dass Migrationen langsam sind
Zwischen den Versuchen wartet der Client linear (backoffMillis * attempt), doch zwei Verfeinerungen machen das produktionsreif:
private static TimeSpan GetDelay(int attempt, int backoffMillis, KvStatus? status, DateTime? deadline)
{
var delayMs = status == KvStatus.Migrating
? backoffMillis * attempt * 8 // a migration takes real time; don't hammer it
: backoffMillis * attempt;
var delay = TimeSpan.FromMilliseconds(Math.Min(delayMs, 2_000)); // hard cap
if (deadline.HasValue)
{
var remaining = deadline.Value - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero) return TimeSpan.Zero;
if (delay > remaining) delay = remaining; // never overshoot
}
return delay;
}
Ein Status Migrating erhält eine 8-fach längere Verzögerung, denn eine Segment-Migration ist ein Ereignis im Sekundenbereich, und ein Retry alle 100 ms erzeugt nur Rauschen auf einem Node, der ohnehin mit dem Verschieben von Daten beschäftigt ist. Jede Verzögerung ist auf zwei Sekunden gedeckelt, damit der Backoff nicht ausufert, und — das Detail, das beide Grenzen zusammen ehrlich hält — die Verzögerung wird auf die verbleibende Zeit bis zur Deadline begrenzt, sodass der Client nie über sein eigenes Limit hinaus schläft.
Ein Refresh, keine Thundering Herd
Dieser Teil zählt erst bei großem Maßstab. Unter Churn scheitern viele Operationen im selben Augenblick, und jede möchte die Cluster-Map aktualisieren. Würde jede ihren eigenen Refresh auslösen, hämmerte eine Thundering Herd auf die überlebenden Nodes, genau dann, wenn diese am empfindlichsten sind. Der Client fasst sie zu einem einzigen gemeinsamen Refresh zusammen:
internal async Task TryRefreshClusterMapSafe(CancellationToken ct = default)
{
Task? existing = _activeRefreshTask;
if (existing != null && !existing.IsCompleted)
{
// A refresh is already in flight — ride it instead of starting another.
await WaitForSharedTaskAsync(existing, ct);
return;
}
// ... under a lock, double-check and start exactly one, storing it in _activeRefreshTask
}
Einige weitere Schutzmaßnahmen machen das robust: Der Refresh selbst entprellt (er tut nichts, wenn die Map in den letzten 500 ms aktualisiert wurde), ein Aufrufer wartet höchstens ein begrenztes Budget auf den gemeinsamen Refresh (standardmäßig 15 Sekunden, ZARIS_CLIENT_MAP_REFRESH_WAIT_MS), bevor er das Warten aufgibt — ohne den Refresh abzubrechen, den andere noch brauchen —, und der gemeinsame Slot wird mit Interlocked.CompareExchange statt mit einem Lock freigegeben, weil ein früheres Lock-basiertes Freigeben innerhalb einer synchronen Continuation den gesamten Refresh-Pfad zu einem Konvoi stauen und gesunde Lesevorgänge blockieren konnte. Die Lehre, die immer wiederkehrt: Der Resilienz-Code muss mit seiner eigenen Contention mindestens so sorgfältig umgehen wie das System, das er schützt.
Wo es endet — und warum das richtig ist
Retries sind keine Magie, und die ehrliche Grenze ist die Idempotenz. Die Retry-Schleife liefert Send-Semantik mit At-least-once: Geht die Bestätigung eines Schreibvorgangs auf dem Draht verloren, nachdem der Store ihn committet hat, sendet ein Retry ihn erneut. Für ein einfaches Last-Writer-Wins-PutAsync mit festem Wert ist das Wiederholen harmlos. Für alles, was nicht von Natur aus idempotent ist, besteht die Lösung nicht darin, Retries abzuschalten, sondern die Operation wiederholungssicher zu machen — mit genau den Primitiven, die Zaris ohnehin bietet:
- Sichern Sie eine Mutation mit
IfMatchVersionab, sodass ein wiederholter Schreibvorgang gegen eine inzwischen verschobene Version alsConflictzurückkommt — den der Client, wie oben festgehalten, direkt an Sie zurückgibt, statt ihn zu wiederholen. - Nutzen Sie
IfAbsent, damit ein Create-once wirklich nur einmal geschieht; eine Wiederholung siehtConflict, und Sie behandeln das als „bereits erledigt".
Anders gesagt: Der Retry auf Transportebene und das Compare-and-Swap auf Anwendungsebene sind darauf ausgelegt, zusammenzuspielen — der Client verbirgt den Churn, und die Vorbedingungen machen die verborgenen Retries sicher. Wenn Sie Retries ganz abschalten müssen — etwa in einem Test, der rohe Status beobachten will —, reduziert ZARIS_DISABLE_RETRIES=true das Budget auf einen einzigen Versuch.
Der tiefere Punkt ist eine Arbeitsteilung. Ein verteilter Store, der den Besitz verschiebt, wird Ihrem Client vorübergehende Fehler liefern; das ist kein Defekt, sondern der Preis dafür, überhaupt Failover und Rebalancing durchführen zu können. Ein guter Client schuldet Ihnen, dass er diese Fehler zum Problem des Stores macht — typisiert, umgeroutet, begrenzt — und dabei peinlich genau darauf achtet, nie die Fehler zu verbergen, die tatsächlich Ihre Antwort sind. Zaris zieht diese Linie an genau einer Stelle: den Churn wiederholen, die Wahrheit durchreichen.