Zum Hauptinhalt springen

Ein Primitiv, viele Garantien: Race-freie Koordination mit optimistischem CAS

· 12 Minuten Lesezeit
Clustron Team
Engineering für verteilte Systeme

Race-freie Koordination mit dem optimistischen CAS von Zaris

Es ist verlockend, einen Datenspeicher an der Länge seiner Befehlsliste zu messen. Redis hat INCR, SETNX, MULTI/EXEC, WATCH, Lua, SCAN, Pub/Sub — für jeden Anlass ein eigenes Verb. Der Zaris-.NET-Client wirkt dagegen fast asketisch. Es gibt kein serverseitiges atomares Increment. Es gibt keine allgemeine Multi-Key-Transaktion, die beliebige Logik ausführt. Es gibt keinen Prefix-Scan. Natives Pub/Sub existiert nur über das RESP-Frontend, nicht im typisierten .NET-Client.

Stattdessen bietet Ihnen der .NET-Client ein kleines, scharfes Koordinationsprimitiv: optimistisches, versioniertes Compare-and-Swap. Dieser Beitrag zeigt, wie weit dieses eine Primitiv tatsächlich trägt — denn die ehrliche Antwort lautet: überraschend weit. Ein Rate Limiter, ein Wallet, das unter tausend gleichzeitigen Abbuchungen nie ins Minus rutscht, eine Job-Queue, in der genau ein Worker jeden Job übernimmt, und ein Feature-Flag-Regelwerk, das bei gleichzeitigen Admin-Änderungen nie ein Update verliert. Alles aus demselben Primitiv, ohne zusätzliche serverseitige Mechanik.

Das Primitiv​

Jeder Wert in Zaris trägt eine monotone Version. Sie setzen sie nicht selbst; der Store verwaltet sie und erhöht sie bei jedem erfolgreichen Schreibvorgang. Beim Lesen erhalten Sie sowohl den Wert als auch die Version, bei der er geschrieben wurde:

KvResult<int> read = await client.GetAsync<int>("counter:api");
int value = read.Value; // the current value
long version = read.Version; // the version that value was written at

Beim Zurückschreiben können Sie eine Vorbedingung auf diese Version festlegen:

var opts = new PutOptions { IfMatchVersion = version };
KvStatus status = await client.PutAsync("counter:api", value + 1, opts);
// status == KvStatus.Ok -> your write committed; version is now version+1
// status == KvStatus.Conflict -> someone else wrote first; your value was stale

IfMatchVersion bedeutet: nur committen, wenn der Schlüssel noch exakt diese Version hat. Hat ein anderer Schreiber zwischen Ihrem Lesen und Ihrem Schreiben committet, hat sich die Version verschoben, Ihre Vorbedingung schlägt fehl, und Sie erhalten KvStatus.Conflict, statt dessen Änderung stillschweigend zu überschreiben. Es gibt eine weitere Variante — IfAbsent = true, also nur committen, wenn der Schlüssel noch nicht existiert —, eine Create-once-Operation, die wir für die Job-Queue verwenden.

Das ist das ganze Primitiv. Ein Lesevorgang, der eine Version liefert, und ein bedingter Schreibvorgang, der nur gegen eine unveränderte Version committet.

Warum das genügt, um Schreiber zu serialisieren​

Das funktioniert, weil die Version monoton und vom Store verwaltet ist. Zwei Clients können im selben Moment Version 7 lesen. Beide berechnen einen neuen Wert. Beide rufen PutAsync mit IfMatchVersion = 7 auf. Der Store verarbeitet Schreibvorgänge auf einen Schlüssel nacheinander, also trifft einer zuerst ein — er passt, committet, und die Version wird 8. Wird der zweite Schreibvorgang ausgewertet, steht der Schlüssel auf 8, nicht auf 7; seine Vorbedingung schlägt fehl, und er erhält Conflict. Genau ein Schreiber gewinnt; der andere erfährt präzise, dass er verloren hat. Es wurde keine Sperre gesetzt, kein Schreiber hat blockierend gewartet, und es gibt kein Fenster, in dem beide Schreibvorgänge „erfolgreich" sind und einer stillschweigend verloren geht.

Der Verlierer scheitert nicht mit der Operation — er hat lediglich diese Runde nicht gewonnen. Er liest den inzwischen aktuellen Wert samt Version erneut, berechnet gegen den frischen Zustand neu und versucht es noch einmal. Diese Schleife ist das Herzstück aller folgenden Muster:

async Task<T> MutateAsync<T>(string key, Func<T, T> update, int maxAttempts = 50)
{
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
KvResult<T> read = await client.GetAsync<T>(key);
T next = update(read.Value);

var status = await client.PutAsync(
key, next, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return next; // we won this round
// Conflict: someone wrote between our read and write. Loop and retry
// against the value they committed — never against our stale copy.
}
throw new ConcurrencyException($"{key} too contended after {maxAttempts} attempts");
}

Zwei Dinge machen das korrekt statt nur hoffnungsvoll. Erstens liest der Retry immer neu — Sie wiederholen nie mit einem veralteten Wert und können daher keinen überschriebenen Zustand wiederbeleben. Zweitens muss update eine reine Funktion des gelesenen Werts sein. Berechnet sie read.Value + 1, ist das sicher; liest sie die Systemuhr oder ein veränderliches, von außen erfasstes Feld, ist der Retry keine getreue Wiederholung. Halten Sie die Transformation rein, und die Schleife ist ein echtes Read-Modify-Write, das sich verhält, als liefen die erfolgreichen Schreiber nacheinander.

Muster 1 — ein verteilter Rate Limiter​

Ein Rate Limiter ist ein atomarer Zähler mit Obergrenze. Es gibt kein INCR im Wire-Protokoll, aber die CAS-Schleife ist ein atomares Increment: Zähler lesen, eins addieren, falls noch Spielraum besteht, und gegen die Version committen.

async Task<bool> TryAcquireAsync(string bucket, int limit)
{
for (int attempt = 0; attempt < 50; attempt++)
{
KvResult<int> read = await client.GetAsync<int>(bucket);
int count = read.Found ? read.Value : 0;
if (count >= limit)
return false; // over the limit, no write at all

var status = read.Found
? await client.PutAsync(bucket, count + 1,
new PutOptions { IfMatchVersion = read.Version })
: await client.PutAsync(bucket, 1,
new PutOptions { IfAbsent = true }); // first caller creates it

if (status == KvStatus.Ok)
return true; // we took a slot
// Conflict -> another caller moved the count; re-read and re-check.
}
return false;
}

Richten Sie tausend gleichzeitige Aufrufer auf denselben Bucket mit limit = 100, und Sie erhalten genau 100 Ergebnisse true und 900 false — nie 101, nie 99. Die Verlierer jeder CAS-Runde lesen neu und stellen fest, dass sich die Obergrenze verschoben hat; sobald der Zähler das Limit erreicht, brechen alle weiteren Aufrufer ohne Schreibvorgang ab. Der IfAbsent-Zweig behandelt das Cold-Start-Race, bei dem mehrere Aufrufer gleichzeitig feststellen, dass der Bucket fehlt — nur ein Create-once gewinnt, die übrigen fallen im nächsten Versuch in den normalen Increment-Pfad.

Muster 2 — ein Wallet, das nie ins Minus rutscht​

Geld ist der anspruchsvolle Fall, denn die Invariante ist ein Guard, nicht nur eine Summe: Eine Abbuchung darf den Kontostand nie unter null treiben, und zwei Abbuchungen dürfen nie beide vom selben Ausgangsstand aus erfolgreich sein. Genau dieses Überschreiben verbietet CAS.

async Task<bool> TryDebitAsync(string account, decimal amount)
{
for (int attempt = 0; attempt < 50; attempt++)
{
KvResult<Wallet> read = await client.GetAsync<Wallet>(account);
Wallet w = read.Value;
if (w.Balance < amount)
return false; // insufficient funds: reject, don't write

var next = w with { Balance = w.Balance - amount };
var status = await client.PutAsync(
account, next, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return true;
// Conflict -> a concurrent debit landed first. Re-read the real balance
// and re-check the guard against it, never against our stale copy.
}
throw new ConcurrencyException($"{account} too contended");
}

Schicken Sie tausend gleichzeitige Abbuchungen über je eine Einheit an ein Konto mit 400, und genau 400 gelingen; der Kontostand landet exakt bei null und fällt nie darunter. Der Guard hält, weil er nach einem Konflikt immer erneut gegen den committeten Kontostand ausgewertet wird. Eine Abbuchung, die gegen einen veralteten Stand von 5 gültig war, darf nicht gegen einen tatsächlichen Stand von 0 committen — ihre Version stimmt nicht mehr, also läuft sie erneut, liest 0 und lehnt korrekt ab. Das ist die schärfste Kante dieses Musters: Die Vorbedingung schützt nicht nur den Schreibvorgang, sondern zwingt die Geschäftsregel, gegen die Wahrheit geprüft zu werden.

Muster 3 — ein Job-Queue-Claim​

Wenn N Worker um denselben Job konkurrieren, soll genau einer den Claim gewinnen. Das ist ein einziger CAS-Wechsel auf einem Statusfeld — oder in der einfachsten Form ein Create-once:

async Task<bool> TryClaimAsync(string jobId, string workerId)
{
var claim = new Claim { Owner = workerId, ClaimedAt = DateTimeOffset.UtcNow };
// IfAbsent: the claim key is created by exactly one worker; the rest see Conflict.
var status = await client.PutAsync(
$"job:{jobId}:claim", claim, new PutOptions { IfAbsent = true });
return status == KvStatus.Ok;
}

Jeder unterlegene Worker erhält Conflict und wendet sich dem nächsten Job zu — kein Polling, keine Lock-Lease, die im Normalfall erneuert werden müsste, kein Gleichstand, der aufzulösen wäre. Modellieren Sie den Job stattdessen als Datensatz mit einem Status-Feld, ist der Claim ein versionsgeschützter Wechsel von Pending auf Claimed mit der MutateAsync-Schleife. Damit lässt sich zusätzlich ein Job neu übernehmen, dessen bisheriger Besitzer abgestürzt ist (lesen, feststellen, dass er Claimed, aber veraltet ist, zurücksetzen) — dasselbe Primitiv, ein Feld tiefer.

Muster 4 — ein Feature-Flag-Regelwerk ohne Lost Updates​

Zwei Admins öffnen die Feature-Flag-Konsole und bearbeiten gleichzeitig dasselbe Regelwerk. Admin A schaltet ein Flag ein; Admin B fügt eine neue Targeting-Regel hinzu. Bei einem naiven Ablauf „JSON lesen, ändern, JSON zurückschreiben" gewinnt, wer zuletzt speichert, und löscht stillschweigend die Änderung des anderen — das klassische Lost Update. CAS macht daraus einen erkannten Konflikt:

async Task SaveRulesetAsync(string flagKey, Func<Ruleset, Ruleset> edit)
{
for (int attempt = 0; attempt < 10; attempt++)
{
KvResult<Ruleset> read = await client.GetAsync<Ruleset>(flagKey);
Ruleset updated = edit(read.Value); // apply this admin's change

var status = await client.PutAsync(
flagKey, updated, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return;
// Conflict -> the other admin saved first. Re-read THEIR version and
// re-apply this admin's edit on top, so neither change is lost.
}
throw new ConcurrencyException($"{flagKey} edited concurrently; please retry");
}

Das Speichern des zweiten Admins läuft in einen Konflikt, liest das Regelwerk einschließlich der Änderung des ersten Admins neu, wendet die eigene Änderung darauf erneut an und committet. Beide Änderungen bleiben erhalten. Die wichtige Nuance: Das funktioniert sauber, wenn die beiden Änderungen unterschiedliche Teile des Regelwerks betreffen, weil das erneute Anwenden kompositionsfähig ist. Bearbeiten beide Admins dieselbe Regel, ist das zweite Speichern der Moment, in dem Sie einem Menschen den Hinweis „jemand anderes hat dies geändert" anzeigen, statt blind zu mergen — der Konflikt ist ein Feature und liefert den Haken, an dem Sie nachfragen können.

Das Idempotenz-Korollar​

Weil ein Konflikt „Wiederholen" bedeutet und eine Wiederholung „noch einmal ausführen", müssen Sie sicherstellen, dass das Noch-einmal unbedenklich ist. Die Wallet-Abbuchung oben ist der Warnfall: Schlägt der Netzwerkaufruf mehrdeutig fehl, nachdem der Store committet hat, aber bevor Sie das Ok erhielten, könnte ein blinder Retry doppelt abbuchen. Die CAS-native Lösung ist ein eigener Index bzw. Dedupe-Schlüssel: Die Operation wird unter einer eindeutigen ID mit IfAbsent festgehalten und vor der Ausführung geprüft.

// Create-once the operation record; if it already exists, this is a retry of
// a debit that already happened — return its recorded outcome, don't repeat it.
var seen = await client.PutAsync(
$"op:{operationId}", new OpMarker(), new PutOptions { IfAbsent = true });
if (seen == KvStatus.Conflict)
return await ReadRecordedOutcomeAsync(operationId); // idempotent replay

Dasselbe Primitiv, das Ihre Schreiber serialisiert, liefert auch den Exactly-once-Haken. Sie greifen nicht zu einem zweiten Mechanismus — IfAbsent ist das Dedupe.

Wo das endet — und was dann zu tun ist​

CAS ist nicht umsonst, und das zu verschweigen wäre genau der Marketing-Nebel, den dieser Beitrag vermeiden will. Drei ehrliche Grenzen:

Hot-Key-Contention. Alle obigen Muster leiten konkurrierende Schreiber durch einen Schlüssel. Je mehr Schreiber, desto mehr Konflikte, desto mehr Retries, und der Durchsatz auf diesem einzelnen Schlüssel sinkt. CAS lässt konstruktionsbedingt pro Runde genau einen Schreiber gewinnen — ein Schlüssel unter hoher Schreib-Contention wird damit zum Serialisierungspunkt. Die Abhilfe ist Sharding des Schlüssels: ein Rate-Limiter-Bucket pro (client, second) statt eines globalen Buckets; ein in Teilguthaben aufgeteiltes Wallet, die regelmäßig abgeglichen werden; eine Job-Queue, die so partitioniert ist, dass Worker auf disjunkten Schlüsselbereichen konkurrieren. Verteilen Sie die Contention, und die Retries verschwinden.

O(n)-Rewrites des gesamten Werts. Ein CAS-Schreibvorgang ersetzt den gesamten Wert, nicht ein Feld. Ein Feature-Flag-Regelwerk mit zehntausend Regeln wird bei jeder einzelnen Änderung neu serialisiert und neu geschrieben — Sie zahlen für das ganze Objekt, um ein Flag zu ändern. Bei kleinen Werten spielt das keine Rolle; bei großen, häufig veränderten Aggregaten ist es ein echter Kostenfaktor, und die Antwort lautet wiederum Zerlegen: unabhängig veränderliche Teile unter getrennten Schlüsseln speichern, damit jede Änderung nur das Geänderte neu schreibt, und das Aggregat unter einem einzelnen Schlüssel für Dinge reservieren, die wirklich klein sind oder selten geschrieben werden.

Keine Atomarität über mehrere Schlüssel. CAS ist atomar auf einem Schlüssel. Erstreckt sich eine Invariante über zwei Schlüssel — Geld von Konto A auf Konto B übertragen —, kann ein einzelnes CAS nicht beide abdecken. Entweder modellieren Sie die Überweisung als Zustandsautomat auf einem Schlüssel (ein „Transfer"-Datensatz, den beide Seiten beobachten), oder Sie akzeptieren, dass Sie eine Saga mit Kompensation benötigen. Greifen Sie nicht zu CAS, um eine Zwei-Schlüssel-Transaktion vorzutäuschen; hier reicht es tatsächlich nicht.

Ein Primitiv, das sich bewährt hat​

Die kurze Befehlsliste erweist sich nicht als Armut. Eine vom Store verwaltete monotone Version plus ein versionsgeschützter bedingter Schreibvorgang genügen, um einen atomaren Zähler, eine geschützte Abbuchung, einen Ein-Gewinner-Claim und eine Änderung ohne Lost Update auszudrücken — vier benannte Probleme verteilter Systeme, ein Primitiv, keine gehaltenen Sperren und kein blockierter Schreiber. Sie verzichten auf den Komfort eines eigens dafür gebauten Verbs je Fall; im Gegenzug erhalten Sie einen einzigen Mechanismus, dessen Korrektheit Sie einmal durchdenken und dann überall nutzen können — samt Dedupe-Haken für die Idempotenz als Zugabe.

Die Disziplin, die er Ihnen abverlangt, ist klein und lohnt sich zu verinnerlichen: die Transformation rein halten, bei Konflikt stets neu lesen, die Invariante gegen den committeten Wert prüfen und shardieren, bevor ein Schlüssel heiß wird. Tun Sie das, und ein einziges Primitiv trägt erstaunlich viel Koordination.