Zum Hauptinhalt springen

Welches Primitiv für welches Problem: Ein Feldführer durch das Zaris-Datenmodell

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

Das richtige Zaris-Primitiv für das Problem wählen

Der Großteil dieses Blogs ist jeweils tief in ein einzelnes Thema eingestiegen — Compare-and-Swap, Multi-Key-Transaktionen, native Datenstrukturen, Streams, Watch, das darunterliegende Op-Log. Jeder Beitrag beantwortet „wie funktioniert dies". Dieser beantwortet eine andere, praktischere Frage: Wonach greifen Sie, wenn ein Problem vor Ihnen liegt?

Diese Frage ist wichtig, weil die falsche Wahl selten ein Absturz ist. Es ist ein System, das in der Demo funktioniert und dann unter Last ein Update verliert, tausend Schreiber durch einen Schlüssel schleust oder eine Fire-and-Forget-Benachrichtigung nutzt, wo ein dauerhaftes Log nötig war. Die Primitive hindern Sie nicht daran, sie falsch zu benutzen. Dies ist also ein Feldführer: das mentale Modell, das sie verbindet, eine Entscheidungstabelle, ein Gang durch jeden Zweig mit ehrlicher Eignung und ehrlicher Grenze und am Ende ein Flussdiagramm.

Ein Kern, viele Views​

Beginnen wir mit dem, worauf alles andere aufbaut. Unter jedem Feature in diesem Beitrag ist Zaris ein einziger partitionierter, replizierter Key/Value-Store, und jeder Wert trägt eine vom Store verwaltete monotone Version. Das ist alles. Das ist der Kern.

Alles andere ist eines von zwei Dingen:

  • Eine typisierte, serverseitige View über Schlüssel — ein Hash, eine Liste, ein Set, ein Sorted Set, ein Stream. Das sind keine Blobs, die Sie abrufen und in Ihrem Prozess verändern; es sind echte serverseitige Collections, die auf der Partition liegen, die ihren Schlüssel besitzt, und sie replizieren genau wie ein einfacher Wert.
  • Eine Koordinations-Schicht über der Version — Compare-and-Swap, Multi-Key-Transaktionen, Locks, Watch. Sie nutzen die Version (und die Ownership-Mechanik darum herum), damit viele Clients sich darauf einigen können, was passiert ist, ohne sich auf die Füße zu treten.

Behalten Sie dieses Bild im Kopf, und die gesamte Oberfläche wirkt nicht mehr wie ein Sammelsurium von Features, sondern wie das, was sie ist: ein kleiner Kern und eine Reihe gezielt gebauter Werkzeuge, die jeweils eine bestimmte Problemklasse darauf lösen. Wählen Sie nach der Form Ihres Problems, nicht nach dem Namen, der am ehesten passt.

Die Entscheidungstabelle​

Ihr ProblemGreifen Sie zuWarumWorauf Sie achten sollten
Bytes ablegen, die andere Nodes zurücklesen könnenEinfaches GetAsync/PutAsync oder eine Cache-IntegrationDer Kern, nichts weiterKein Ablauf beim Schreiben — das TTL ist ein zweiter Aufruf (siehe unten)
Ein einzelner Wert muss sich unter Nebenläufigkeit sicher ändernCompare-and-SwapEin Gewinner pro Runde, keine Lost Updates, kein gehaltenes LockEin heißer einzelner Schlüssel wird zum Serialisierungspunkt — sharden Sie ihn
Eine Invariante erstreckt sich über zwei oder mehr SchlüsselMulti-Key-TransaktionAlles-oder-nichts über Schlüssel und NodesSchwergewichtiger als CAS; es ist ein 2PC, nicht gratis
Die Daten sind eine Collection — Map, Liste, Mitgliedschaft, RankingNative DatenstrukturenServerseitige Operationen pro Element, kein Neuschreiben des gesamten WertsWählen Sie die Collection, die zum Zugriffsmuster passt
Ein geordnetes, wiederabspielbares Event-Log mit At-least-once-ConsumernStreamsDauerhaftes Log, Consumer Groups, Pending + Ack pro ConsumerEs ist ein Log, keine Queue mit Prioritäten
Auf Änderungen reagieren, während sie geschehenWatch (nativ) / Keyspace-Benachrichtigungen (Redis-Clients)Repliziert, clusterweit, übersteht FailoverFire-and-Forget-Pub/Sub ist kein dauerhafter Feed
Grober gegenseitiger Ausschluss um eine RessourceLock (Lease-basiert)Ein benanntes, TTL-begrenztes Gate, das sich bei Absturz automatisch freigibtEin Lease-Lock ist kein Korrektheits-Fence — siehe unten

Der Rest dieses Beitrags ist diese Tabelle in Zeitlupe.

KV und Cache: wenn ein Wert nur ein Wert ist​

Müssen Sie lediglich Bytes irgendwo ablegen, wo jeder Node sie lesen kann — ein gerendertes Fragment, ein serialisiertes DTO, einen Konfigurations-Snapshot —, brauchen Sie keine Koordinationsschicht. PutAsync(key, value) und GetAsync<T>(key) sind die ganze Geschichte, und serverseitig werden diese Bytes für Sie partitioniert und repliziert.

Zwei ehrliche Hinweise. Erstens: Der Ablauf wird nicht beim Schreiben gesetzt. Die Schreiboptionen haben kein TTL-Feld; ein Time-to-Live setzen Sie mit einem separaten Aufruf — ExpireAsync(key, ttl) —, entfernen es mit PersistAsync(key) oder lesen den Rest mit GetTimeToLiveAsync(key). Zweitens: Ist Ihr Anwendungsfall buchstäblich „ein verteilter Cache für meine ASP.NET-Core-App", bauen Sie nichts davon selbst: Registrieren Sie eines der Drop-in-Cache-Pakete, und das IDistributedCache des Frameworks (oder HybridCache oder der Session-Store) wird von Ihrem Cluster getragen — mit einer einzeiligen Änderung beim Start und ohne dass Code die Cache-Abstraktion berührt.

Greifen Sie zu einfachem KV, wenn der Wert keine Nebenläufigkeits-Invariante hat — Last-Writer-Wins ist in Ordnung, oder es gibt immer nur einen Schreiber. Tun Sie es nicht, sobald zwei Schreiber um denselben Schlüssel konkurrieren können und es Sie stören würde, wenn einer den anderen stillschweigend überschreibt. Das ist die Aufgabe des nächsten Werkzeugs.

Compare-and-Swap: wenn ein Wert unter Nebenläufigkeit korrekt bleiben muss​

Ein Zähler mit Obergrenze, ein Kontostand, der nicht negativ werden darf, ein Konfigurationsobjekt, das zwei Admins gleichzeitig bearbeiten. Der Fehler, den Sie vermeiden wollen, ist das Lost Update — lesen, lesen, schreiben, schreiben, und der zweite Schreibvorgang löscht den ersten. CAS macht aus diesem stillen Verlust einen erkannten, wiederholbaren Konflikt: einen Wert samt Version lesen, nur zurückschreiben, wenn sich die Version nicht bewegt hat, und ein Conflict erhalten statt eines Überschreibens, wenn Ihnen jemand zuvorkam.

KvResult<int> read = await client.GetAsync<int>("counter:api");
var put = await client.PutAsync(
"counter:api", read.Value + 1,
new PutOptions { IfMatchVersion = read.Version });

// put.Status == KvStatus.Success -> you won this round
// put.Status == KvStatus.Conflict -> someone wrote first; re-read and retry

Das ist der gesamte Mechanismus, und der CAS-Feldführer zeigt, wie weit er trägt — ein Rate Limiter, ein Wallet, das unter tausend gleichzeitigen Abbuchungen nie überzogen wird, ein Job-Claim mit nur einem Gewinner (über „nur erstellen, wenn nicht vorhanden"), Konfiguration ohne Lost Updates und der Idempotenz-Haken, der gratis mitläuft.

Greifen Sie zu CAS, wenn die Invariante auf einem Schlüssel liegt und Sie Korrektheit ohne gehaltenes Lock wollen. Richten Sie nicht eine Menge Schreiber auf einen einzelnen heißen Schlüssel und erwarten Sie nicht, dass das skaliert — CAS lässt bewusst pro Runde genau einen Schreiber gewinnen, ein heißer Schlüssel wird also zum Serialisierungspunkt. Sharden Sie ihn (ein Bucket pro Client-Sekunde, ein in abgeglichene Teilguthaben zerlegter Kontostand), und die Contention schmilzt.

Multi-Key-Transaktionen: wenn die Invariante Schlüssel überspannt​

Die harte Linie, die CAS zieht, ist, dass es auf einem Schlüssel atomar ist. Geld von Konto A auf Konto B übertragen oder einen Sekundärindex im Gleichschritt mit dem Datensatz halten, auf den er zeigt — kein einzelnes Compare-and-Swap deckt beides ab. Das ist kein Grund, es mit zwei CAS-Schreibvorgängen vorzutäuschen und zu hoffen — es ist genau der Fall, für den die Multi-Key-Transaktion existiert.

Sie öffnen eine Transaktion, stagen Lese- und Schreibvorgänge hinein und committen. Intern ist es ein vom Client koordiniertes Two-Phase-Commit über optimistische Versionen: Die Schlüssel können auf verschiedenen Nodes liegen, der Client gruppiert das Write-Set also nach Owner, jeder Node prüft, dass jeder von Ihnen gelesene Schlüssel noch auf der gelesenen Version steht, und nimmt ein kurzlebiges Lock; nur wenn alle mit Ja stimmen, wird das gesamte Set angewendet. Hat sich irgendeine Version bewegt, liefert der Commit Conflict, und nichts ist verändert.

Greifen Sie zu einer Transaktion, wenn zwei oder mehr Schlüssel gemeinsam oder gar nicht wechseln müssen. Nutzen Sie sie nicht als Standard-Schreibpfad — sie ist ein echtes 2PC mit Prepare-/Commit-Roundtrips und kurzlebigen Locks, schwerer als ein einzelnes CAS. Passt die Invariante ehrlich auf einen Schlüssel, lassen Sie sie auf einem Schlüssel.

Native Datenstrukturen: wenn die Daten eine Collection sind​

Manchmal ist der Wert kein Skalar, den Sie schützen, sondern eine Form. Ein Benutzerprofil ist eine Map von Feldern. Ein Feed jüngster Aktivitäten ist eine Liste. Eine Tag-Mitgliedschaft ist ein Set. Eine Bestenliste ist ein Ranking. Sie könnten jedes als einen Blob serialisieren und per CAS absichern, aber dann schreibt jede Einzelfeld-Änderung das gesamte Objekt neu, und aus einem O(1)-Update wird ein O(n)-Update.

Zaris gibt diesen eigene native, serverseitige Collection-Typen — Hashes, Listen, Sets, Sorted Sets —, bei denen eine Operation auf einem Feld oder Element nur dieses Element auf dem Server berührt, nicht den gesamten Wert in Ihrem Prozess. Sie werden wie jeder Schlüssel partitioniert und repliziert, und weil das RESP-Frontend dieselben Redis-Befehle auf dieselben serverseitigen Collections abbildet, berühren ein .NET-Service und ein Python-Service, die HSET nutzen, denselben Hash.

Greifen Sie zu einer Collection, wenn Ihr Zugriffsmuster elementweise ist — ein Feld setzen, einen Eintrag pushen, eine Mitgliedschaft prüfen, die Top N nach Score lesen. Greifen Sie nicht zu einem Sorted Set, wenn Sie eigentlich ein geordnetes, bestätigtes, wiederabspielbares Event-Log brauchen. Das ist ein Stream.

Streams: wenn Sie ein geordnetes, wiederabspielbares Log brauchen​

Eine Queue und ein Log sehen ähnlich aus, bis ein Consumer abstürzt. Zaris-Streams sind ein echtes Append-only-Log: Einträge erhalten monotone IDs, Sie können Bereiche lesen, und eine Consumer Group verteilt das Log auf einen Pool von Workern, verfolgt die Pending-Einträge jedes Workers (zugestellt, aber nicht bestätigt) und lässt einen anderen Worker übernehmen, was ein abgestürzter nie bestätigt hat. Diese Pending-und-Ack-Mechanik liefert At-least-once-Zustellung — das, was eine Fire-and-Forget-Benachrichtigung nicht leisten kann.

Greifen Sie zu einem Stream, wenn Sie dauerhafte, geordnete, wiederabspielbare Events mit expliziter Bestätigung und Arbeitsteilung brauchen — Order-Ingestion, ein Outbox-Pattern, ein Audit-Trail, aus dem sich eine Projektion neu aufbaut. Nutzen Sie ihn nicht als Prioritäts-Queue oder Request/Response-Kanal; er ist ein Log, und seine Stärken sind Ordnung und Replay, nicht Routing.

Watch, Benachrichtigungen und die Fan-out-Familie: wenn Sie auf Änderungen reagieren​

Es gibt mehrere Wege zu erfahren, dass sich etwas geändert hat, und sie sind nicht austauschbar — der Unterschied liegt darin, ob das Signal dauerhaft ist.

Der Weg aus .NET ist Watch: ein nativer, clusterweiter Change Feed. Sie abonnieren einen Schlüssel oder ein Präfix, binden optional einen initialen Snapshot ein, damit zwischen „aktuellen Zustand lesen" und „Änderungen abonnieren" keine Lücke entsteht, und erhalten Events, sobald Schreibvorgänge landen:

var (subscription, initial) = await client.Watch.WatchKeyAsync(
"job:42",
new WatchOptions { IncludeInitialSnapshot = true },
evt => Handle(evt));

Die wichtige Eigenschaft ist, dass der Watch clusterweit registriert ist und Failover übersteht — er ist nicht an den einen Node gebunden, der zufällig einen Schreibvorgang verarbeitet hat. Deshalb sind die Redis-Keyspace-Benachrichtigungen, die Zaris spricht (__keyevent@0__ / __keyspace@0__, ein einfaches PSUBSCRIBE von jedem Redis-Client), hier mehr wert als auf einer einzelnen Redis-Box: Sie sind eine dünne Brücke auf denselben replizierten Watch, sodass ein Failover den Event-Strom nicht stillschweigend abreißen lässt.

Greifen Sie zu Watch/Keyspace-Benachrichtigungen, wenn Sie auf Zustandsänderungen reagieren wollen — einen Cache invalidieren, einen Worker wecken, eine Projektion aktualisieren. Verwechseln Sie einfaches Best-Effort-Pub/Sub-Fan-out nicht mit einem dauerhaften Feed: Brauchen Sie jedes Event auch über Abstürze und Neustarts hinweg, ist das ein Stream, keine Benachrichtigung.

Locks: grober gegenseitiger Ausschluss, mit einem scharfen Vorbehalt​

Zaris hat tatsächlich einen erstklassigen verteilten Lock — ein benanntes, Lease-basiertes Gate mit TTL, vom Client erworben und beim Dispose oder bei Ablauf nach einem Absturz freigegeben:

await using var gate = await client.Locks.AcquireAsync("order:123", TimeSpan.FromSeconds(10));
if (gate is null)
{
// someone else holds it — back off, do other work, or retry later
return;
}
// critical section; the lease auto-expires if we crash, and can be renewed

Er ist wirklich nützlich für groben gegenseitigen Ausschluss — verhindern, dass zwei Scheduler denselben Sweep ausführen, einen Wartungstask serialisieren —, und das TTL sorgt dafür, dass ein abgestürzter Halter den Lock nicht für immer blockiert.

Doch hier ist der Vorbehalt, den die glänzende Version weglässt, und es ist der wichtigste Satz dieses Beitrags über Locks: Ein Lease-basierter Lock ist kein Korrektheits-Fence. Pausiert Ihr Halter — ein GC-Stall, ein langsamer Syscall — lange genug, dass die Lease abläuft, kann ein zweiter Halter denselben Lock erwerben, während der erste noch glaubt, ihn zu halten, und nun befinden sich zwei Worker gleichzeitig im kritischen Abschnitt. Für Liveness und Effizienz (meist läuft nur ein Worker den Job, geringe Contention) ist ein Lease-Lock genau richtig. Für eine Korrektheits-Invariante, die nie verletzt werden darf, verlassen Sie sich nicht darauf, dass der Lock sie durchsetzt — setzen Sie sie auf den Daten durch, mit Compare-and-Swap oder einer Transaktion, damit selbst ein irrtümlicher zweiter Akteur beim Schreiben durch eine Versionsprüfung abgewiesen wird.

Greifen Sie zu einem Lock, wenn Sie groben Best-Effort-gegenseitigen-Ausschluss wollen und gelegentliche Überlappung lediglich verschwenderisch, nicht falsch ist. Greifen Sie stattdessen zu CAS, wenn Überlappung Daten beschädigen würde — lassen Sie die Version, nicht den Lock, das Nein sagen.

Der übergreifende Teil: Ownership, Langlebigkeit, Konsistenz​

Drei Eigenschaften gelten für jedes obige Primitiv, weil sie Eigenschaften des Kerns sind, nicht eines einzelnen Werkzeugs:

  • Der Owner wechselt, und Sie müssen es nicht bemerken. Jeder Schlüssel gehört einer Partition, die per Failover wechseln, migrieren oder zurückfallen kann. Der .NET-Client macht aus diesem Churn ein typisiertes, wiederholbares Signal und routet zum neuen Owner um, begrenzt durch ein Versuchsbudget und eine Wanduhr-Deadline — ein PutAsync während eines Failovers dauert also meist nur ein paar Millisekunden länger und liefert Erfolg.
  • Langlebigkeit entsteht durch Replikation, nicht durch Festplatte. Zaris hält Daten im Speicher und macht sie dauerhaft, indem es Kopien auf anderen Nodes hält. Deshalb ist der Replikationsfaktor nicht dasselbe wie Fehlertoleranz: Die Zahl schützt Sie nur, wenn die Topologie tatsächlich eine zweite Kopie auf einem Node platziert, der den Verlust des ersten überleben kann. Das gesamte Ownership-und-Recovery-Fundament existiert, damit kein bestätigter Schreibvorgang verloren geht, während der Besitz in Bewegung ist.
  • Schreib- und Lesevorgänge werden zum aktuellen Owner geroutet. Die Retry-und-Reroute-Mechanik sorgt dafür, dass Ihre Operationen den Node finden, der den Schlüssel gerade besitzt, sodass ein Lesevorgang einem committeten Schreibvorgang folgt, statt dem soeben erfolgten Ownership-Wechsel vorauszueilen.

Nichts davon ist ein Primitiv, das Sie wählen. Es ist der Boden, auf dem jede Wahl steht.

Das Flussdiagramm​

Die Kurzfassung​

Die Länge der Feature-Liste war nie der Punkt. Zaris ist ein versionierter, partitionierter, replizierter Key/Value-Kern, plus typisierte serverseitige Views (Hash, Liste, Set, Sorted Set, Stream) und Koordinationsschichten über der Version (CAS, Transaktionen, Locks, Watch). Ordnen Sie das Werkzeug der Form des Problems zu: Ein Skalar unter Contention will CAS; eine schlüsselübergreifende Invariante will eine Transaktion; Daten, die eine Collection sind, wollen die passende Collection; dauerhafte geordnete Events wollen einen Stream; auf Änderungen zu reagieren will Watch; grober gegenseitiger Ausschluss will einen Lock — und alles, was nie verletzt werden darf, will seine Garantie auf den Daten durchgesetzt haben, mit einer Versionsprüfung, nicht auf einem Lock, der verfallen kann.

Wählen Sie nach der Form, verlassen Sie sich für die Korrektheit auf die Version, und der kleine Kern trägt erstaunlich viel System.