Aller au contenu principal

Une primitive, de nombreuses garanties : coordination sans course sur le CAS optimiste

· 13 minutes de lecture
Clustron Team
Ingénierie des systèmes distribués

Coordination sans course sur le CAS optimiste de Zaris

Il est tentant de juger un store de données à la longueur de sa liste de commandes. Redis a INCR, SETNX, MULTI/EXEC, WATCH, Lua, SCAN, pub/sub — un verbe pour chaque occasion. Le client .NET de Zaris, en comparaison, paraît presque austère. Il n'y a pas d'incrément atomique côté serveur. Il n'y a pas de transaction multi-clés générale qui exécute une logique arbitraire. Il n'y a pas de scan de préfixe. Le pub/sub natif n'existe qu'à travers le front-end RESP, pas le client .NET typé.

Ce que le client .NET vous donne à la place est une petite primitive de coordination tranchante : le compare-and-swap versionné optimiste. Cet article porte sur la distance que parcourt réellement cette seule primitive — car la réponse honnête est : étonnamment loin. Un limiteur de débit, un portefeuille qui ne devient jamais négatif sous un millier de débits concurrents, une file de jobs où exactement un worker revendique chaque job, et un jeu de règles de feature-flag qui ne perd jamais une mise à jour sous des éditions d'administration concurrentes. Tous, à partir de la même primitive, sans machinerie supplémentaire côté serveur.

La primitive​

Chaque valeur dans Zaris porte une version monotone. Vous ne la définissez pas ; le store la possède et l'incrémente à chaque écriture réussie. La lecture vous donne à la fois la valeur et la version à laquelle elle se trouvait :

KvResult<int> read = await client.GetAsync<int>("counter:api");
int value = read.Value; // the current value
long version = read.Version; // the version that value was written at

Réécrire vous permet d'attacher une précondition sur cette version :

var opts = new PutOptions { IfMatchVersion = version };
KvStatus status = await client.PutAsync("counter:api", value + 1, opts);
// status == KvStatus.Ok -> your write committed; version is now version+1
// status == KvStatus.Conflict -> someone else wrote first; your value was stale

IfMatchVersion signifie committe seulement si la clé est toujours à cette version exacte. Si un autre écrivain s'est committé dans l'intervalle entre votre lecture et votre écriture, la version a bougé, votre précondition échoue, et vous obtenez KvStatus.Conflict au lieu d'écraser silencieusement son changement. Il y a une saveur de plus — IfAbsent = true, qui signifie committe seulement si la clé n'existe pas encore — une opération de création unique que nous utiliserons pour la file de jobs.

C'est toute la primitive. Une lecture qui renvoie une version, et une écriture conditionnelle qui ne se committe que contre une version inchangée.

Pourquoi cela suffit à sérialiser les écrivains​

La raison pour laquelle cela fonctionne est que la version est monotone et possédée par le store. Deux clients peuvent lire la version 7 au même instant. Les deux calculent une nouvelle valeur. Les deux appellent PutAsync avec IfMatchVersion = 7. Le store traite les écritures sur une clé donnée une à la fois, donc l'une d'elles arrive la première — elle correspond, se committe, et la version devient 8. Lorsque la seconde écriture est évaluée, la clé est à 8, pas à 7, donc sa précondition échoue et elle obtient Conflict. Exactement un écrivain gagne ; l'autre se voit dire, précisément, qu'il a perdu. Aucun verrou n'a été pris, aucun écrivain n'a bloqué en attente, et il n'y a aucune fenêtre dans laquelle les deux écritures « réussissent » et l'une est silencieusement perdue.

Le perdant n'échoue pas l'opération — il n'a simplement pas gagné ce tour-ci. Il relit la valeur et la version désormais courantes, recalcule contre l'état frais, et réessaie. Cette boucle est le cœur de chaque schéma ci-dessous :

async Task<T> MutateAsync<T>(string key, Func<T, T> update, int maxAttempts = 50)
{
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
KvResult<T> read = await client.GetAsync<T>(key);
T next = update(read.Value);

var status = await client.PutAsync(
key, next, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return next; // we won this round
// Conflict: someone wrote between our read and write. Loop and retry
// against the value they committed — never against our stale copy.
}
throw new ConcurrencyException($"{key} too contended after {maxAttempts} attempts");
}

Deux choses rendent cela correct plutôt que plein d'espoir. D'abord, la nouvelle tentative relit toujours — vous ne réessayez jamais avec une valeur périmée, donc vous ne pouvez pas ressusciter un état écrasé. Ensuite, update doit être une fonction pure de la valeur que vous avez lue. Si elle calcule read.Value + 1, c'est sûr ; si elle lit l'horloge murale ou un champ mutable capturé de l'extérieur, la nouvelle tentative ne sera pas un rejeu fidèle. Gardez la transformation pure et la boucle est un véritable read-modify-write qui se comporte comme si les écrivains gagnants s'étaient exécutés l'un après l'autre.

Schéma 1 — un limiteur de débit distribué​

Un limiteur de débit est un compteur atomique avec un plafond. Il n'y a pas d'INCR sur le fil, mais la boucle CAS est un incrément atomique : lire le décompte, ajouter un s'il y a de la marge, committer contre la version.

async Task<bool> TryAcquireAsync(string bucket, int limit)
{
for (int attempt = 0; attempt < 50; attempt++)
{
KvResult<int> read = await client.GetAsync<int>(bucket);
int count = read.Found ? read.Value : 0;
if (count >= limit)
return false; // over the limit, no write at all

var status = read.Found
? await client.PutAsync(bucket, count + 1,
new PutOptions { IfMatchVersion = read.Version })
: await client.PutAsync(bucket, 1,
new PutOptions { IfAbsent = true }); // first caller creates it

if (status == KvStatus.Ok)
return true; // we took a slot
// Conflict -> another caller moved the count; re-read and re-check.
}
return false;
}

Pointez un millier d'appelants concurrents vers le même bucket avec limit = 100 et vous obtenez exactement 100 résultats true et 900 false — jamais 101, jamais 99. Les perdants de chaque tour de CAS relisent et découvrent que le plafond a bougé ; dès l'instant où le décompte atteint la limite, chaque appelant suivant court-circuite sans écriture. La branche IfAbsent gère la course de démarrage à froid où plusieurs appelants trouvent le bucket manquant en même temps — seule une création unique gagne, les autres tombent dans le chemin d'incrément normal à leur tentative suivante.

Schéma 2 — un portefeuille qui ne devient jamais négatif​

L'argent est le cas exigeant, car l'invariant est un garde, pas juste un décompte : un débit ne doit jamais faire descendre le solde sous zéro, et deux débits ne doivent jamais réussir tous deux à partir du même solde de départ. C'est exactement l'écrasement que le CAS interdit.

async Task<bool> TryDebitAsync(string account, decimal amount)
{
for (int attempt = 0; attempt < 50; attempt++)
{
KvResult<Wallet> read = await client.GetAsync<Wallet>(account);
Wallet w = read.Value;
if (w.Balance < amount)
return false; // insufficient funds: reject, don't write

var next = w with { Balance = w.Balance - amount };
var status = await client.PutAsync(
account, next, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return true;
// Conflict -> a concurrent debit landed first. Re-read the real balance
// and re-check the guard against it, never against our stale copy.
}
throw new ConcurrencyException($"{account} too contended");
}

Lancez un millier de débits concurrents d'une unité chacun sur un compte détenant 400 et exactement 400 réussissent ; le solde atterrit sur exactement zéro et ne descend jamais en dessous. Le garde tient parce qu'il est toujours réévalué contre le solde committé après un conflit. Un débit qui était valide contre un solde périmé de 5 ne peut pas se committer contre un solde réel de 0 — sa version ne correspond plus, donc il boucle, relit 0, et rejette correctement. C'est l'arête la plus tranchante du schéma : la précondition ne protège pas seulement l'écriture, elle force la règle métier à être vérifiée contre la vérité.

Schéma 3 — une revendication de file de jobs​

Lorsque N workers courent pour traiter le même job, vous avez besoin que exactement un gagne la revendication. C'est un seul basculement CAS sur un champ de statut — ou, dans sa forme la plus simple, une création unique :

async Task<bool> TryClaimAsync(string jobId, string workerId)
{
var claim = new Claim { Owner = workerId, ClaimedAt = DateTimeOffset.UtcNow };
// IfAbsent: the claim key is created by exactly one worker; the rest see Conflict.
var status = await client.PutAsync(
$"job:{jobId}:claim", claim, new PutOptions { IfAbsent = true });
return status == KvStatus.Ok;
}

Chaque worker qui perd obtient Conflict et passe au job suivant — pas de polling, pas de bail de verrou à renouveler sur le chemin heureux, pas d'égalité à départager. Si au lieu de cela vous modélisez le job comme un enregistrement avec un champ Status, la revendication est un basculement gardé par version de Pending vers Claimed à l'aide de la boucle MutateAsync, qui vous permet en plus de re-revendiquer un job dont le propriétaire précédent a planté (le lire, voir qu'il est Claimed mais périmé, le rebasculer) — la même primitive, un champ plus profond.

Schéma 4 — un jeu de règles de feature-flag sans mises à jour perdues​

Deux administrateurs ouvrent la console de feature-flags et éditent le même jeu de règles en même temps. L'administrateur A active un flag ; l'administrateur B ajoute une nouvelle règle de ciblage. Avec un flux naïf « lire le JSON, éditer, réécrire le JSON », celui qui enregistre en second l'emporte et efface silencieusement le changement de l'autre — la mise à jour perdue classique. Le CAS transforme cela en conflit détecté :

async Task SaveRulesetAsync(string flagKey, Func<Ruleset, Ruleset> edit)
{
for (int attempt = 0; attempt < 10; attempt++)
{
KvResult<Ruleset> read = await client.GetAsync<Ruleset>(flagKey);
Ruleset updated = edit(read.Value); // apply this admin's change

var status = await client.PutAsync(
flagKey, updated, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return;
// Conflict -> the other admin saved first. Re-read THEIR version and
// re-apply this admin's edit on top, so neither change is lost.
}
throw new ConcurrencyException($"{flagKey} edited concurrently; please retry");
}

L'enregistrement du second administrateur entre en conflit, relit le jeu de règles y compris le changement du premier administrateur, réapplique sa propre édition par-dessus, et se committe. Les deux changements survivent. La nuance importante : cela fonctionne proprement lorsque les deux éditions touchent des parties différentes du jeu de règles, car la réapplication est composable. Si les deux administrateurs éditent la même règle, le second enregistrement est l'endroit où vous faites remonter une invite « quelqu'un d'autre a changé ceci » à un humain plutôt que de fusionner aveuglément — le conflit est une fonctionnalité, vous donnant l'accroche pour demander.

Le corollaire d'idempotence​

Comme un conflit signifie « réessayer », et qu'une nouvelle tentative signifie « le refaire », vous devez vous assurer que le refaire est sûr. Le débit de portefeuille ci-dessus est le cas d'avertissement : si votre appel réseau échoue de façon ambiguë après que le store s'est committé mais avant que vous ayez reçu le Ok, une nouvelle tentative aveugle pourrait débiter deux fois. Le correctif natif au CAS est une clé d'index propre / de déduplication : enregistrer l'opération sous un identifiant unique avec IfAbsent, et la vérifier avant d'agir.

// Create-once the operation record; if it already exists, this is a retry of
// a debit that already happened — return its recorded outcome, don't repeat it.
var seen = await client.PutAsync(
$"op:{operationId}", new OpMarker(), new PutOptions { IfAbsent = true });
if (seen == KvStatus.Conflict)
return await ReadRecordedOutcomeAsync(operationId); // idempotent replay

La même primitive qui sérialise vos écrivains vous donne aussi l'accroche exactement-une-fois. Vous ne recourez pas à un second mécanisme — IfAbsent est la déduplication.

Où cela s'arrête — et quoi y faire​

Le CAS n'est pas gratuit, et prétendre le contraire serait le baratin marketing que cet article tente d'éviter. Trois limites honnêtes :

Contention sur clé chaude. Chaque schéma ci-dessus canalise des écrivains concurrents à travers une seule clé. Plus il y a d'écrivains, plus il y a de conflits, plus il y a de nouvelles tentatives, et le débit sur cette clé unique se dégrade. Le CAS fait gagner exactement un écrivain par tour par conception — donc une clé sous forte contention d'écriture devient un point de sérialisation. Le correctif est de partitionner la clé : un bucket de limiteur de débit par (client, seconde) au lieu d'un bucket global unique ; un portefeuille scindé en sous-soldes réconciliés périodiquement ; une file de jobs partitionnée pour que les workers se disputent des plages de clés disjointes. Répartissez la contention et les nouvelles tentatives s'évanouissent.

Réécritures O(n) de la valeur entière. Une écriture CAS remplace la valeur entière, pas un champ. Un jeu de règles de feature-flag avec dix mille règles est re-sérialisé et réécrit à chaque édition unique — vous payez pour que l'objet entier change afin de modifier un flag. Pour les petites valeurs c'est un non-problème ; pour les grands agrégats fréquemment mutés c'est un coût réel, et la réponse est de nouveau de décomposer : stocker les parties mutables indépendamment sous des clés séparées pour que chaque édition ne réécrive que ce qui a changé, et réserver l'agrégat sur clé unique aux choses véritablement petites ou rarement écrites.

Pas d'atomicité inter-clés. Le CAS est atomique sur une clé. Si un invariant couvre deux clés — transférer de l'argent du compte A vers le compte B — un seul CAS ne peut pas couvrir les deux. Vous modélisez soit le transfert comme une machine à états sur une clé (un enregistrement de « transfert » que les deux côtés observent), soit acceptez d'avoir besoin d'une saga avec compensation. Ne recourez pas au CAS pour feindre une transaction à deux clés ; c'est là qu'il n'atteint véritablement pas.

Une primitive, qui a mérité sa place​

La courte liste de commandes s'avère ne pas être une pauvreté. Une version monotone possédée par le store plus une écriture conditionnelle gardée par version suffisent à exprimer un compteur atomique, un décrément gardé, une revendication à un seul gagnant et une édition sans mise à jour perdue — quatre problèmes nommés des systèmes distribués, une primitive, aucun verrou détenu et aucun écrivain bloqué. Ce à quoi vous renoncez est la commodité d'un verbe dédié pour chacun ; ce que vous obtenez est un mécanisme unique dont vous pouvez raisonner l'exactitude une fois puis faire confiance partout, plus l'accroche de déduplication pour l'idempotence qui vient gratuitement avec.

La discipline qu'elle vous demande est petite et vaut la peine d'être intériorisée : gardez la transformation pure, relisez toujours en cas de conflit, vérifiez votre invariant contre la valeur committée, et partitionnez avant qu'une clé ne devienne chaude. Faites cela, et une primitive porte une quantité remarquable de coordination.