Anatomie d'un basculement : ce qu'il advient d'une écriture en vol quand le propriétaire meurt
Un basculement est le moment où un store distribué est le plus honnête sur ce qu'il est réellement. En régime permanent, tout store se ressemble : une écriture atterrit sur un propriétaire, est acquittée, vous passez à la suite. La conception ne se montre que lorsque le propriétaire disparaît au milieu d'une phrase — un nœud détient la copie active de l'une de vos partitions, des écritures y atterrissent à l'instant même, puis il n'est plus là. Tué net. Partitionné par le réseau. OOM. Le cluster a quelques secondes pour s'en apercevoir, s'accorder sur un nouveau propriétaire et faire repartir le trafic — et le fait de s'y prendre correctement décide si votre écriture acquittée survit ou s'évapore discrètement.
Cet article suit cette séquence de bout en bout, dans l'ordre où Zaris l'exécute réellement, et nomme les cas où la réponse est véritablement « l'écriture est perdue » — car un article sur le basculement qui ne décrit que le chemin heureux ne vaut pas la lecture.
L'unique invariant qui rend le reste traitable
Avant la chronologie, l'unique décision de conception dont tout le reste dépend : Zaris sépare la carte de partitions du runtime de partitions.
La carte de partitions est l'affectation lente et délibérée — quels nœuds sont responsables de la partition P : un primaire préféré et ses réplicas. Elle ne change que lors d'événements topologiques réels (un nœud rejoint ou quitte définitivement, une montée en charge, un rééquilibrage) et elle change par une régénération planifiée et versionnée.
Le runtime est la réponse rapide et vivante à une question différente — quel nœud affecté est le propriétaire actif servant P à l'instant présent, et dans quelle santé. Pendant un basculement, la carte ne bouge pas du tout. Seul le primaire actif du runtime bouge. C'est l'invariant énoncé dans le service de basculement lui-même : un signal de défaillance indépendant ouvre la barrière de basculement du runtime, « PartitionMap remains stable and only PartitionRuntimeMap.Active is allowed to move during failover. »
Pourquoi cela importe : régénérer une carte de partitions est coûteux et dangereux — cela peut rebrasser des partitions saines et déclencher une migration de données. Un nœud qui meurt ne devrait pas déclencher cela. Il devrait déclencher une décision de runtime bon marché et locale : choisir un réplica survivant de la même partition et en faire le primaire actif. La carte dit toujours « P est possédé par n1, n2, n3 » ; le runtime bascule de « actif = n1 » à « actif = n2 ». Garder ces deux plans séparés est ce qui permet au basculement d'être rapide sans être téméraire.
Étape 1 — détecter, mais ne pas croire la première rumeur
Le basculement est coordonné par le leader. Un nœud du cluster est le leader élu, et seul le leader promeut — un non-leader qui entend un événement node-left l'ignore simplement. Cela évite que deux nœuds n'élisent indépendamment deux nouveaux propriétaires différents pour la même partition.
Lorsque le leader reçoit un signal NodeLeft pour un nœud, il ne promeut pas immédiatement des réplicas pour chaque partition que ce nœud possédait. La promotion est désignée dans le code comme « an irreversible, high-impact action », et elle est protégée :
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);
}
La garde existe parce que le message de défaillance et la table de pairs de la couche d'appartenance se mettent à jour de façon asynchrone. Un NodeLeft peut arriver un battement de cœur avant que la table de pairs ne le reflète — ou être tout simplement faux. Donc, avant l'action à fort impact, le leader revérifie directement la vivacité. Un propriétaire « mort » encore joignable est laissé exactement où il est.
Il existe un second chemin, plus lent, pour le cas que le premier ne peut pas couvrir : un propriétaire actif devenu silencieux sans un NodeLeft propre. Un balayage de réconciliation périodique (HandleUnavailableActiveOwnersAsync) parcourt chaque partition, et pour un propriétaire qui n'est pas vivant mais n'a pas été confirmé en panne, il démarre une minuterie de suspicion plutôt que d'agir :
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");
Ce n'est qu'après que le propriétaire a été injoignable pendant toute la fenêtre d'escalade (dix secondes par défaut) que le leader le confirme en panne et poursuit. La conception est délibérément asymétrique : marquer une partition dégradée pour perte de réplica est bon marché et réversible, donc cela se déclenche immédiatement ; promouvoir un nouveau primaire actif est irréversible, donc cela attend une preuve.
Étape 2 — choisir un survivant, et incrémenter l'époque
Une fois le propriétaire confirmé en panne, le leader choisit un réplica à promouvoir. La sélection est disponibilité d'abord : parmi les réplicas affectés de la partition, il préfère celui qui a synchronisé le plus de segments — le nœud le plus susceptible de pouvoir servir bientôt.
La promotion elle-même est une unique diffusion qui fait avancer une époque par partition :
var nextEpoch = GenerateNextEpoch(partition.PartitionId, runtime, "ReplicaPromotion");
// ...
await _runtimeOwnership.BroadcastAsync(
partition.PartitionId, promotedNodeId, nextEpoch.Value, promotionState, ct);
L'époque est le jeton de protection, et c'est le héros discret de tout le mécanisme. Elle est monotone par partition, et le générateur refuse de revenir en arrière — une époque proposée qui n'est pas strictement supérieure à la plus haute déjà observée est rejetée d'emblée :
var nextEpoch = baseEpoch + 1;
if (nextEpoch <= highestObservedEpoch)
{
Log.StaleEpochGenerationRejected(/* ... */);
return null; // never regress ownership
}
Ainsi, après la promotion, le primaire actif de la partition est « n2 à l'époque 8 ». Si l'ancien propriétaire n1 était simplement partitionné (pas mort) et revient en pensant qu'il est toujours le primaire actif à l'époque 7, son époque périmée perd toutes les comparaisons. Il ne peut pas récupérer la propriété, et toute écriture qu'il essaie d'accepter en tant que « actif » est écartée. C'est ainsi que Zaris évite le split-brain à deux primaires qui laisserait sinon un ancien propriétaire guéri-mais-confus accepter des écritures que personne ne reverra jamais.
Étape 3 — la preuve de possession : servir, ou se synchroniser d'abord ?
Voici la partie subtile, et celle qui a demandé un vrai débogage pour être correcte. Ayant choisi le réplica le plus disponible, peut-il réellement commencer à servir les lectures immédiatement ?
Pas nécessairement — car « le plus de segments synchronisés » n'est pas la même chose que « détient chaque clé acquittée ». Un réplica peut être à jour sur le nombre de segments tout en étant encore en retard sur les vraies données des segments qu'il détient. Promouvez un tel nœud directement vers un état servant et il devient un primaire actif incomplet : il répond NotFound pour des clés qui étaient durablement acquittées sur le propriétaire mort et qui existent sur un frère plus complet. C'est une perte de données silencieuse déguisée en lecture réussie.
Donc, avant que le nœud promu ne soit autorisé à servir, Zaris applique une preuve de possession. Le leader tient une table fraîche, alignée sur le terme, du nombre de clés que chaque nœud déclare détenir pour la partition. La règle est une pure comparaison :
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
Deux issues :
- Aucun détenteur vivant strictement plus complet n'existe — le candidat est le nœud le plus complet, ou un véritable unique survivant n'ayant rien avec quoi se synchroniser. Il est promu en Degraded : un état servant. Il n'a rien de mieux à attendre, donc le bloquer en non-servant ne ferait que rendre la partition inutilement indisponible. Il sert immédiatement.
- Un détenteur vivant strictement plus complet existe — le candidat est promu en Recovering : un état non servant. Il se synchronise d'abord depuis cette meilleure source et ne commence à servir qu'une fois sa complétude prouvée. On dit aux clients que la partition est encore en train de démarrer plutôt que de leur remettre un nœud qui leur mentirait.
La distinction entre Degraded et Recovering est exactement la distinction entre « la redondance est réduite mais la vérité est servie » et « ne faites pas encore confiance à ce propriétaire pour les lectures ». Amener correctement un unique survivant en Degraded importe tout autant que maintenir un nœud incomplet en Recovering — une unique copie restante ne doit jamais être bloquée en non-servant pour toujours simplement parce que c'est la seule copie qui reste.
Une note rapide sur l'honnêteté : cette preuve est fondée sur le décompte, tirée des rapports de clés détenues par nœud avec un filigrane de fraîcheur (un rapport d'un nœud devenu silencieux n'est pas compté comme source vivante). C'est une garde forte et bon marché contre la promotion d'un nœud manifestement en retard pour servir ; ce n'est pas une attestation cryptographique que deux nœuds détiennent des données identiques octet pour octet. La convergence qui rend les copies réellement identiques est le chemin de synchronisation des réplicas, couvert ci-dessous.
Étape 4 — le client redirige, et votre nouvelle tentative atterrit
Pendant que tout cela se produit sur le serveur, que voit le client qui était en pleine écriture ?
Il voit la vérité, sous forme de statut typé. Une requête qui atteint l'ancien propriétaire (désormais écarté, ou simplement parti) ne reste pas suspendue et ne lève pas d'erreur vague — elle revient en Moved ou OwnershipChanging. Le client Zaris traite ceux-ci comme des signaux de redirection réessayables : il rafraîchit sa carte de propriété, découvre le nouveau primaire actif et rejoue l'opération là-bas. Dans le cas courant, votre PutAsync prend simplement quelques millisecondes de plus et renvoie Ok — face au propriétaire nouvellement promu, à la nouvelle époque. Le code de l'application n'apprend jamais que le propriétaire a bougé.
Cette mécanique côté client — la taxonomie des statuts, la nouvelle tentative avec budget-de-tentatives-et-échéance, le rafraîchissement de la carte — est une histoire en soi, racontée dans Les tentatives que vous n'écrivez pas. Pour cet article, le point est simplement que l'incrément d'époque du serveur et la redirection du client sont les deux moitiés d'un seul transfert : le serveur rend l'ancien propriétaire non-autoritaire, et le client cesse de lui parler.
Étape 5 — converger, puis guérir jusqu'à Healthy
La promotion restaure la disponibilité ; elle ne restaure pas la redondance. Juste après un basculement, la partition tourne en Degraded avec une copie de moins que sa cible. Deux choses se produisent alors en arrière-plan.
D'abord, le nœud promu (s'il est monté en Recovering) ainsi que tout réplica en retard ou de retour rattrapent en rejouant le journal d'opérations par segment depuis une révision connue — le même substrat rejouable qui pilote la réplication ordinaire. Un nœud qui vient d'hériter du rôle actif et un réplica simplement en retard convergent par le mécanisme identique ; c'est le sujet de Un journal, deux rôles.
Ensuite, une fois que chaque participant affecté est de nouveau vivant et synchronisé, le balayage de récupération du leader fait repasser la partition en Healthy — mais uniquement via la même diffusion qui fait avancer l'époque, et uniquement après une courte temporisation afin qu'un nœud instable ne puisse pas entraîner le runtime à travers une tempête de transitions. La machine à états de santé est donc une petite boucle fermée : Healthy → Degraded/Recovering → Healthy, chaque arête un incrément d'époque, chaque promotion protégée.
Si aucun réplica éligible ne survit du tout — chaque nœud affecté à la partition est en panne — le leader fait la seule chose honnête : il marque la partition Unavailable (aucun propriétaire, aucune fausse promotion) et démarre un compte à rebours d'éviction qui, si personne ne revient à temps, laisse une régénération de carte planifiée reloger la partition. Une partition Unavailable échoue bruyamment les lectures et écritures plutôt que de servir un mensonge.
Là où une écriture est véritablement perdue — énoncé clairement
Un mécanisme de basculement gagne la confiance en étant précis sur ses limites, et non en laissant entendre qu'il n'y en a pas.
- Facteur de réplication 1. Une partition avec une unique copie et aucun réplica n'a rien à promouvoir. Lorsque ce nœud meurt, la partition passe en Unavailable jusqu'à son retour ; les données ne sont pas perdues (elles sont dans la mémoire de ce nœud) mais elles sont indisponibles, et si le nœud ne revient jamais, elles sont disparues. Le RF1 est non durable par conception — c'est un choix délibéré pour des données que vous pouvez vous permettre de perdre, pas une garantie que le chemin de basculement peut sauver.
- Acquittement asynchrone. Zaris permet qu'une écriture soit acquittée sur le propriétaire actif avant qu'elle n'ait atteint un réplica, pour la latence. C'est le chemin rapide — et son coût honnête est une fenêtre : si le primaire actif meurt dans l'intervalle entre l'acquittement au client et l'arrivée de l'opération sur un détenteur survivant, cette écriture acquittée est perdue. La preuve de possession vous protège de la promotion pour servir d'un nœud connu en retard ; elle ne peut pas faire apparaître une opération qui n'a jamais quitté le nœud mort. Si vous avez besoin qu'une écriture survive à la perte de son propriétaire, elle ne doit être acquittée qu'après être durable sur un réplica — le chemin synchrone échange un peu de latence contre exactement cette garantie. Choisissez par charge de travail, les yeux ouverts.
- La fenêtre de confirmation elle-même. Pendant le délai d'escalade après qu'un propriétaire devient silencieux, les écritures de la partition sont dans les limbes — réessayées par le client, pas encore en train d'atterrir sur un nouveau propriétaire. La correction est préservée (rien n'est promu à tort), mais la disponibilité baisse pendant cette fenêtre. Resserrer le délai échange un basculement plus rapide contre davantage de fausses promotions sous des hoquets transitoires ; c'est un cadran, pas un repas gratuit.
Aucun de ces cas n'est un bug que le chemin de basculement peut concevoir pour l'éliminer — ce sont les véritables compromis entre disponibilité, durabilité et latence, et le travail de Zaris est de faire du choix sûr le défaut et du choix rapide l'explicite, jamais de prétendre que la fenêtre n'existe pas.
La forme de la chose
Réduisez le basculement à l'essentiel et ce sont quatre mouvements au-dessus d'un invariant. L'invariant : la carte reste immobile ; seul le primaire actif du runtime bouge. Les mouvements : confirmer la mort avant d'agir, promouvoir un survivant derrière une époque monotone qui écarte l'ancien propriétaire, prouver la possession pour que le nouveau propriétaire serve la vérité ou se synchronise avant de servir tout court, et rediriger le client pour que sa nouvelle tentative atterrisse sur le nouveau propriétaire. Puis converger les copies et guérir jusqu'à Healthy.
Que cela tienne n'est pas affaire d'argument — c'est mesuré. Le même chemin de promotion, de possession et de convergence s'exécute en continu sous un chaos délibéré de mise à mort de nœuds pendant des heures d'affilée, avec le registre vérifié pour une seule écriture acquittée perdue à la fin. Cette certification est sa propre histoire : Aucune écriture acquittée laissée pour compte.