Fermer

juin 7, 2018

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 à / 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 .

Le fichier index.js pour notre serveur ressemble à ceci:

 '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)

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 etagger qui calcule un en-tête ETag pour l'état le plus récent du contenu.

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:

 '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}
  
}

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

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
231 demandes en 10s, 2.4 MB en lecture

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:


 Clinic Doctor a détecté un problème de boucle d'événement
Clinic Doctor results

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: :


 Ouverture du panneau des recommandations du médecin clinique
Consultation des recommandations spécifiques au problème

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:


 Le graphique de la flamme de la clinique montre que server.on est le goulot d'étranglement
Visualisation du graphique de la flamme de la clinique

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:


 Graphique de la flamme fusionnée
Fusion du graphique de la flamme

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):


 Le graphique de la flamme a deux petits blocs en surbrillance qui représentent la fonction de traitement HTTP
Recherche du graphique de la flamme pour les fonctions de traitement HTTP

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 ):


 Le graphique de la flamme a zoomé sur une vue différente montrant les piles liées au HTTP
Piles HTTP pertinentes

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:


 Des blocs supplémentaires liés à C ++ ont été révélés dans le graphe de la flamme (vue principale)
Révéler la sérialisation et la cryptographie

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:


 Le graphique des flammes montre que les piles du système d'événements Node.js sont toujours le goulot d'étranglement
Graphique des flammes du serveur quand server.on est une fonction vide

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 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

Maintenant que nous avons découvert les zones problématiques, voyons si nous pouvons rendre le serveur plus rapide.

Low-Hanging Fruit

Mettons le serveur . 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()
  }
}

Maintenant, nous vérifions notre correction en profilant à nouveau. Démarrer le serveur dans un terminal:

 node index.js

Puis profil avec AutoCannon:

 autocannon -c100 localhost: 3000 / seed / v1

Nous devrions voir des résultats quelque part dans la gamme d'une amélioration de 200 fois (Running 10s test @ 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 /Sec51,8 Mo 5,45 Mo 58,72 Mo
50 Ko demandes dans 10s, 519,64 Mo lus

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.