Anatomie eines Failovers: Was mit einem laufenden Schreibvorgang geschieht, wenn der Owner stirbt
Ein Failover ist der Moment, in dem ein verteilter Store am ehrlichsten zeigt, was er wirklich ist. Im Normalbetrieb sehen alle Stores gleich aus: Ein Schreibvorgang landet auf einem Owner, wird bestätigt, weiter geht's. Das Design zeigt sich erst, wenn der Owner mitten im Satz verschwindet — ein Node hält die aktive Kopie einer Ihrer Partitionen, Schreibvorgänge landen gerade auf ihm, und dann ist er weg. Hart beendet. Netzwerkpartitioniert. OOM. Der Cluster hat ein paar Sekunden, um das zu bemerken, sich auf einen neuen Owner zu einigen und den Verkehr wieder fließen zu lassen — und ob er das korrekt tut, entscheidet darüber, ob Ihr bestätigter Schreibvorgang überlebt oder stillschweigend verdunstet.
Dieser Beitrag folgt dieser Abfolge von Anfang bis Ende, in der Reihenfolge, in der Zaris sie tatsächlich ausführt, und benennt die Fälle, in denen die Antwort wirklich „der Schreibvorgang ist verloren" lautet — denn ein Failover-Beitrag, der nur den Happy Path beschreibt, ist nicht lesenswert.
Die eine Invariante, die den Rest handhabbar macht
Vor der Zeitachse die eine Designentscheidung, an der alles Weitere hängt: Zaris trennt die Partition-Map von der Partition-Runtime.
Die Partition-Map ist die langsame, bedachte Zuordnung — welche Nodes sind für Partition P zuständig: ein bevorzugter Primary und seine Replikate. Sie ändert sich nur bei echten Topologieereignissen (ein Node tritt dauerhaft bei oder aus, ein Scale-out, ein Rebalance) und über eine geplante, versionierte Neuberechnung.
Die Runtime ist die schnelle, aktuelle Antwort auf eine andere Frage — welcher zugewiesene Node ist gerade der aktive Owner, der P bedient, und in welchem Gesundheitszustand. Bei einem Failover bewegt sich die Map überhaupt nicht. Nur der Runtime-Active wechselt. Das ist die im Failover-Service selbst festgehaltene Invariante: Ein unabhängiges Ausfallsignal öffnet das Runtime-Failover-Gate, „PartitionMap remains stable and only PartitionRuntimeMap.Active is allowed to move during failover."
Warum das wichtig ist: Eine Partition-Map neu zu berechnen ist teuer und gefährlich — sie kann gesunde Partitionen umverteilen und Datenmigration auslösen. Ein sterbender Node sollte das nicht auslösen. Er sollte eine billige, lokale Runtime-Entscheidung auslösen: ein überlebendes Replikat derselben Partition auswählen und zum Active machen. Die Map sagt weiterhin „P gehört n1, n2, n3"; die Runtime wechselt von „active = n1" auf „active = n2". Dass diese beiden Ebenen getrennt bleiben, macht das Failover schnell, ohne leichtsinnig zu sein.
Schritt 1 — erkennen, aber dem ersten Gerücht nicht glauben
Das Failover wird vom Leader koordiniert. Ein Node im Cluster ist der gewählte Leader, und nur der Leader befördert — ein Nicht-Leader, der ein Node-left-Ereignis hört, ignoriert es einfach. So wird vermieden, dass zwei Nodes unabhängig voneinander zwei verschiedene neue Owner für dieselbe Partition wählen.
Erhält der Leader ein NodeLeft-Signal für einen Node, befördert er nicht sofort Replikate für jede Partition, die dieser Node besaß. Die Promotion wird im Code als „irreversible, high-impact action" bezeichnet und ist abgesichert:
if (string.Equals(activeOwner, nodeId, StringComparison.Ordinal))
{
// A NodeLeft message can race ahead of the peer-table update.
// If the "dead" owner is in fact still reachable, do NOT fail it over.
if (IsNodeAlive(nodeId))
{
Log.NodeUnavailableRejectedReachable(/* ... */);
continue;
}
await PromoteOrMarkUnavailableAsync(partition, nodeId, runtime, ct);
}
Die Absicherung existiert, weil die Ausfall-Nachricht und die Peer-Tabelle der Mitgliedschaftsschicht asynchron aktualisiert werden. Ein NodeLeft kann einen Heartbeat vor der Peer-Tabelle eintreffen — oder schlicht falsch sein. Vor der folgenreichen Aktion prüft der Leader die Erreichbarkeit daher direkt erneut. Ein noch erreichbarer „toter" Owner bleibt genau dort, wo er ist.
Es gibt einen zweiten, langsameren Pfad für den Fall, den der erste nicht abdecken kann: ein aktiver Owner, der ohne sauberes NodeLeft verstummt ist. Ein periodischer Reconcile-Durchlauf (HandleUnavailableActiveOwnersAsync) geht jede Partition durch, und bei einem Owner, der nicht am Leben, aber nicht als ausgefallen bestätigt ist, startet er einen Verdachts-Timer, statt zu handeln:
var suspectedSince = _suspectedUnavailableSinceUtc.GetOrAdd(activeOwner, _ => DateTime.UtcNow);
if (DateTime.UtcNow - suspectedSince < _suspectEscalationTimeout)
{
Log.UnconfirmedActiveOwnerFailure(/* ... */);
continue; // not yet — give it until the escalation timeout
}
ConfirmNodeUnavailable(activeOwner, "RuntimeOwnershipSuspectEscalationTimeout");
Erst nachdem der Owner das gesamte Eskalationsfenster lang (standardmäßig zehn Sekunden) unerreichbar war, bestätigt der Leader den Ausfall und fährt fort. Das Design ist bewusst asymmetrisch: Eine Partition wegen Replikatverlust als degradiert zu markieren ist billig und reversibel und erfolgt daher sofort; einen neuen Active zu befördern ist irreversibel und wartet daher auf einen Beweis.
Schritt 2 — einen Überlebenden wählen und die Epoch erhöhen
Sobald der Owner als ausgefallen bestätigt ist, wählt der Leader ein Replikat zur Promotion. Die Auswahl ist Verfügbarkeit zuerst: Unter den zugewiesenen Replikaten der Partition bevorzugt er dasjenige, das die meisten Segmente synchronisiert hat — den Node, der am ehesten bald bedienen kann.
Die Promotion selbst ist ein einziger Broadcast, der eine Epoch je Partition erhöht:
var nextEpoch = GenerateNextEpoch(partition.PartitionId, runtime, "ReplicaPromotion");
// ...
await _runtimeOwnership.BroadcastAsync(
partition.PartitionId, promotedNodeId, nextEpoch.Value, promotionState, ct);
Die Epoch ist das Fencing-Token und der stille Held des gesamten Mechanismus. Sie ist je Partition monoton, und der Generator weigert sich, rückwärts zu gehen — eine vorgeschlagene Epoch, die nicht strikt größer als die höchste bereits beobachtete ist, wird glatt abgelehnt:
var nextEpoch = baseEpoch + 1;
if (nextEpoch <= highestObservedEpoch)
{
Log.StaleEpochGenerationRejected(/* ... */);
return null; // never regress ownership
}
Nach der Promotion ist der Active der Partition also „n2 bei Epoch 8". War der alte Owner n1 nur netzwerkpartitioniert (nicht tot) und kehrt zurück in dem Glauben, bei Epoch 7 noch der Active zu sein, verliert seine veraltete Epoch jeden Vergleich. Er kann den Besitz nicht zurückfordern, und jeder Schreibvorgang, den er als „Active" annehmen will, wird ausgesperrt. So vermeidet Zaris den Split-Brain mit zwei Primaries, bei dem ein geheilter, aber verwirrter alter Owner Schreibvorgänge annähme, die nie wieder jemand sieht.
Schritt 3 — der Possession-Proof: bedienen oder zuerst synchronisieren?
Hier kommt der subtile Teil, an dessen Korrektheit wir ernsthaft debuggen mussten. Kann das gewählte, am meisten verfügbare Replikat tatsächlich sofort Lesevorgänge bedienen?
Nicht unbedingt — denn „die meisten Segmente synchronisiert" ist nicht dasselbe wie „hält jeden bestätigten Schlüssel". Ein Replikat kann bei der Anzahl der Segmente aufgeholt haben und dennoch bei den Daten der Segmente, die es hat, hinter der Wahrheit zurückliegen. Befördert man einen solchen Node direkt in einen bedienenden Zustand, wird er zu einem unvollständigen Active: Er antwortet NotFound auf Schlüssel, die auf dem toten Owner dauerhaft bestätigt wurden und auf einem vollständigeren Geschwister existieren. Das ist stiller Datenverlust im Gewand eines erfolgreichen Lesevorgangs.
Bevor der beförderte Node bedienen darf, wendet Zaris daher einen Possession-Proof an. Der Leader führt eine frische, zum Term passende Tabelle darüber, wie viele Schlüssel jeder Node für die Partition zu halten meldet. Die Regel ist ein reiner Vergleich:
public static PartitionHealthState ResolvePromotionState(
long candidateHeldKeys, long bestOtherLiveHolderKeys)
=> bestOtherLiveHolderKeys > candidateHeldKeys
? PartitionHealthState.Recovering // a more-complete live peer exists: sync first, do NOT serve
: PartitionHealthState.Degraded; // candidate is the authority: serve now
Zwei Ergebnisse:
- Es existiert kein strikt vollständigerer lebender Halter — der Kandidat ist der vollständigste Node oder ein echter einziger Überlebender, ohne etwas, von dem er synchronisieren könnte. Er wird zu Degraded befördert: einem bedienenden Zustand. Er hat nichts Besseres, auf das er warten könnte, und ihn nicht bedienen zu lassen, würde die Partition nur unnötig unverfügbar machen. Er bedient sofort.
- Es existiert ein strikt vollständigerer lebender Halter — der Kandidat wird zu Recovering befördert: einem nicht bedienenden Zustand. Er synchronisiert zuerst von dieser besseren Quelle und beginnt erst zu bedienen, wenn seine Vollständigkeit erwiesen ist. Clients wird mitgeteilt, dass die Partition noch hochkommt, statt ihnen einen Node zu geben, der sie belügen würde.
Die Unterscheidung zwischen Degraded und Recovering ist genau die zwischen „die Redundanz ist reduziert, aber die Wahrheit wird bedient" und „diesem Owner für Lesevorgänge noch nicht trauen". Einen einzigen Überlebenden korrekt in Degraded zu bringen ist ebenso wichtig, wie einen unvollständigen Node in Recovering zu halten — eine einzelne verbliebene Kopie darf nie dauerhaft nicht bedienend festgenagelt werden, nur weil sie die letzte Kopie ist.
Ein Wort der Ehrlichkeit: Dieser Nachweis ist zählbasiert, gewonnen aus Berichten gehaltener Schlüssel je Node mit einer Frischegrenze (ein Bericht eines verstummten Nodes zählt nicht als lebende Quelle). Er ist ein starker, billiger Schutz davor, einen offensichtlich zurückliegenden Node zum Bedienen zu befördern; er ist keine kryptografische Bestätigung, dass zwei Nodes byte-identische Daten halten. Die Konvergenz, die die Kopien tatsächlich identisch macht, ist der Replica-Sync-Pfad, der weiter unten behandelt wird.
Schritt 4 — der Client routet um, und Ihr Retry landet
Während all das auf dem Server geschieht: Was sieht der Client, der mitten im Schreibvorgang war?
Er sieht die Wahrheit als typisierten Status. Eine Anfrage, die den alten Owner erreicht (jetzt gefenct oder schlicht weg), hängt nicht und wirft keinen vagen Fehler — sie kommt als Moved oder OwnershipChanging zurück. Der Zaris-Client behandelt diese als wiederholbare Reroute-Signale: Er aktualisiert seine Ownership-Map, entdeckt den neuen Active und spielt die Operation dort erneut ab. Im Normalfall dauert Ihr PutAsync einfach ein paar Millisekunden länger und liefert Ok — gegen den neu beförderten Owner, bei der neuen Epoch. Der Anwendungscode erfährt nie, dass der Owner gewechselt hat.
Diese Client-Mechanik — die Status-Taxonomie, der Retry mit Versuchsbudget und Deadline, die Map-Aktualisierung — ist eine eigene Geschichte, erzählt in Retries, die Sie nicht schreiben müssen. Für diesen Beitrag zählt nur, dass die Epoch-Erhöhung des Servers und das Reroute des Clients zwei Hälften einer Übergabe sind: Der Server macht den alten Owner nicht mehr maßgeblich, und der Client hört auf, mit ihm zu sprechen.
Schritt 5 — konvergieren, dann zurück auf Healthy heilen
Die Promotion stellt die Verfügbarkeit wieder her, nicht die Redundanz. Direkt nach einem Failover läuft die Partition als Degraded mit einer Kopie weniger als ihr Ziel. Danach geschehen im Hintergrund zwei Dinge.
Erstens holen der beförderte Node (falls er als Recovering hochkam) und alle zurückliegenden oder zurückkehrenden Replikate auf, indem sie das Operations-Log je Segment ab einer bekannten Revision erneut abspielen — dasselbe wiederabspielbare Substrat, das die gewöhnliche Replikation antreibt. Ein Node, der gerade die Active-Rolle geerbt hat, und ein Replikat, das lediglich zurückgefallen ist, konvergieren über denselben Mechanismus; das ist das Thema von One Log, Two Jobs.
Zweitens überführt der Recovery-Durchlauf des Leaders die Partition zurück nach Healthy, sobald jeder zugewiesene Teilnehmer wieder lebt und synchron ist — aber nur über denselben Epoch-erhöhenden Broadcast und erst nach einer kurzen Abkühlzeit, damit ein flatternder Node die Runtime nicht durch einen Sturm von Übergängen treiben kann. Der Gesundheits-Zustandsautomat ist somit eine kleine, geschlossene Schleife: Healthy → Degraded/Recovering → Healthy, jede Kante eine Epoch-Erhöhung, jede Promotion gefenct.
Überlebt gar kein geeignetes Replikat — ist jeder zugewiesene Node der Partition ausgefallen —, tut der Leader das einzig Ehrliche: Er markiert die Partition als Unavailable (kein Owner, keine falsche Promotion) und startet einen Eviction-Countdown, der, falls niemand rechtzeitig zurückkehrt, eine geplante Map-Neuberechnung die Partition neu verorten lässt. Eine Unavailable-Partition lässt Lese- und Schreibvorgänge laut scheitern, statt eine Lüge zu bedienen.
Wo ein Schreibvorgang tatsächlich verloren geht — klar benannt
Ein Failover-Mechanismus verdient Vertrauen, indem er seine Grenzen präzise benennt, nicht indem er andeutet, es gäbe keine.
- Replikationsfaktor 1. Eine Partition mit einer einzigen Kopie und ohne Replikat hat nichts, was sie befördern könnte. Stirbt dieser Node, wird die Partition Unavailable, bis er zurückkehrt; die Daten sind nicht verloren (sie liegen im Speicher dieses Nodes), aber nicht verfügbar, und kehrt der Node nie zurück, sind sie weg. RF1 ist konstruktionsbedingt nicht dauerhaft — eine bewusste Wahl für Daten, die Sie sich leisten können zu verlieren, keine Garantie, die der Failover-Pfad retten könnte.
- Asynchrone Bestätigung. Zaris erlaubt, einen Schreibvorgang auf dem aktiven Owner zu bestätigen, bevor er ein Replikat erreicht hat — für die Latenz. Das ist der schnelle Pfad, und sein ehrlicher Preis ist ein Fenster: Stirbt der Active in der Lücke zwischen der Bestätigung gegenüber dem Client und dem Eintreffen der Operation bei irgendeinem überlebenden Halter, ist dieser bestätigte Schreibvorgang verloren. Der Possession-Proof schützt davor, einen bekanntermaßen zurückliegenden Node zum Bedienen zu befördern; er kann keine Operation herbeizaubern, die den toten Node nie verlassen hat. Muss ein Schreibvorgang den Verlust seines Owners überleben, darf er erst bestätigt werden, nachdem er auf einem Replikat dauerhaft ist — der synchrone Pfad tauscht ein wenig Latenz genau gegen diese Garantie. Wählen Sie pro Workload, mit offenen Augen.
- Das Bestätigungsfenster selbst. Während des Eskalations-Timeouts, nachdem ein Owner verstummt ist, befinden sich die Schreibvorgänge der Partition in der Schwebe — vom Client wiederholt, aber noch nicht auf einem neuen Owner gelandet. Die Korrektheit bleibt gewahrt (nichts wird fälschlich befördert), aber die Verfügbarkeit sinkt für dieses Fenster. Den Timeout zu verkürzen tauscht schnelleres Failover gegen mehr Fehlpromotions bei vorübergehenden Aussetzern; es ist ein Regler, kein Gratis-Mittagessen.
Nichts davon sind Bugs, die der Failover-Pfad wegkonstruieren könnte — es sind die echten Zielkonflikte zwischen Verfügbarkeit, Langlebigkeit und Latenz, und Zaris' Aufgabe ist es, die sichere Wahl zum Default und die schnelle Wahl explizit zu machen, ohne je so zu tun, als gäbe es das Fenster nicht.
Das Gesamtbild
Reduziert man das Failover, sind es vier Züge auf einer Invariante. Die Invariante: Die Map bleibt stehen; nur der Runtime-Active bewegt sich. Die Züge: den Tod bestätigen, bevor man darauf reagiert, einen Überlebenden hinter einer monotonen Epoch befördern, die den alten Owner aussperrt, den Besitz nachweisen, sodass der neue Owner die Wahrheit bedient oder erst synchronisiert, bevor er überhaupt bedient, und den Client umleiten, sodass sein Retry beim neuen Owner landet. Dann die Kopien wieder konvergieren lassen und auf Healthy heilen.
Dass das hält, ist keine Frage der Argumentation — es wird gemessen. Derselbe Promotion-, Possession- und Konvergenzpfad läuft stundenlang kontinuierlich unter gezieltem Node-Kill-Chaos, und am Ende wird das Ledger auf einen einzigen verlorenen bestätigten Schreibvorgang geprüft. Diese Zertifizierung ist eine eigene Geschichte: Kein bestätigter Schreibvorgang bleibt zurück.