Optimisation de performances avec Node et Express

Suite Ă  la prĂ©sentation de Jeff Maury Ă  laquelle j’ai eu le plaisir de participer Ă  Devoxx France 2014 sur les performances web (Web performances, regardons les rĂ©sultats de prĂšs), j’ai eu quelques retours intĂ©ressants sur la partie Node, notamment par @alexiskinsella. Du coup j’ai dĂ©cidĂ© de tenter de mettre en Ɠuvre ses conseils.

Alors, c’est un peu rĂ©barbatif Ă  mettre en Ɠuvre, Ă  lire aussi, mais les conclusions sont intĂ©ressantes.

N’ayant pas ce week-end d’instance sur le cloud Ă  ma disposition, j’ai testĂ© de la maniĂšre suivante :

  • mon serveur d’application (node + express) dans une VM Linux (2 procs + 1 Go de ram)
  • les tests lancĂ©s Ă  partir du host avec Gatling

Pour commencer j’ai utilisĂ© du node 0.6 puis du node 0.11, du express 3.5.1 puis du express 4.1.

J’ai testĂ© 2 services web qui retournent tous les 2 du json :

  • un service qui ramĂšne une liste de films (toujours la mĂȘme, tous les films) issue d’un fichier json
  • un service qui ramĂšne les 300 1ers films d’une catĂ©gorie donnĂ©e

J’ai lancĂ© 22 tests, qui m’ont dĂ©jĂ  permis de tirer quelques conclusions Ă  propos de node/express mais aussi de mes jeux de tests.

Je sais que mon environnement de tests n’est pas le plus adaptĂ© Ă  une situation rĂ©el, mais cela permet dĂ©jĂ  de mettre en Ɠuvre quelques optimisations et de vĂ©rifier certaines hypothĂšses.

Vous trouverez :

Code des services

Je vais donc tester 2 services :

Tous les films

var movies = (JSON.parse(fs.readFileSync("./db/movies.json", "utf8")));

app.get("/movies", function(req, res) {
  res.send(movies);
});  

N 1ers films d’une catĂ©gorie

puis

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  res.send(movies.filter(function(movie) {
    return movie.Genre.toLowerCase().search(new RegExp(req.params.genre.toLowerCase()),"g") != -1;
  }).slice(0,req.params.limit));
});

Test 1er service : Charger tous les films

# Code du test

class AllMoviesLoadingScenario extends Simulation {
  val title = System.getProperty("title", "localhost")
  val server = System.getProperty("buddyserver", "http://192.168.128.142:3000");
  val totalUsers = toInt(System.getProperty("gatling.users", "100"));
  val loops = toInt(System.getProperty("gatling.loops", "100"));
  val scn = scenario("Loading all movies (" + totalUsers + " users/" + loops + " loops)").repeat(loops) {
    exec(
      http("Loading all movies")
        .get(server + "/movies")
        .check(status.is(200)))
  }
  setUp(scn
    .inject(ramp(totalUsers users) over (totalUsers seconds)))
}

# RĂ©sultats

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
01- Tous les films 6     10000 1578

Comme cela, mĂȘme sans comparer avec une autre techno (ce sera un autre exercice), ce n’est pas super puissant 


# Optimisation du service

Avant:

app.get("/movies", function(req, res) {
  res.send(movies);
}); 

AprĂšs :

app.get("/movies", function(req, res) {
  res.sendfile("./db/movies.json", "utf8");
});

# RĂ©sultats aprĂšs optimisation

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
02- Tous les films 99 10000     100

# ???

DĂ©jĂ  allez lire l’introduction Ă  node par CĂ©dric Exbrayat http://hypedrivendev.wordpress.com/2011/06/28/getting-started-with-node-js-part-1/.

Je cite : “Node.js ne se base pas sur des threads : c’est un serveur asynchrone qui utilise un process monothread et des I/O non bloquants. L’asynchronisme permet au thread d’exĂ©cuter des callbacks lorsqu’il est notifiĂ© d’une connexion” 
 “Les I/O regroupent les accĂšs disques, accĂšs base de donnĂ©es, accĂšs rĂ©seaux, bref tout ce qui prend du temps sur un ordinateur moderne (latence, dĂ©bit limitĂ© etc
). Ici tous les I/O sont non bloquants, c’est Ă  dire que tout appel est effectuĂ© en asynchrone, avec un callback qui sera exĂ©cutĂ© une fois l’accĂšs rĂ©alisĂ©.”

En fait la 1Ăšre fois, j’allais lire mes donnĂ©es en mĂ©moire, du coup je ne profitais pas des I/O et finalement je perdais les avantages de l’asynchrone.

Merci encore Ă  RĂ©mi Forax pour ses explications sur le sujet :)

# MĂȘme test avec Node 0.11

J’ai ensuite upgradĂ© la version de node, pas d’amĂ©lioration notable

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
06- Tous les films 99 10000     100

Pas d’amĂ©lioration notable

Test 2Úme service : Charger un certain nombre de films dans une catégorie donnée

On est Ă  nouveau avec du Node 0.6

# Code du test

class SomeMoviesLoadingScenario extends Simulation {
  val title = System.getProperty("title", "localhost")
  val server = System.getProperty("buddyserver", "http://192.168.128.142:3000");

  val totalUsers = Integer.getInteger("gatling.users", 100).toInt
  val delayInjection = Integer.getInteger("gatling.delay", 100).toInt
  val loops = Integer.getInteger("gatling.loops", 100).toInt
  val kindOfSearch = System.getProperty("kinsofsearch", "genre")
  val searchValue = System.getProperty("searchvalue", "comedy")
  val limit = System.getProperty("limit", "300")

  val scn = scenario(s"$title : Loading some (max $limit) movies by $kindOfSearch  = $searchValue ($totalUsers users/$loops loops)")
    .repeat(loops) {
    exec(
      http(s"Loading some (max $limit) movies by $kindOfSearch = $searchValue")
        .get(server + "/movies/search/" + kindOfSearch + "/" + searchValue + "/" +limit)
        .check(status.is(200)))
  }

  setUp(scn
    .inject(ramp(totalUsers) over (delayInjection seconds))
  )
}

# RĂ©sultats

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
03- 300 1Úres comédies 99 10000     100

# Optimisation 1 du service

Alors on m’a conseillĂ© plusieurs optimisations, comme “sortir” new RegExp() de filter

Avant :

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  res.send(movies.filter(function(movie) {
    return movie.Genre.toLowerCase().search(new RegExp(req.params.genre.toLowerCase()),"g") != -1;
  }).slice(0,req.params.limit));
});

AprĂšs :

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  var regex = new RegExp(req.params.genre.toLowerCase(),"g");
  res.send(movies.filter(function(movie) {
    return movie.Genre.toLowerCase().search(regex) != -1;
  }).slice(0,req.params.limit));
});

# RĂ©sultats

J’ai obtenu les mĂȘmes rĂ©sultats

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
04- 300 1Úres comédies 99 10000     100

Je serais tentĂ© de dire que la condition de filter n’est exĂ©cutĂ© qu’une seule fois, ce n’est pas un forEach

# Optimisation 2 du service

On m’a aussi conseillĂ© de ne pas utiliser toLowerCase() mais plutĂŽt la clause i (insensitive) des regex.

Avant :

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  var regex = new RegExp(req.params.genre.toLowerCase(),"g");
  res.send(movies.filter(function(movie) {
    return movie.Genre.toLowerCase().search(regex) != -1;
  }).slice(0,req.params.limit));
});

AprĂšs :

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  var regex = new RegExp(req.params.genre,"i");
  res.send(movies.filter(function(movie) {
    return movie.Genre.search(regex) != -1;
  }).slice(0,req.params.limit));
});

J’ai lĂ  aussi, obtenu les mĂȘmes rĂ©sultats

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
05- 300 1Úres comédies 99 10000     100

Je serais donc tentĂ© de dire que la VM de Node est plutĂŽt bien optimisĂ©e, ainsi que l’implĂ©mentation de toLowerCase()

# Optimisation 3 du service : avec Node 0.11

J’ai ensuite upgradĂ© la version de node Ă  0.11 : mĂȘmes rĂ©sultats, pas d’amĂ©lioration notable avec la derniĂšre version de Node

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
07- 300 1Úres comédies 99 10000     100

# Optimisation 4 du service : avec Node 0.11 et en jouant avec http.globalAgent.maxSockets

En standard sur ma VM j’ai http.globalAgent.maxSockets = Infinity, j’ai forcĂ© la valeur Ă  5, 50 puis 150, je n’ai pas eu d’amĂ©lioration :

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
08- 300 1Úres maxSockets=5 99 10000     100
09- 300 1Úres maxSockets=50 99 10000     100
10- 300 1Úres maxSockets=150 99 10000     100

Je m’aperçois que Node n’a aucun problĂšme Ă  servir ses 100 requĂȘtes secondes, du coup je me dĂ©cide Ă  le stresser un peu et modifie mon code de test :

Test 2Ăšme service “plus dur” : Charger un certain nombre de films dans une catĂ©gorie donnĂ©e

Pour le mĂȘme dĂ©lai d’injection, je vais augmenter le nombre d’utilisateurs :

val totalUsers = Integer.getInteger("gatling.users", 300).toInt

et le délai (qui ne change pas)

val delayInjection = Integer.getInteger("gatling.delay", 100).toInt

ce qui nous donnera 30 000 requĂȘtes

# Code du test (toujours en Node 0.11)

class SomeMoviesLoadingScenarioHarder extends Simulation {
  val title = System.getProperty("title", "localhost")
  val server = System.getProperty("buddyserver", "http://192.168.128.142:3000");

  val totalUsers = Integer.getInteger("gatling.users", 300).toInt
  val delayInjection = Integer.getInteger("gatling.delay", 100).toInt
  val loops = Integer.getInteger("gatling.loops", 100).toInt
  val kindOfSearch = System.getProperty("kinsofsearch", "genre")
  val searchValue = System.getProperty("searchvalue", "comedy")
  val limit = System.getProperty("limit", "300")

  val scn = scenario(s"$title : Loading some (max $limit) movies by $kindOfSearch  = $searchValue ($totalUsers users/$loops loops)")
    .repeat(loops) {
    exec(
      http(s"Loading some (max $limit) movies by $kindOfSearch = $searchValue")
        .get(server + "/movies/search/" + kindOfSearch + "/" + searchValue + "/" +limit)
        .check(status.is(200)))
  }

  setUp(scn
    .inject(ramp(totalUsers) over (delayInjection seconds))
  )
}

Je repars du code non optimisé du service :

Avant :

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  res.send(movies.filter(function(movie) {
    return movie.Genre.toLowerCase().search(new RegExp(req.params.genre.toLowerCase()),"g") != -1;
  }).slice(0,req.params.limit));
});

AprĂšs :

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  var regex = new RegExp(req.params.genre.toLowerCase(),"g");
  res.send(movies.filter(function(movie) {
    return movie.Genre.toLowerCase().search(regex) != -1;
  }).slice(0,req.params.limit));
});

Puis :

app.get("/movies/search/genre/:genre/:limit", function(req, res) {
  var regex = new RegExp(req.params.genre,"i");
  res.send(movies.filter(function(movie) {
    return movie.Genre.search(regex) != -1;
  }).slice(0,req.params.limit));
});

Puis : http.globalAgent.maxSockets = 400, puis http.globalAgent.maxSockets = 10

# RĂ©sultats

On voit bien que cette fois-ci, on a “stressĂ©â€ node :

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
11- 300 1ùres pas d’optimisation 132 9008 5926 15066 227
12- 300 1Ăšres optimisation 1 138 9411 5936 14653 217
13- 300 1Ăšres optimisation 2 132 9264 4434 16302 227
14- 300 1Ăšres maxSockets=400 130 9173 6322 14505 230
15- 300 1Ăšres maxSockets=10 130 5957 7220 16823 230

Ma premiÚre conclusion serait de garder la valeur par défaut de http.globalAgent.maxSockets et creuser sur les optimisations concernant les regexs (faire plus de tirs), mais je continue à penser que toLowerCase() fait trÚs bien son boulot.

Avant de passer à la suite, je vais aussi modifier mon code de test pour le chargement de tous les films, là aussi avec 300 utilisateurs avec un délai de 100 secondes.

# Code du test (toujours en Node 0.11)

class AllMoviesLoadingScenarioHarder extends Simulation {
  val title = System.getProperty("title", "localhost")
  val server = System.getProperty("buddyserver", "http://192.168.128.142:3000");
  val totalUsers = toInt(System.getProperty("gatling.users", "300"));
  val delayInjection = toInt(System.getProperty("gatling.delay", "100"));

  val loops = toInt(System.getProperty("gatling.loops", "100"));
  val scn = scenario("Loading all movies (" + totalUsers + " users/" + loops + " loops)").repeat(loops) {
    exec(
      http("Loading all movies")
        .get(server + "/movies")
        .check(status.is(200)))
  }

  setUp(scn
    .inject(ramp(totalUsers users) over (delayInjection seconds)))
}

# RĂ©sultats

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
16- Tous les films (fichier sur disque) 300 users 141 8558 6487 14955 213

Module cluster

Maintenant je vais essayer le module cluster de node avec nos 2 services : “Tous les films” et “les 300 1Ăšres comĂ©dies”, toujours sur du node 0.11. L’utilisation du module cluster de node, implique quelques modifications. Globalement, votre code applicatif est dĂ©placĂ© dans le else ci-dessous :

var cluster = require('cluster');

//...

// Code to run if we're in the master process
if (cluster.isMaster) {

  // Count the machine's CPUs
  var cpuCount = require('os').cpus().length;

  // Create a worker for each CPU
  for (var i = 0; i < cpuCount; i += 1) {
    cluster.fork();
  }

  // Listen for dying workers
  cluster.on('exit', function (worker) {

    // Replace the dead worker, we're not sentimental
    console.log('Worker ' + worker.id + ' died :(');
    cluster.fork();

  });

// Code to run if we're in a worker process
} else {
  // your application ...
}

RĂ©sultats

On s’aperçoit que la mise en Ɠuvre du module cluster est particuliùrement payante :

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
17- Tous les films (disque) 300 users / cluster 181 22207 3993 3800 165
18- 300 1Úres comédies 300 users / cluster 206 26417 3149 434 145

si on passe les tests avec seulement 100 utilisateurs :

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
19- Tous les films (disque) 100 users / cluster 99 10000     100
20- 300 1Úres comédies 100 users / cluster 99 10000     100

Pas de changement par rapport Ă  la version sans le module cluster pour le mĂȘme scĂ©nario, donc le module cluster n’est intĂ©ressant qu’à partir d’un certain nombre d’utilisateurs.

Utilisation d’Express 4

J’ai ensuite procĂ©dĂ© Ă  la mise Ă  jour d’express et relancĂ© les tests sur les 2 services (optimisĂ©s) avec 300 utilisateurs :

RĂ©sultats

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
21- Tous les films (disque) 300 users / cluster 196 25436 1777 2787 152
22- 300 1Úres comédies 300 users / cluster 232 29789 191   129

On note que la mise Ă  jour en version 4.1.x d’Express met un sĂ©rieux coup de boost Ă  l’application.

1Ăšre consolidation

  Nb Req/s t < 800ms 800ms < t < 1200ms t > 1200ms Durée (secs)
01- Tous les films (node 0.6 fichier en mémoire) 6     10000 1578
02- Tous les films (node 0.6 fichier sur disque) 99 10000     100
03- 300 1Úres comédies (node 0.6) 99 10000     100
04- 300 1Úres (node 0.6) optimisation 1 99 10000     100
05- 300 1Úres (node 0.6) optimisation 2 99 10000     100
06- Tous les films (node 0.11 fichier sur disque) 99 10000     100
07- 300 1Úres (node 0.11) optimisation 2 99 10000     100
08- 300 1Úres (node 0.11) maxSockets=5 99 10000     100
09- 300 1Úres (node 0.11) maxSockets=50 99 10000     100
10- 300 1Úres (node 0.11) maxSockets=150 99 10000     100
11- 300 1ùres pas d’optimisation 100->300 users 132 9008 5926 15066 227
12- 300 1Ăšres optimisation 1 100->300 users 138 9411 5936 14653 217
13- 300 1Ăšres optimisation 2 100->300 users 132 9264 4434 16302 227
14- 300 1Ăšres maxSockets=400 100->300 users 130 9173 6322 14505 230
15- 300 1Ăšres maxSockets=10 100->300 users 130 5957 7220 16823 230
16- Tous les films (fichier sur disque) 300 users 141 8558 6487 14955 213
17- Tous les films (disque) 300 users / cluster 181 22207 3993 3800 165
18- 300 1Úres comédies 300 users / cluster 206 26417 3149 434 145
19- Tous les films (disque) 100 users / cluster 99 10000     100
20- 300 1Úres comédies 100 users / cluster 99 10000     100
21- Tous les films (disque) 300 users / cluster exp. v4 196 25436 1777 2787 152
22- 300 1Úres comédies 300 users / cluster exp. v4 232 29789 191   129

Conclusion n°1

Pour le moment, les seul rĂ©els axes d’optimisation concernent les I/O, le module cluster et la mise Ă  jour majeure d’Express. L’exercice suivant (Ă  venir) sera de comparer avec d’autres stacks mais aussi avec un backend (type base de donnĂ©es) pour avoir une idĂ©e plus prĂ©cise. Faites des tests suffisamment “stressants” pour noter les diffĂ©rences (ex module cluster). Et bien sĂ»r, faites attention au dĂ©veloppeur :).

blog comments powered by Disqus

Related posts