Was über die Leitung geht: Die Anatomie eines Zaris-Records
Das meiste von dem, was Zaris gut macht — einen Schreibzugriff auf ein Backup replizieren, eine hinterherhinkende Replica aufholen lassen, eine Partition auf einen neuen Owner umschalten —, läuft auf eine stille Frage hinaus: Was genau ist die Dateneinheit, die zwischen Nodes wandert? Stimmt diese Einheit, ist Konvergenz fast langweilig. Lässt sie ein Feld weg oder trägt das falsche, bekommen Sie einen wiederauferstandenen gelöschten Key, eine Replica, die Veraltetes nicht von Frischem unterscheiden kann, oder die Buchführung eines Nodes sickert auf einen anderen.
In Zaris ist diese Einheit ein einziger Typ — StoredRecord — und es lohnt sich, ihn Feld für Feld auseinanderzunehmen, denn jede Entscheidung darin ist tragend. Dieser Beitrag ist die Anatomie: was im Record steckt, wie er auf der Leitung gerahmt wird, warum ein Löschvorgang als Record statt als Abwesenheit reist, warum es zwei verschiedene Versionsnummern gibt und — genauso wichtig — was bewusst auf jedem Node zurückbleibt und die Leitung nie überquert.
Eine Payload, überall
Ein Key/Value-Store hat überraschend viele Stellen, an denen „ein Wert" präzise beschrieben werden muss: die In-Memory-Map, der Replikationsstrom, der Migrations-Snapshot, das Operation-Log. Zaris verwendet für alle dieselbe Record-Form. Was im Dictionary des Segments liegt, ist dasselbe, was eine Replica erhält, und dasselbe, was ein neuer Owner beim Cut-over erhält. Diese Einheitlichkeit ist der Grund, warum das Operation-Log zwei Aufgaben zugleich erfüllen kann und warum ein Migrations-Snapshot und ein Replica-Backfill keine getrennten Kodierungen brauchen.
Hier ist der gesamte Typ, reduziert auf seine Wire-Oberfläche:
[MessagePackObject]
public class StoredRecord
{
[Key(0)] public ZarisEntry Entry { get; set; } // key + value + metadata
[Key(1)] public Revision Revision { get; set; } // segment-wide ordering
[Key(2)] public ItemVersion Version { get; set; } // per-key CAS version
[Key(3)] public DateTime TimestampUtc { get; set; }
[Key(4)] public bool IsDeleted { get; set; } = false; // tombstone flag
[IgnoreMember] public long AccountedBytes { get; set; } // node-local
[IgnoreMember] public object? MaterializedValue { get; set; } // node-local
}
Fünf Felder überqueren die Leitung. Zwei weitere leben auf dem Objekt, sind aber mit [IgnoreMember] markiert und reisen nie. Der Rest dieses Beitrags erklärt, warum jedes Feld dort sitzt, wo es sitzt.
Die Rahmung: MessagePack mit positionellen Keys
Zaris serialisiert Records mit MessagePack, und die [Key(N)]-Attribute sind der Grund, warum die Leitung klein bleibt. MessagePack kann ein Objekt auf zwei Arten serialisieren: als Map von Feldname → Wert (selbstbeschreibend, aber jede Nachricht wiederholt jeden Feldnamen als String) oder als positionelles Array, in dem jedes Feld durch einen ganzzahligen Index identifiziert wird. Die Attribute [Key(0)], [Key(1)], … wählen die Array-Form. Auf der Leitung ist ein Record ein kurzes Array von Werten in fester Reihenfolge — keine Feldnamen, keine JSON-artige Interpunktion, nur die Payloads.
Das ist wichtig, weil dieser Record die heiße Datenstruktur des gesamten Systems ist. Er wird bei jedem replizierten Schreibzugriff und jedem migrierten Key serialisiert; bei Hunderttausenden Schreibzugriffen pro Sekunde ist der Unterschied zwischen dem millionenfachen Versand von "TimestampUtc" als String und dem Versand von nichts als der Position keine Mikrooptimierung, sondern das Budget.
Das positionelle Schema legt auch die Regel für die sichere Weiterentwicklung des Formats fest: Ganzzahl-Keys sind Append-only. Ein neues Feld bekommt den nächsten unbenutzten Index; bestehende Indizes ändern nie ihre Bedeutung. Ein älterer Node, der Index 7 nicht kennt, ignoriert ihn einfach; ein neuerer Node, der einen alten Record liest, findet Index 7 nicht vor und fällt auf einen Default zurück. Wir werden ein Feld sehen — Labels bei Map-Key 3 innerhalb der Metadaten —, das sich genau auf dieses Verhalten „abwesend heißt Default" stützt, um im Nichtgebrauch nichts zu kosten.
Im Entry: Key, Value und nicht-null Metadaten
ZarisEntry ist das Feld Entry — der Teil, den ein Benutzer als „seine Daten" erkennen würde:
[MessagePackObject]
public class ZarisEntry
{
[Key(0)] public string Key { get; set; } = string.Empty;
[Key(1)] public object Value { get; set; } = default!;
[Key(2)] public EntryMetadata Metadata { get; } // getter guarantees non-null
}
Zwei Details hier lernt man nur, wenn man sich einmal verbrannt hat. Erstens ist Value als object typisiert, nicht als byte[] oder string — die konkrete Form des Wertes ist, was auch immer der Schreiber dort abgelegt hat, im Entry gerahmt; so trägt derselbe Record-Typ einen einfachen String, einen Zähler oder eine ganze replizierte Collection, ohne dass der Record wissen müsste, welche.
Zweitens werden Metadata über einen Getter bereitgestellt, der nie null zurückgeben kann:
private EntryMetadata? _metadata;
[Key(2)]
public EntryMetadata Metadata
{
get => _metadata ??= new EntryMetadata();
set => _metadata = value;
}
MessagePack umgeht bei der Deserialisierung den Konstruktor und ruft Setter direkt auf, sodass ein einfacher Initialisierer = new() einen Round-Trip nicht überlebt — ein Record, der ohne Metadaten ankam, würde mit null deserialisiert, und jede nachgelagerte Aufrufstelle bräuchte eine Null-Prüfung. Der Getter macht die Garantie stattdessen strukturell: Metadaten sind immer da, auch wenn die Leitung keine trug. Eine Stelle, um die Invariante durchzusetzen, keine Null-Guards sonst irgendwo.
Metadaten, die im leeren Zustand nichts kosten
EntryMetadata trägt die optionalen Attribute eines Keys — CreatedAt, Ttl, ContentType, ein optionales Lease- oder Lock-Handle und Labels. Das Labels-Feld ist eine kleine Meisterklasse darin, nicht für zu bezahlen, was man nicht nutzt. Die meisten Keys haben keine Labels, und ein naives, pro Entry initialisiertes Dictionary ist reine Verschwendung — in einem realen Store hielten 390K Entries ohne Labels jeweils ein leeres 80-Byte-Dictionary, etwa 31 MB an Nichts.
Die Lösung ist zweiseitig. Auf dem Heap teilen sich Entries ohne Labels ein einziges statisches leeres Dictionary, sodass Leser weiterhin eine nicht-null (leere) Map sehen, aber nichts allokiert wird. Auf der Leitung ist die serialisierte Oberfläche null, wenn es keine Labels gibt:
[Key(3)]
public Dictionary<string, LabelValue>? LabelsSerialized
{
get => _labels != null && _labels.Count > 0 ? _labels : null;
set => _labels = value != null && value.Count > 0 ? value : null;
}
Ein Entry ohne Labels schreibt nil an Index 3, und der Leser stellt daraus „keine Labels" wieder her. Hier zeigt das Append-only-Key-Schema seinen Wert: Der Normalfall kostet ein Byte, und das Feature ist in vollem Umfang da, sobald ein Key tatsächlich ein Label braucht.
Zwei Zahlen, zwei Aufgaben: Revision und ItemVersion
Die häufigste Verwirrung bei Zaris-Records ist, dass sie zwei monoton steigende Zahlen tragen. Sie sind nicht redundant — sie beantworten verschiedene Fragen, und sie zu vermischen würde die eine oder die andere Aufgabe brechen.
ItemVersion gilt pro Key. Sie beginnt bei 1 und wird um eins erhöht, jedes Mal, wenn dieser Key geschrieben wird. Das ist die Zahl, die der Client sieht, und die Zahl, gegen die das optimistische Compare-and-Swap prüft: „nur committen, wenn dieser Key noch auf Version 7 steht." Wenn der Store ein Put anwendet, ist die neue Version schlicht die alte Version des Keys plus eins:
long newVersion = (existing?.Version.Version ?? 0) + 1;
Revision gilt pro Segment. Sie ist ein einziger Zähler, der von jedem Key der Partition geteilt und bei jedem Schreib- und Löschvorgang erhöht wird:
revision = new Revision(Interlocked.Increment(ref _storeRevision));
Damit ist Revision eine Totalordnung über alle Mutationen eines Segments, genau das, was Replikation und Recovery brauchen. Einer Replica ist es egal, dass der Key user:1 auf seiner 7. Version ist; ihr kommt es darauf an, welche segmentweiten Revisionen sie bereits gesehen hat, damit sie alles ab diesem Punkt anfordern kann. Das Operation-Log ist nach Revision geschlüsselt, das Catch-up-Protokoll fordert „alles nach Revision R" an, und die Konfliktauflösung zwischen einem eingehenden und einem lokalen Record vergleicht Revisionen, nicht Versionen pro Key:
// An incoming record older than what we already hold is stale — ignore it.
if (existing is not null && existing.Revision.Value >= record.Revision.Value)
return;
Also: ItemVersion ist die Einheit der für den Client sichtbaren optimistischen Nebenläufigkeit auf einem Key; Revision ist die Einheit der Ordnung und Konvergenz über Nodes hinweg im gesamten Segment. Derselbe Record, zwei Uhren, jede erledigt eine Aufgabe, die die andere nicht kann.
Ein Löschvorgang ist ein Record, keine Abwesenheit
Hier ist das Feld, das die Leute am meisten überrascht: IsDeleted. Wenn Sie in Zaris einen Key löschen, entfernt der Store den Entry nicht einfach aus seinem Dictionary. Er schreibt einen StoredRecord mit IsDeleted = true — einen Tombstone —, versieht ihn mit der nächsten Segment-Revision und hängt ihn als Löschung an das Operation-Log an:
var revision = new Revision(Interlocked.Increment(ref _storeRevision));
_deleteRevisions[key] = revision.Value;
AppendOperationLog(revision.Value, key, ReplicationOperation.Delete, new StoredRecord
{
// ...
Revision = revision,
IsDeleted = true,
});
Der Grund ist der ganze Sinn des Record-Formats. Wäre ein Löschvorgang bloß eine Abwesenheit — der Key verschwindet stillschweigend aus einer Map —, gäbe es nichts zu replizieren. Eine Replica, die den Moment der Löschung verpasst hat, würde den alten Wert ewig weiter ausliefern, denn „nichts" reist nicht und lässt sich nicht gegen die umgebenden Schreibzugriffe ordnen. Indem die Löschung ein erstklassiger Record mit eigener Revision wird, fließt sie durch genau denselben Replikations- und Catch-up-Pfad wie ein Schreibzugriff und ordnet sich an der richtigen Stelle in die Totalordnung des Segments ein.
Tombstones schützen auch vor einem subtileren Fehler: einem wiederauferstehenden veralteten Schreibzugriff. Stellen Sie sich eine Löschung bei Revision 50 und ein älteres Put für denselben Key, Revision 48, vor, das noch im Netzwerk unterwegs ist. Ohne Gedächtnis für die Löschung würde das späte Put landen und den Key von den Toten zurückholen. Zaris merkt sich die Lösch-Revision und weist alles ab, was bei oder vor ihr liegt:
private bool HasDeleteRevisionAtOrAfter(string key, long revision)
=> _deleteRevisions.TryGetValue(key, out var deleteRevision)
&& deleteRevision >= revision;
Die Kehrseite von Tombstones ist, dass sie sich nicht ewig ansammeln dürfen, sonst würde eine löschlastige Workload Speicher verlieren. Zaris trimmt Lösch-Revisionen, sobald sie sicher unterhalb der ältesten Revision liegen, die irgendeine Replica noch braucht — dieselbe Low-Water-Mark-Logik, die das Operation-Log trimmt —, sodass der Tombstone seine Konvergenzaufgabe erfüllt und dann zurückgewonnen wird. (Die Trimm-Disziplin ist das Thema des Operation-Log-Beitrags.)
Was zu Hause bleibt: die [IgnoreMember]-Felder
Die beiden Felder, die der Record trägt, aber nie serialisiert, sind so bewusst gewählt wie die fünf, die er serialisiert. Beide sind mit [IgnoreMember] markiert, und beide sind node-lokal by Design — korrekt nur für den Node, der sie berechnet hat.
AccountedBytes ist der Speicher, der diesem Record im Budget des Stores angerechnet wird, einmal beim Schreiben gestempelt, damit der Store Byte-Deltas melden kann, ohne den vorherigen Record neu zu vermessen. Es wird ausdrücklich nicht serialisiert, weil jeder Node es aus dem eigenen Key und Value des Records nach seinen eigenen Bilanzierungsregeln berechnet. Würde es mitreisen, trüge ein von Node A replizierter Record die Byte-Schätzung von A in die Bücher von Node B — die Buchführung eines Nodes würde stillschweigend die eines anderen verzerren. Mit [IgnoreMember] berechnet jeder Node es unabhängig neu, und ein per Replikation oder Migration eintreffender Record wird von dem Node bilanziert, der ihn nun hält.
MaterializedValue ist eine träge aufgebaute, lebende In-Memory-Form einer nativen Collection (ein Hash, eine Liste, ein Set oder ein Sorted Set), die gecacht wird, damit Operationen auf Elementebene nicht jedes Mal den ganzen Wert neu deserialisieren müssen. Es ist reine lokale Beschleunigung. Die maßgebliche Form sind immer die gerahmten Wert-Bytes innerhalb von Entry; ein Record, der von einem anderen Node eintrifft, materialisiert diesen Cache träge beim ersten Zugriff. Ihn zu serialisieren hieße, den privaten Arbeitsspeicher eines Nodes zu versenden, als wären es Daten.
Die Disziplin hier ist eine klare, die man laut aussprechen sollte: Die Leitung trägt die Wahrheit, der Node trägt Annehmlichkeiten. Alles, was ein Node aus dem Record selbst neu berechnen kann — seine Speicherkosten, seine materialisierte Struktur —, bleibt lokal und wird mit [IgnoreMember] markiert. Alles, was zur Identität und Ordnung des Records gehört — Key, Value, Metadaten, die beiden Versionsnummern, das Tombstone-Flag —, überquert die Leitung. Die Attributgrenze ist genau die Grenze zwischen Wahrheit und Annehmlichkeit, und sie scharf zu halten macht den Record sicher kopierbar zwischen Nodes, ohne einen zweiten Gedanken.
Warum das testbar ist — und getestet wird
Weil die Record-Form tragend für die Konvergenz ist, wird ihr Wire-Verhalten durch Tests festgenagelt statt der Inspektion überlassen. Zwei Invarianten verdienen eigene Assertions: Das Tombstone-Flag muss einen Round-Trip überleben (ein verlorenes IsDeleted lässt einen gelöschten Key auf einer Replica wiederauferstehen), und AccountedBytes muss nach einem Round-Trip als null zurückkommen (Beweis, dass es nie gereist ist):
back.IsDeleted.Should().BeTrue(
"a dropped tombstone flag resurrects a deleted key on the replica");
back.AccountedBytes.Should().Be(0,
"AccountedBytes is [IgnoreMember]; each node recomputes it, it must not cross the wire");
Diese lesen sich fast wie die These des ganzen Beitrags, als Assertions geschrieben: Was die Leitung überqueren muss, überlebt nachweislich; was nicht, verschwindet nachweislich.
Der Record ist der Vertrag
Man denkt leicht, die schweren Probleme eines verteilten Stores lägen in den ausgefallenen Teilen — dem Failover-Protokoll, dem Cut-over-Handshake, dem Chaos-Testing. Das tun sie. Aber diese Protokolle sind nur so korrekt wie das, was sie bewegen, und das, was sie bewegen, ist dieser eine kleine Record. Ein Tombstone statt einer Abwesenheit ist der Grund, warum eine Löschung konvergiert. Zwei Versionsnummern sind der Grund, warum die optimistische Nebenläufigkeit eines Keys und die Ordnung eines ganzen Segments entflochten bleiben. Positionelle MessagePack-Keys sind der Grund, warum der Hot Path billig bleibt und das Format dennoch wachsen kann. Und ein Paar [IgnoreMember]-Felder ist der Grund, warum die Replikation einen Record zwischen Nodes kopieren und darauf vertrauen kann, dass nichts Node-Lokales mitgereist ist.
Bringt man die Dateneinheit in Ordnung, haben die großen Protokolle etwas Solides, worauf sie stehen können. Das ist das ganze Argument dafür, einen Blogbeitrag auf fünf Felder und zwei, die zu Hause bleiben, zu verwenden.