Datenstrukturen, die replizieren: Hashes, Listen, Sets und Sorted Sets in Zaris
Zaris 1.x speicherte Werte. Zaris 2.0.0 speichert Collections. Hashes, Listen, Sets und Sorted Sets sind nun erstklassige, serverseitige Typen mit echten, typisierten .NET-APIs — und, ebenso wichtig, sie verhalten sich wie alles andere im Store.
Genau diesen letzten Punkt machen viele falsch. In vielen Systemen sind „Datenstrukturen“ eine clientseitige Illusion: Eine Bibliothek serialisiert ein Dictionary in einen Blob, schreibt es per Put und nennt das einen Hash. Das funktioniert, bis zwei Clients gleichzeitig dieselbe Collection anfassen oder bis Sie fragen, was bei einem Failover passiert. In Zaris ist ein Hash ein Hash auf dem Server. Er liegt auf der Partition, die seinen Key besitzt, repliziert auf das Replikat dieser Partition und überlebt den Verlust eines Nodes genau so wie ein einfacher Key.
Vier Typen, eine typisierte API
Jede Collection-Art hängt an client.DataStructures, adressiert über den Namen, den Sie auch für jeden anderen Key verwenden würden:
// Hash — field/value map
await client.DataStructures.Hash("user:42").SetAsync("email", "ada@example.com");
await client.DataStructures.Hash("user:42").SetAsync("plan", "pro");
var email = await client.DataStructures.Hash("user:42").GetAsync("email");
var all = await client.DataStructures.Hash("user:42").GetAllAsync();
// Sorted set — members ordered by score
await client.DataStructures.SortedSet("leaderboard").AddAsync("ada", score: 4096);
await client.DataStructures.SortedSet("leaderboard").AddAsync("grace", score: 8192);
var top = await client.DataStructures.SortedSet("leaderboard").RangeByRankAsync(0, 9);
var band = await client.DataStructures.SortedSet("leaderboard").RangeByScoreAsync(1000, 5000);
// List — ordered, push and range
await client.DataStructures.List("events:42").PushAsync("login");
var recent = await client.DataStructures.List("events:42").RangeAsync(0, 19);
// Set — unique membership
await client.DataStructures.Set("tags:42").AddAsync("beta");
var isMember = await client.DataStructures.Set("tags:42").IsMemberAsync("beta");
var members = await client.DataStructures.Set("tags:42").MembersAsync();
Nichts davon ist ein Helper, der einen Blob abruft und ihn in Ihrem Prozess verändert. Hash("user:42").SetAsync(...) ist eine serverseitige Operation auf der Collection namens user:42, so wie Put("user:42", ...) eine auf dem Wert namens user:42 ist.
Gleiche Platzierung, gleiche Replikation, gleiches Failover
Hier liegt die Designentscheidung, die diese Typen vertrauenswürdig macht: Eine Datenstruktur-Operation läuft über exakt denselben replizierten Pfad wie ein gewöhnlicher Key-Schreibvorgang.
Jede Collection ist ein einzelner logischer Wert, der auf der Partition liegt, die ihren Key besitzt. Sie wird über dieselbe Partitionszuordnung platziert, vom selben Replikationsfaktor abgedeckt und fällt auf dieselbe Weise aus. Es gibt kein separates „Collections-Subsystem“ mit eigener Dauerhaftigkeitsgeschichte — wenn Sie verstehen, wie ein Zaris-Key platziert und repliziert wird, verstehen Sie bereits, wie ein Sorted Set funktioniert.
Unter der Haube ist eine mutierende Operation ein Whole-Value-Compare-and-Set-(CAS-)Read-Modify-Write über denselben replizierten Put/Get-Pfad, den einfache Keys verwenden. Das Hinzufügen eines Members zu einem Sorted Set liest die aktuelle Collection, wendet die Änderung an und committet sie mit einem Compare-and-Set; war ein anderer Schreiber schneller, schlägt das CAS fehl und die Operation wird gegen den frischen Wert wiederholt. Da der Commit dasselbe replizierte Put ist, ist die Aktualisierung:
- Atomar pro Key — eine einzelne Collection-Mutation wird entweder vollständig oder gar nicht übernommen.
- Auf das Replikat der Partition repliziert — die Änderung bleibt über den Verlust eines Nodes hinweg erhalten, den Zaris überstehen soll.
- Konsistent mit einfachen Keys — eine Hash-Aktualisierung und eine Key-Aktualisierung erhalten dieselben Garantien, weil sie derselbe Schreibvorgang sind.
Das ist der ganze Trick: Statt eines maßgeschneiderten Konsistenzmodells pro Datentyp drückt Zaris jede Mutation in Form der einen Operation aus, die es ohnehin korrekt repliziert.
Dieselben Collections von jedem Redis-Client
Jeder dieser Typen ist auch über das Redis-Protokoll-(RESP-)Frontend verfügbar, sodass ein Redis-Client in jeder Sprache auf denselben serverseitigen Collections arbeitet — nicht auf einem parallelen Satz, der irgendwo anders liegt.
# Hash
redis-cli -h zaris-0 -p 6379 HSET user:42 email ada@example.com plan pro
redis-cli -h zaris-0 -p 6379 HGETALL user:42
# Sorted set
redis-cli -h zaris-0 -p 6379 ZADD leaderboard 4096 ada 8192 grace
redis-cli -h zaris-0 -p 6379 ZRANGE leaderboard 0 9
# List and set
redis-cli -h zaris-0 -p 6379 LPUSH events:42 login
redis-cli -h zaris-0 -p 6379 LRANGE events:42 0 19
redis-cli -h zaris-0 -p 6379 SADD tags:42 beta
redis-cli -h zaris-0 -p 6379 SMEMBERS tags:42
Ein .NET-Dienst, der über client.DataStructures.Hash("user:42") schreibt, und ein Python-Dienst, der HSET user:42 absetzt, berühren denselben Hash auf derselben Partition. Die RESP-Schicht übersetzt den Befehl auf die native Collection; sie ist keine zweite Implementierung.
Wir haben dies sprachübergreifend validiert, statt es nur zu behaupten: Die sprachübergreifenden Client-Suiten laufen mit 38/38 bestanden in .NET, 51/51 in Java und 40/40 in Ruby und prüfen dieselbe Collection-Semantik über den Client jeder Sprache.
Der ehrliche Kompromiss: Whole-Value-CAS
Whole-Value-CAS erkauft die saubere Garantie, und hier liegt auch der Preis. Jede Mutation liest und schreibt den gesamten Collection-Wert neu, und gleichzeitige Schreiber auf dieselbe Collection konkurrieren um das Compare-and-Set — der Verlierer wiederholt gegen den neuen Wert.
In der Praxis passen diese Typen hervorragend zu kleinen bis mittelgroßen Collections, bei denen das Neuschreiben des Gesamtwerts günstig ist, und zu leselastigen oder moderat schreibenden Mustern, bei denen CAS-Wiederholungen selten sind.
Spürbar wird es am anderen Extrem: eine sehr große Collection mit vielen sehr aktiven gleichzeitigen Schreibern. Ein riesiger Hash, auf den Dutzende Clients gleichzeitig einschlagen, verbringt reichlich Zeit mit dem Verlieren und Wiederholen von Compare-and-Sets, und jeder Schreibvorgang muss den gesamten Wert serialisieren.
Die Abhilfe ist der übliche Kniff verteilter Systeme — die riesige Collection über Keys sharden, sodass sich Schreibvorgänge auf viele unabhängige CAS-Domänen und Partitionen verteilen. Ein einzelner sessions-Hash mit einer Million Feldern, auf den jeder Request einschlägt, ist ein Anti-Pattern; tausend sessions:{shard}-Hashes mit jeweils eigenem Besitzer und eigenem CAS skalieren so, wie der Rest von Zaris es tut. Passen Sie die Collection-Größe an die Schreib-Nebenläufigkeit an, und diese Typen bleiben schnell.
Fünf Arten von Collections
Hashes, Listen, Sets und Sorted Sets sind vier der fünf serverseitigen Collection-Arten, die Zaris mitbringt. Die fünfte sind Streams — ein natives, repliziertes Append-only-Log mit Consumer Groups und blockierenden Lesezugriffen —, dem wir einen eigenen Beitrag widmen.
Wenn Sie Daten bereits in Redis modellieren, überträgt sich das mentale Modell nahezu unverändert: Die Typen sind die, die Sie kennen, die Befehle sind die, die Sie kennen, und der Unterschied zeigt sich, wenn ein Node ausfällt. In Zaris ist Ihr Leaderboard immer noch da — es wurde repliziert, so wie Ihre Keys es schon immer wurden.