Le plafond de mémoire : pourquoi un nœud Zaris ne croît jamais sans borne
Voici une question qui semble simple et ne l'est pas : qu'arrive-t-il quand un store en mémoire tombe à court de mémoire ?
La réponse naïve est « il plante » — le processus grimpe jusqu'à ce que le système d'exploitation le tue. La réponse un peu moins naïve est « il refuse les écritures ». Zaris ne fait ni l'un ni l'autre par défaut. Un nœud Zaris est un store en mémoire, mais il n'est délibérément pas illimité : chaque nœud est un buffer borné avec un plafond dur, et quand les données qu'il possède approchent ce plafond, le nœud évince ses données les plus froides pour faire de la place. Les écritures continuent de réussir ; la limite tient ; le processus ne gonfle pas. Le nœud se comporte comme un cache, parce qu'en dessous, c'en est un.
Chaque nœud est un buffer borné
Un nœud Zaris ne croît pas sans limite. Chaque nœud comptabilise en continu la taille des données qu'il possède activement et maintient ce total courant sous un plafond configuré. La comptabilité n'est jamais optionnelle — un nœud est toujours un buffer borné. Les seules choses que vous configurez sont la taille du plafond et, en principe, ce que le nœud fait quand il l'atteint.
Le plafond par défaut est de 1 Gio de données activement possédées par nœud. Il n'y a, par conception, aucun réglage « illimité ». Si vous ne fixez pas de plafond, vous obtenez la valeur par défaut — vous n'obtenez jamais l'infini par accident.
Une subtilité compte pour la façon de raisonner l'empreinte d'un cluster : le plafond couvre les données qu'un nœud possède activement — les copies primaires qu'il sert aux clients. Les copies de réplica qu'un nœud détient pour d'autres partitions sont suivies séparément et ne comptent pas contre le plafond de propriétaire actif. La vraie mémoire résidente d'un nœud est donc son jeu de travail possédé plus les copies de réplica qu'il porte pour ses pairs ; le plafond régit le premier, pas la somme.
Deux façons de tenir la ligne
Il y a exactement deux choses qu'un buffer borné peut faire quand une écriture le pousserait au-delà de son plafond : déposer quelque chose d'ancien pour faire de la place, ou refuser la nouvelle écriture.
| Mode | Comportement au plafond | Une écriture qui le dépasserait |
|---|---|---|
| Éviction activée (par défaut) | Évince les données plus anciennes pour faire de la place | Admise — des données plus froides sont déposées |
| Éviction désactivée | Refuse les nouvelles données | Rejetée avec KvStatus.CapacityExceeded |
Avec l'éviction activée, un nœud est un cache : sous pression il dépose les données les moins précieuses pour que les écritures continuent de circuler. Avec l'éviction désactivée, c'est un store qui rejette à capacité et protège les données existantes en refoulant les nouvelles écritures.
Voici la partie honnête. Dans la version actuelle, un nœud fonctionne toujours avec l'éviction activée, en utilisant la politique LRU. Le mode sans éviction et les politiques alternatives (LFU, TTL-aware) existent dans le moteur, mais ils ne sont pas encore sélectionnables par configuration. La conséquence pratique est que CapacityExceeded — le statut de rejet à capacité — est essentiellement inatteignable aujourd'hui : un nœud plein évince plutôt que de refuser. C'est un statut réel et typé contre lequel vous pouvez écrire du code, mais sous la politique LRU toujours active vous ne le verrez normalement pas. Le seul bouton mémoire que vous contrôlez réellement en ce moment est la taille du plafond lui-même, memory.maxSizeBytes.
L'exécution d'éviction chevauche une bande calme
L'ingénierie intéressante n'est pas *qu'*un nœud évince — c'est comment, car une conception naïve « évincer à chaque écriture une fois plein » s'emballe misérablement. Imaginez un nœud posé exactement à son plafond : chaque écriture entrante déclencherait une éviction pour se faire de la place, et vous paieriez ce coût sur le chemin chaud à jamais.
Zaris évite cela avec deux manœuvres. D'abord, l'éviction est pilotée par une boucle d'arrière-plan, pas par l'écriture individuelle — l'écriture qui fait basculer un nœud par-dessus bord est admise, et la boucle récupère l'espace peu après, hors du chemin de requête. Ensuite, la boucle utilise l'hystérésis : un seuil de déclenchement haut et une cible plus basse, de sorte qu'elle récupère par rafales puis se tait au lieu de tirer continûment au bord.
- Déclenchement : une exécution démarre quand l'usage actif dépasse 80 % du plafond.
- Cible : une fois démarrée, l'exécution continue d'évincer jusqu'à ce qu'environ 30 % du plafond soit à nouveau libre — jusqu'à ce que l'usage retombe à environ 70 %.
- Cadence : la boucle se réveille à intervalle fixe (une seconde par défaut) et échantillonne un ensemble borné d'items candidats à chaque cycle.
Parce que la cible post-éviction (70 %) se situe strictement en dessous du déclenchement (80 %), chaque exécution fait retomber l'usage dans une bande calme plutôt que de s'arrêter juste au point qui le re-déclencherait immédiatement. C'est la dent de scie dans l'image d'en-tête : l'usage grimpe à 80 %, une exécution le ramène à 70 %, puis il remonte — jamais plaqué contre le plafond, jamais d'emballement d'une éviction par écriture.
La politique d'éviction elle-même est LRU — least recently used, la moins récemment utilisée — et elle est échantillonnée, pas stricte. Plutôt que de maintenir une liste globalement ordonnée de chaque clé (ce qui coûterait de la mémoire et de la contention de verrou sur le chemin chaud), Zaris estampille des signaux de récence approximatifs sur place sur le chemin de lecture et, quand une exécution se déclenche, échantillonne un petit ensemble de candidats et dépose ceux au plus bas score. C'est la même approche échantillonnée qu'utilise Redis, et le même compromis : vous abandonnez l'ordre parfait « évincer la plus ancienne clé unique » en échange d'une éviction qui ne coûte presque rien à maintenir et n'a jamais besoin d'un index séparé.
L'autre plafond : la taille par item
Le plafond de mémoire global est une limite sur le total. Il y a une seconde limite indépendante sur tout item unique : par défaut une valeur ne peut pas dépasser 4 Mio. Elle est vérifiée à chaque écriture quel que soit le mode d'éviction, et c'est là qu'une écriture atteint un rejet véritablement atteignable.
La distinction compte parce que les deux défaillances signifient des choses opposées et veulent des réponses opposées :
var result = await client.PutAsync("report:2026-07", payload);
if (!result.IsSuccess)
{
switch (result.Status)
{
case KvStatus.ItemTooLarge:
// This one value is too big. No amount of eviction helps —
// split it, or store a reference to it elsewhere.
break;
case KvStatus.CapacityExceeded:
// The node is full and eviction is disabled (not the default).
// Retry later or shed load.
break;
}
}
ItemTooLarge est un résultat vivant et atteignable dans tous les modes — l'éviction ne peut pas faire de place pour une valeur qui dépasse le plafond par item, car le plafond n'est pas une question d'espace libre, c'est une question de valeur pathologique unique. CapacityExceeded, comme couvert ci-dessus, n'apparaît que dans le mode sans éviction que vous ne pouvez pas encore sélectionner. Les traiter de façon identique est un bug qui attend d'arriver : réessayer une écriture ItemTooLarge éternellement ne réussira jamais.
Une ride apparentée vit à la frontière entre ce plafond par item et le runtime .NET : les valeurs d'environ 85 000 octets et plus atterrissent sur le Large Object Heap, donc relever maxItemSizeBytes pour admettre de grosses valeurs est une décision délibérée avec des conséquences GC, pas un cadran gratuit. Si vous mettez à jour à chaud de grosses valeurs simples, les découper en morceaux sous-LOH est le mécanisme qui empêche le LOH de s'agiter — une fonctionnalité séparée avec ses propres compromis, que nous avons détaillée dans Stocker de gros objets sans agiter le heap.
Ce que vous configurez, et ce que vous ne configurez pas
Le plafond de mémoire vit dans la configuration du store sous la section memory :
{
"memory": {
"maxSizeBytes": 2147483648
}
}
Cela fait 2 Gio. Mettez maxSizeBytes à null, ou omettez-le, et vous obtenez la valeur par défaut de 1 Gio. Dans la version actuelle c'est le réglage de mémoire que vous contrôlez — la politique LRU, les seuils d'hystérésis 80 %/70 % et la cadence d'une seconde sont des valeurs par défaut fixes, pas des boutons. Il vaut la peine d'être lucide à ce sujet : les articles de blog et le moteur décrivent un espace de politiques plus riche que ce que la surface de configuration expose actuellement, et cet article décrit ce que vous pouvez réellement régler aujourd'hui.
Ce que cela signifie pour dimensionner un cluster
Parce que chaque nœud est un buffer borné, la capacité totale d'un store est à peu près le plafond par nœud multiplié par le nombre de partitions qu'un nœud possède, ajusté par le facteur de réplication (les réplicas détiennent des copies supplémentaires qui vivent hors du plafond de propriétaire actif). Deux conséquences pratiques en découlent :
- Dimensionnez le plafond sur votre jeu de travail plus une marge d'éviction, pas sur vos données totales. Un nœud exécute aujourd'hui un cache LRU toujours actif, donc les données froides sont déposées sous pression plutôt que rejetées. Si votre jeu de travail tient sous ~70 % du plafond avec de la place pour que la bande respire, l'éviction reste calme et le jeu chaud reste résident. Si vous dimensionnez le plafond en dessous de votre jeu de travail, vous avez construit un cache qui évince constamment des données dont il va avoir à nouveau besoin — correct, mais lent, et la dent de scie se transforme en meule.
- N'oubliez pas l'empreinte des réplicas. Le plafond régit les données possédées, mais la RAM réelle d'un nœud est les données possédées plus les copies de réplica. Quand vous provisionnez la mémoire par pod ou par machine, budgétez pour les deux, ou un nœud dimensionné « à son plafond » tournera quand même plus chaud que le plafond ne le laisse entendre.
Pour le calcul de capacité dans le contexte des partitions, du facteur de réplication et des nombres de pods, Dimensionner un cluster Zaris pour la durabilité sur Kubernetes parcourt le calcul complet.
À retenir
Un store en mémoire qui croît sans borne n'est pas une fonctionnalité — c'est une panne qui attend un pic de trafic. Zaris traite chaque nœud comme un buffer borné avec un vrai plafond (1 Gio par défaut, aucune option illimitée), un cache LRU échantillonné toujours actif en dessous, et une bande d'hystérésis qui récupère l'espace par rafales calmes plutôt que de s'emballer avec une éviction par écriture au bord. Le plafond total et le plafond par item sont deux limites différentes avec deux statuts typés différents, et celui qui est réellement atteignable aujourd'hui — ItemTooLarge — veut une réponse différente d'une boucle de réessai. Dimensionnez le plafond sur votre jeu de travail, budgétez les copies de réplica en plus, et le nœud tient sa ligne sans que vous ayez à le surveiller.
Le modèle complet, y compris les politiques que le moteur porte mais n'expose pas encore, est dans Mémoire et éviction ; les champs et valeurs par défaut exacts sont dans la référence de configuration, et chaque statut de capacité est catalogué dans la référence des statuts et exceptions.