descriptive text Hammerbot
descriptive text
node

Limiter le nombre de requêtes concurrentes dans NodeJS

Hier, j’ai partagé un lien dans un channel discord, qui présente une technique pour faire patienter certaines requêtes qui arriveraient dans NodeJS, en attendant que le traitement d’une autre se termine.

J’ai reçu en retour un pouce négatif d’un membre qui m’a expliqué que l’article que j’avais partagé puait la merde et qu’il ne valait rien.

Alors effectivement, l’article traite d’un use case assez peu commun qui peut être adressé différemment et propose un benchmark sans grand intérêt.

Cependant, il répond tout de même à un problème:

Comment éviter que la mémoire de son application NodeJS sature lors d’un pic soudain de traffic.

On parle souvent de NodeJS comme d’un runtime permettant de traiter plusieurs milliers de requêtes à la seconde, mais dans la vraie vie, quand on a une connexion à une base de données derrière, qu’on manipule de la donnée etc, si on se prend 1000 requêtes d’un coup, notre application plante très souvent, et nos utilisateurs recevront donc une erreur comme quoi notre serveur n’est pas capable de traiter sa requête.

C’est assez embêtant. Et c’est valable quelque soit la librairie ou le framework qu’on utilise, express, fastify, nestJS ou même AdonisJS.

Pour vous expliquer un peu plus en détail ce dont je parle, je vais vous partager un petit bout de code d’une application qui pourra crasher assez vite si vous la bombardez de requêtes, et que vous n’avez que quelques MB de mémoire vive à votre disposition sur votre serveur:

import express from 'express'
import fs from 'node:fs'

const allocateMemory = async () => {
  const buffer = await fs.promises.readFile('big-file.exe') // Some 20MB file
  return buffer
}


const app = express()

app.get('/', async (req, res) => {
  const memory = await allocateMemory() // Allocate memory
  console.log(memory.byteLength) // prints the allocation size of this variable.
  res.send('ok')
})

app.listen(3000, () => {
  console.log('listening on http://localhost:3000')
})
javascript

Comme vous le voyez, à chaque requête, l’application va allouer 20MB de mémoire. Si vous recevez 1000 requêtes d’un coup, votre application tentera d’allouer 20GB de mémoire d’un coup, ce qui ne passera pas du tout sur un petit serveur.

Utilisation d’une queue

Pour répondre à ce problème, j’ai écrit un petit paquet dont vous pouvez consulter le code source ici:

https://github.com/coderhammer/express-concurrent

Je vous passe les détails pour l’instant, mais l’idée est de limiter le nombre de requêtes concurrentes grâce à un middleware pour laisser le temps au garbage collector de passer entre les requêtes et donc d’éviter à votre application d’exploser.

L’utilisation se fait de la façon suivante:

import express from 'express'
import {concurrent} from '@hammerbot/express-concurrent'

const app = express()

app.use(concurrent({
  max: 2
}))

...
typescript

Implémentation

L’implémentation est plutôt simple, on met le traitement des requêtes dans une queue, et lorsqu’on a la bande passant disponible, on execute cette queue. Pour les curieux, voici ce que ça donne:

import type { NextFunction, Request, Response } from "express";

export const concurrent = (options: { max: number }) => {
  if (options.max < 1) {
    throw new TypeError(
      "Maximum concurrent requests must be greater or equal than 1"
    );
  }

  const queue: NextFunction[] = [];

  const running = new Set<NextFunction>();

  const maxConcurrentRequests = options.max;

  const processQueue = () => {
    if (!queue.length) {
      // queue is empty, we stop processing.
      return;
    }
    if (running.size >= maxConcurrentRequests) {
      // running requests have reach maximum, we delay processing.
      return;
    }
    const next = queue.shift() as NextFunction;
    running.add(next);
    next();
  };

  const queueMiddleware = async (
    req: Request,
    res: Response,
    next: NextFunction
  ) => {
    req.on("close", () => {
      // The request is closed. Either it responded or timed out.
      running.delete(next);
      processQueue();
    });
    queue.push(next);
    processQueue();
  };

  return queueMiddleware;
};
typescript

Tests

Pour vérifier que ça répond correctement au problème, je vous conseille d’utiliser Docker pour configurer une limite de mémoire sur le conteneur. Exemple de fichier docker-compose.yml:

version: '3.5'

services:
  node:
    mem_limit: 112m
    image: node:18
    volumes: 
      - .:/app
    working_dir: /app
    ports: 
      - 3000:3000
    command: ['node', 'index.js']
yaml

Comme vous le voyez, j’ai limité la mémoire à 112m. Vous pouvez maintenant balancer un script sur votre app qui va venir envoyer plusieurs centaines de requêtes en même temps à votre application:

const promises = []

for (let i = 0; i < 100; i++) {
  promises.push(fetch('http://localhost:3000/'))
}

await Promise.all(peromises)
typescript

Constatez maintenant avant et après le réglage que votre application ne plante plus 🎉

Conclusion

Si vous lisez cet article, vous pouvez entrer en contact avec moi via l’espace commentaire ci-dessous qui fonctionne avec Discord. Vous pouvez donc bien évidemment venir directement sur Discord pour chatter comme disent les jeunes.


Chargement des commentaires