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 :
- les codes javascript par ici : https://github.com/k33g/movie.buddy.webperfs/tree/master/node.experiments Attention, tout nâest pas optimisĂ©, câest de lâexpĂ©rimentation, donc Ă prendre avec des pincettes
- les codes de test sont par lĂ : https://github.com/k33g/movie.buddy.webperfs/tree/master/tests Attention ⊠MĂȘme remarque ;)
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 :).
Tweet