Explication des flux Node.js : comment résoudre les plantages dus à un manque de mémoire pour les fichiers
Découvrez pourquoi le chargement de fichiers entiers en mémoire provoque la panne des serveurs Node.js, et comment les flux lisible, écrivable, duplex et de transformation résolvent ce problème grâce à la contrainte de débit.
Imaginez un serveur de production qui tombe en panne au milieu d’un après-midi ordinaire et calme.
Il n’y avait ni pic de trafic ni afflux soudain d’utilisateurs simultanés. Seule une personne utilisait l’application, en cliquant sur un bouton pour exporter un rapport volumineux.
En quelques secondes, le processus cessa complètement de répondre, et la console afficha un message familier : "JavaScript heap out of memory."
Si vous avez déjà rencontré cette erreur, vous savez à quel point elle est inquiétante.
La réaction naturelle est la confusion. Comment un seul fichier, demandé par un seul utilisateur, peut-il faire tomber toute une application en cours d’exécution ?
Ce type d’incident est un excellent enseignement. Il met en évidence un concept fondamental de Node.js que tout développeur backend doit finir par comprendre : les flux.
L’erreur commune des débutants
Lorsque les développeurs découvrent Node.js, ils ont généralement recours aux outils les plus simples disponibles.
Pour lire un fichier depuis le disque, la solution privilégiée est souvent fs.readFile(). Elle est simple d’utilisation : on passe un chemin, on utilise une fonction de rappel ou await, et le contenu complet du fichier est restitué.
Une version typique de ce code ressemble à ceci :
import fs from 'node:fs/promises';
async function sendFile(filePath) {
// Reading the entire file at once
const bigData = await fs.readFile(filePath);
return bigData;
}
Cette approche fonctionne bien tant que les fichiers restent de petite taille. Un fichier texte de 50 kilooctets se charge instantanément, tout comme une petite photo de profil.
Puisque tout fonctionne correctement lors des tests locaux, on a tendance à penser que le code est prêt pour la production tel quel.
Puis la réalité reprend ses droits.
Pourquoi lire tout d’un coup échoue
Examinez comment la RAM de votre machine est réellement utilisée. Lorsque fs.readFile() est exécuté, Node.js charge tout le fichier en mémoire, octet par octet, avant de vous le restituer.
Supposons que votre serveur n’ait alloué qu’un gigaoctet de RAM à l’application.
Imaginons maintenant qu’un utilisateur tente de télécharger une vidéo ou demande un fichier de journal brut de 900 mégaoctets.
L’appel à fs.readFile() sur ce fichier de 900 mégaoctets déclenche une chaîne d’événements :
- Node.js demande immédiatement 900 mégaoctets de mémoire au système d’exploitation.
- Le collecteur de déchets travaille plus dur à mesure que la mémoire disponible diminue.
- Si un deuxième utilisateur demande le même fichier en même temps, la demande en mémoire passe à 1800 mégaoctets.
- Le serveur épuise son budget de mémoire et plante complètement.
Ce dysfonctionnement n’est pas dû à un fichier corrompu. Il se produit parce que tout le chargement est avalé d’un seul coup au lieu d’être consommé progressivement.
Qu’est-ce que les flux en termes simples ?
Éloignez-vous un instant du code et pensez à une analogie du monde réel.
Imaginons que vous ayez besoin de transférer de l’eau d’un grand lac dans le jardin de votre arrière-cour.
Vous ne tenteriez pas de remplir un seau énorme avec toute l’eau du lac pour ensuite le transporter — c’est simplement un poids trop important à soulever par qui que ce soit.
Au lieu de cela, vous connecteriez un tuyau d’arrosage.
L’eau s’écoule dans ce tuyau sous forme d’un filet fin et continu : un peu d’eau entre à une extrémité, parcourt le tuyau et sort de l’autre côté pour atteindre le sol.
Avec simplement un tuyau étroit, vous pouvez transporter des millions de litres au fil du temps, sans jamais avoir à soulever toute la quantité d’un coup.
Un flux dans Node.js fonctionne exactement comme ce tuyau.
Au lieu de charger tout un fichier en mémoire d’un seul coup, un flux le lit par de petits morceaux faciles à gérer appelés chunks.
Par défaut, un chunk fait généralement environ 64 kilooctets.
Node.js récupère un chunk, le traite, le transmet à l’endroit où il doit être envoyé, puis le libère de la mémoire avant de passer au chunk suivant.
C’est pourquoi un serveur peut transmettre un fichier de 10 gigaoctets tout en consommant seulement environ 20 à 30 mégooctets de RAM.
Les quatre types de flux dans Node.js
Node.js propose quatre éléments de base pour travailler avec des données en flux. Vous n’avez pas besoin de maîtriser tous les détails immédiatement, mais il est utile de savoir comment chacun s’appelle :
1. Flux lisible
Un flux lisible est celui à partir duquel on récupère des données.
- Les exemples incluent la lecture d’un fichier depuis le disque, la réception du corps d’une requête HTTP entrante, ou la lecture des lignes issues d’une requête à une base de données.
2. Flux écrivables
Un flux écrivable est un flux dans lequel on insère des données.
- Les exemples incluent l’écriture de contenu dans un nouveau fichier, l’envoi d’une réponse à un navigateur, ou l’écriture de bytes via un socket réseau.
3. Flux duplex
Un flux duplex vous permet d’effectuer les deux opérations en même temps : vous pouvez y lire et y écrire simultanément.
- Exemple : une connexion réseau, telle qu’un socket TCP, où vous envoyez des données et en recevez en retour via la même connexion.
4. Flux de transformation
Un flux de transformation est un flux duplex spécialisé. Son rôle est de modifier les données au fur et à mesure qu’elles le traversent, plutôt que de se contenter de les transmettre telles quelles.
- Exemple : compresser un fichier au format
.gzippendant son transfert, ou chiffrer le texte en cours d’enregistrement sur disque.
Voir la différence : exemples de code
Comparons ces approches à l’aide d’un scénario concret. Imaginez que vous développez un serveur HTTP de base permettant aux visiteurs de télécharger un grand fichier.
La mauvaise méthode (forte consommation de mémoire)
JavaScript
import http from 'node:http';
import fs from 'node:fs/promises';
const server = http.createServer(async (req, res) => {
try {
// We load the whole file into RAM first
const fileData = await fs.readFile('./massive-dataset.csv');
res.writeHead(200, { 'Content-Type': 'text/csv' });
res.end(fileData);
} catch (error) {
res.writeHead(500);
res.end('Something broke');
}
});server.listen(3000);
Si massive-dataset.csv fait 2 gigaoctets, ce code tentera de conserver l’intégralité de ces 2 gigaoctets en mémoire avant même d’envoyer un seul octet au client. Dans la plupart des configurations de hébergement cloud, cela provoquera l’arrêt immédiat du processus.
La meilleure méthode (faible consommation de mémoire)
Construisons maintenant la même fonction de téléchargement en utilisant des flux au lieu de cela :
JavaScript
import http from 'node:http';
import fs from 'node:fs';
const server = http.createServer((req, res) => {
// We create a readable stream
const readStream = fs.createReadStream('./massive-dataset.csv'); res.writeHead(200, { 'Content-Type': 'text/csv' }); // We connect our read stream directly to the response
readStream.pipe(res); readStream.on('error', (err) => {
res.writeHead(500);
res.end('File not found or error reading');
});
});server.listen(3000);
Remarquez l’appel à .pipe() ?
Cette seule invocation de méthode permet d’obtenir un résultat puissant : elle relie directement le flux de lecture du fichier à la réponse HTTP envoyée (res).
Dès que le disque fournit le premier petit morceau de données (par exemple, 64 KB), Node.js le transmet immédiatement au client. Il n’est pas nécessaire d’attendre que tout le fichier ait été lu. L’utilisation de la mémoire reste faible et stable pendant toute la durée du téléchargement.
Comprendre le backpressure (le problème de congestion)
Il existe un concept fondamental dans le streaming que tout développeur devrait maîtriser : le backpressure.
Retournons un instant à l’analogie du tuyau d’arrosage.
Imaginez que vous forcez de l’eau à entrer dans un tuyau à 100 litres par seconde, tandis que la vanne de sortie ne laisse s’échapper que 10 litres par seconde.
La pression continue d’augmenter à l’intérieur du tuyau, et s’il n’est pas suffisamment solide, il éclate.
Le même type de problème se produit constamment dans les logiciels. Une SSD peut fournir des données à des vitesses de plusieurs centaines de mégaoctets par seconde. En revanche, la personne qui télécharge votre fichier peut être connectée via une connexion mobile lente.
Ainsi, si Node.js continue de récupérer des données du disque plus rapidement que le client ne peut les recevoir, où vont finir ces données excédentaires ?
Elles s’accumulent dans la RAM de votre serveur, en attendant d’être envoyées.
Si cela n’est pas contrôlé, cela annule tout l’intérêt de l’utilisation des flux, car la consommation de mémoire remonte aussitôt à son niveau initial.
Comment le Node.js moderne résout ce problème
Heureusement, les versions actuelles de Node.js intègrent une solution intégrée pour ce problème précis : la fonction pipeline, disponible dans le module stream/promises.
Au lieu de compter sur l’ancienne méthode .pipe(), le code moderne devrait privilégier pipeline:
JavaScript
import http from 'node:http';
import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
const server = http.createServer(async (req, res) => {
const readStream = fs.createReadStream('./massive-dataset.csv'); try {
// pipeline handles backpressure and cleans up automatically
await pipeline(readStream, res);
} catch (error) {
if (!res.headersSent) {
res.writeHead(500);
res.end('Transfer failed');
}
}
});server.listen(3000);
Alors, qu’est-ce qui fait de pipeline un choix meilleur que .pipe() ?
- Il réagit aux vitesses incompatibles : lorsque le client est lent à recevoir les données, il pause automatiquement le flux lisible jusqu’à ce que le client puisse en accepter davantage.
- Il gère les erreurs de manière appropriée : si quelqu’un ferme son navigateur au milieu du téléchargement,
pipelinearrête le flux de lecture et libère correctement le handle du fichier, évitant ainsi les fuites mémoire.
Situations réelles où les flux vous sont utiles
Les flux ne sont pas réservés uniquement au transfert de gros fichiers vidéo ou de téléchargements volumineux. Ils apparaissent discrètement dans toutes sortes de scénarios de production quotidiens :
- Traitement des journaux : Il n’est pas nécessaire de charger tout le fichier dans la mémoire pour analyser un journal serveur volumineux à la recherche d’erreurs. Vous pouvez plutôt le parcourir ligne par ligne.
- Transformation d’images et de vidéos :Lorsqu’une personne télécharge une photo en haute résolution, vous pouvez acheminer directement le fichier reçu vers un outil de redimensionnement d’images, en évitant l’étape de sauvegarde préalable du fichier brut sur le disque.
- Exportation de bases de données :Lors de l’exportation de millions de lignes vers un fichier CSV, récupérez les lignes par petits groupes à partir d’un curseur de base de données et transmettez-les directement au client au fur et à mesure qu’elles arrivent.
- Chiffrement des données : Chiffrer les informations sensibles en temps réel au moment où elles sont écrites dans le stockage cloud.
Erreurs courantes à éviter
Même les développeurs qui comprennent la théorie des flux peuvent se heurter à quelques pièges pratiques :
- Défaut des gestionneurs d’erreurs : Les anciennes API de flux ne propagent pas automatiquement les erreurs. Si une étape de votre pipeline génère une erreur et qu’aucun mécanisme n’est prévu pour la gérer, tout le processus peut échouer. Préférez utiliser
pipelineou écoutez explicitement l’événement'error'. - Transformation des flux en buffers : Il est tentant de collecter tous les événements
'data'dans un tableau pour ensuite concaténer tout cela en une seule grande chaîne de caractères ou buffer. Cela annule les avantages en termes de mémoire que vous cherchiez à obtenir au départ. - Laisser des ressources ouvertes : Si une opération échoue en cours de route, assurez-vous que tous les descripteurs de fichiers ouverts soient correctement fermés afin qu’ils ne restent pas en attente.
Conclusions finales
Lorsque les développeurs sont novices en programmation, ils ont tendance à envisager les données comme quelque chose de fixe et complet, prêt à être utilisé, qu’il s’agisse d’un fichier entier, d’une table de base de données complète ou d’une réponse finale.
Travailler professionnellement sur des systèmes backend exige de renoncer à ce modèle mental.
Les données ne sont pas toujours un objet solide et immobile. Le plus souvent, elles se comportent comme une rivière en cours de flux.
Il n’est pas nécessaire de rassembler toute la rivière pour interagir avec elle. Il suffit de la laisser s’écouler à côté de soi, petit à petit.
Dès que les flux font partie de votre outil de travail, les gros fichiers cessent d’être une source d’inquiétude. Votre infrastructure peut fonctionner sur des serveurs plus légers et moins coûteux. Vos applications répondent mieux aux utilisateurs. Et peut-être le plus important : vous pouvez être tranquille, sachant qu’un téléchargement inattendu de 2 Go ne fera pas planter votre serveur au milieu de la nuit.
Lectures complémentaires
- Explication de la concurrence en Node.js : libuv, la boucle d’événements et le pool de threads — Découvrez comment Node.js utilise les primitives du système d’exploitation de libuv ainsi que son pool de threads travailleurs pour gérer les opérations I/O asynchrones, ainsi que les pièges courants liés au pool de threads et des conseils d’optimisation.