Eine Partition ohne Datenverlust migrieren: das Cut-over-Protokoll
Ein Failover ist das, was passiert, wenn ein Owner stirbt — der Cluster hat Sekunden, um einen Überlebenden zu wählen, und die Partition-Map bewegt sich nie. Migration ist der ruhigere, gefährlichere Cousin: Der Owner ist völlig gesund, und Sie wollen eine Partition absichtlich von ihm wegbewegen — um den Cluster zu vergrößern, neu zu balancieren oder einen Node zu leeren, den Sie außer Dienst stellen wollen. Nichts ist ausgefallen, und genau deshalb liegt die Latte höher. Ein Failover darf im schlimmsten Fall einige ehrlich verlorene Schreibzugriffe am Ende in Kauf nehmen; eine geplante Migration nicht. Wenn das Verschieben einer Partition von Node A zu Node B einen einzigen bestätigten Schreibzugriff verlieren kann, ist Migration kein Feature, sondern eine Belastung.
Dieser Beitrag geht das Cut-over-Protokoll durch, das Zaris tatsächlich ausführt, in dieser Reihenfolge, und verbringt die meiste Zeit bei dem einen Schritt, den alle falsch machen: dem Moment, in dem die Quelle schließlich „frei" ist, ihre Kopie zu verwerfen. Dieser Schritt ist eine geladene Waffe, und das Interessante an der Ingenieursarbeit ist die Sicherung daran.
Die Form des Problems
Eine Partition ist eine Menge von Segmenten, und ein Segment ist ein Beutel von Key/Value-Records, jeder mit einer monotonen Revision. Die Migration verschiebt die Daten eines Segments von einem Quell-Node zu einem Ziel-Node und kippt dann das Ownership, sodass Clients zum Ziel routen. Das Ganze geschieht, während das Segment live ist: Lese- und Schreibzugriffe landen die ganze Zeit weiter.
Diese Live-Eigenschaft ist das Problem. Wenn Sie die Quelle snapshotten, die Bytes verschicken und das Ownership kippen, liegt jeder Schreibzugriff, der während des Versands landete, auf der Quelle und nicht auf dem Ziel — und im Moment des Kippens verschwinden diese Schreibzugriffe aus der gerouteten Sicht. Sie haben sie bestätigt; der Client wurde über „committet" informiert; jetzt sind sie weg. Die naive Migration verliert genau die Schreibzugriffe, die stattfanden, während sie damit beschäftigt war, sorgfältig mit denen davor umzugehen.
Das Protokoll muss also zwei Dinge tun, die in Spannung zueinander stehen: bis unmittelbar zum Flip weiter Schreibzugriffe auf der Quelle bedienen und garantieren, dass das Ziel jeden dieser Schreibzugriffe hält, bevor der Flip wirksam wird. Zaris löst diese Spannung mit einer Sequenz aus fünf Schritten, von denen jeder sich weigert fortzufahren, bevor der vorherige nachweislich abgeschlossen ist.
Schritt 1 — ein an eine Revision verankerter Snapshot, mit festgenageltem Trim-Floor
Das Ziel beginnt damit, die Daten des Segments von der Quelle anzufordern (SegmentMigrationRequest). Die Quelle serialisiert nicht einfach ihr aktuelles Dictionary; sie erstellt einen an eine Revision verankerten Snapshot. Sie notiert die aktuelle Revision des Segments — nennen wir sie R — und erfasst die Live-Records zum Stand R.
In diesem einen Schritt stecken zwei Feinheiten. Erstens schaltet die Quelle ihr Op-Log scharf, bevor sie den Anker erfasst. Zaris kann das Op-Log für ein Segment mit einzigem Primary (RF1) als Optimierung des Schreibpfads überspringen; es zuerst scharf zu schalten bedeutet, dass jeder Schreibzugriff ab diesem Punkt protokolliert wird, sodass ein zuvor ohne Log geschriebener Schreibzugriff trotzdem eine Revision bei oder unter R hat und vom Snapshot selbst getragen wird. Das Delta, das wir später berechnen — „Operationen nach R" —, ist dann nachweislich lückenlos.
Zweitens nagelt die Quelle den Trim-Floor ihres Op-Logs bei R fest:
var (snapshotRevision, snapshotEntries) = _store.CreateSegmentSnapshot(segmentId);
// Pin BEFORE streaming so any op the source acks between the snapshot and the
// new owner's final-delta request is still replayable — the floor cannot advance past R.
_store.PinSegmentTrim(segmentId, snapshotRevision.Value);
Normalerweise trimmt das Op-Log alte Einträge, um Speicher zurückzugewinnen. Während einer Migration wäre das ein Desaster: Wenn der Floor während der laufenden Massenübertragung über R hinaus vorrückte, wären die Schreibzugriffe zwischen R und dem Flip nicht mehr abspielbar, und das finale Delta wäre stillschweigend unvollständig. Das Festnageln des Floors bei R garantiert, dass diese Schreibzugriffe überleben, bis das Ziel sie hat. Die Quelle streamt dann die Snapshot-Einträge — und bedient die ganze Zeit weiter Lese- und Schreibzugriffe. Sie ist weiterhin der geroutete Owner. Noch hat sich nichts bewegt.
Schritt 2 — das Ziel wartet, absichtlich
Das Ziel wendet den Bulk-Snapshot an und tut dann etwas, das wie Untätigkeit aussieht, aber das Herz des Designs ist: Es versetzt das Segment in einen Zustand Warten auf Cut-over und hält an. Es bestätigt der Quelle nichts. Es erhebt keinen Anspruch auf Ownership. Es sagt dem Leader nicht, dass es fertig ist. Der Kommentar im Code ist unverblümt, warum:
Kein Ack an die Quelle (sie ist noch der geroutete Owner), KEIN Ownership-Anspruch und noch KEINE Completion-Benachrichtigung — die Completion wird zurückgehalten, bis das gefencte finale Delta bestätigt, dass dieser Node alles hält, was die Quelle bestätigt hat.
Das ist der „Old-owner-authoritative"-Vertrag. Bis zum Flip antwortet genau ein Node für das Segment, und das ist die Quelle. Ein Ziel, das voreilig startet — und von einem Snapshot bedient, der bereits um einige Hundert Schreibzugriffe veraltet ist —, wäre ein Split-Brain. Das Ziel hält also inne und bittet um das Eine, mit dem es nachweisen kann, dass es aufgeholt hat: das finale Delta.
Schritt 3 — das durch einen Write-Fence abgesicherte finale Delta und der Digest-Nachweis
Das Ziel sendet ein SegmentMigrationFinalDeltaRequest, verankert bei R. Hier erledigt die Quelle den sorgfältigen Teil.
// Fence FIRST, then capture a revision after the fence — so no acked write can exist beyond it.
var fenceEpoch = _topologyManager.EngageMigrationWriteFence(seg, request.CreationMapVersion);
var fenceRevision = _localStore.GetSegmentCurrentRevision(seg);
Sie aktiviert einen Write-Fence auf dem Segment und liest dann die aktuelle Revision. Die Reihenfolge zählt: erst fencen, dann messen, damit es kein Fenster gibt, in dem ein Schreibzugriff an der Messung vorbeischlüpft. Dann liest sie jeden Op-Log-Eintrag nach R — das Delta von allem, was während der Massenübertragung landete — und berechnet einen Content-Digest des Live-Zustands des Segments. Dieser Digest ist der Dreh- und Angelpunkt des gesamten Protokolls, daher notiert die Quelle ihn als Cut-over-Nachweis:
response.Operations = finalOps.ToList();
response.SourceDigest = _localStore.ComputeSegmentLiveDigest(seg);
// Record this served digest as the cut-over PROOF: the post-cut-over orphan drop will refuse to
// destroy this segment's data unless its live digest still equals this exact value.
_localStore.RecordCutoverProofDigest(seg, response.SourceDigest);
Genau hier ist ein ehrlicher Fehlermodus eingebaut. Sollte der Trim-Floor tatsächlich über R hinaus vorgerückt sein — ein verlorener oder zurückgesetzter Pin —, wäre „Operationen nach R" unvollständig, und das Verschicken dieses Deltas würde bestätigte Schreibzugriffe stillschweigend verwerfen. Die Quelle weigert sich:
if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
response.Aborted = true;
response.AbortReason = "FinalDeltaBelowTrimFloor";
// release the fence, re-snapshot, retry — a loud abort, never a silent partial cut-over.
}
Ein Abbruch unter dem Floor ist kein Verlust; er baut ab und erstellt von einem frischen Anker aus einen neuen Snapshot. Die Regel, an die sich der Code hält, lautet: lauter Abbruch statt stillem Teilergebnis — die Migration startet lieber neu, als auf einem unvollständigen Delta zu kippen.
Schritt 4 — das Gate: das Ziel weist nach, dass es dominiert
Das Ziel wendet das Delta an, berechnet dann seinen eigenen Content-Digest des Segments und vergleicht ihn mit dem der Quelle:
var localDigest = _localStore.ComputeSegmentLiveDigest(seg);
var matchesSource = string.Equals(localDigest, response.SourceDigest, StringComparison.Ordinal);
Sind die beiden Digests gleich, hält das Ziel Byte für Byte das, was die Quelle am Fence hielt — der Zustand des Ziels ist eine Obermenge jedes Schreibzugriffs, den die Quelle bestätigt hat. Unterscheiden sie sich, liegt es fast immer daran, dass der gestreamte Snapshot noch angewendet wird, wenn das erste Delta berechnet wird (das Completion-Signal kann eingereihte Daten-Batches überholen); das Ziel bleibt dann ausstehend und lässt einen zeitlich versetzten Retry das Delta erneut anfordern, statt zu spinnen. Eine Abweichung kippt nie. Der Flip hängt an einem positiven Nachweis der Gleichheit, nicht an der Abwesenheit eines Fehlers.
Es gibt noch etwas, das das Ziel tut, bevor es den Sieg erklärt, und es ist eine Garantie, die die meisten Migrationsimplementierungen auslassen. Die Quelle ist nicht die einzige Kopie, die vorne liegen könnte — eine Replica des Segments könnte einen Schreibzugriff halten, den die gefencte Quelle verpasst hat. Bevor es abschließt, faltet das Ziel daher den schreibgeschützten Tail jeder Live-Replica ein:
Der Flip darf nur ausgelöst werden, wenn der Digest des neuen Owners die Quelle und jede Live-Replica des Segments dominiert.
Die Faltung erhält die Revisionen: Ältere Records einer hinterherhinkenden Replica werden vom Revisions-Gate ignoriert, und eine vorauseilende Replica steuert genau den Schreibzugriff bei, den die Quelle nicht hatte. Erst wenn das Ziel die Quelle und jede Live-Replica dominiert, geht es weiter. Das ist die Invariante „Dauerhaftigkeit vor Ownership" — Ownership ist die Belohnung für nachweisbaren Besitz, nicht seine Voraussetzung.
Schritt 5 — der Flip und der Drop, der fast Ihre Daten verliert
Jetzt meldet das Ziel dem Leader, dass es fertig ist (SegmentMigrationCompletionNotify). Der Leader validiert die Completion gegen den aktuellen Migrations-Override und schaltet eine atomare Cut-over-Neuberechnung der Partition-Map scharf — eine einzige versionierte Map-Änderung, die den Owner des Segments auf das Ziel kippt und den Migrations-Override entfernt. Nachdem diese Map angewendet wurde, ist das Ziel der geroutete Owner. Clients routen zu ihm. Der Umzug ist aus Sicht des Clusters erledigt.
Und die Quelle, die nun ein Segment hält, für das sie keine Rolle mehr hat, führt einen Aufräumlauf aus, um die verwaiste Kopie zu verwerfen und den Speicher freizugeben. Das ist die geladene Waffe.
Hier ist die Falle, und sie ist subtil genug, dass sie bis in einen Zertifizierungslauf überlebte, bevor sie gefangen wurde (Missing=8629 bestätigte Keys weg). Ein Write-Fence reduziert das Bestätigen, bringt es bei einem Segment mit einer einzigen Kopie unter Last aber nicht sofort perfekt auf null. Also ist folgende Sequenz möglich:
- Die Quelle liefert den Digest des finalen Deltas als Nachweis. Das Ziel gleicht ihn ab und kippt.
- Im winzigen Zeitfenster zwischen der Auslieferung des Nachweises und dem Wirksamwerden des Flips wird auf der Quelle noch ein weiterer Schreibzugriff bestätigt.
- Das Ziel hat diesen Schreibzugriff nie erhalten — der Nachweis, den es abgeglichen hat, stammte von vor ihm.
- Die Quelle, nun verwaist, verwirft ihre Kopie, um Platz zu gewinnen.
- Dieser letzte Schreibzugriff existierte nur auf der Quelle. Er ist weg. Einem Client wurde gesagt, er sei committet.
Die Lösung besteht darin, den Drop davon abhängig zu machen, dass der Nachweis weiterhin zutrifft. Die Quelle vernichtet ein verwaistes Segment nicht blind. Sie vergleicht den aktuellen Live-Digest des Segments mit dem Digest, den sie als Cut-over-Nachweis notiert hat, und zerstört die Daten nur, wenn beide noch übereinstimmen:
public static bool ShouldRetainAfterCutover(bool hasCutoverProof, string? currentLiveDigest, string? proofDigest)
{
if (!hasCutoverProof || string.IsNullOrEmpty(proofDigest))
return false; // not a proof-carrying cut-over segment -> this policy does not intervene
return !string.Equals(currentLiveDigest, proofDigest, System.StringComparison.Ordinal);
}
Ist nach dem Nachweis ein Schreibzugriff gelandet, unterscheidet sich der aktuelle Digest vom Nachweis-Digest, ShouldRetainAfterCutover liefert true, und die Quelle behält ihre Kopie:
if (CutoverDropSafetyPolicy.ShouldRetainAfterCutover(hasProof, currentDigest, proofDigest))
{
Log.CutoverDropRetainedUnprovenTail(_logger, store.Count, segmentId);
continue; // keep the store — a post-proof tail exists that the new owner has not received
}
Die Asymmetrie ist der ganze Punkt. Behalten ist immer sicher: Die Quelle ist nicht mehr der geroutete Owner, also ist eine behaltene Kopie ein redundantes, nicht bedientes Waisenkind — sie kann nie ein Split-Brain verursachen, sie kostet nur etwas Speicher, bis ein erneutes Bedienen den Tail an den neuen Owner ausliefert und ein späterer Lauf, der die Digests wieder übereinstimmend findet, sie sauber verwirft. Zerstören auf einem veralteten Nachweis ist nie sicher: Es ist die eine Operation, die einen bestätigten Schreibzugriff dauerhaft löschen kann. Die Richtlinie ist deshalb bewusst eng gefasst — sie überstimmt ausschließlich einen Drop, der sonst stattfinden würde, und nur für ein Segment mit einem aufgezeichneten Nachweis, dessen Digest nicht mehr passt. Segmente ohne Nachweis (eine Eviction, ein sofortiger Flip, ein nie migriertes Segment) behalten ihr bisheriges Verhalten unverändert. Das Gate fügt ein Behalten hinzu; es fügt nie ein Verwerfen hinzu.
Warum der Digest und nicht eine Revisionsnummer?
Man könnte fragen, warum der Nachweis ein Content-Digest ist und nicht einfach „die höchste Revision, die das Ziel bestätigt hat". Die Antwort: Eine Revisionsnummer sagt Ihnen, wie weit das Ziel gekommen ist, aber ein Digest sagt Ihnen, ob das, was es bekommen hat, das ist, was Sie hatten. Beides kann in den Nahtstellen auseinanderlaufen — eine Replica-Faltung, ein neu verankerter Retry, ein noch abfließender Snapshot —, und die einzige Frage, die Daten sicher hält, ist die inhaltliche: Hält der neue Owner genau dies? Ein Digest beantwortet das direkt. Ein Watermark beantwortet nur einen Stellvertreter dafür, und Stellvertreter sind der Ort, an dem bestätigte Schreibzugriffe sterben.
Was das ehrlich bringt
Klar gesagt lautet die Garantie: Eine geplante Partition-Migration verliert keinen bestätigten Schreibzugriff. Jeder Schreibzugriff, den die Quelle vor dem Fence bestätigt hat, ist im Snapshot oder im Delta; jeder Schreibzugriff, den sie nach dem Nachweis bestätigt hat, hält die Kopie der Quelle am Leben, bis auch der neue Owner ihn hat. Das Protokoll bricht lieber laut ab und startet neu, als auf einem unvollständigen Delta zu kippen, und es faltet Live-Replicas ein, sodass der neue Owner jede Kopie dominiert, nicht nur die Quelle.
Was es nicht ist, ist kostenlos. Das gefencte finale Delta ist ein synchroner Round-Trip, der Schreibzugriffe auf dem zu migrierenden Segment kurz fenced — eine kurze, begrenzte Pause auf einem Segment, nicht auf der Partition und nicht im Cluster. Eine Migration, die nicht konvergieren kann (ein Trim-Floor, der immer weiter vorrückt, ein Replica-Tail, der sich aus seinem Log nicht ausdrücken lässt), erstellt einen neuen Snapshot und wiederholt, statt Fortschritt zu erzwingen; unter pathologischem Churn bedeutet das, dass eine Migration länger dauert, statt eine Abkürzung durch Ihre Daten zu nehmen. Das sind die richtigen Kompromisse für eine geplante Operation: Ein Umzug, für den Sie sich entschieden haben, sollte nie der Grund sein, dass ein committeter Schreibzugriff verschwindet.
Dies ist dasselbe Rückgrat, das die zwölfstündige Chaos-Zertifizierung von außen belastet — einen Node mitten in der Migration stoppen, die Map durcheinanderwirbeln und zusehen, wie bestätigte Schreibzugriffe überleben. Dieser Beitrag ist die Innenansicht einer der Nahtstellen, die dieser Lauf als geschlossen nachweist: der Moment, in dem eine Partition den Besitzer wechselt, und die Sicherung am Abzug, die verhindert, dass die Übergabe genau die Schreibzugriffe verschlingt, die sie schützen soll. Er stützt sich auf zwei weitere Teile, die für sich lesenswert sind — das wiederholbare Op-Log, das „Operationen nach Revision R" zu einer echten, lückenlosen Abfrage macht, und das elastische Scale-out, der häufigste Grund, warum eine Partition überhaupt umziehen muss.