Aller au contenu principal

Quelle primitive, quel problème : guide de terrain du modèle de données Zaris

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

Choisir la bonne primitive Zaris pour le problème

L'essentiel de ce blog est allé en profondeur sur une chose à la fois — comparaison-et-échange, transactions multi-clés, structures de données natives, streams, watch, le journal d'opérations en dessous. Chaque article répond à « comment ceci fonctionne ». Celui-ci répond à une question différente, plus pratique : quand vous avez un problème devant vous, lequel choisissez-vous ?

Cette question compte parce que le mauvais choix est rarement un plantage. C'est un système qui marche dans la démo puis perd une mise à jour sous charge, ou qui canalise un millier d'écrivains à travers une seule clé, ou qui utilise une notification tire-et-oublie là où il lui fallait un journal durable. Les primitives ne vous empêchent pas de les mal utiliser. Alors voici un guide de terrain : le modèle mental qui les relie, une table de décision, une descente de chaque branche avec son adéquation honnête et sa limite honnête, et un organigramme à la fin.

Un cœur, plusieurs vues​

Commencez par la chose sur laquelle tout le reste est bâti. Sous chaque fonctionnalité de cet article, Zaris est un store clé-valeur partitionné et répliqué, et chaque valeur porte une version monotone appartenant au store. C'est tout. C'est le cœur.

Tout le reste est l'une de deux choses :

  • Une vue typée, côté serveur, sur les clés — un hash, une liste, un set, un sorted set, un stream. Ce ne sont pas des blobs que vous récupérez et mutez dans votre processus ; ce sont de vraies collections côté serveur qui vivent sur la partition qui possède leur clé, et elles se répliquent exactement comme une valeur nue le fait.
  • Une couche de coordination au-dessus de la version — comparaison-et-échange, transactions multi-clés, verrous, watch. Celles-ci utilisent la version (et la mécanique de propriété autour d'elle) pour laisser de nombreux clients s'accorder sur ce qui s'est passé sans se marcher dessus.

Gardez cette image et toute la surface cesse de ressembler à un fourre-tout de fonctionnalités et commence à ressembler à ce qu'elle est : un petit cœur, et un ensemble d'outils conçus à dessein qui résolvent chacun une classe spécifique de problème par-dessus. Choisissez par la forme de votre problème, pas par le nom qui sonne le plus proche.

La table de décision​

Votre problèmeChoisissezPourquoiAttention à
Ranger des octets que d'autres nœuds peuvent relireGetAsync/PutAsync nus, ou une intégration de cacheLe cœur, rien de plusPas d'expiration à l'écriture — le TTL est un second appel (ci-dessous)
Une valeur unique doit changer en toute sûreté sous concurrenceComparaison-et-échangeUn seul gagnant par tour, aucune mise à jour perdue, aucun verrou tenuUne clé unique chaude devient un point de sérialisation — fragmentez-la
Un invariant s'étend sur deux clés ou plusTransaction multi-clésTout-ou-rien à travers les clés et les nœudsPlus lourde que le CAS ; c'est un 2PC, pas gratuit
La donnée est une collection — map, liste, appartenance, classementStructures de données nativesOpérations par élément côté serveur, pas de réécriture de la valeur entièreChoisissez la collection qui correspond au motif d'accès
Un journal d'événements ordonné et rejouable avec des consommateurs au moins une foisStreamsJournal durable, groupes de consommateurs, en attente + acquittement par consommateurC'est un journal, pas une file avec priorités
Réagir aux changements au fur et à mesureWatch (natif) / notifications d'espace de clés (clients Redis)Répliqué, à l'échelle du cluster, survit au basculementLe pub/sub tire-et-oublie n'est pas un flux durable
Exclusion mutuelle grossière autour d'une ressourceVerrou (fondé sur un bail)Une barrière nommée, bornée par un TTL, qui se libère automatiquement au plantageUn verrou à bail n'est pas une barrière de correction — voir ci-dessous

Le reste de cet article est cette table, ralentie.

KV et cache : quand une valeur n'est qu'une valeur​

Si tout ce dont vous avez besoin est de mettre des octets quelque part où chaque nœud peut les lire — un fragment rendu, un DTO sérialisé, un instantané de config — vous n'avez pas besoin d'une couche de coordination. PutAsync(key, value) et GetAsync<T>(key) sont toute l'histoire, et côté serveur ces octets sont partitionnés et répliqués pour vous.

Deux notes honnêtes. D'abord, l'expiration n'est pas définie à l'écriture. Il n'y a pas de champ TTL sur les options d'écriture ; vous définissez une durée de vie avec un appel séparé — ExpireAsync(key, ttl) — et vous l'effacez avec PersistAsync(key), ou vous lisez ce qu'il reste avec GetTimeToLiveAsync(key). Ensuite, si votre cas d'usage est littéralement « un cache distribué pour mon application ASP.NET Core », ne le fabriquez pas à la main : enregistrez l'un des paquets de cache prêts à l'emploi et le IDistributedCache du framework (ou HybridCache, ou le store de session) est adossé à votre cluster avec un changement de démarrage d'une ligne et aucun code touchant l'abstraction de cache.

Choisissez le KV nu quand la valeur n'a pas d'invariant de concurrence — le dernier écrivain qui gagne convient, ou il n'y a qu'un seul écrivain. Ne le faites pas dès l'instant où deux écrivains peuvent entrer en course sur la même clé et où vous seriez gêné si l'un écrasait silencieusement l'autre. C'est le travail de l'outil suivant.

Comparaison-et-échange : quand une valeur doit rester correcte sous concurrence​

Un compteur avec un plafond, un solde qui ne peut pas devenir négatif, un objet de config que deux administrateurs modifient en même temps. La défaillance que vous cherchez à éviter est la mise à jour perdue — lecture, lecture, écriture, écriture, et la seconde écriture efface la première. Le CAS transforme cette perte silencieuse en un conflit détecté et réessayable : lisez une valeur et sa version, réécrivez seulement si la version n'a pas bougé, et obtenez un Conflict plutôt qu'un écrasement si quelqu'un vous a devancé.

KvResult<int> read = await client.GetAsync<int>("counter:api");
var put = await client.PutAsync(
"counter:api", read.Value + 1,
new PutOptions { IfMatchVersion = read.Version });

// put.Status == KvStatus.Success -> you won this round
// put.Status == KvStatus.Conflict -> someone wrote first; re-read and retry

C'est tout le mécanisme, et le guide de terrain du CAS montre jusqu'où il va — un limiteur de débit, un portefeuille qui ne se met jamais à découvert sous un millier de débits concurrents, une revendication de tâche à un seul gagnant (via « créer seulement si absent »), une config sans mise à jour perdue, et le crochet d'idempotence qui vient gratuitement avec.

Choisissez le CAS quand l'invariant vit sur une clé et que vous voulez la correction sans tenir de verrou. Ne pointez pas une foule d'écrivains vers une unique clé chaude en espérant que ça passe à l'échelle — le CAS laisse délibérément exactement un écrivain gagner par tour, de sorte qu'une clé chaude devient un point de sérialisation. Fragmentez-la (un seau par client-seconde, un solde scindé en sous-soldes réconciliés) et la contention fond.

Transactions multi-clés : quand l'invariant s'étend sur plusieurs clés​

La ligne dure que trace le CAS est qu'il est atomique sur une clé. Transférer de l'argent du compte A au compte B, ou maintenir un index secondaire au pas avec l'enregistrement qu'il pointe, et aucune comparaison-et-échange unique ne couvre les deux. Ce n'est pas une raison de faire semblant avec deux écritures CAS en espérant — c'est le cas exact pour lequel la transaction multi-clés existe.

Vous ouvrez une transaction, y préparez des lectures et des écritures, et validez. Sous le capot, c'est une validation en deux phases coordonnée par le client sur des versions optimistes : les clés peuvent vivre sur différents nœuds, donc le client groupe l'ensemble d'écriture par propriétaire, chaque nœud vérifie que chaque clé que vous avez lue est encore à la version à laquelle vous l'avez lue et prend un verrou à courte durée de vie, et ce n'est que si tout le monde vote oui que l'ensemble complet s'applique. La moindre version qui a bougé, et la validation renvoie Conflict sans rien changer.

Choisissez une transaction quand deux clés ou plus doivent bouger ensemble ou pas du tout. Ne l'utilisez pas comme votre chemin d'écriture par défaut — c'est un vrai 2PC avec des allers-retours prepare/commit et des verrous à courte durée de vie, plus lourd qu'un seul CAS. Si l'invariant tient honnêtement sur une clé, gardez-le sur une clé.

Structures de données natives : quand la donnée est une collection​

Parfois la valeur n'est pas un scalaire que vous protégez — c'est une forme. Un profil utilisateur est une map de champs. Un flux d'activité récente est une liste. Une appartenance d'étiquette est un set. Un classement est un ranking. Vous pourriez sérialiser chacun comme un seul blob et le CAS, mais alors chaque modification d'un seul champ réécrit l'objet entier, et vous avez transformé une mise à jour en O(1) en une mise à jour en O(n).

Zaris leur donne leurs propres types de collection natifs, côté serveur — hashes, listes, sets, sorted sets — où une opération sur un seul champ ou un seul élément ne touche que cet élément sur le serveur, pas la valeur entière dans votre processus. Ils partitionnent et se répliquent comme n'importe quelle clé, et parce que la façade RESP fait correspondre les mêmes commandes Redis aux mêmes collections côté serveur, un service .NET et un service Python utilisant HSET touchent le hash identique.

Choisissez une collection quand votre motif d'accès est par élément — définir un champ, pousser une entrée, tester une appartenance, lire le top N par score. Ne choisissez pas un sorted set quand ce dont vous avez réellement besoin est un journal d'événements ordonné, acquitté et rejouable. Ça, c'est un stream.

Streams : quand vous avez besoin d'un journal ordonné et rejouable​

Une file et un journal se ressemblent jusqu'à ce qu'un consommateur plante. Les streams Zaris sont un vrai journal en ajout-seulement : les entrées obtiennent des identifiants monotones, vous pouvez lire des plages, et un groupe de consommateurs divise le journal sur un pool de travailleurs, suit les entrées en attente (livrées-mais-non-acquittées) de chaque travailleur, et laisse un autre travailleur récupérer ce qu'un travailleur planté n'a jamais acquitté. Cette mécanique d'attente-et-acquittement est ce qui vous achète une livraison au moins une fois — la chose qu'une notification tire-et-oublie ne peut pas donner.

Choisissez un stream quand vous avez besoin d'événements durables, ordonnés et rejouables avec un acquittement explicite et un partage du travail — ingestion de commandes, une boîte d'envoi, une piste d'audit qu'une projection reconstruit. Ne l'utilisez pas comme une file à priorités ou un canal requête/réponse ; c'est un journal, et ses forces sont l'ordre et le rejeu, pas le routage.

Watch, notifications et la famille de diffusion : quand vous réagissez au changement​

Il existe plusieurs façons d'apprendre que quelque chose a changé, et elles ne sont pas interchangeables — la différence est de savoir si le signal est durable.

Celle à choisir depuis .NET est Watch : un flux de changements natif, à l'échelle du cluster. Vous vous abonnez à une clé ou un préfixe, y intégrez éventuellement un instantané initial pour qu'il n'y ait pas de trou entre « lire l'état courant » et « s'abonner aux changements », et recevez les événements au fur et à mesure que les écritures atterrissent :

var (subscription, initial) = await client.Watch.WatchKeyAsync(
"job:42",
new WatchOptions { IncludeInitialSnapshot = true },
evt => Handle(evt));

La propriété importante est que le watch est enregistré à l'échelle du cluster et survit au basculement — il n'est pas lié à l'unique nœud qui a justement traité une écriture. C'est aussi pourquoi les notifications d'espace de clés Redis que Zaris parle (__keyevent@0__ / __keyspace@0__, un simple PSUBSCRIBE depuis n'importe quel client Redis) valent davantage ici que sur une machine Redis unique : ce sont un pont mince vers ce même watch répliqué, de sorte qu'un basculement ne laisse pas tomber silencieusement le flux d'événements.

Choisissez watch/notifications d'espace de clés quand vous voulez réagir à des changements d'état — invalider un cache, réveiller un travailleur, rafraîchir une projection. Ne confondez pas la simple diffusion pub/sub au mieux avec un flux durable : si vous avez besoin de chaque événement même à travers plantages et redémarrages, c'est un stream, pas une notification.

Verrous : exclusion mutuelle grossière, avec un avertissement tranchant​

Zaris dispose bien d'un verrou distribué de première classe — une barrière nommée, fondée sur un bail avec un TTL, acquise depuis le client et libérée à la libération ou à l'expiration par plantage :

await using var gate = await client.Locks.AcquireAsync("order:123", TimeSpan.FromSeconds(10));
if (gate is null)
{
// someone else holds it — back off, do other work, or retry later
return;
}
// critical section; the lease auto-expires if we crash, and can be renewed

Il est véritablement utile pour l'exclusion mutuelle grossière — empêcher deux planificateurs d'exécuter le même balayage, sérialiser une tâche de maintenance — et le TTL signifie qu'un détenteur planté ne bloque pas le verrou pour toujours.

Mais voici l'avertissement que la version léchée omet, et c'est la phrase la plus importante de cet article sur les verrous : un verrou fondé sur un bail n'est pas une barrière de correction. Si votre détenteur fait une pause — un blocage GC, un appel système lent — assez longtemps pour que le bail expire, un second détenteur peut acquérir le « même » verrou tandis que le premier croit encore le détenir, et voilà deux travailleurs dans la section critique à la fois. Pour la vivacité et l'efficacité (d'ordinaire un seul travailleur exécute la tâche, la contention est faible) un verrou à bail est exactement bon. Pour un invariant de correction qui ne doit jamais être violé, ne comptez pas sur le verrou pour l'imposer — imposez-le sur la donnée avec la comparaison-et-échange ou une transaction, de sorte que même un second acteur parasite soit rejeté par une vérification de version à l'écriture.

Choisissez un verrou quand vous voulez une exclusion mutuelle grossière, au mieux, et qu'un chevauchement occasionnel est simplement gaspilleur, pas incorrect. Choisissez plutôt le CAS quand un chevauchement corromprait les données — laissez la version, pas le verrou, être la chose qui dit non.

La partie transversale : propriété, durabilité, cohérence​

Trois propriétés tiennent à travers chaque primitive ci-dessus, parce que ce sont des propriétés du cœur, pas d'un outil en particulier :

  • Le propriétaire bouge, et vous n'avez pas à le remarquer. Chaque clé est possédée par une partition qui peut basculer, migrer ou être rendue. Le client .NET transforme ce changement en un signal typé et réessayable et redirige vers le nouveau propriétaire, borné par un budget de tentatives et une échéance en temps réel — de sorte qu'un PutAsync pendant un basculement prend d'ordinaire simplement quelques millisecondes de plus et renvoie un succès.
  • La durabilité est par réplication, pas par disque. Zaris garde les données en mémoire et durables en gardant des copies sur d'autres nœuds. C'est pourquoi le facteur de réplication n'est pas la même chose que la tolérance aux pannes : le nombre ne vous protège que si la topologie place réellement une seconde copie sur un nœud qui peut survivre à la perte du premier. Toute l'épine dorsale de propriété et de récupération existe pour s'assurer qu'aucune écriture acquittée n'est perdue pendant que la propriété est en mouvement.
  • Les écritures et les lectures routent vers le propriétaire courant. La mécanique de nouvelle tentative et de redirection fait que vos opérations trouvent le nœud qui possède actuellement la clé, de sorte qu'une lecture suit une écriture validée plutôt que de devancer le changement de propriété qui vient de se produire.

Aucune de celles-ci n'est une primitive que vous choisissez. Ce sont le socle sur lequel chaque choix repose.

L'organigramme​

La version courte​

La longueur de la liste de fonctionnalités n'a jamais été le propos. Zaris est un cœur clé-valeur versionné, partitionné et répliqué, plus des vues typées côté serveur (hash, liste, set, sorted set, stream) et des couches de coordination au-dessus de la version (CAS, transactions, verrous, watch). Faites correspondre l'outil à la forme du problème : un scalaire sous contention veut le CAS ; un invariant inter-clés veut une transaction ; une donnée qui est une collection veut la collection correspondante ; des événements ordonnés et durables veulent un stream ; réagir au changement veut watch ; l'exclusion mutuelle grossière veut un verrou — et tout ce qui ne doit jamais être violé veut sa garantie imposée sur la donnée, avec une vérification de version, pas sur un verrou qui peut expirer.

Choisissez par la forme, appuyez-vous sur la version pour la correction, et le petit cœur porte une quantité de système remarquable.