Combien de requêtes un serveur Node.js peut-il vraiment gérer ?
Combien de requêtes un serveur Node.js peut-il vraiment gérer ? Tests de charge avec Express sur Heroku et AWS par rapport à Django, une estimation de capacité de 40 000 requêtes par seconde, et ce qui ralentit les API.
Sources: How Many Requests Can a Real-World Node.js Server-Side App Handle?
Tout nouveau venu dans le monde de Node.js se pose tôt ou tard la même question pratique : combien de requêtes peut gérer une application Node.js en production, et combien d’instances serveur sont nécessaires pour un volume de trafic donné ? La réponse dépend de bien plus que du seul framework.
Un objectif à viser
La plateforme de création d’images PicsArt afficherait environ 40 000 requêtes par seconde, son backend étant principalement basé sur Node.js.
Ce chiffre est énorme, et il dépend de nombreux facteurs en dehors du serveur backend. Pour cet exercice, ces autres facteurs sont mis de côté, ne laissant que le matériel et le logiciel backend, et plus particulièrement Node.js.
Ce que montrent les benchmarks synthétiques
L’équipe derrière Fastify a publié un benchmark selon lequel Express gère environ 15 000 requêtes par seconde, tandis que le module HTTP basique de Node atteint environ 70 000.
Un MacBook Pro 15″ de 2015 a donné des résultats presque identiques. Même 15 000 requêtes par seconde avec Express est impressionnant, mais des tests de ce type s’effectuent avec un petit gestionnaire, qui est loin d’être une API réelle. Ce qui rend une API « du monde réel » ce sont les données, leur manière de circuler et la façon dont les composants interagissent ; par conséquent, un gestionnaire réaliste doit communiquer avec une base de données ou d’autres services.
La différence entre la version brute et Express n’est d’ailleurs pas très importante. Des frameworks établis comme Express sont généralement de meilleurs choix, car en pratique aucune instance unique ne pourra gérer 100 000 requêtes par seconde. C’est pourquoi Express a été installé pour ces tests, accompagné d’un petit gestionnaire effectuant diverses opérations.
Le gestionnaire de test
Le gestionnaire exécute six étapes :
- Insérer un nouvel utilisateur dans la base de données.
- Récupérer 20 utilisateurs depuis la base de données.
- Convertir en chaîne de caractères puis parser l’array des 20 utilisateurs.
- Convertir de manière récursive ces 20 objets d’utilisateur en camelCase.
- Trier le tableau.
- Effectuer des calculs simples et des opérations d’affichage, tels que
Date.nowetconsole.log, qui consomment également des ressources de calcul.
La réponse pesait environ 12 KB. Chaque objet d’utilisateur avait une taille de 609 octets :
Résultat 1 : Node.js sur Heroku avec AWS RDS
L’application a été déployée sur Heroku sur du matériel coûtant 25 dollars par mois, et les requêtes ont été envoyées à un rythme maximal sans augmenter la latence moyenne. L’instance a géré 31 000 requêtes en 60 secondes, soit en moyenne 515 requêtes par seconde.
Résultat 2 : Django sur Heroku avec AWS RDS
Pour voir ce que ferait une technologie non single-threadée, un gestionnaire équivalent a été développé dans Django dans des conditions identiques et placé sur le même serveur. Les résultats se sont révélés légèrement inférieurs à ceux de Node.js, avec 23 000 requêtes traitées en 60 secondes, soit 389 requêtes par seconde.
Résultat 3 : trois instances Node.js sur AWS Elastic Beanstalk
Une troisième configuration a simulé un déploiement en production réel sur AWS. La base de données et le backend se trouvaient tous deux à Francfort (eu-central-1), la région la plus proche, à environ 3 000 km du testeur. Trois instances Node.js étaient gérées par le mécanisme de charge équilibrée d’AWS. Elles ont traité 19 000 requêtes en 10 secondes, soit 1 858 requêtes par seconde. Un test de charge continu a ensuite permis d’effectuer 1,5 million de requêtes réussies en 13 minutes.
Échelle jusqu’à 40 000 requêtes par seconde
Une instance gère environ 600 requêtes par seconde, alors combien en faudrait-il pour 40 000 ? En supposant que l’endpoint moyen sur une grande plateforme soit deux fois plus gourmand en ressources que le gestionnaire de tests, on obtient 40 000 / (600 / 2) = 133 instances réparties dans le monde pour une application de taille mondiale. Ce n’est pas un problème majeur.
Les endpoints plus gourmands pourraient nécessiter trois fois plus de ressources, mais le cache et la communication basée sur des événements permettent de récupérer une partie de la bande passante, donc un facteur deux constitue une estimation raisonnable.
Qu’est-ce qui ralentit une API ?
De nombreux facteurs peuvent ralentir la vitesse d’une API. Ce sont les problèmes de performance que l’on retrouve dans la plupart des applications web côté serveur.
Taille des données
Le volume de données stocké dans la base de données ou récupéré d’elle est important, en particulier dans les bases de données relationnelles, où de gros volumes de données entraînent souvent des requêtes complexes ou une charge importante entre le SGBD et sa mémoire disque. Dans tous les cas, plus il y a de bytes, plus le temps de transmission est long. Stocker des fichiers dans la base de données constitue un antipattern ; le stockage d’objets est une solution bien plus adaptée. Idéalement, un enregistrement reste dans une fourchette de quelques dizaines de kilooctets, généralement de 1 à 20 KB.
Distance par rapport à la base de données
Une plus grande distance entre le backend et la base de données entraîne des délais plus longs pour les requêtes et la récupération des résultats. Prenons deux backends, l’un en Virginie et l’autre à Francfort, avec une seule base de données en Virginie. La demande d’un utilisateur américain atteint le backend américain en environ 10 à 40 ms, et le trajet du backend vers la base de données prend de 1 à 5 ms. C’est acceptable. Un utilisateur allemand atteint le backend de Francfort en 10 à 20 ms, mais la requête vers la Virginie prend de 120 à 130 ms. Une application monorégionale devrait donc conserver les données et la logique backend à proximité l’une de l’autre, tandis qu’une application mondiale doit faire face à des solutions techniques complexes pour rester rapide.
Matériel de la base de données
Le matériel a également un impact important. Une petite application devrait acquérir un matériel plus performant pour faire face à son trafic, sinon elle devra recourir à des solutions techniques complexes.
Configuration de la base de données
Les bases de données sont livrées avec des paramètres par défaut afin d’éviter des problèmes supplémentaires de gestion, mais à grande échelle, elles nécessitent une attention particulière. Le nombre maximal de connexions est un paramètre à surveiller de près : il peut créer des goulots d’étranglement dans l’application ou surcharger le serveur de base de données, et les bibliothèques clients telles que les ORM modifient souvent cette valeur. Le délai de timeout des connexions en est un autre.
Nombre d’appels à la base de données
Une véritable API peut effectuer des dizaines d’appels distincts à la base de données pour terminer une tâche, et elle peut également appeler d’autres services qui, chacun, en effectuent eux aussi des dizaines. Un cas marquant concerne une API de monolithe bancaire dont le temps de réponse dans l’environnement de développement cloud était de 9 secondes. Son gestionnaire exécutait 300 lignes de code, avec environ 30 appels à la base de données et une dizaine d’appels à des services internes. Cela a tendance à se produire avec une approche « code d’abord » plutôt qu’avec une approche « conception d’abord ».
Les correctifs dépendent de la logique métier. Les files d’attente et d’autres techniques asynchrones permettent de déplacer une partie des tâches de base de données en arrière-plan, ce qui exige de diviser les grands services en petits. Les requêtes jointes peuvent également réduire le nombre d’appels.
Hooks
Les ODM et ORM populaires offrent des hooks ou du middleware, utiles pour gérer certaines logiques. Lors des opérations en masse, ils peuvent devenir un goulot d’étranglement caché, car des plugins tels que les convertisseurs camelCase ou pascal_case consomment également des ressources de calcul et du temps. Les hooks aident, mais ce qui y est écrit et où cela se trouve mérite une réflexion supplémentaire.
Tâches gourmandes en CPU
Les tâches gourmandes en ressources CPU ralentissent le serveur et font également attendre l’utilisateur final. Dans Node.js, beaucoup d’entre elles reposent sur le pool de threads. Cela ne bloque pas les opérations I/O asynchrones, mais cela ralentit néanmoins l’API. Par exemple, un clic pour s’inscrire attend que le pool de threads haché un mot de passe, ce qui prend plus de 100 millisecondes.
Obstacles liés au boucle d’événements
Node.js exécute le code sur la boucle d’événements, sur un seul thread, de sorte que les performances du code affectent l’ensemble de l’application. JSON.parse et JSON.stringify peuvent ralentir les API, et les boucles imbriquées ainsi que les fonctions récursives sont dangereuses. Ignorer le fonctionnement de Node.js peut rendre une application indisponible, en fonction de la taille des données et du trafic.
Conception déficiente de l’infrastructure
Une infrastructure étendue ne présente pas seulement plus de problèmes qu’un monolithe, mais aussi de différents problèmes, qui exigent souvent des solutions architecturales complexes. Avec de nombreux composants, une mauvaise architecture a un impact négatif important sur la vitesse des API et rend le système peu efficace en termes de ressources, ce qui entraîne des coûts plus élevés pour quelque chose qui pourrait en réalité coûter moins cher. Lors de la conception d’applications complexes, réfléchissez aux problèmes que résout chaque technologie et connaissez les alternatives possibles. Évaluez comment les exigences pourraient changer au cours de deux ou six mois, et testez chaque décision en fonction de ces prévisions. Pensez également aux accords de niveau de service ainsi qu’aux exigences non fonctionnelles, y compris ce qui se passerait en cas d’augmentation du trafic.
Échelle et configuration des composants
Les composants ajoutés à l’infrastructure peuvent ralentir toute la plateforme. Prenons RabbitMQ : comme toute technologie, il dispose de nombreux paramètres de configuration. Les valeurs par défaut conviennent pour commencer, mais une étude approfondie d’un composant spécifique, ainsi qu’une révision de la configuration, peuvent améliorer considérablement l’infrastructure. Les composants doivent également être évolutifs en fonction de l’utilisation afin de ne pas devenir un goulot d’étranglement.