Zum Hauptinhalt springen

Ein Owner, der neu startet, hat kein Recht auf seine eigene Leere

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

Einen neu gestarteten Owner aus seinen Replicas wiederaufbauen

Ein Failover deckt den Moment ab, in dem ein Owner stirbt: Der Cluster wählt einen Überlebenden und bedient weiter. Eine geplante Migration deckt das absichtliche Verschieben der Partition eines gesunden Owners ab. Dieser Beitrag handelt vom dritten Akt, dem, der nach dem Ende der Sirenen abläuft — der ursprüngliche Owner-Prozess startet neu, tritt dem Cluster wieder bei und will seine Partition zurück. Sein Store, der nichts auf der Platte und alles im Speicher hält, kam leer zurück. Die Daten, für die er verantwortlich ist, leben noch, aber auf den Replicas, die weiter bedient haben, während er ausgefallen war.

Das gesamte Korrektheitsproblem steckt in einem Satz: Dem neu gestarteten Owner darf nicht erlaubt werden, seiner eigenen Leere zu trauen. Ein leerer Store sieht genau so aus wie ein Store, den jemand bewusst geleert hat. Nimmt der Node an „ich bin der Owner und habe keine Daten, also ist diese Partition leer", wird er bereitwillig NotFound für Millionen lebender Keys ausliefern und, schlimmer, seinen leeren Zustand zurück auf die Replicas fließen lassen und sie löschen. Dieser Beitrag geht die fünf kleinen, reinen Policies durch, mit denen Zaris sicherstellt, dass das nie geschieht.

Warum Leere von innen nicht entscheidbar ist​

Beginnen wir mit der Falle, denn jede Regel unten ist von ihr geprägt. Ein Node, der hochfährt und null Records für ein Segment hält, das er besitzt, befindet sich in einem von zwei Zuständen, und er kann allein aus lokalen Informationen nicht sagen, in welchem:

  1. Er ist leer, weil er gerade neu gestartet ist und seine Daten nie zurückgeholt hat. Er muss pullen.
  2. Er ist leer, weil er diese Keys maßgeblich gelöscht hat oder sie abgelaufen sind, und eine Replica, die kurz ausgefallen war, hält noch die veralteten Kopien. Pullt er, lässt er gelöschte Daten wiederauferstehen.

Diese beiden sind lokal ununterscheidbar — der Store ist in beiden Fällen leer. Eine frühere Version von Zaris versuchte, das mit einem EmptyActive-Anomaliesignal aufzulösen, und es wurde gerade deshalb entfernt, weil das Signal eine Vermutung ist. Man kann nicht fragen „bin ich aus gutem Grund leer?" und von dem leeren Node eine vertrauenswürdige Antwort erhalten.

Zaris fragt die Leere daher gar nicht erst. Es leitet die Entscheidung über eine andere Tatsache: ein klebriges Flag auf Partitionsebene namens certified, das genau bedeutet: „Mein Startup-Sync ist abgeschlossen, und mein Store ist nun maßgeblich." Ein Node setzt es erst, nachdem er den Besitz nachgewiesen hat. Ein frisch neu gestarteter Owner ist per Definition nicht certified — und ein nicht zertifizierter Owner hat keine Autorität, leer zu sein. Er muss von dem pullen, der die Daten hält. Ein zertifizierter Owner ist maßgeblich und wird strikt in Ruhe gelassen, sodass ein legitimes Leeren nie wiederaufersteht.

Diese eine Umdeutung — von Leere (unentscheidbar) zu Zertifizierung (eine nachgewiesene, klebrige Tatsache) — macht den Rest der Wiederherstellung sicher.

Schritt 1 — der neu gestartete Owner pullt, statt anzunehmen​

Wenn der Node wieder beitritt, weist die Map ihn weiterhin als Owner seiner alten Partition aus. Der Reverse-Sync-Trigger entscheidet, ob jedes besessene Segment von einer Live-Replica gepullt werden soll, statt als lokal maßgeblich behandelt zu werden:

public static bool ShouldReverseSyncOwnedSegment(
bool partitionCertified,
bool isMapPreferredPrimary,
bool liveNonSelfReplicaExists)
=> !partitionCertified && liveNonSelfReplicaExists;

Drei Dinge an diesem winzigen Prädikat sind tragend, und jedes ist ein Bug, der einen Zertifizierungslauf erreichte, bevor die Regel aufgeschrieben wurde.

Es ignoriert isMapPreferredPrimary. Dieser Parameter wird weiterhin übergeben — als dokumentierter Entscheidungskontext —, aber die Entscheidung liest ihn bewusst nicht. Eine frühere Version löste Reverse-Sync nur aus, wenn der Node zusätzlich der bevorzugte Primary der Map war. Ein Node, der zur Laufzeit aktiv, aber nicht der Map-Primary ist — genau der Zustand, in dem ein neu verorteter oder nach der Wiederherstellung beförderter Owner landet —, hatte seine besessenen Segmente komplett übersprungen. Er forderte seine Daten nie an und blieb für immer leer gestrandet, während eine Live-Replica jeden Record hielt (ein Cert-Lauf: Active hielt 0, Replica hielt 4600 auf fünfzehn Segmenten, über acht Minuten nie konvergiert). Das Ownership des Daten-Pulls darf nicht daran hängen, der bevorzugte Primary zu sein; es muss für jeden nicht zertifizierten Active feuern.

Das Signal ist !partitionCertified, nicht Leere. Das ist die Lösung der Unentscheidbarkeit aus dem vorigen Abschnitt, als Code. Ein nicht zertifizierter Active hat den Besitz nie nachgewiesen, also pullt er; ein zertifizierter ist maßgeblich und wird übersprungen.

liveNonSelfReplicaExists ist ein Guard, keine Nettigkeit. Ist die konfigurierte Replica ausgefallen, gibt es nichts, wovon man synchronisieren könnte. Die Wiederherstellung gegen eine tote Quelle zu treiben dreht sich ewig im Kreis (RECOVERY STUCK source=self synced=0/128). Existiert also keine Live-Replica, ist der Active die einzige überlebende Kopie — er ist die Autorität und zertifiziert sofort, statt auf eine Quelle zu warten, die nie antworten wird. Der Pull selbst ist durch Revisionen abgesichert und durch ein Watermark begrenzt, was ihn zu einem harmlosen No-op macht, wenn die Replica zufällig nichts hält.

Schritt 2 — einen Key nicht bedienen, bei dem man noch nicht aufgeholt hat​

Das Pullen ist nicht augenblicklich, und das Fenster, während es läuft, ist der Ort, an dem bestätigte Lesezugriffe stillschweigend zu NotFound werden. Ein naives Gate ist ein einzelner Boolean, IsSynced, der bedeutet „ein Sync-Durchlauf ist beendet." Dieser Boolean reicht nicht, und drei getrennte Cert-Fehler bewiesen es: Ein zurückfordernder Active wurde 34 Keys zu kurz befördert; ein Handoff verlor einen bestätigten Key; ein wieder beitretender Active lieferte während des Churns für 57 Keys NotFound. In jedem Fall war ein Sync-Durchlauf beendet — aber die Quelle (der temporäre Active, der weiter bediente) hatte inzwischen mehr Schreibzugriffe akzeptiert, die der wiederherstellende Node nicht gepullt hatte. IsSynced war true, und der Node hinkte trotzdem hinterher.

Die Lösung ist, an einer Position zu gaten, nicht an einem Flag. Die Op-Log-Revision ist eine replizierte, nodeübergreifend vergleichbare Zahl: Der Active vergibt sie und verschickt sie, sodass eine aufgeholte Replica zum selben Head konvergiert. Das Serve-Gate vergleicht Heads:

public static SegmentServeDecision Decide(bool isSynced, long localHeadRevision, long sourceHeadRevision)
{
if (!isSynced)
return SegmentServeDecision.HoldUnsynced;

// A watermark of 0 carries no information — do not block on it; trust the completed sync pass.
if (sourceHeadRevision > 0 && localHeadRevision < sourceHeadRevision)
return SegmentServeDecision.HoldBehindSource;

return SegmentServeDecision.Serve;
}

HoldBehindSource ist der neue Zustand, auf den es ankommt: Ein Sync-Durchlauf wurde beendet, aber der Head der Quelle (bei diesem Sync erfasst) liegt vor unserem, die Quelle hat also Schreibzugriffe akzeptiert, die wir nicht gepullt haben. Jetzt zu bedienen würde für genau diese Keys NotFound liefern. Der Node hält an, bis sein lokaler Head das Watermark erreicht. Beachten Sie den bewussten Rückfall: Ein aufgezeichnetes Watermark von 0 bedeutet „keine Watermark-Information", und das Gate fällt auf das reine IsSynced-Verhalten zurück, statt für immer an fehlenden Daten zu blockieren. Die Regel blockiert nie an der Abwesenheit einer Tatsache — nur an einer Tatsache, die sagt: Sie hinken hinterher.

Schritt 3 — pullen, aber nicht überschreiben​

Nun die gefährliche Richtung: den Snapshot anwenden, den die Replica zurückschickt. Der Instinkt ist, den lokalen Store zu leeren und den Snapshot im Ganzen hineinzukopieren. Dieser Instinkt ist genau zur Hälfte richtig und zur anderen Hälfte katastrophal falsch. Die entscheidende Frage ist, wessen Revisionen mit wessen vergleichbar sind:

public static ResyncApplyMode Decide(
bool digestMatches,
bool isPassiveSubordinateReplica,
bool priorReconcileShortfall = false)
=> digestMatches
? ResyncApplyMode.Skip
: (isPassiveSubordinateReplica && !priorReconcileShortfall)
? ResyncApplyMode.Reconcile
: ResyncApplyMode.Wipe;

Drei Ergebnisse:

  • Skip — die Content-Digests stimmen bereits überein. Nichts zu übertragen, nichts anzuwenden. (Siehe das Cut-over-Protokoll, warum ein Content-Digest und keine Revisionszählung das ist, was sich zu vergleichen lohnt.)
  • Reconcile (nicht-destruktiv) — der lokale Node ist ein rein passiver Subordinate des einen Live-Actives. Seine Revisionen pro Key stammen alle von diesem einen Active und sind daher direkt vergleichbar. Der Snapshot wird revisionsgesichert gemergt: Ein PutRaw behält den lokal neueren Record und füllt fehlende Keys auf; Tombstones werden über DeleteRaw angewendet. Entscheidend: Dies bewahrt Schreibzugriffe, die die Replica über den Live-Kanal während eines langsamen Bootstraps angesammelt hat, statt sie durch vorheriges Leeren wegzuwerfen.
  • Wipe (destruktives Leeren + Kopieren) — der lokale Node ist oder war selbst ein Active für diese Partition. Zwei unabhängig aktive Nodes haben unvergleichbare Revisionen pro Key: Dieselbe Revisionsnummer bezeichnet auf beiden unterschiedliche Schreibzugriffe. Sie zu mergen würde stillschweigend zwei Historien verzahnen. Der einzig sichere Schritt ist also, den lokalen Zustand zu verwerfen und den maßgeblichen Snapshot im Ganzen zu übernehmen.

Der Default ist die sichere destruktive Kopie; Reconcile wird nur freigeschaltet, indem nachgewiesen wird, dass der Node ein rein passiver Subordinate ist. Das ist der „Wipe-Guard": Er verhindert den gefährlichen Merge über zwei frühere Actives hinweg und verhindert symmetrisch, dass ein Komplett-Wipe gute Live-Kanal-Schreibzugriffe auf einer einfachen Replica verwirft.

In priorReconcileShortfall steckt noch eine Falte, die direkt aus einem Abbruch der Zwölf-Stunden-Zertifizierung stammt. Ein revisionsgesicherter Reconcile kann Records aussperren, die er hätte anwenden sollen, und abschließen, während er noch hinter dem maßgeblichen Snapshot liegt — ein Segment hing bei 12643/15435 über 232 Sync-Sessions fest und stimmte jedes Mal dieselben Records aus dem Fenster ab, in einer Endlosschleife. Das Flag sagt: Wenn ein früherer Reconcile abgeschlossen wurde, uns aber zu kurz hielt, hör auf zu reconcilen und eskaliere zu einem Wipe, damit der eingefrorene Snapshot im Ganzen übernommen wird und wir tatsächlich konvergieren. Es wird nur nach einem gemessenen Defizit gesetzt — eine vorauseilende oder gleichstehende Replica löst es nie aus, sodass Live-Kanal-Schreibzugriffe nie verworfen werden, um einem Phantom nachzujagen.

Schritt 4 — die andere Seite: ein nicht zertifizierter Owner kann trotzdem eine gute Quelle sein​

Bei der Wiederherstellung geht es nicht nur um den Node, der hinterherhinkt. Betrachten Sie den Spiegelfall: Node A startet neu, und Node B — eine Replica, die ausgefallen war — will nun von A synchronisieren. Soll A die Anfrage bedienen, solange A selbst noch nicht zertifiziert ist?

Der naive Guard sagt nein: „nicht zertifiziert heißt, mein Store ist leer, ein Bedienen würde B's gültige Daten mit meiner Lücke überschreiben." Aber diese Prämisse ist in dem Moment falsch, in dem A die maßgebliche Kopie tatsächlich hält — was auf zwei Wegen geschieht: A war ein bevorzugter Primary, der vor der Beförderung den Catch-up verifizierte, dessen Beförderungspfad aber nie CertifyPartition aufrief (das klebrige Flag bleibt also false, obwohl die Daten alle da sind), oder A übernahm das Ownership über eine Wiederherstellung nach Totalausfall der Partition und hält als Active die vollständigen Daten, ohne je als IsSynced gestempelt worden zu sein. In beiden Fällen ist A's Kopie die Quelle der Wahrheit, und die Verweigerung ließ die Replica bei null gestrandet zurück (ein Dump: 1.792 verweigerte Requests; ein anderer: alle 128 Segmente für eine ganze Partition verweigert).

Der Guard auf der Quellseite wird daher datenbewusst gemacht:

public static bool ShouldRefuse(
bool selfIsRuntimeOwnerMapPrimary,
bool partitionCertified,
bool segmentSynced,
bool selfHoldsSegmentData)
=> selfIsRuntimeOwnerMapPrimary && !partitionCertified && !segmentSynced && !selfHoldsSegmentData;

Verweigert wird nur, wenn der Node der Owner ist, nicht zertifiziert ist und das Segment sowohl unsynchronisiert ist als auch keine Daten hält — der eine echte Fall „mitten in der Wiederherstellung und tatsächlich leer", in dem das Bedienen einer Lücke den Anfragenden gegen sie zertifizieren lassen könnte. Da der Empfänger jeden Chunk revisionsgesichert anwendet, ist das Bedienen echter Daten immer sicher; der Guard beschränkt die Verweigerung auf den einen Zustand, in dem es nichts Sicheres zu bedienen gibt.

Schritt 5 — den Sieg erklären, genau einmal​

Schließlich hat der wiederherstellende Active jedes besessene Segment gepullt und jedes auf den Head seiner Quelle aufgeholt. Er ist wieder maßgeblich und muss das sagen — das klebrige Certified-Flag umlegen —, damit der Reverse-Sync-Trigger aus Schritt 1 aufhört, seine Segmente erneut einzureihen, und der Cluster die Partition als konvergiert registriert.

public static bool ShouldCertify(
bool selfIsRuntimeActive,
bool partitionCertified,
int ownedSegmentCount,
int syncedSegmentCount)
=> selfIsRuntimeActive
&& !partitionCertified
&& ownedSegmentCount > 0
&& syncedSegmentCount >= ownedSegmentCount;

Die Zertifizierung ist der symmetrische Abschluss des Triggers: Der Trigger reiht die Segmente eines nicht zertifizierten Actives zum Pullen ein; dies hier legt das Flag um, sobald jedes besessene Segment synchronisiert ist, und die Partition beruhigt sich. Der bisherige Certify-Pfad lief nur für den bevorzugten Primary der Map, sodass ein neu verorteter Nicht-Primary-Active zwar alle seine Daten korrekt pullte und dann nie zertifizierte — er reihte Reverse-Sync endlos neu ein, und der Cluster registrierte sich nie als konvergiert (ein Lauf: 30 Checkpoints liefen ~106 Sekunden nach dem Start in den Timeout, während Missing=0 und die Zählungen nie auseinanderliefen — die Daten waren richtig, nur das Zur-Ruhe-Kommen fehlte).

Und beachten Sie die eine Abkürzung, die diese Policy bewusst verweigert: Es gibt keinen Pfad „keine Live-Replica, also zertifiziere ich meine Leere als Autorität" für einen Nicht-Primary-Active. Ein Nicht-Primary-Active ist nicht die Autorität der Map; ihn leer zu zertifizieren, während eine vorübergehend ausgefallene Replica die Daten noch hält, würde dieser Replica erlauben, später von diesem leeren Node zu synchronisieren und alles zu verlieren. Die Autorität, leer zu sein, gehört nur dem bevorzugten Primary der Map, in seinem eigenen Reconcile-Pfad. Überall sonst bringt Leere nichts ein.

Die Form des Ganzen​

Tritt man zurück, sind die fünf Policies eine Idee an fünf Stellen angewendet: Besitz wird nachgewiesen, nie angenommen.

  • Ein neu gestarteter Owner ist nicht zertifiziert, also pullt er, statt seinem leeren Store zu trauen (Schritt 1).
  • Er bedient ein Segment nicht, bevor sein Op-Log-Head den der Quelle erreicht — ein beendeter Sync-Durchlauf ist nicht dasselbe wie aufgeholt zu sein (Schritt 2).
  • Er mergt, statt zu überschreiben, wenn Revisionen vergleichbar sind, und überschreibt, statt zu verzahnen, wenn sie es nicht sind (Schritt 3).
  • Als Quelle bedient er immer dann, wenn er die Daten wirklich hält, und verweigert nur, wenn er mitten in der Wiederherstellung wirklich leer ist (Schritt 4).
  • Er zertifiziert — legt das klebrige Autoritäts-Flag um — nur auf den Nachweis, dass jedes besessene Segment synchronisiert ist, ohne eine Abkürzung „Leere als Autorität" für einen Nicht-Primary (Schritt 5).

Jede dieser Regeln ist ein reines, IO-freies Prädikat — eine Handvoll Booleans und zwei Revisionsnummern hinein, eine Entscheidung heraus —, weshalb sie isoliert unit-testbar sind und jede an genau den Zertifizierungsfehler festgenagelt werden konnte, der sie motivierte. Sie sind die Innenansicht der Invariante, die die zwölfstündige Chaos-Zertifizierung von außen beweist: einen Owner mitten im Flug töten, seine Replicas die Last tragen lassen, ihn zurückbringen und zusehen, wie Missing=0 über Millionen bestätigter Operationen hält. Der Grund, warum der Neustart keinen Schreibzugriff verliert, ist, dass dem zurückkehrenden Owner nie auch nur einmal erlaubt wird, seiner eigenen Leere zu glauben. Er muss sich seine Daten zurückverdienen — von den Replicas, die sie in Sicherheit gehalten haben.

Wenn Sie die Teile wollen, auf die sich dies stützt: Das wiederholbare Op-Log macht „hole meinen Head auf den Head der Quelle auf" zu einer echten, lückenlosen Operation, und Eigene Schreibzugriffe lesen ist das clientseitige Versprechen, das das Serve-Gate aus Schritt 2 einhalten soll.