Zum Hauptinhalt springen

Ein Log, zwei Aufgaben: Wie ein Replikat und ein neuer Owner auf dieselbe Weise aufholen

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

Aufholen durch Wiedergeben des Logs — ein nach Revision geordnetes Op-Log versorgt Replikate und neue Owner gleichermaßen

Zwei sehr unterschiedlich aussehende Ereignisse in einem verteilten Store sind im Kern dasselbe Problem. Das erste: Ein Replikat fällt hinter seinen Primary zurück — eine GC-Pause, eine langsame Platte, eine kurze Netzwerkpartition — und hält nun eine veraltete Kopie einer Partition, der es irgendwann entsprechen muss. Das zweite: Eine Partition wird einem Node zugewiesen, der sie nie gehalten hat, und dieser Node muss den gesamten Datenbestand vom aktuellen Owner übernehmen. Das eine ist Replikationsverzug; das andere ist Migration. Sie haben unterschiedliche Auslöser, unterschiedliche Betreiber, unterschiedliche Ausfallgeschichten.

Doch die Frage, die beide beantworten müssen, ist identisch: Wie konvergiere ich zur aktuellen Wahrheit dieser Partition, ohne Schreibzugriffe einzufrieren, während ich es tue? Zaris beantwortet beide mit einem einzigen Mechanismus — einem nach Revision geordneten Operations-Log pro Segment, wiedergegeben ab einem bekannten Punkt. Dieser Beitrag handelt von diesem Log: wie es auf dem Schreibpfad aufgebaut wird, wie es begrenzt wird, damit es nicht unbegrenzt wächst, wie es wiedergegeben wird und warum dasselbe Substrat sowohl ein zurückgefallenes Replikat als auch einen brandneuen Owner zur Konvergenz bringt.

Die Revision ist das Rückgrat​

Alles beginnt mit einem einzigen Zähler. Jedes Segment — die Einheit der partitionierten Speicherung in Zaris — besitzt in seinem SegmentStore eine monotone Revision:

private long _storeRevision = 0;
public Revision CurrentRevision => new(_storeRevision);

Jede Mutation erhöht sie. Ein Put, ein Delete, eine TTL-Änderung — jede nimmt die nächste Revision über Interlocked.Increment(ref _storeRevision), während sie die Stripe-Sperre dieses Keys hält, und zeichnet in derselben kritischen Sektion die Operation auf:

revision = new Revision(Interlocked.Increment(ref _storeRevision));
// ... apply to the live map ...
AppendOperationLog(revision, key, ReplicationOperation.Put, record);

Die Revision ist also kein Zeitstempel oder eine Versionsnummer, die Sie verwalten — sie ist eine vom Store besessene, streng steigende Sequenznummer, die jeder Änderung in Commit-Reihenfolge aufgeprägt wird. Diese Ordnung ist der ganze Trick. Kennen Sie die Revision, die eine Kopie zuletzt gesehen hat, wissen Sie genau, was ihr fehlt: alles, was höher gestempelt ist.

Der Log-Eintrag selbst ist bewusst klein und flach:

[MessagePackObject]
public sealed class SegmentOperationLogEntry
{
[Key(0)] public long Revision { get; set; }
[Key(1)] public string Key { get; set; }
[Key(2)] public ReplicationOperation Operation { get; set; } // Put | Delete
[Key(3)] public StoredRecord Record { get; set; }
}

Das Log ist ein ConcurrentDictionary<long, SegmentOperationLogEntry>, nach Revision geschlüsselt — ein Append-only-, nach Revision indiziertes Write-Ahead-Log. Ein Delete ist ein vollwertiger Eintrag, keine Abwesenheit; das ist wichtig, denn „Key X wurde bei Revision 1202 entfernt" ist eine Information, die eine veraltete Kopie wiedergeben muss, und man kann ein Entfernen nicht darstellen, indem man schlicht etwas nicht sendet.

Snapshot, dann Tail​

Man kann ein Log nicht seit Anbeginn der Zeit wiedergeben — es wäre unbegrenzt, und das meiste davon ist längst überschrieben. Aufholen besteht daher immer aus zwei Teilen: einem Snapshot der Live-Menge zum Stand der Revision S, gefolgt vom Tail des Logs, jeder Operation, die nach S gestempelt wurde.

var (snapshotRevision, snapshotEntries) = _store.CreateSegmentSnapshot(segmentId);
// ... later, the delta ...
var operations = _store.GetSegmentOperationsAfter(segmentId, snapshotRevision.Value);

Der Snapshot ist der Großteil der Daten; der Tail sind die wenigen Schreibzugriffe, die eintrafen, während der Snapshot gebaut und versendet wurde. Der Konsument wendet zuerst den Snapshot an und gibt dann den Tail in Revisionsreihenfolge wieder, und damit ist er bei Revision S + n angekommen.

Hier gibt es eine Feinheit, die eine korrekte Implementierung von einer hoffnungsvollen trennt. Zwischen der Erfassung des Snapshots bei S und dem Ende des Streams treffen weiter neue Schreibzugriffe ein. Die Serve-Seite behandelt das mit einer begrenzten Drain-Schleife — sie fragt den Tail nach der letzten bereits gesendeten Revision erneut ab und wiederholt dies, bis ein Durchlauf leer zurückkommt, sodass die Übergabe den tatsächlichen aktuellen Zustand der Quelle widerspiegelt statt eines vor Sekunden eingefrorenen Punkts. Schreibzugriffe, die nach dem letzten Drain-Durchlauf eintreffen, laufen über den gewöhnlichen Live-Replikationspfad, und die Abschlussnachricht trägt eine AckedHighRevision-Wasserlinie, damit die Quelle alles darüber erneut senden kann. Der Riss zwischen „Snapshot" und „Live" wird gezielt geschlossen, nicht herbeigewünscht.

Das Log ist begrenzt — und das ändert den Vertrag​

Ein nach Revision indiziertes WAL, das ewig wüchse, wäre ein Speicherleck mit zusätzlichen Schritten. Das Op-Log von Zaris ist begrenzt: ein weiches Aufbewahrungsziel von 512 Einträgen und eine harte Obergrenze von 200.000, opportunistisch getrimmt, sobald die Live-Anzahl das Doppelte des weichen Ziels überschreitet (Hysterese, damit nicht bei jedem Schreibzugriff getrimmt wird). Das Trimmen entfernt den ältesten zusammenhängenden Revisionsbereich und veröffentlicht einen Trim-Floor — die Wasserlinie, unterhalb derer Operationen endgültig verschwunden sind:

public long OpLogTrimFloor => Volatile.Read(ref _opLogTrimWatermark);

Die Begrenzung des Logs schafft einen neuen Fehlermodus, und das ehrliche Design liegt darin, wie er behandelt statt verborgen wird. Fragt ein Konsument den Tail nach Revision S an, aber S liegt nun unter dem Trim-Floor, existieren die benötigten Operationen nicht mehr — der Bereich (S, floor] wurde weggetrimmt. Ein naives Lesen würde stillschweigend einen Teil-Tail zurückgeben, und der Konsument würde zu einer fehlerhaften, lückenhaften Kopie konvergieren. Zaris weigert sich, das zu tun. Das geschützte Lesen gibt den Tail nur zurück, wenn es ihn lückenlos bedienen kann:

// returns false if `after` < trim floor — the delta would have a hole
if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
response.Aborted = true;
response.AbortReason = "FinalDeltaBelowTrimFloor";
// ... release fence, unpin ...
}

Ein Konsument, der diesen Abbruch erhält, schleppt sich nicht weiter — er verwirft seinen veralteten Anker und startet mit einem frischen Snapshot neu, dessen Revision per Definition aktuell ist. „Unter dem Trim-Floor" ist ein lautes, behebbares Signal, nie eine stille Lücke. Das ist die Garantie des lückenlosen Deltas, und sie erlaubt es dem Log, klein zu bleiben, ohne je zu lügen, was es enthält.

Damit ein laufendes Aufholen nicht unter seinen eigenen Füßen weggetrimmt wird, fixiert die Serve-Seite den Floor für die Dauer des Transfers auf der Snapshot-Revision:

_store.PinSegmentTrim(segmentId, snapshot.Revision.Value);  // released in finally

Ein Pin hält den Trim-Floor auf oder unter dem Snapshot-Punkt, sodass der Tail, den ein Konsument noch braucht, erhalten bleibt. Das Trimmen überschreitet nie den niedrigsten aktiven Pin. Begrenzung und Pin zusammen bedeuten, dass das Log im Dauerbetrieb klein ist, aber genau für die Transfers, die darauf angewiesen sind, garantiert vollständig.

Replikat-Catch-up: der direkte Pfad​

Muss ein Replikat neu synchronisieren, führt ReplicaSyncRequestHandler das Snapshot-dann-Tail-Modell direkt aus. Die Reihenfolge der ersten Zeilen ist das gesamte Korrektheitsargument:

  1. ArmSegmentOpLog(segmentId) — garantiert, dass das Log aufzeichnet, bevor der Anker genommen wird (mehr dazu unten).
  2. CreateSegmentSnapshot(segmentId) — erfasst die Live-Menge bei Revision S.
  3. PinSegmentTrim(segmentId, S) — fixiert den Floor, damit der Tail den Stream überlebt.
  4. Streamen Sie den Snapshot als Chunks vom Typ ReplicaSyncChunkKind.Snapshot, dann den Tail als Chunks vom Typ ReplicaSyncChunkKind.Replay.
  5. Führen Sie die begrenzte Drain-Schleife aus, bis ein Tail-Durchlauf leer zurückkommt.

Auf der Anwendungsseite wird jeder Chunk an einen Prozessor pro Segment geroutet, der die Snapshot-Einträge anwendet und dann die Operationen streng in Revisionsreihenfolge wiedergibt — ein Delete ruft den Delete-Pfad des lokalen Stores auf, alles andere schreibt den Datensatz:

foreach (var op in chunk.Operations.OrderBy(o => o.Revision))
{
if (op.Operation == ReplicationOperation.Delete)
_localStore.DeleteRecord(op.Key, op.Record);
else
_localStore.SaveRecord(op.Key, op.Record);
}

Die Reihenfolge ist wichtig, weil derselbe Key mehrfach in einem Tail vorkommen kann — Set, Delete, wieder Set. Die Wiedergabe in Revisionsreihenfolge reproduziert die exakte Sequenz des Primary; eine Wiedergabe in falscher Reihenfolge könnte einen gelöschten Key wiederauferstehen lassen oder einen neueren Schreibzugriff zurückdrehen.

Migration: dieselben Schienen, mit einem Fence​

Ein brandneuer Owner bekommt keine andere Engine — er bekommt dieselbe, plus ein Cutover-Gate. Die Migration hat zwei Phasen. Die Bulk-Phase (SegmentMigrationRequestHandler) armiert das Log, erstellt den Snapshot, fixiert den Trim-Floor und streamt die Datensätze — identische Eröffnungszüge wie bei der Replikat-Synchronisation. Sobald der neue Owner die Bulk-Menge angewendet hat, fordert er das finale Delta an, verankert an der Snapshot-Revision, die er erhalten hat:

public class SegmentMigrationFinalDeltaRequest
{
public ushort SegmentId { get; set; }
public long SnapshotRevision { get; set; } // the anchor
// ...
}

Die Antwort der Quelle auf diese Anfrage ist der Ort, an dem die Migration das eine hinzufügt, was die Replikation nicht braucht. Sie aktiviert zuerst einen Write-Fence, erfasst die Fence-Revision und baut dann das Delta über dasselbe geschützte Tail-Lesen — sodass jede Operation im finalen Delta auf oder unter dem Fence liegt, weil Schreibzugriffe nun angehalten sind:

EngageMigrationWriteFence(seg);
var fenceRevision = GetSegmentCurrentRevision(seg);
if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
// below trim floor -> abort, owner re-drives from a fresh snapshot
}
response.Operations = finalOps.ToList();
response.SourceDigest = ComputeSegmentLiveDigest(seg);

Die Antwort trägt außerdem einen SourceDigest. Der neue Owner gibt die Operationen des finalen Deltas wieder — wieder OrderBy(o => o.Revision), wieder mit getrenntem Routing von Deletes und Puts — und schließt das Cutover nur ab, wenn sein eigener neu berechneter Digest dem der Quelle entspricht. Stimmen die Inhalte nicht Bit für Bit überein, findet das Cutover nicht statt; der Owner startet erneut. Der Fence macht das finale Delta endlich und ruhig; der Digest macht die Übergabe verifizierbar.

Replikation und Migration unterscheiden sich also in genau zwei Punkten und sind ansonsten eine Codebasis: Die Migration fügt einen Write-Fence und ein Cutover-Gate mit Digest-Gleichheit hinzu; die Replikation liest den Tail unter einem Pin mit einer Drain-Schleife. Das Log, der Snapshot, die Revisionsordnung, der Trim-Floor-Vertrag — alles geteilt.

Warum die Wiedergabe den „Raw"-Schreibpfad nutzt​

Ein Detail, das herausgegriffen werden sollte: Das Aufholen spielt nicht über das normale Put/Delete wieder ab, das ein Client verwenden würde. Es nutzt PutRaw / DeleteRaw, die die Revision und Item-Version der Quelle beibehalten, statt sie neu zu stempeln. Ein normales Put würde eine frische Version vergeben, als stamme der Schreibzugriff lokal; das ließe die Versionen des Replikats von denen des Primary abweichen und würde den Digest-Vergleich zerstören, auf den die Migration baut. Der Raw-Pfad rückt den lokalen Revisionszähler auf den eingehenden Wert vor (Interlocked.Exchange(ref _storeRevision, Math.Max(_storeRevision, record.Revision.Value))), statt einen neuen zu erfinden. Wiedergabe ist originalgetreue Reproduktion, keine Neu-Ursprünglichkeit — die Kopie hat am Ende nicht nur dieselben Daten, sondern dieselbe Versions-Abstammung, und das macht „stimmen unsere Digests überein?" zu einer sinnvollen Frage.

Die ehrliche Optimierung: das Log überspringen, wenn niemand zuhört​

Das Anhängen an das Op-Log ist nicht kostenlos — es ist bei jedem Schreibzugriff eine Allokation und ein zweiter Dictionary-Insert, der auf dem Hot Path gemeinsame Cachelines berührt. Hat ein Segment kein Replikat und ist dieser Node sein alleiniger Primary, wird nie jemand dieses Log wiedergeben, also kann Zaris das Anhängen überspringen. Das wird ausgeliefert und ist sorgfältig abgesichert.

Die Verpflichtung ist ein Flag pro Segment, standardmäßig sicher an:

private long _logObligation = 1;  // DEFAULT ON — safe by default

Sie wird aus der Topologie abgeleitet, nicht aus einer Konfigurationsdatei. Bei jeder Anwendung der Partition-Map berechnet RefreshOpLogObligations() sie pro Segment neu: obligation = !(noReplicas && selfIsPrimary). Das Log wird nur übersprungen, wenn die Partition nachweislich kein Replikat hat und dieser Node ihr alleiniger Primary ist; ein fehlender oder mehrdeutiger Eintrag fällt auf „an" zurück. Es gibt keinen Operator-Schalter, der es fälschlich ausschalten könnte — es folgt der tatsächlichen Gestalt des Clusters.

Das Überspringen ist eine Zeile am Anfang des Anhängens:

if (Volatile.Read(ref _logObligation) == 0 && _trimPins.IsEmpty) return;

Beachten Sie die zweite Klausel: Selbst wenn das Flag „keine Verpflichtung" sagt, erzwingt ein gehaltener Trim-Pin das Logging. Läuft ein Transfer, hält sein Pin das Log unabhängig von der Topologie am Aufzeichnen.

Der Teil, der dies nachweislich sicher macht, ist die Arm-vor-Snapshot-Barriere. Jeder Konsument armiert das Log unmittelbar bevor er seinen Snapshot-Anker erfasst:

public void ArmOpLog()
{
Interlocked.Exchange(ref _logObligation, 1);
foreach (var stripe in _keyLocks) lock (stripe) { } // happens-before barrier
}

Das Setzen des Flags und das anschließende Durchlaufen aller Key-Stripe-Sperren etabliert eine Happens-before-Kante: Jeder Schreibzugriff, der nach dem Armieren eine Revision vergibt, beobachtet die Verpflichtung und loggt. Jeder Schreibzugriff, der das Log übersprungen hat, hat also zwangsläufig vor dem Armieren committet, was bedeutet, dass seine Revision auf oder unter der Snapshot-Revision S liegt — und daher vom Snapshot getragen wird, nicht vom Tail. Die übersprungenen Schreibzugriffe gehen nicht verloren; sie werden nur als Teil der Bulk-Menge statt des Deltas geliefert. Die Verpflichtung auszuschalten wird abgelehnt, solange ein Pin gehalten wird, was die andere Seite des Fensters schließt.

Es ist die Art von Optimierung, die nur auszuliefern sich lohnt, wenn man genau sagen kann, warum sie sicher ist. Hier ist das Argument eine einzige Invariante — armieren, bevor man verankert — und sowohl der Replikat-Sync- als auch der Migrations-Serve-Pfad befolgen sie in den zwei Zeilen vor dem Snapshot.

Ein Substrat, verdient​

Der Reiz eines gemeinsamen Substrats liegt nicht in Eleganz um ihrer selbst willen. Er liegt darin, dass das schwierige Denken — Revisionsordnung, der Riss zwischen Snapshot und Tail, der Vertrag des lückenlosen Deltas gegenüber einem begrenzten Log, originalgetreue, versionserhaltende Wiedergabe — einmal geleistet wird und dann von jedem Konsumenten vertraut wird. Ein zurückgefallenes Replikat und ein frisch zugewiesener Owner sehen wie unterschiedliche Betriebsereignisse aus, stellen aber dieselbe Frage und erhalten von demselben Code dieselbe Antwort. Die Migration legt einen Fence und eine Digest-Prüfung obenauf; die Replikation einen Pin und eine Drain-Schleife. Keine erfindet das Log neu, und keine kann von der Korrektheit der anderen abdriften.

Das Log bleibt klein, weil es begrenzt ist und nur bei Bedarf fixiert wird; es bleibt ehrlich, weil ein Delta unter dem Trim-Floor zu einem frischen Snapshot abbricht, statt ein Loch zurückzugeben; und es bleibt günstig, weil der eine Schreibzugriff, den nie jemand wiedergeben wird, der eine ist, den es überspringen darf — nachweislich, unter einer Barriere, und überall sonst standardmäßig an.