Simuler le comportement humain, c’est un art délicat. Je l’ai appris a mes dépends cette semaine les ami(e)s codeurs. Mon cas concret ? Un système d'encaissement et de fidélité d'une chaîne de restauration (160 restaurants, 480 caisses actives, des dizaines de requêtes à la seconde).

Pour “stresser” l'API de fidélité, je devais injecter des numéros de cartes. Le premier réflexe de tout développeur est instinctif. Ou alors ce fut juste le mien, celui d’un probable mauvais développeur. Allez savoir. Piocher une carte au hasard dans un jeu de données avec Math.random().

C'est simple. Mais avec le recul, ca fait pas du tout ce que je veux… Et surtout ca fausse le test de charge que je souhaite réaliser. Le hasard pur fausse les tests de charge. Heureusement pour moi, et peut être pour vous, des esprits bien plus matheux que moi ont mis au point un simple concept mathématique de "distribution par décalage" (Ouais c’est un peu barbare au premier abord) qui m’a permis de sauver mes métriques.

Utiliser cardIds[Math.floor(Math.random() * cardIds.length)] dans un outil massivement parallèle comme k6 engendre en fait plusieurs problèmes.

Dans la vraie vie, vous serez probablement d’accord avec moi les ami(e)s codeurs pour dire qu’il est statistiquement impossible que 5 clients dans 5 restaurants différents présentent la même carte de fidélité à la même milliseconde. Pourtant, avec Math.random(), sur 480 utilisateurs virtuels (VU) s'exécutant en parallèle, les probabilités de collision sont immenses (c'est d’ailleurs ce qu’on appelle le fameux paradoxe des anniversaires si ca vous intéresse de creuser le sujet).

Ce qui ne m’a pas plu dans ce constat et compte tenu du contexte de mon test de charge, c’est que j’avais possibilité de me frotter a des système de cache parmi les services concernés qui allait potentiellement absorber la charge d'un coup, rendant les temps de réponse de l'API faussement excellents.

Finalement ainsi je ne teste plus du tout mon système, mais presque seulement les mécanismes de cache…

Sachez les ami(e)s codeurs qu’on dit toujours que le hasard n’a pas de mémoire. Si vous lancez un dé 100 fois, vous n'obtiendrez pas exactement un sixième de chaque face. Eh bien avec Math.random , certaines cartes de mon jeu de données seront appelées 50 fois, et d'autres 0 fois.

Il est là le problème. Je perds la garantie d’une couverture de test homogène.

On pourrait croire que c’est pas si important. Mais finalement, c’est peut être un peu le pire cauchemar d’un développeur. Imaginez, si lors de l’exécution de mon test de charge, l'itération 42 de l’utilisateur virtuel 17 déclenche une erreur 500. Comment je relance le test dans les mêmes conditions exactes pour revérifier le fonctionnement de ce cas ?

C’est impossible. A cause de ce fameux Math.random.

L'objectif, c’est donc de remplacer ce hasard chaotique par une répartition pseudo-aléatoire et déterministe.

Dans mon projet de test de charge avec K6, cela se traduit par cette minuscule fonction qui fait cependant pas mal de choses.

export function pickCardId(): string {
  const index = ((__VU - 1) * 13 + __ITER) % cardIds.length;
  return cardIds[index];
}

Cette ligne d'apparence simpliste et, avouons le un peu obscure au premier abord, est en réalité une horlogerie de précision. Dont on va justement décortiquer les engrenages ensemble plus bas.

Dans k6, chaque utilisateur virtuel (VU pour Virtual User dans le nommage K6) possède un identifiant unique commençant à 1. Les tableaux en JavaScript commencent à l'index 0. Je ne pense que je ne vous apprends rien ici les ami(e)s codeurs. On soustrait donc 1 pour normaliser l'identifiant et partir de 0. Concrètement, Le VU 1 devient l'entité 0, le VU 2 l'entité 1, etc…

A cela, on va venir y ajouter ce qu’on pourrait appeler un espacement, ou un pas pour les plus bricolos d’entre nous. C’est ce * 13 que l’on voit juste après. Et c'est un peu le coup de génie de l'algorithme. Son essence je dirais même ! Parce que si tous les utilisateurs virtuels commençaient au même point, ils liraient les mêmes données au même moment. On multiplie donc l'ID de l’utilisateur virtuel par ce "pas" (ici, 13) pour espacer géographiquement leur point de départ dans le tableau.

Et là je le sens venir gros comme une maison, parce que moi même je me suis posé la question. Vous allez me demander “Ok Greg… Mais pourquoi 13 ? Pourquoi pas 2, ou 6 ? Après tout je préfère ce chiffre, c’est l’anniversaire de ma femme, l’âge de ma fille, ou même le nombre de conquête que je me suis envoyer en l’air !“

Alors pourquoi 13 ? Comme je vous disais les ami(e)s codeurs, je ne suis pas un fin mathématicien. Mais j’ai fait mes petites recherches. Il s’avère en fait que l'utilisation d'un nombre premier est cruciale. Si mon tableau de données contient 100 éléments, un pas de 10 créerait visiblement des boucles redondantes très vite (10, 20, 30... 100, puis retour à 10). Mais avec un nombre premier (comme 11, 13 ou 17), il sont apparemment très souvent premier entre eux (on dit qu’ils sont copremier) avec la taille du tableau. Et c’est ça qui garantit qu'il n'y aura pas de "résonance", si on peut le dire ainsi, ou de motifs répétitifs qui seraient justement très dommageables dans le cadre de mon test de charge.

L'itération __ITER c’est le compteur de boucles de l’utilisateur virtuel (0, 1, 2, 3...). À chaque nouveau passage en caisse, cet utilisateur virtuel avance simplement d'un pas par rapport à son point de départ. En d’autre mot et pour imager cela, chaque utilisateur virtuel trace sa propre ligne, sans jamais empiéter sur le voisin immédiat.

Et le modulo %, qui renvoie le reste d'une division euclidienne, c’est la barrière de sécurité ultime. Il permet de parcourir un tableau infini dans un espace fini. Si l’utilisateur virtuel arrive à l'index 100 d'un tableau de 100 éléments, 100 % 100 = 0 . Il repart donc au début du tableau sans lever d'erreur parce qu’il serait “hors des limites“.

C’est tout. Au final, c’est pas si mathématiquement compliqué !

Imaginons une petite exécution de mon test de charge, avec 20 cartes (cardIds.length = 20) et observons le comportement chronologique de 2 utilisateurs virtuels. Ce sera quand même plus visuel que du code et des logiques mathématiques.

Itération

VU 1 (Commence à l'index 0)

VU 2 (Commence à l'index 13)

Boucle 0

Lit la carte 0

Lit la carte 13

Boucle 1

Lit la carte 1

Lit la carte 14

Boucle 2

Lit la carte 2

Lit la carte 15

Boucle 7

Lit la carte 7

Lit la carte 20 -> 0

Le constat est sans appel. J’ai zéro collision immédiate. À la milliseconde T=0, les appels API visent les cartes 0 et 13. Ainsi, la charge sur les services est organiquement et parfaitement distribuée. Je n’ai plus de risque d’avoir des appels répondu par du cache qui fausserait mes résultats lors du test de charge.

Et en laissant le test tourner, mathématiquement, 100% des cartes du tableau seront utilisées un nombre égal de fois.

Mais surtout, si une erreur survient a l’Itération 1 de l’utilisateur virtuel 2, je sais avec une certitude absolue, sans même avoir besoin de logs, que l'API a planté sur la carte à l'index 14.

Finalement, je pense que dans les tests de charge, le réalisme ne vient pas du hasard, mais du contrôle. Là où Math.random() génère du bruit et des anomalies matérielles qui faussent les résultats, l'algorithme de distribution par décalage apporte l'élégance de l'arithmétique. (C’est beau comme phrase quand même).

Si vous devez écrire un script k6 un jour les ami(e)s codeurs, oubliez les dés. Laissez plutôt les mathématiques répartir votre charge.

Keep Reading