Zum Hauptinhalt springen

Alles oder nichts: Multi-Key-Transaktionen in Zaris

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

Multi-Key-Transaktionen in Zaris

Ein Begleitbeitrag, Race-Free Coordination on Optimistic CAS, hat dargelegt, dass ein einziges Primitiv — eine vom Store vergebene Version plus ein versionsgeschütztes bedingtes Schreiben — erstaunlich viel Koordination trägt. Er zog auch eine harte Grenze: CAS ist atomar auf einem Key. Bewegen Sie Geld von Konto A auf Konto B, kann ein einzelnes Compare-and-Swap nicht beides abdecken; der ehrliche Rat lautete, die Überweisung als Zustandsmaschine auf einem Key zu modellieren oder zu einer Saga zu greifen.

Diese Grenze ist real, aber sie ist nicht die ganze Geschichte. Zaris hat eine echte Multi-Key-Transaktion — BeginTransactionAsync, ein gestaffeltes Lese-/Schreib-Set, CommitAsync — und sie committet über Keys und über Nodes hinweg mit einer Alles-oder-nichts-Garantie. Dieser Beitrag handelt davon, was diese Transaktion unter der Haube tatsächlich ist (ein clientkoordinierter Two-Phase-Commit über optimistische Versionen), was sie genau garantiert und welche Feinheit darüber entscheidet, ob Ihre Key-übergreifende Invariante tatsächlich hält.

Die Form der API​

Eine Transaktion ist ein Kontext, den Sie öffnen, in den Sie Arbeit stagen und den Sie committen. Sie ist IAsyncDisposable, sodass await using garantiert, dass sie freigegeben wird — und eine nicht committete Transaktion, die den Gültigkeitsbereich verlässt, wird zurückgerollt.

await using var tx = await client.BeginTransactionAsync();

KvResult<int> a = await tx.GetAsync<int>("account:A");
KvResult<int> b = await tx.GetAsync<int>("account:B");

await tx.PutAsync("account:A", a.Value - 50);
await tx.PutAsync("account:B", b.Value + 50);

TransactionResult result = await tx.CommitAsync();
// result.IsSuccess, result.Status (Committed | Aborted | Conflict | Timeout | InternalError)

Zwei Verhaltensweisen zählen von der ersten Zeile an. Lesezugriffe innerhalb der Transaktion erfassen die Version jedes berührten Keys. Schreibzugriffe werden gestaged — auf dem Client gepuffert, für den Store und alle anderen Clients unsichtbar, bis der Commit erfolgreich ist. Ihre eigenen späteren Lesezugriffe in derselben Transaktion sehen Ihre gestagten Schreibzugriffe (Read-your-writes): Setzen Sie einen Key und lesen ihn zurück, erhalten Sie, was Sie gestaged haben; löschen Sie einen Key und lesen ihn zurück, erhalten Sie NotFound — alles vor dem Commit.

CommitAsync ist das Einzige, was den Store berührt, und es wendet entweder jede gestagte Operation an oder keine. RollbackAsync (oder einfaches Disposen) verwirft das gesamte Set.

Was beim Commit tatsächlich passiert​

Der interessante Teil ist CommitAsync, denn hier wird „alles oder nichts über Nodes hinweg" verdient. Die Keys Ihres Schreib-Sets können Partitionen auf verschiedenen Nodes gehören, daher führt Zaris einen Two-Phase-Commit mit dem Client als Koordinator aus:

  1. Gruppieren Sie die gestagten Schreibzugriffe nach dem Node, dem der jeweilige Key gehört.
  2. Prepare — senden Sie jedem Node seinen Anteil als bedingten Batch. Für jeden Key, den Sie auch gelesen haben, trägt der Batch die Version, mit der Sie ihn gelesen haben. Jeder Node prüft, dass der Key noch immer auf dieser Version steht, und nimmt darauf eine kurzlebige Sperre. Hat sich die Version eines Keys bewegt oder hält eine andere Transaktion bereits seine Sperre, stimmt dieser Node mit Nein.
  3. Commit oder Rollback — haben alle Nodes mit Ja gestimmt, weist der Koordinator sie alle an, ihre Schreibzugriffe anzuwenden und ihre Sperren freizugeben. Hat ein Node mit Nein gestimmt, rollt der Koordinator die Nodes zurück, die bereits vorbereitet hatten, und der Commit gibt TransactionStatus.Conflict zurück.

Hätte Node B mit Nein gestimmt — weil jemand account:B zwischen Ihrem Lesen und Ihrem Prepare weitergerückt hat —, hätte der Koordinator Node A ein Rollback gesendet, die Sperre von A wäre ohne angewendeten Schreibzugriff freigegeben worden, und Sie hätten ein Conflict erhalten. Keines der beiden Konten ändert sich. Das ist die Garantie: Die beiden Keys driften nie auseinander, obwohl sie auf verschiedenen Maschinen liegen.

Die Garantien, genau​

Lassen Sie das Marketing weg, und das ist, was die Transaktion Ihnen gibt — und nichts, was sie nicht gibt:

  • Atomarität über Keys und Nodes hinweg. Jedes gestagte Put und Delete wird gemeinsam angewendet oder gar nicht. Ein Commit, der Conflict zurückgibt, hat nichts geschrieben — es gibt keinen Teilzustand aufzuräumen.
  • Optimistische Nebenläufigkeit, geprüft bei Prepare. Kein Key ist gesperrt, während Sie nachdenken. Die Versionsprüfung erfolgt zur Commit-Zeit; ein nebenläufiger Schreiber, der einen Key bewegt hat, von dem Sie abhängen, wird zu einem erkannten Konflikt, nie zu einem stillen Überschreiben.
  • Serializable Isolation während des Commit-Fensters. Prepare nimmt Sperren pro Key, die bis zum Commit oder Rollback gehalten werden, sodass zwei Transaktionen, die einen überlappenden Key berühren, nicht beide vorbereiten können — der zweiten wird gesagt, dass der Key gesperrt ist, und sie stimmt mit Nein. IsolationLevel.Serializable ist heute die einzige Stufe; das Enum ist bewusst eine Menge mit einem Element und kein Anspruch mit versteckten schwächeren Modi.
  • Gestagte Schreibzugriffe mit Read-your-writes. Nichts, was Sie stagen, ist außerhalb der Transaktion beobachtbar, bis der Commit erfolgreich ist, und alles, was Sie stagen, ist für Sie sofort beobachtbar.

Ein Konflikt ist kein Fehler — es ist der Schutz bei der Arbeit. Behandeln Sie einen fehlgeschlagenen Commit so, wie der CAS-Beitrag ein fehlgeschlagenes bedingtes Schreiben behandelt: Transaktion neu öffnen, neu lesen, gegen den aktuellen Zustand neu berechnen und wiederholen, mit einer begrenzten Anzahl von Versuchen, damit ein heißer Key nicht endlos schleift.

Der eine Randfall, den die meisten übersehen​

Hier ist die Feinheit, die darüber entscheidet, ob Ihre Invariante tatsächlich hält, und es lohnt sich, sie klar zu sagen, weil sie leicht falsch gemacht wird: Die Versionsprüfung hängt an den Keys, die Sie schreiben, nicht an den Keys, die Sie lediglich lesen.

Lesen und schreiben Sie einen Key — das Überweisungsbeispiel liest account:A und schreibt account:A —, wird die gelesene Version in das Prepare mitgeführt und validiert. Gut. Liest Ihre Transaktion aber einen Key und stützt eine Entscheidung darauf, ohne diesen Key zu schreiben, ist der Key nicht Teil des Prepare, und seine Version wird nie neu validiert. Er kann sich zwischen Ihrem Lesen und Ihrem Commit unter Ihnen ändern, und der Commit wird trotzdem erfolgreich sein.

await using var tx = await client.BeginTransactionAsync();

// Decision input: we only READ this.
var limit = await tx.GetAsync<int>("policy:max-transfer");

// We WRITE these. Only these are version-checked at commit.
var from = await tx.GetAsync<int>("account:A");
if (from.Value <= limit.Value)
await tx.PutAsync("account:A", from.Value - limit.Value);

await tx.CommitAsync();
// If an admin lowered policy:max-transfer after our read, we did NOT catch it:
// that key was read but never written, so its version wasn't part of prepare.

Die Lösung ist mechanisch: Nehmen Sie jeden Key, von dem die Korrektheit Ihres Commits abhängt, in das Schreib-Set auf. Ist ein Key eine reine Entscheidungseingabe, stagen Sie einen Schreibzugriff, der seinen aktuellen Wert unverändert erneut setzt — das heftet seine Version an das Prepare, und jede nebenläufige Änderung daran wird nun zu einem Konflikt. Der ausgelieferte Transaktions-Leitfaden formuliert den Normalfall als „lesen Sie die Werte, die Sie brauchen, bevor Sie schreiben" — das ist der Grund, warum dieser Rat funktioniert, und das, was zu tun ist, wenn ein benötigter Wert einer ist, den Sie sonst nicht schreiben würden.

Transaktion, CAS oder Batch?​

Zaris bietet Ihnen drei Wege, mehr anzufassen, als ein bloßes PutAsync erlaubt, und sie sind nicht austauschbar. Greifen Sie zum kleinsten, der Ihre Invariante hält:

Single-Key-CASBatch (ExecuteBatchAsync)Transaktion
UmfangEin KeyViele KeysViele Keys
Atomar?Ja, ein KeyNein — Ergebnisse pro Element, TeilerfolgJa, alles oder nichts
KonflikterkennungIfMatchVersionKeineVersionsprüfung bei Prepare
Gehaltene SperrenKeineKeinePro Key, Prepare → Commit
KostenEin Round Trip, sperrenfreiEin Round Trip pro Owner-NodePrepare- und Commit-Round-Trips, 2PC
Einsetzen, wennDie Aktualisierung eines einzelnen Keys in sich geschlossen istSie weniger Round Trips möchten und Teilerfolg in Ordnung istEine Invariante wirklich mehrere Keys umspannt

Der Batch ist der, den man leicht missversteht. ExecuteBatchAsync gruppiert gemischte GET/PUT/DELETE-Operationen zu einem Round Trip pro besitzendem Node und gibt ein Ergebnis pro Element in der richtigen Reihenfolge zurück. Er ist ein Durchsatz-Werkzeug, kein Konsistenz-Werkzeug: Elemente gelingen oder scheitern unabhängig, und ein Batch kann halb angewendet enden. Wenn die Operationen zusammen stehen oder fallen sollen, ist das eine Transaktion, kein Batch.

Und wenn ein einzelner Key Ihre Invariante abdeckt, bevorzugen Sie schlichtes CAS — es hält keine Sperren, blockiert niemanden und kostet einen Round Trip. Eine Transaktion ist genau dann das richtige Werkzeug, und nur dann, wenn die Invariante, die Sie schützen, nicht auf einen Key gequetscht werden kann.

Wo das endet — und was man dagegen tun kann​

Die Transaktion ist real, aber sie ist ein clientkoordinierter Two-Phase-Commit, und 2PC hat bekannte Kosten, die man ehrlich nennen sollte:

Der Koordinator ist Ihr Prozess. Prepare nimmt Sperren auf jedem beteiligten Node und hält sie, bis der Koordinator Commit oder Rollback sagt. Dieses Fenster sollte kurz sein: Denken und lesen Sie, dann committen Sie zügig. Halten Sie langlaufende oder fachfremde Arbeit außerhalb der Transaktion und verlassen Sie sich auf await using, damit eine fallengelassene Transaktion zurückrollt, statt vorbereitete Arbeit in der Schwebe zu lassen. Mit TransactionOptions.Timeout können Sie die Lebensdauer einer Transaktion begrenzen.

Es ist ein blockierendes Protokoll. Zwei Transaktionen, die um denselben Key konkurrieren, serialisieren sich über dessen Prepare-Sperre — die zweite stimmt mit Nein und wiederholt. Wie bei CAS besteht die Abhilfe gegen Contention darin, nicht alles durch einen Key zu schleusen: Sharden Sie und halten Sie die Menge der Keys in einer Transaktion klein und disjunkt von allem anderen, was heiß ist.

Es komponiert deklarierte Lese- und Schreibzugriffe, keine beliebige serverseitige Logik. Die Verzweigung — wenn der Kontostand ausreicht, belaste — läuft in Ihrem Client, genau wie bei CAS; der Server führt ein deklariertes, durch Versionen geschütztes Lese-/Schreib-Set aus. Es gibt keine Stored Procedure, die Ihre Logik atomar auf dem Node ausführt. Für die meisten Key-übergreifenden Invarianten genügt das. Für einen Workflow über Dienste, externe Aufrufe oder Minuten an Echtzeit ist ein Two-Phase-Commit die falsche Form — das bleibt Saga-und-Kompensations-Terrain, und es ist wichtiger, die Schritte idempotent zu machen (der IfAbsent-Dedupe-Trick aus dem CAS-Beitrag funktioniert unverändert), als einen einzelnen atomaren Commit zu haben.

Wählen Sie das kleinste Werkzeug, das die Invariante hält​

Der CAS-Beitrag argumentierte, dass ein scharfes Primitiv weit trägt, und das tut es. Die Transaktion ist das, was Sie hinzunehmen, wenn eine Invariante partout nicht auf einen Key passt — und sie ist ein echter Multi-Key-, Multi-Node-Alles-oder-nichts-Commit, keine Fassade. Die Disziplin, die sie verlangt, ist klein und vertraut: kurz halten, bei Konflikt wiederholen, sharden, bevor ein Key heiß wird, und — der Teil, der spezifisch für Transaktionen ist — sicherstellen, dass jeder Key, auf den sich Ihre Korrektheit stützt, im Schreib-Set steht, nicht nur im Lese-Set. Bekommen Sie das richtig hin, hört „Geld von A nach B bewegen" auf, das warnende Beispiel zu sein, und wird zu vier ehrlichen Zeilen Code.