Outils, techniques et astuces pour créer des serveurs Node.js haute performance
Si vous avez construit quelque chose avec Node.js assez longtemps, alors vous avez sans doute éprouvé la douleur des problèmes de vitesse inattendus. JavaScript est un langage événementiel asynchrone. Cela peut rendre le raisonnement sur la performance difficile comme cela deviendra apparent. La popularité croissante de Node.js a fait ressortir le besoin d'outils, de techniques et de pensées adaptés aux contraintes du JavaScript côté serveur.
En matière de performances, ce qui fonctionne dans le navigateur ne convient pas forcément à Node.js. Alors, comment pouvons-nous nous assurer que la mise en œuvre de Node.js est rapide et adaptée? Parcourons un exemple concret.
Node est une plate-forme très polyvalente, mais l'une des applications prédominantes est la création de processus en réseau. Nous allons nous concentrer sur le profilage le plus commun: les serveurs web HTTP.
Nous aurons besoin d'un outil capable de faire exploser un serveur avec beaucoup de demandes tout en mesurant la performance. Par exemple, nous pouvons utiliser AutoCannon :
D'autres bons outils de benchmarking HTTP incluent Apache Bench (ab) et wrk2 mais AutoCannon est écrit en Node, fournit une pression de charge similaire (ou parfois supérieure), et est très facile à installer sur Windows, Linux et Mac OS X.
Après avoir établi une mesure de performance de base, si nous décidons que notre processus pourrait être plus rapide, nous allons besoin d'un moyen de diagnostiquer des problèmes avec le processus. Un excellent outil pour diagnostiquer divers problèmes de performance est Node Clinic qui peut également être installé avec npm:
npm --install -g clinic
Cela installe réellement une suite d'outils. Nous allons utiliser Clinic Doctor et Clinic Flame (une enveloppe autour de 0x ) comme nous allons
Note : Pour cet exemple pratique, nous Le code Notre exemple est un serveur REST simple avec une seule ressource: une grande charge JSON exposée comme une route GET à Le fichier index.js pour notre serveur ressemble à ceci: Ce serveur est représentatif du cas courant de diffusion de contenu dynamique mis en cache par le client. Ceci est réalisé avec le middleware Le fichier util.js fournit des éléments d'implémentation ce qui serait couramment utilisé dans un tel scénario, une fonction pour récupérer le contenu pertinent à partir d'un backend, le middleware etag et une fonction d'horodatage qui fournit des timestamps minute par minute: Ne prenez nullement ce code comme exemple de bonnes pratiques! Il y a plusieurs odeurs de code dans ce fichier, mais nous les localiserons lorsque nous mesurerons et profilerons l'application. Pour obtenir la source complète de notre point de départ, le serveur lent peut être trouvé sur ici / seed / v1
] Le serveur est un dossier app
qui consiste en un fichier package.json (en fonction de restify 7.1.0
), un index.js fichier et fichier util.js . 'use strict'
const restify = require ('restifier')
const {etagger, timestamp, fetchContent} = require ('./ util') ()
serveur const = restify.createServer ()
server.use (etagger (). bind (serveur))
server.get ('/ seed / v1', fonction (req, res, next) {
fetchContent (req.url, (err, content) => {
if (err) renvoie next (err)
res.send ({data: contenu, url: req.url, ts: timestamp ()})
prochain()
})
})
server.listen (3000)
etagger
qui calcule un en-tête ETag
pour l'état le plus récent du contenu. 'use strict'
require ('events'). defaultMaxListeners = Infinity
const crypto = require ('crypto')
module.exports = () => {
const content = crypto.rng (5000) .toString ('hex')
const ONE_MINUTE = 60000
var last = Date.now ()
horodatage de la fonction () {
var maintenant = Date.now ()
if (now - last> = ONE_MINUTE) last = maintenant
retour dernier
}
function etagger () {
var cache = {}
var afterEventAttached = faux
function attachAfterEvent (serveur) {
if (attachAfterEvent === true) renvoie
afterEventAttached = true
server.on ('après', (req, res) => {
if (res.statusCode! == 200) retour
if (! res._body) renvoie
clé const = crypto.createHash ('sha512')
.update (req.url)
.digérer()
.toString ('hex')
const etag = crypto.createHash ('sha512')
.update (JSON.stringify (res._body))
.digérer()
.toString ('hex')
if (cache [key]! == etag) cache [key] = etag
})
}
fonction de retour (req, res, next) {
attachAfterEvent (this)
clé const = crypto.createHash ('sha512')
.update (req.url)
.digérer()
.toString ('hex')
if (clé dans le cache) res.set ('Etag', cache [key])
res.set ('Cache-Control', 'public, max-age = 120')
prochain()
}
}
function fetchContent (url, cb) {
setImmediate (() => {
if (url! == '/ seed / v1') cb (Object.assign (Erreur ('Not Found'), {statusCode: 404}))
else cb (null, contenu)
})
}
return {timestamp, etagger, fetchContent}
}
Profilage
Pour le profilage, nous avons besoin de deux terminaux, un pour démarrer l'application, et l'autre pour le tester.
Dans un terminal, dans l'application
]dossier que nous pouvons exécuter:
node index.js
Dans un autre terminal, nous pouvons le profiler comme ceci:
autocannon -c100 localhost: 3000 / seed / v1
Cela ouvrira 100 connexions simultanées et bombardera le serveur avec des requêtes pendant dix secondes.
Les résultats devraient ressembler à ce qui suit (Running 10s test @ http: // localhost: 3000 / seed / v1
– 100 connexions):
Stat | Moyenne | Stdev | Max |
---|---|---|---|
Latence (ms) | 3086.81 | 1725.2 | 5554 |
Req / Sec [19659032] 23.1 | 19.18 | 65 | |
Octets / Sec | 237.98 kB | 197.7 ko | 688.13 ko |
Les résultats varieront en fonction sur la machine. Cependant, étant donné qu'un serveur Node.js "Hello World" est facilement capable de trente mille requêtes par seconde sur cette machine qui a produit ces résultats, 23 requêtes par seconde avec une latence moyenne dépassant 3 secondes est lamentable.
Diagnostic
] Découverte de la zone à problème
Nous pouvons diagnostiquer l'application avec une seule commande, grâce à la commande -on-port de Clinic Doctor. Dans le dossier app
nous courons:
médecin de clinique --on-port = 'autocannon -c100 localhost: $ PORT / seed / v1' - noeud index.js
Cela créera un fichier HTML qui s'ouvrira automatiquement dans notre navigateur lorsque le profilage sera terminé
Les résultats devraient ressembler à ceci:

Le docteur nous dit que nous avons probablement eu un problème de boucle d'événement.
Avec le message en haut de l'interface, nous pouvons également voir que le graphique de la boucle d'événement est rouge et montre un retard croissant. . Avant de creuser plus profondément ce que cela signifie, commençons par comprendre l'effet que le problème diagnostiqué a sur les autres métriques.
Nous pouvons voir que le CPU est toujours égal ou supérieur à 100% car le processus traite les requêtes en file d'attente. Le moteur JavaScript de Node (V8) utilise actuellement deux cœurs de processeur. Un pour la boucle d'événements et l'autre pour la récupération des ordures. Lorsque nous observons que le CPU atteint jusqu'à 120% dans certains cas, le processus collecte des objets liés aux requêtes traitées
Nous voyons cela corrélé dans le graphique Mémoire. La ligne continue dans le graphique Mémoire est la statistique Heap Used. Chaque fois qu'il y a un pic dans le CPU, nous voyons une chute dans la ligne Heap Used, montrant que la mémoire est désallouée.
Les Handles actifs ne sont pas affectés par le délai de la boucle d'événement. Un descripteur actif est un objet qui représente soit des E / S (comme un socket ou un descripteur de fichier), soit un temporisateur (comme un setInterval
). Nous avons demandé à AutoCannon d'ouvrir 100 connexions ( -c100
). Les poignées actives conservent un nombre cohérent de 103. Les trois autres sont des poignées pour STDOUT, STDERR et le handle pour le serveur lui-même.
Si nous cliquons sur le panneau Recommandations en bas de l'écran, nous devrions voir quelque chose comme: :

Atténuation à court terme
L'analyse des causes profondes des problèmes de performance peut prendre du temps. Dans le cas d'un projet déployé en direct, il vaut la peine d'ajouter une protection contre les surcharges aux serveurs ou aux services. L'idée de la protection contre les surcharges est de surveiller le délai de la boucle d'événements (entre autres choses) et de répondre avec "503 Service Unavailable" si un seuil est dépassé. Cela permet à un équilibreur de charge de basculer vers d'autres instances ou, dans le pire des cas, d'actualiser les utilisateurs. Le module de protection contre les surcharges peut fournir cela au minimum pour Express, Koa et Restify. Le cadre de Hapi a un paramètre de configuration de charge qui fournit la même protection.
Comprendre la zone à problème
Comme l'explique brièvement Clinic Doctor, si la boucle d'événement est retardée au niveau Il est très probable qu'une ou plusieurs fonctions "bloquent" la boucle d'événements
Il est particulièrement important avec Node.js de reconnaître cette caractéristique JavaScript primaire: les événements asynchrones ne peuvent pas se produire tant que le code en cours d'exécution n'est pas terminé. C'est pourquoi un setTimeout
ne peut pas être précis.
Par exemple, essayez d'exécuter ce qui suit dans le DevTools d'un navigateur ou dans le REPL du nœud:
console.time ('timeout')
setTimeout (console.timeEnd, 100, 'timeout')
soit n = 1e7
while (n--) Math.random ()
La mesure du temps qui en résulte ne sera jamais de 100 ms. Il sera probablement dans la gamme de 150ms à 250ms. Le setTimeout
a planifié une opération asynchrone ( console.timeEnd
), mais le code en cours d'exécution n'est pas encore terminé; il y a deux autres lignes. Le code en cours d'exécution est connu sous le nom de "tick" actuel. Pour que la coche soit complète, Math.random
doit être appelé dix millions de fois. Si cela prend 100ms, alors la durée totale avant que le délai d'attente ne soit résolu sera de 200ms (plus la fonction setTimeout
est longue pour mettre en file d'attente le timeout, généralement quelques millisecondes).
le contexte côté serveur, si une opération dans la coche actuelle prend beaucoup de temps pour terminer les requêtes ne peut pas être gérée, et la récupération de données ne peut pas se produire car le code asynchrone ne sera pas exécuté tant que la coche actuelle n'est pas terminée. Cela signifie que le code de calcul coûteux va ralentir toutes les interactions avec le serveur. Il est donc recommandé de séparer le travail intense de ressources en processus distincts et de les appeler depuis le serveur principal, ce qui évitera les cas où une route rarement utilisée mais coûteuse ralentit les performances d'autres routes fréquemment utilisées mais peu coûteuses.
un code qui bloque la boucle d'événements, donc l'étape suivante consiste à localiser ce code.
Analyse
Une façon d'identifier rapidement un code peu performant consiste à créer et analyser un graphique de flamme. Un graphique de la flamme représente les appels de fonction en tant que blocs superposés – pas dans le temps mais dans l'ensemble. La raison pour laquelle il est appelé un «graphique de flamme» est parce qu'il utilise généralement un schéma de couleur orange à rouge, où plus un bloc est rouge, plus une fonction est «chaude», plus il bloque la boucle d'événement. La capture de données pour un graphe de flamme est effectuée en échantillonnant le CPU – ce qui signifie qu'un instantané de la fonction en cours d'exécution et de sa pile est pris. La chaleur est déterminée par le pourcentage de temps pendant le profilage qu'une fonction donnée est au sommet de la pile (par exemple la fonction en cours d'exécution) pour chaque échantillon. Si ce n'est pas la dernière fonction à être appelée dans cette pile, alors elle bloque la boucle d'événement.
Utilisons la flamme clinique
pour générer un graphique de flamme de l'exemple d'application:
Flèche clinique --on-port = 'autocannon -c100 localhost: $ PORT / seed / v1' - noeud index.js
Le résultat devrait s'ouvrir dans notre navigateur avec quelque chose comme ceci:

La largeur d'un bloc représente le temps qu'il a passé sur l'ensemble du processeur. Trois piles principales peuvent être observées occupant le plus de temps, toutes soulignant server.on
comme la fonction la plus chaude. En vérité, les trois piles sont les mêmes. Ils divergent car lors du profilage, les fonctions optimisées et non optimisées sont traitées comme des blocs d'appel distincts. Les fonctions précédées d'un *
sont optimisées par le moteur JavaScript, et celles précédées d'un ~
ne sont pas optimisées. Si l'état optimisé n'est pas important pour nous, nous pouvons encore simplifier le graphique en appuyant sur le bouton Fusionner. Cela devrait conduire à une vue similaire à ce qui suit:

Dès le début, nous pouvons déduire que le code incriminé est dans le fichier util.js
du code de l'application
La fonction lente est aussi un gestionnaire d'événements: les fonctions qui mènent au La fonction font partie du module événements
de base, et server.on
est un nom de secours pour une fonction anonyme fournie comme une fonction de gestion d'événement. Nous pouvons également voir que ce code n'est pas dans la même coche que le code qui gère réellement la demande. S'il y avait des fonctions dans le noyau, http
net
et stream
seraient dans la pile.
De telles fonctions de base peuvent être trouvées en développant d'autres fonctions. , beaucoup plus petites, des parties du graphique de la flamme. Par exemple, essayez d'utiliser l'entrée de recherche en haut à droite de l'interface utilisateur pour rechercher send
(le nom des deux méthodes internes restify
et http
). Il devrait être sur la droite du graphique (les fonctions sont classées par ordre alphabétique):

Notez à quel point tous les blocs de traitement HTTP sont relativement petits
On peut cliquer sur l'un des blocs surlignés en cyan pour afficher des fonctions comme writeHead
et write
dans le fichier http_outgoing.js (partie de la bibliothèque Node core http
):

On peut cliquer sur toutes les piles pour revenir à la vue principale.
Le point clé ici est que même si la fonction server.on
n'est pas dans la même case en tant que code de gestion de requête, il affecte toujours les performances globales du serveur en retardant l'exécution du code autrement performant.
Débogage
Nous savons d'après le graphique de flamme que la fonction problématique est le gestionnaire d'événements passé au serveur .on
dans le fichier util.js .
Jetons un coup d'oeil:
server.on ('after', (req, res) => {
if (res.statusCode! == 200) retour
if (! res._body) renvoie
clé const = crypto.createHash ('sha512')
.update (req.url)
.digérer()
.toString ('hex')
const etag = crypto.createHash ('sha512')
.update (JSON.stringify (res._body))
.digérer()
.toString ('hex')
if (cache [key]! == etag) cache [key] = etag
})
Il est bien connu que la cryptographie tend à être coûteuse, tout comme la sérialisation ( JSON.stringify
) mais pourquoi n'apparaissent-ils pas dans le graphique de la flamme? Ces opérations sont dans les échantillons capturés, mais elles sont cachées derrière le filtre cpp
. Si nous appuyons sur le bouton cpp
nous devrions voir quelque chose comme ceci:

Les instructions V8 internes relatives à la sérialisation et à la cryptographie sont maintenant montrées comme les piles les plus chaudes et comme prenant la plupart du temps. La méthode JSON.stringify
appelle directement du code C ++; C'est pourquoi nous ne voyons pas de fonction JavaScript. Dans le cas de la cryptographie, des fonctions comme createHash
et update
figurent dans les données, mais elles sont soit en ligne (ce qui signifie qu'elles disparaissent dans la vue fusionnée), soit trop petites. ] Une fois que nous commençons à raisonner sur le code dans la fonction etagger
il devient vite évident qu'il est mal conçu. Pourquoi prenons-nous l'instance du serveur
à partir du contexte de la fonction? Il y a beaucoup de hachage, est-ce que tout cela est nécessaire? Il n'y a pas non plus de support d'en-tête If-None-Match
dans l'implémentation qui atténuerait une partie de la charge dans certains scénarios du monde réel parce que les clients feraient seulement une demande de tête pour déterminer la fraîcheur. de ces points pour le moment et de valider la constatation que le travail en cours effectué dans server.on
est en effet le goulot d'étranglement. Cela peut être réalisé en définissant le code server.on
sur une fonction vide et en générant un nouveau flggraph
Modifiez la fonction etagger
en:
function etagger ( ) {
var cache = {}
var afterEventAttached = faux
function attachAfterEvent (serveur) {
if (attachAfterEvent === true) renvoie
afterEventAttached = true
server.on ('après', (req, res) => {})
}
fonction de retour (req, res, next) {
attachAfterEvent (this)
clé const = crypto.createHash ('sha512')
.update (req.url)
.digérer()
.toString ('hex')
if (clé dans le cache) res.set ('Etag', cache [key])
res.set ('Cache-Control', 'public, max-age = 120')
prochain()
}
}
La fonction d'écouteur d'événement passée à server.on
est maintenant une no-op.
Courons flamme clinique
à nouveau:
clinique flame --on-port = 'autocannon -c100 localhost: $ PORT / seed / v1' - noeud index.js
Ceci devrait produire un graphe de flammes semblable au suivant:

Cela semble mieux, et nous aurions dû remarquer une augmentation de la demande par seconde. Mais pourquoi l'événement émet-il un code si chaud? Nous nous attendrions à ce stade que le code de traitement HTTP prenne la majorité du temps CPU, il n'y a rien du tout dans l'événement server.on
.
Ce type de goulot d'étranglement est causé par une fonction étant exécuté plus que ce qu'il devrait être.
Le code suspect suivant en haut de util.js
peut être une indication:
require ('events') defaultMaxListeners = Infinity
Supprimons cette ligne et commençons notre processus avec le drapeau - trace-warnings
:
node --trace-warnings index.js
Si on profile avec AutoCannon dans un autre terminal, comme ça:
autocannon -c100 localhost: 3000 / seed / v1
Notre processus affichera quelque chose de similaire à:
(node: 96371) MaxListenersExceededWarning: Eventuellement, une fuite de mémoire EventEmitter est détectée. 11 après les auditeurs ajouté. Utilisez emitter.setMaxListeners () pour augmenter la limite
à _addListener (events.js: 280: 19)
à Server.addListener (events.js: 297: 10)
à attachAfterEvent
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:22:14)
au serveur.
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/util.js:25:7)
à l'appel
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:164:9)
à la prochaine
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:120:9)
à Chain.run
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/chain.js:123:5)
à Server._runUse
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:976:19)
à Server._runRoute
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:918:10)
à Server._afterPre
(/Users/davidclements/z/nearForm/keeping-node-fast/slow/node_modules/restify/lib/server.js:888:10)
Node nous dit que beaucoup d'événements sont attachés à l'objet server . Ceci est étrange car il y a un booléen qui vérifie si l'événement a été attaché et qui revient plus tôt en faisant essentiellement attachAfterEvent un no-op après l'attachement du premier événement.
Jetons un coup d'œil au ] attachAfterEvent
fonction:
var afterEventAttached = false
function attachAfterEvent (serveur) {
if (attachAfterEvent === true) renvoie
afterEventAttached = true
server.on ('après', (req, res) => {})
}
Le contrôle conditionnel est faux! Il vérifie si Maintenant que nous avons découvert les zones problématiques, voyons si nous pouvons rendre le serveur plus rapide. Mettons le serveur Maintenant, nous vérifions notre correction en profilant à nouveau. Démarrer le serveur dans un terminal: Puis profil avec AutoCannon: Nous devrions voir des résultats quelque part dans la gamme d'une amélioration de 200 fois (Running 10s test @ Il est important d'équilibrer les réductions de coûts potentiels du serveur avec les coûts de développement. Nous devons définir, dans nos propres contextes, jusqu'où nous devons aller pour optimiser un projet. Sinon, il peut être trop facile de mettre 80% de l'effort dans 20% des améliorations de vitesse. Est-ce que les contraintes du projet le justifient? Dans certains scénarios, il pourrait être approprié de réaliser une amélioration de 200 fois avec un fruit à faible pendaison et de l'appeler un jour. Dans d'autres, nous pouvons vouloir rendre notre implémentation aussi rapide que possible. Cela dépend vraiment des priorités du projet. Une façon de contrôler les dépenses en ressources consiste à définir un objectif. Par exemple, amélioration de 10 fois, ou 4000 demandes par seconde. Baser cela sur les besoins de l'entreprise est le plus logique. Par exemple, si les coûts du serveur sont supérieurs de 100% au budget, nous pouvons définir un objectif de 2x amélioration. attachAfterEvent
est vrai au lieu de afterEventAttached
. Cela signifie qu'un nouvel événement est attaché à l'instance server
à chaque requête, puis tous les événements attachés antérieurs sont déclenchés après chaque requête. Optimisation Low-Hanging Fruit
. sur
retour du code d'écoute (au lieu d'une fonction vide) et utilisez le nom booléen correct dans la vérification conditionnelle. Notre fonction etagger
se présente comme suit: function etagger () {
var cache = {}
var afterEventAttached = faux
function attachAfterEvent (serveur) {
if (afterEventAttached === true) renvoie
afterEventAttached = true
server.on ('après', (req, res) => {
if (res.statusCode! == 200) retour
if (! res._body) renvoie
clé const = crypto.createHash ('sha512')
.update (req.url)
.digérer()
.toString ('hex')
const etag = crypto.createHash ('sha512')
.update (JSON.stringify (res._body))
.digérer()
.toString ('hex')
if (cache [key]! == etag) cache [key] = etag
})
}
fonction de retour (req, res, next) {
attachAfterEvent (this)
clé const = crypto.createHash ('sha512')
.update (req.url)
.digérer()
.toString ('hex')
if (clé dans le cache) res.set ('Etag', cache [key])
res.set ('Cache-Control', 'public, max-age = 120')
prochain()
}
}
node index.js
autocannon -c100 localhost: 3000 / seed / v1
http: // localhost: 3000 / seed / v1
– 100 connexions): Stat Stdév Max Temps de latence (ms) 19.47 4.29 103 Req / s 5011.11 506.2 5487 Octets /Sec 51,8 Mo 5,45 Mo 58,72 Mo