Accueil / Articles / Idées reçues courantes sur Node.js et les bases de données qui causent des bugs en production

Idées reçues courantes sur Node.js et les bases de données qui causent des bugs en production

Découvrez pourquoi async/await, le pooling de connexions et les ORM ne préviennent pas automatiquement les conditions de course, l’épuisement des connexions ou les injections SQL dans les applications Node.js.

1442 mots

Node.js et les bases de données entretiennent une relation tendue, fondée sur un piège récurrent : il est si facile de mettre en place une solution fonctionnelle que les développeurs se basent sur ce premier succès pour faire des suppositions sans jamais les remettre en question par la suite. Ces suppositions restent valables tant que le trafic réel, les utilisateurs simultanés ou de plus grands volumes de données ne révèlent pas l’écart entre ce qui semblait vrai et ce qui se passait réellement. Ci-dessous figurent cinq des idées reçues les plus dommageables, accompagnées d’une explication de ce qui se passe réellement dans chaque cas.

L’idée reçue : l’utilisation de async/await protège automatiquement les opérations sur la base de données des conditions de concurrence.

Ce qui est vraiment vrai : async/await permet simplement au code asynchrone de paraître séquentiel lorsqu’on le lit. Il ne fournit aucune garantie d’atomicité pour les opérations de base de données, et deux requêtes distinctes peuvent encore s’intercaler de manière à produire un résultat incorrect.

// looks sequential, isn't safe under concurrency
async function reserveSeat(eventId, seatNumber) {
  const seat = await db.query(
    "SELECT status FROM seats WHERE event_id = $1 AND number = $2",
    [eventId, seatNumber]
  );
  if (seat.status === "available") {
    await db.query(
      "UPDATE seats SET status = 'reserved' WHERE event_id = $1 AND number = $2",
      [eventId, seatNumber]
    );
  }
}

Deux requêtes séparées peuvent chacune exécuter la commande SELECT, voir le siège marqué comme available, et procéder à la réservation de ce même siège. Cela se produit parce que await ne fait qu’arrêter temporairement l’exécution de la requête qui l’a invoqué — il ne fait rien pour empêcher une deuxième requête, non liée à la première, de s’insérer entre la lecture et l’écriture correspondante. La véritable solution ne peut être trouvée par une correction au niveau de JavaScript ; elle doit être mise en œuvre au niveau de la base de données, en utilisant soit une mise à jour conditionnelle atomique, soit une transaction avec un verrouillage adéquat des lignes.

async function reserveSeat(eventId, seatNumber) {
  const result = await db.query(
    `UPDATE seats SET status = 'reserved'
     WHERE event_id = $1 AND number = $2 AND status = 'available'
     RETURNING *`,
    [eventId, seatNumber]
  );
  return result.rowCount > 0; // false means someone beat you to it
}

async/await n’est rien de plus qu’un sucre syntaxique pour travailler avec des promesses. Il n’a jamais été conçu pour garantir la sécurité en matière de concurrence, et le traiter comme s’il le faisait est précisément la cause des erreurs de double réservation.

L’hypothèse : comme Node s’exécute sur un seul thread, le pooling de connexions n’est pas aussi crucial qu’il le serait dans un langage multi-threadé.

La réalité : la nature single-threadée de Node concerne uniquement le mode d’exécution du JavaScript, et non la manière dont la base de données gère les opérations I/O. Un seul processus Node peut facilement exécuter des centaines de requêtes à la base de données en même temps, et chacune d’elles implique un véritable aller-retour réseau vers une base de données réelle, qui doit ouvrir, maintenir active, puis fermer une connexion réelle pour chacune d’elles.

// a new connection per query, under real traffic, this collapses fast
async function getUser(id) {
  const conn = await mysql.createConnection(config);
  const [rows] = await conn.query("SELECT * FROM users WHERE id = ?", [id]);
  await conn.end();
  return rows[0];
}

Chaque appel à une fonction comme createConnection implique un échange TCP et une étape d’authentification, et pratiquement toutes les bases de données imposent un plafond strict au nombre de connexions qu’elles acceptent en même temps. Le pooling de connexions réduit cette charge en maintenant un ensemble de connexions ouvertes à l’avance et en les distribuant au fur et à mesure des besoins :

const pool = mysql.createPool({ ...config, connectionLimit: 10 });
async function getUser(id) {
  const [rows] = await pool.query("SELECT * FROM users WHERE id = ?", [id]);
  return rows[0];
}

Le modèle de concurrence de Node est précisément la raison pour laquelle le pooling est important, et non une raison de l’ignorer. Un seul processus Node peut effectivement essayer d’exécuter des dizaines de requêtes en parallèle à tout moment.

L’hypothèse : un ORM élimine complètement la nécessité de se soucier des injections SQL.

Ce qui est vraiment vrai : Cette protection ne persiste que tant que le code reste dans l’API de construction de requêtes propre à l’ORM. Elle disparaît dès qu’une requête brute est écrite ou qu’une clause WHERE est assemblée par concaténation de chaînes — ce qui se produit plus fréquemment que prévu, surtout lorsque la requête devient suffisamment complexe pour que les abstractions de l’ORM semblent restrictives.

// still vulnerable, ORM or not
const results = await sequelize.query(
  `SELECT * FROM users WHERE email = '${userInput}'`
);

La sécurité d’un ORM provient spécifiquement des requêtes paramétrées qui s’exécutent en arrière-plan, et non d’un bouclier universel qui suivrait le code où qu’il aille. Dès que du SQL est construit en tant que simple chaîne de caractères, cette protection a disparu, quel que soit le fait qu’un ORM se trouve au-dessus de ce code :

const results = await sequelize.query(
  "SELECT * FROM users WHERE email = :email",
  { replacements: { email: userInput }, type: QueryTypes.SELECT }
);

La règle qui reste valable : les données fournies par l’utilisateur ne doivent jamais être concaténées directement dans une chaîne de requête, quel que soit le niveau d’abstraction entre le code et le SQL brut.

L’hypothèse : une erreur non gérée provenant d’une requête à la base de données aboutira automatiquement au middleware de gestion des erreurs d’Express.

La réalité : la gestion intégrée des erreurs d’Express capture les exceptions synchrones générées à l’intérieur des gestionnaires de route, ainsi que les erreurs transmises explicitement via next(err). Elle ne capture pas automatiquement une promesse rejetée provenant d’un gestionnaire de route async, sauf si la version d’Express utilisée prend en charge nativement ce comportement ou a été configurée pour le gérer manuellement.

// on many Express setups, a rejected promise here never reaches your error handler
app.get("/users/:id", async (req, res) => {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
  res.json(user);
});

Si la promesse de cette requête est rejetée et qu’il n’y a rien pour la capturer, cela entraîne un rejet de promesse non géré — ce qui, dans les versions actuelles de Node, peut faire planter tout le processus. Cela entraîne l’arrêt de toutes les autres requêtes en cours de traitement à ce moment-là, et non seulement celle qui a provoqué l’échec.

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
    res.json(user);
  } catch (err) {
    next(err); // now Express's error handler actually sees it
  }
});

Encadrer manuellement chaque route asynchrone devient rapidement fastidieux, c’est précisément pourquoi il est judicieux d’installer, dès le début d’un projet, soit un wrapper de middleware léger, soit une version d’Express disposant d’une prise en charge native des erreurs asynchrones, plutôt que de supposer que les erreurs se géreront d’elles-mêmes correctement.

L’hypothèse : une requête qui s’exécute rapidement en développement local fonctionnera de la même manière en production.

La vérité réelle : Les bases de données utilisées pour le développement local ont tendance à être petites, indexées de manière sommaire au mieux, et fonctionnent sur du matériel qui n’est pas soumis à de réelles contraintes. Une requête qui parcourt dix mille lignes sur un ordinateur portable et la même requête qui parcourt dix millions de lignes en environnement de production sont, à tous égards pratiques, des requêtes distinctes, même si le texte SQL est identique.

// fine with 500 test rows, a real problem with 5 million production rows
const orders = await db.query(
  "SELECT * FROM orders WHERE customer_email = $1 ORDER BY created_at DESC"
);

En l’absence d’index sur customer_email, cette requête provoque un balayage complet de la table, et l’écart entre une exécution « instantanée » et une exécution prenant plusieurs secondes dépend uniquement de la taille de la table — un facteur que les environnements de développement locaux ne reflètent presque jamais fidèlement. L’habitude qui offre réellement une protection n’est pas d’écrire le code différemment, mais de tester avec des volumes de données similaires à ceux en production, ou au minimum d’exécuter EXPLAIN sur une table de taille réelle avant d’affirmer que ce qui fonctionne localement indique quoi que ce soit de significatif quant au comportement sous charge réelle.

Ce qui relie les cinq points

Toutes ces idées fausses remontent à la même cause profonde : quelque chose semblait fonctionner, et ce succès apparent a été élevé au rang de règle plutôt que reconnu comme un simple résultat qui, par chance, n’a pas échoué. Node et une base de données sont deux systèmes distincts qui communiquent via un réseau, chacun ayant ses propres garanties et ses propres modes de défaillance ; la syntaxe lisible de JavaScript ne dissout pas cette frontière simplement parce qu’elle rend le code plus facile à suivre. Les correctifs réels sont rarement compliqués. La véritable compétence consiste à savoir quelles hypothèses méritent d’être mises en question en premier lieu.

Lectures complémentaires