Aller au contenu principal

Beaucoup de clés, peu d'allers-retours : comment Zaris éventaille une opération en masse

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

Éventailler une opération en masse vers ses nœuds propriétaires

Demandez une clé à un store distribué et le coût est dominé par un seul nombre : l'aller-retour vers le nœud qui la possède. La recherche elle-même est de l'ordre de la nanoseconde ; le réseau est de l'ordre de la microseconde. Demandez maintenant un millier de clés. Si vous bouclez et appelez Get un millier de fois, vous payez cet aller-retour un millier de fois, en série, et le store passe presque tout le temps d'horloge à rester inactif en attendant des paquets. Les clés pourraient être réparties sur quatre nœuds qui auraient pu répondre en parallèle — mais une boucle naïve ne leur en donne jamais la chance.

Zaris a trois API en masse — GetManyAsync, PutManyAsync, DeleteManyAsync — et un ExecuteBatchAsync mixte en dessous, et toute leur raison d'exister est de transformer « un millier de clés » en « une requête par nœud propriétaire, émise en parallèle ». Cet article est le chemin de l'éventail de bout en bout : comment le client regroupe les clés par propriétaire, pourquoi les quatre API se réduisent à un seul moteur, comment un champ Index garde les réponses dans l'ordre à travers deux couches de concurrence, et pourquoi — contrairement à une transaction multi-clés — une opération en masse est par item et le succès partiel est normal.

Le modèle de coût : des allers-retours, pas des opérations​

Ce contre quoi une API en masse se bat n'est pas le CPU. Un GET à clé unique sur un nœud propriétaire est une recherche de dictionnaire — le plancher de latence est d'une microseconde de travail réel dans dix de plomberie. La partie coûteuse est la plomberie : la requête doit traverser le réseau vers le propriétaire et la réponse doit revenir. Appelez ce temps d'aller-retour R, typiquement des dizaines à des centaines de microsecondes sur un réseau de datacenter, des ordres de grandeur de plus que la recherche.

Une boucle en série de N clés coûte environ N × R, car chaque appel attend que le paquet du précédent revienne avant d'envoyer le suivant. C'est le piège. La solution n'est pas une recherche plus rapide — c'est de cesser de payer R par clé. Si les N clés vivent sur M nœuds propriétaires, le plancher est R payé M fois en concurrence, c'est-à-dire environ un R au total quel que soit le nombre de clés que chaque nœud détient. Pour un millier de clés sur quatre nœuds, c'est la différence entre un millier d'allers-retours en série et en pratique un seul. Toute la conception ci-dessous existe pour réduire N × R à ~R.

Un seul moteur : tout est un batch d'items​

La première chose surprenante dans le client de cluster est que GetManyAsync, PutManyAsync et DeleteManyAsync ne sont pas trois implémentations distinctes. Chacune est une fine coquille qui transforme ses arguments en une liste de KvBatchItem et les remet à l'unique méthode qui fait le vrai travail, ExecuteBatchAsync. Voici GetManyAsync faisant exactement cela :

var list = keys.Select((k, i) => new KvBatchItem
{
Index = i, // caller's position — the correlation key
Op = KvBatchOp.Get,
Key = k,
GetOptions = options?.ToGetOptions()
}).ToList();

var resp = await ExecuteBatchAsync<T>(
new KvBatchRequest
{
Items = list,
DefaultConsistency = options?.Consistency ?? Defaults.DefaultConsistency
}, ct).ConfigureAwait(false);

PutManyAsync et DeleteManyAsync font de même avec KvBatchOp.Put et KvBatchOp.Delete. Donc la distinction que les exemples tracent entre Bulk (un seul type d'opération à la fois) et Batch (un mélange de PUT / GET / DELETE en un appel) est une commodité de surface : les helpers en masse ne sont qu'un batch où chaque item partage par hasard une op. Il y a exactement un moteur d'éventail, et il est agnostique à l'op.

Remarquez Index = i. Chaque item est estampillé de sa position dans la liste de l'appelant avant que quoi que ce soit ne soit groupé, réordonné ou envoyé. Cet estampillage est ce qui survit aux deux sauts concurrents à venir et permet de remettre la réponse finale dans l'ordre. Gardez-le en tête.

L'éventail : regrouper par propriétaire, une requête chacun​

Voici le cœur de tout l'article — le dispatch de batch du client de cluster, pour l'essentiel en entier :

var table   = _routing;
var buckets = new Dictionary<ZarisRemoteClient, List<KvBatchItem>>();

foreach (var item in batch.Items)
{
var seg = SegmentMapper.ComputeSegmentId(item.Key, table.TotalSegments);
var client = GetClientForSegment(seg, table);

if (!buckets.TryGetValue(client, out var list))
buckets[client] = list = new List<KvBatchItem>();

list.Add(item);
}

var tasks = buckets.Select(kv =>
kv.Key.ExecuteBatchAsync<T>(new KvBatchRequest
{
Items = kv.Value,
DefaultConsistency = batch.DefaultConsistency
}, ct));

var perOwner = await Task.WhenAll(tasks).ConfigureAwait(false);

return new KvBatchResponse
{
Results = perOwner.SelectMany(r => r.Results)
.OrderBy(r => r.Index)
.ToList()
};

Lisez-le comme quatre manœuvres :

  1. Résoudre chaque clé vers son propriétaire, localement. SegmentMapper.ComputeSegmentId est la même fonction de placement qu'utilise le chemin à clé unique — CRC16 sur le hash-tag, modulo le nombre de segments — et GetClientForSegment mappe ce segment à la connexion déjà ouverte vers son nœud propriétaire. Pas de proxy, pas de coordinateur : le client calcule la possession lui-même, exactement comme décrit dans un saut vers le propriétaire. La différence est qu'ici il le fait pour chaque clé de l'ensemble d'un coup.

  2. Regrouper. Les clés possédées par le même nœud s'empilent dans la liste de ce nœud. Un millier de clés réparties sur quatre propriétaires deviennent quatre listes. C'est là que N opérations deviennent M requêtes.

  3. Dispatcher en concurrence. buckets.Select(...) construit un appel ExecuteBatchAsync par propriétaire et Task.WhenAll les laisse tous s'envoler d'un coup. Quatre nœuds répondent en parallèle ; le temps d'horloge est la réponse du nœud le plus lent, pas la somme.

  4. Recombiner dans l'ordre. Chaque propriétaire renvoie sa tranche de résultats ; SelectMany les aplatit et OrderBy(r => r.Index) restaure l'ordre original de l'appelant. L'appelant récupère une liste dont la position i est la réponse pour la clé i, même si les clés ont voyagé vers des nœuds différents et sont revenues dans l'ordre où le réseau les a livrées.

C'est la revendication d'amortissement complète rendue concrète : le nombre d'allers-retours égale le nombre de nœuds propriétaires distincts dans l'ensemble de clés, pas le nombre de clés.

À l'intérieur d'un propriétaire : concurrent à nouveau, ordonné à nouveau​

Chaque sous-batch par propriétaire atterrit sur le BatchCommandHandler de ce nœud. Il n'exécute pas non plus les items l'un après l'autre — il les exécute tous d'un coup puis retrie :

var items = batchReq.Request.Items.Cast<KvBatchItemWire>().ToArray();

// Execute every item in the sub-batch concurrently.
var tasks = items.Select(item => ExecuteItemAsync(item)).ToArray();
var results = await Task.WhenAll(tasks).ConfigureAwait(false);

return new ZarisResponse
{
Payload = new BatchResponsePayload
{
Response = new KvBatchResponse
{
Results = results.OrderBy(r => r.Index).ToList()
}
}
};

ExecuteItemAsync est un petit switch sur l'op qui dispatche chaque item vers le store — GetRawAsync, PutRawAsync ou DeleteRawAsync — de sorte qu'un batch mixte de lectures et d'écritures est traité item par item à l'intérieur de la même requête. (Pour les routeurs en processus et mono-nœud, la même forme apparaît comme un Parallel.ForEachAsync sur les clés, borné par MaxDegreeOfParallelism — une option MaxParallelism ou Environment.ProcessorCount par défaut — ce qui est pourquoi les lectures montent avec les cœurs : un gros sous-batch garde chaque cœur occupé au lieu d'un seul.)

Donc l'estampillage Index gagne sa place deux fois. La concurrence brouille l'ordre sur le nœud, donc le handler trie par Index avant de répondre. L'éventail brouille l'ordre sur le client, donc le client trie par Index après la fusion. L'appelant ne voit jamais l'un ni l'autre brouillage. Un petit entier, porté sur chaque item dès l'instant où la requête est construite, est tout le mécanisme qui rend « tout balancer au cluster en parallèle » sûr à exposer comme une liste ordonnée.

Une opération en masse n'est pas une transaction​

C'est la distinction que les gens se trompent le plus souvent, alors elle vaut la peine d'être énoncée sans détour. Chaque item d'un batch porte son propre résultat. Le type de résultat par item enregistre le succès, le statut et — pour les opérations qui en ont — la révision, la version et le TTL de cette seule clé :

private static KvBatchItemResult MapKvToBatch(int index, KvBatchOp op, KvResult r) => new()
{
Index = index,
Op = op,
Success = r.IsSuccess,
Status = r.Status, // e.g. Success, NotFound, Moved
Revision = r.Revision?.Value,
ItemVersion = r.Version?.Version,
Ttl = r.TimeToLive
};

Il n'y a pas de point de commit partagé. Un GetMany sur une centaine de clés dont trente n'existent pas renvoie soixante-dix résultats Success et trente résultats NotFound, entrelacés par index — pas une erreur, pas une liste vide. Un PutMany où le segment d'une clé est en pleine migration renvoie cet unique item avec le statut Moved et le reste Success. Le succès partiel est la forme normale et attendue de la réponse, et l'appelant est censé inspecter chaque item.

C'est l'exact opposé d'une transaction multi-clés, qui est tout-ou-rien par construction : un commit à deux phases coordonné par le client sur des versions optimistes où soit chaque clé bouge, soit aucune. Les deux fonctionnalités se ressemblent de l'extérieur — toutes deux prennent de nombreuses clés en un appel — et sont bâties pour des besoins opposés. Optez pour un batch quand les clés sont indépendantes et que vous voulez du débit ; optez pour une transaction quand un invariant s'étend sur les clés et qu'un résultat à moitié appliqué le corromprait. Zaris les garde comme des API séparées précisément pour que la sémantique ne soit jamais ambiguë : un batch ne fait jamais silencieusement un rollback, et une transaction ne commit jamais silencieusement à moitié.

Survivre à une possession qui bouge en plein batch​

Une opération en masse est une cible plus large qu'un seul GET pour le problème de la possession qui change en dessous. Entre le moment où le client regroupe une clé vers le nœud A et le moment où le nœud A traite le sous-batch, un cut-over de partition peut avoir déplacé ce segment vers le nœud B. Le nœud A répondra à ces items avec le statut Moved plutôt que de deviner.

Le client de cluster enveloppe tout l'éventail dans un réessai borné pour exactement cela :

public override async Task<KvBatchResponse> ExecuteBatchAsync<T>(
KvBatchRequest batch, CancellationToken ct = default)
{
return await RetryHelper.RetryAsync(
() => ExecuteBatchClusterAsync<T>(batch, ct),
maxAttempts: 2,
backoffMillis: 100,
logger: _logger).ConfigureAwait(false);
}

Lors d'un réessai, la table de routage s'est généralement rafraîchie, donc le re-regroupement envoie les clés déplacées vers leur nouveau propriétaire. Le réessai est délibérément peu profond — deux tentatives, un bref backoff — car le but est de survivre à une table de routage momentanément périmée, pas de masquer un nœud réellement en panne. C'est la même discipline décrite dans les réessais sous churn de possession, appliquée à tout un ensemble de clés d'un coup : le réessai réexécute le regroupement, pas juste l'envoi, de sorte que chaque tentative reflète la possession la plus récente plutôt que de rejouer un plan périmé.

Un vrai bug, parce que la forme de fil a deux formes​

Il vaut la peine de montrer un endroit où ce chemin a cassé, car il dit quelque chose de spécifique aux opérations en masse. La valeur du résultat d'un GET peut arriver sur le fil sous deux formes — enveloppée dans une enveloppe ZarisEntry, ou comme une valeur sérialisée brute — selon la façon dont elle a été stockée (voir ce qui traverse le fil). Le décodeur de bulk-GET d'origine supposait seulement la forme enveloppée et levait une exception sur la forme brute.

Pour un seul GET c'est un échec à une clé. Pour un GetMany c'était catastrophique d'une façon subtile : chaque clé du batch échouait, car le seul encodage non géré levait avant qu'aucun résultat ne soit décodé. Une exécution de certification l'a signalé comme « échecs de bulk get », et la correction a été de router le décodage à travers un helper partagé de double encodage qui gère les deux formes :

// Decode through the shared helper (handles both the ZarisEntry-wrapper and
// the raw-value forms the server can emit). The old inline code assumed only
// the wrapper and threw, failing every key of a bulk GET.
var obj = KvBatchItemResultExtensions.GetValue<T>(r);

La leçon qui se généralise : dans un chemin en masse, un défaut dans la machinerie partagée par item est multiplié par la taille du batch, et une erreur qui est une gêne pour une clé est une panne pour un millier. Le modèle de résultat par item est ce qui contient le rayon d'explosion — une fois le décodage correct, une valeur malformée échoue un item et laisse les 999 autres intacts.

Ce que l'éventail achète​

Mettez les pièces ensemble et la forme du gain est claire. Un appel en masse estampille chaque item de son index, résout le propriétaire de chaque clé avec la même arithmétique de placement qu'utilise le chemin à clé unique, regroupe les clés pour que N opérations deviennent une requête par nœud propriétaire, tire ces requêtes en concurrence, et réassemble les réponses dans l'ordre de l'appelant — survivant à une table de routage qui se décale en plein vol et rapportant le sort de chaque clé indépendamment. Le nombre d'allers-retours tombe du nombre de clés au nombre de nœuds, qui est le seul nombre qui allait jamais compter.

Rien de tout cela n'a exigé un nouveau chemin de stockage ni un nouvel enregistrement de fil. Les API en masse sont une couche d'ordonnancement par-dessus les primitives que Zaris possède déjà : le placement conscient des partitions qui dit au client qui possède quoi, le contrat par enregistrement que chaque résultat d'item réutilise, et l'exécution parallèle sur les cœurs qui mâche un sous-batch une fois qu'il a atterri. La partie difficile de l'accélération sur de nombreuses clés n'a jamais été les clés — c'était de refuser de payer le réseau une clé à la fois.