Tout ou rien : les transactions multi-clés dans Zaris
Un article compagnon, Coordination sans course sur le CAS optimiste, a défendu l'idée qu'une primitive unique — une version possédée par le store plus une écriture conditionnelle gardée par la version — porte une quantité remarquable de coordination. Il a aussi tracé une ligne nette : le CAS est atomique sur une clé. Transférez de l'argent du compte A vers le compte B et un seul compare-and-swap ne peut pas couvrir les deux ; le conseil honnête était de modéliser le transfert comme une machine à états sur une clé, ou de recourir à une saga.
Cette ligne est réelle, mais ce n'est pas toute l'histoire. Zaris dispose d'une véritable transaction multi-clés — BeginTransactionAsync, un ensemble de lecture/écriture mis en attente, CommitAsync — et elle commite à travers les clés et à travers les nœuds avec une garantie du tout ou rien. Cet article porte sur ce qu'est réellement cette transaction sous le capot (un commit à deux phases coordonné par le client sur des versions optimistes), précisément ce qu'elle garantit, et la seule subtilité qui décide si votre invariant inter-clés tient réellement.
La forme de l'API
Une transaction est un contexte que vous ouvrez, dans lequel vous mettez du travail en attente, et que vous commitez. Elle est IAsyncDisposable, donc await using garantit qu'elle est libérée — et une transaction non committée qui sort de portée est annulée (rollback).
await using var tx = await client.BeginTransactionAsync();
KvResult<int> a = await tx.GetAsync<int>("account:A");
KvResult<int> b = await tx.GetAsync<int>("account:B");
await tx.PutAsync("account:A", a.Value - 50);
await tx.PutAsync("account:B", b.Value + 50);
TransactionResult result = await tx.CommitAsync();
// result.IsSuccess, result.Status (Committed | Aborted | Conflict | Timeout | InternalError)
Deux comportements comptent dès la première ligne. Les lectures à l'intérieur de la transaction enregistrent la version de chaque clé qu'elles touchent. Les écritures sont mises en attente — tamponnées sur le client, invisibles au store et à tout autre client jusqu'à ce que le commit réussisse. Vos propres lectures ultérieures dans la même transaction voient vos écritures en attente (lecture de vos écritures) : écrivez une clé et relisez-la et vous obtenez ce que vous avez mis en attente ; supprimez une clé et relisez-la et vous obtenez NotFound — tout cela avant le commit.
CommitAsync est la seule chose qui touche le store, et elle applique soit toutes les opérations en attente, soit aucune. RollbackAsync (ou simplement la libération) écarte l'ensemble complet.
Ce qui se passe réellement au commit
La partie intéressante est CommitAsync, car c'est là que le « tout ou rien à travers les nœuds » se gagne. Les clés de votre ensemble d'écriture peuvent être possédées par différentes partitions sur différents nœuds, donc Zaris exécute un commit à deux phases avec le client comme coordinateur :
- Grouper les écritures en attente par le nœud qui possède chaque clé.
- Préparer — envoyer à chaque nœud sa tranche sous forme de lot conditionnel. Pour chaque clé que vous avez aussi lue, le lot porte la version à laquelle vous l'avez lue. Chaque nœud vérifie que la clé est toujours à cette version et prend un verrou de courte durée dessus. Si la version d'une clé a bougé, ou si une autre transaction détient déjà son verrou, ce nœud vote non.
- Commiter ou annuler — si chaque nœud a voté oui, le coordinateur leur dit à tous d'appliquer leurs écritures et de libérer leurs verrous. Si un nœud a voté non, le coordinateur annule les nœuds qui s'étaient déjà préparés, et le commit renvoie
TransactionStatus.Conflict.
Si le nœud B avait voté non — parce que quelqu'un a fait avancer account:B entre votre lecture et votre préparation — le coordinateur aurait envoyé au nœud A une annulation, le verrou de A se serait libéré sans écriture appliquée, et vous auriez obtenu un Conflict. Aucun des deux comptes ne change. C'est la garantie : les deux clés ne divergent jamais, même si elles vivent sur des machines différentes.
Les garanties, précisément
Écartez le marketing et voici ce que la transaction vous donne, et rien qu'elle ne donne pas :
- Atomicité à travers les clés et les nœuds. Chaque put et delete en attente s'applique ensemble ou pas du tout. Un commit qui renvoie
Conflictn'a rien écrit — il n'y a aucun état partiel à nettoyer. - Concurrence optimiste, vérifiée à la préparation. Aucune clé n'est verrouillée pendant que vous réfléchissez. La vérification de version se produit au moment du commit ; un écrivain concurrent qui a déplacé une clé dont vous dépendez se transforme en conflit détecté, jamais en écrasement silencieux.
- Isolation sérialisable pendant la fenêtre de commit. La préparation prend des verrous par clé qui sont détenus jusqu'au commit ou à l'annulation, de sorte que deux transactions touchant une clé commune ne peuvent pas toutes deux se préparer — la seconde se voit dire que la clé est verrouillée et vote non.
IsolationLevel.Serializableest le seul niveau aujourd'hui ; l'énumération est délibérément un ensemble à un seul élément, pas une aspiration avec des modes plus faibles cachés. - Écritures en attente avec lecture de vos écritures. Rien de ce que vous mettez en attente n'est observable en dehors de la transaction jusqu'à ce que le commit réussisse, et tout ce que vous mettez en attente est observable pour vous immédiatement.
Un conflit n'est pas une erreur — c'est la protection qui fait son travail. Traitez un commit échoué comme l'article sur le CAS traite une écriture conditionnelle échouée : rouvrez la transaction, relisez, recalculez contre l'état courant, et réessayez, avec un nombre de tentatives borné pour qu'une clé chaude ne puisse pas boucler indéfiniment.
Le seul cas limite que la plupart des gens manquent
Voici la subtilité qui décide si votre invariant tient réellement, et il vaut la peine de le dire clairement car c'est facile à rater : la vérification de version repose sur les clés que vous écrivez, pas sur les clés que vous vous contentez de lire.
Lorsque vous lisez et écrivez une clé — l'exemple du transfert d'argent lit account:A et écrit account:A — la version que vous avez lue est transportée dans la préparation et validée. Bien. Mais si votre transaction lit une clé et fonde une décision dessus sans écrire cette clé, la clé ne fait pas partie de la préparation, donc sa version n'est jamais revalidée. Elle peut changer sous vos pieds entre votre lecture et votre commit, et le commit réussira quand même.
await using var tx = await client.BeginTransactionAsync();
// Decision input: we only READ this.
var limit = await tx.GetAsync<int>("policy:max-transfer");
// We WRITE these. Only these are version-checked at commit.
var from = await tx.GetAsync<int>("account:A");
if (from.Value <= limit.Value)
await tx.PutAsync("account:A", from.Value - limit.Value);
await tx.CommitAsync();
// If an admin lowered policy:max-transfer after our read, we did NOT catch it:
// that key was read but never written, so its version wasn't part of prepare.
Le correctif est mécanique : incluez dans l'ensemble d'écriture chaque clé dont dépend l'exactitude de votre commit. Si une clé est une pure entrée de décision, mettez en attente une écriture qui ré-écrit sa valeur courante inchangée — cela épingle sa version dans la préparation, et tout changement concurrent sur elle se transforme désormais en conflit. Le guide des transactions livré formule le chemin heureux ainsi : « lisez les valeurs dont vous avez besoin avant d'écrire » — voici pourquoi ce conseil fonctionne, et quoi faire quand une valeur dont vous avez besoin est une valeur que vous n'alliez pas écrire par ailleurs.
Transaction, CAS ou lot ?
Zaris vous donne trois façons de toucher plus que vous ne pourriez avec un simple PutAsync, et elles ne sont pas interchangeables. Optez pour la plus petite qui tient votre invariant :
| CAS mono-clé | Lot (ExecuteBatchAsync) | Transaction | |
|---|---|---|---|
| Portée | Une clé | Plusieurs clés | Plusieurs clés |
| Atomique ? | Oui, une clé | Non — résultats par élément, succès partiel | Oui, tout ou rien |
| Détection de conflit | IfMatchVersion | Aucune | Vérification de version à la préparation |
| Verrous détenus | Aucun | Aucun | Par clé, préparation → commit |
| Coût | Un aller-retour, sans verrou | Un aller-retour par nœud propriétaire | Allers-retours préparation + commit, 2PC |
| À utiliser quand | La mise à jour d'une seule clé est autonome | Vous voulez moins d'allers-retours et le succès partiel convient | Un invariant couvre réellement plusieurs clés |
Le lot est celui qu'il est facile de mal interpréter. ExecuteBatchAsync groupe des opérations GET/PUT/DELETE mixtes en un aller-retour par nœud propriétaire et renvoie un résultat par élément, dans l'ordre. C'est un outil de débit, pas un outil de cohérence : les éléments réussissent ou échouent indépendamment, et un lot peut atterrir à moitié appliqué. Si vous avez besoin que les opérations tiennent ou tombent ensemble, c'est une transaction, pas un lot.
Et si une seule clé couvre votre invariant, préférez le simple CAS — il ne détient aucun verrou, ne bloque personne, et coûte un aller-retour. Une transaction est le bon outil précisément quand, et seulement quand, l'invariant que vous protégez ne peut pas être comprimé sur une seule clé.
Où cela s'arrête — et quoi y faire
La transaction est réelle, mais c'est un commit à deux phases coordonné par le client, et le 2PC a des coûts bien connus qu'il vaut la peine d'énoncer honnêtement :
Le coordinateur est votre processus. La préparation prend des verrous sur chaque nœud participant et les détient jusqu'à ce que le coordinateur dise commit ou rollback. Cette fenêtre doit être courte : faites votre réflexion et vos lectures, puis committez promptement. Gardez le travail long ou sans rapport hors de la transaction, et appuyez-vous sur await using pour qu'une transaction abandonnée s'annule plutôt que de laisser du travail préparé en suspens. TransactionOptions.Timeout vous permet de borner la durée de vie d'une transaction.
C'est un protocole bloquant. Deux transactions se disputant la même clé se sérialisent à travers son verrou de préparation — la seconde vote non et réessaie. Comme avec le CAS, le remède à la contention est de ne pas tout canaliser à travers une seule clé : partitionnez, et gardez l'ensemble de clés de toute transaction petit et disjoint de ce qui est chaud par ailleurs.
Elle compose des lectures et écritures déclarées, pas une logique arbitraire côté serveur. Le branchement — si le solde est suffisant, débiter — s'exécute dans votre client, exactement comme avec le CAS ; le serveur exécute un ensemble de lecture/écriture déclaré, gardé par des versions. Il n'y a pas de procédure stockée qui exécute votre logique de façon atomique sur le nœud. Pour la plupart des invariants inter-clés, c'est tout ce dont vous avez besoin. Pour un workflow qui couvre des services, des appels externes, ou des minutes de temps réel, un commit à deux phases est la mauvaise forme — c'est encore le territoire des sagas et de la compensation, et rendre les étapes idempotentes (l'astuce de déduplication IfAbsent de l'article sur le CAS fonctionne telle quelle) compte plus que n'importe quel commit atomique unique.
Choisissez le plus petit outil qui tient l'invariant
L'article sur le CAS a soutenu qu'une primitive tranchante va loin, et c'est le cas. La transaction est ce que vous ajoutez quand un invariant refuse véritablement de tenir sur une seule clé — et c'est un vrai commit multi-clés, multi-nœuds, du tout ou rien, pas un vernis. La discipline qu'elle demande est petite et familière : gardez-la courte, réessayez en cas de conflit, partitionnez avant qu'une clé ne devienne chaude, et — la part spécifique aux transactions — assurez-vous que chaque clé sur laquelle repose votre exactitude est dans l'ensemble d'écriture, pas seulement dans l'ensemble de lecture. Faites cela correctement et « transférer de l'argent de A vers B » cesse d'être l'exemple d'avertissement pour devenir quatre lignes de code honnêtes.