Accueil / Articles / Prévenir les mises à jour perdues dans Node.js et MongoDB en cas d’écritures concurrentes

Prévenir les mises à jour perdues dans Node.js et MongoDB en cas d’écritures concurrentes

Apprenez comment les mises à jour conditionnelles atomiques, le verrouillage optimiste basé sur les versions, les réponses 409 et les transactions empêchent les écritures concurrentes dans MongoDB de supprimer silencieusement des données.

2030 mots

Deux personnes appuient sur « Enregistrer » pour le même enregistrement ; les deux demandes renvoient 200 OK, mais les modifications de l’une d’elles disparaissent silencieusement. Rien ne plante, rien n’est enregistré, et le code responsable semble parfaitement logique lorsqu’on l’examine demande par demande. Cet article explique pourquoi de telles mises à jour perdues se produisent dans un backend typique Node.js et MongoDB, et vous fournit un ensemble d’outils pour les prévenir : mises à jour conditionnelles atomiques, verrouillage optimiste basé sur les versions, réponses 409 Conflict, transactions, ainsi que des tests capables de reproduire réellement ce phénomène.

Un scénario réaliste

Prenons le tableau de bord d’administration d’une boutique en ligne. Un produit a actuellement un prix de 100 $ et 10 unités en stock.

Un employé aux États-Unis ouvre le produit et abaisse son prix à 90 $. Presque en même temps, un collègue en Europe ouvre le même produit et met le stock à 8. Tous deux ont chargé le produit avant de l’enregistrer, ce qui signifie qu’ils modifient tous deux la même version obsolète. C’est ce point de départ commun qui est à l’origine des problèmes.

L’écart lecture-modification-écriture

Une méthode courante pour effectuer chaque modification consiste à charger le document, changer une propriété en mémoire puis à l’enregistrer. La première requête modifie le prix. (Le fragment présenté contient une copie superflue de product.price = 90; après l’appel à save() ; ignorez-la, car elle sert uniquement à illustrer le schéma.)

const product = await Product.findById(productId);

product.price = 90;

await product.save();product.price = 90;

La deuxième requête fait de même pour le champ du stock :

const product = await Product.findById(productId);

product.stock = 8;

await product.save();

Chaque requête lit l’état ancien, modifie sa copie locale et l’écrase à nouveau. findById() n’est pas la cause du problème. Le danger réside dans l’intervalle entre la lecture des données et leur écriture, car une autre requête peut modifier le même enregistrement pendant cette période. Ce qui est écrit en dernier l’emporte, et toute modification effectuée entre-temps peut être écrasée : il s’agit d’une mise à jour perdue.

Jusqu’à quel point Mongoose gère déjà cela

Il est utile d’être précis quant aux situations où ce problème se produit. Lorsque vous appelez save() sur un document Mongoose existant, Mongoose n’envoie que les champs que vous avez modifiés, sous forme de $set. Ainsi, dans l’exemple précédent où les deux requêtes touchent des champs différents, les modifications du prix et des stocks survivraient généralement toutes deux. La mise à jour perdue apparaît réellement lorsque :

  • les deux demandes modifient le même champ (deux administrateurs qui modifient le prix) ;
  • la nouvelle valeur est calculée à partir de l’ancienne (product.stock = product.stock - 1), de sorte que le deuxième utilisateur travaille avec un chiffre obsolète ;
  • votre API accepte le document complet envoyé par le client, comme c’est le cas dans un gestionnaire typique de PUT, et écrit à nouveau chaque champ, y compris ceux que un autre utilisateur vient de modifier.

D’autres ODM, drivers bruts et ORM SQL se comportent différemment, il ne faut donc pas compter sur le suivi des modifications non synchronisées comme stratégie de concurrence. Considérez par défaut les opérations lecture-modification-écriture comme non sécurisées et choisissez délibérément l’un des outils ci-dessous.

Partir de l’invariant

Au préalable de choisir une technique, déterminez ce qui ne doit en aucun cas être modifié de manière incorrecte. La réponse varie selon le domaine :

  • Pour les stocks : le nombre de pièces en stock ne doit jamais descendre en dessous de zéro.
  • Pour un éditeur administrateur : les modifications d’une personne ne doivent jamais remplacer silencieusement celles d’une autre.
  • Pour les questions financières : les modifications des soldes associés doivent rester cohérentes entre elles.
  • C’est cette règle métier, et non un schéma préféré, qui doit déterminer la solution technique.

    Mises à jour atomiques : laissez la base de données effectuer le changement

    Lorsque vous modifiez un seul champ, il est souvent inutile de lire le document du tout. Envoyez à la base de données exactement la modification que vous souhaitez apporter. Définir un prix devient alors une opération updateOne avec un paramètre $set:

    await Product.updateOne(
      { _id: productId },
      {
        $set: {
          price: 90
        }
      }
    );
    

    La modification des stocks est tout aussi indépendante :

    await Product.updateOne(
      { _id: productId },
      {
        $set: {
          stock: 8
        }
      }
    );
    

    Chaque opération décrit désormais son intention réelle, au lieu d’envoyer une ancienne version du document vers le serveur. MongoDB applique chaque mise à jour de document de manière atomique, de sorte que deux opérations sur des champs différents ne peuvent pas s’annuler mutuellement.

    Placer la règle métier à l’intérieur de la mise à jour

    Le modèle devient encore plus puissant lorsque vous intégrez la condition directement dans la requête. Supposons qu’il ne reste qu’un seul ticket. Le flux naïf consiste à lire le stock, à le vérifier dans le code de l’application puis à le décrémenter, ce qui laisse une chance à un autre acheteur d’intervenir. Au lieu de cela, faites en sorte que le filtre exprime la règle, et laissez $inc effectuer la modification au cours de la même opération :

    const result = await Product.updateOne(
      {
        _id: productId,
        stock: { $gt: 0 }
      },
      {
        $inc: {
          stock: -1
        }
      }
    );
    

    Si la mise à jour modifie un document, cela signifie que le stock était disponible au moment où l’opération a été exécutée. Si elle ne modifie rien, c’est qu’une autre demande a déjà pris la dernière unité, et vous pouvez alors informer l’utilisateur que le produit est épuisé. Il n’y a pas d’intervalle entre la vérification et l’écriture, car ce sont deux étapes distinctes en réalité. Il s’agit de l’un des modèles de concurrence les plus simples et les plus efficaces qui existent, et il fonctionne pour les compteurs, les quotas, les réservations de places ainsi que pour toute règle pouvant être exprimée sous forme de filtre de requête.

    Verrouillage optimiste pour les modifications de longue durée

    Les mises à jour atomiques ne peuvent pas couvrir tous les cas. Imaginez qu’un employé ouvre une configuration de produit complexe, passe cinq minutes à modifier plusieurs champs puis enregistre les changements. Entre-temps, un collègue a déjà enregistré des modifications sur le même produit. Le premier employé ne devrait pas écraser la version plus récente sans savoir qu’elle existe.

    La solution standard consiste à stocker un numéro de version dans le document :

    Product
    Price: $100
    Stock: 10
    Version: 7
    

    Les deux utilisateurs chargent la version 7. L’utilisateur A enregistre ses modifications en premier, et la version devient 8. L’utilisateur B conserve encore la version 7, donc son enregistrement doit signifier « appliquer ces modifications uniquement si le produit est toujours à la version 7 ». Dans MongoDB, on exprime cela en indiquant la version attendue dans le filtre et en l’incrémentant lors de la même mise à jour :

    const result = await Product.updateOne(
      {
        _id: productId,
        version: currentVersion
      },
      {
        $set: {
          price: newPrice
        },
        $inc: {
          version: 1
        }
      }
    );
    
    if (result.modifiedCount === 0) {
      return res.status(409).json({
        message: "This product was updated by another user."
      });
    }
    

    Si le filtre ne correspond plus, rien n’est écrit et le gestionnaire renvoie une indication de conflit au lieu d’ignorer silencieusement le travail effectué. Il s’agit d’un contrôle de concurrence optimiste : on suppose que les conflits sont rares, on ne verrouille rien pendant que l’utilisateur modifie des données, et on détecte le conflit au moment de l’écriture.

    Deux améliorations rendent cette approche plus fiable en pratique. Premièrement, modifiedCount === 0 correspond également au cas où le produit n’existe pas du tout ; donc en vérifiant matchedCount ou en effectuant une recherche supplémentaire, on peut renvoyer 404 pour un produit manquant et 409 uniquement en cas de véritable conflit de version. Deuxièmement, si vous utilisez des documents Mongoose plutôt que updateOne, prenez en compte l’option optimisticConcurrency au niveau du schéma ; la clé __v intégrée à Mongoose est sinon utilisée uniquement pour protéger certaines opérations sur les tableaux, et non chaque enregistrement.

    Pourquoi le statut approprié est 409 Conflict

    Un désaccord de version n’est pas une panne du serveur. L’API est en bon état et la requête est bien formulée ; elle entre simplement en conflit avec l’état actuel du ressource. 409 Conflict indique précisément cela et permet au client de réagir de manière appropriée :

    • recharger la dernière version ;
    • montrer à l’utilisateur ce qui a changé depuis qu’il a commencé ;
    • lui permettre de fusionner ses modifications ou de réessayer ;
    • appliquer des mécanismes de gestion des conflits spécifiques au produit.

    Quelle que soit l’action de l’interface utilisateur, le principe reste le même : ne jamais détruire le travail d’autrui sans en informer personne.

    Transactions : lorsque plusieurs écritures doivent réussir ensemble

    Considérez maintenant la passation d’une commande. Celle-ci peut impliquer la création du document de commande, la réserve des stocks ainsi que l’enregistrement d’informations connexes telles qu’une entrée de paiement ou une note d’audit. Si les deux premières étapes réussissent tandis que la troisième échoue, le système se retrouve dans un état d’opération inachevé.

    Lorsque plusieurs opérations doivent soit toutes aboutir, soit toutes échouer, une transaction assure cette atomicité. Conceptuellement, on commence une transaction, on effectue les écritures nécessaires puis on la confirme ; si l’une des étapes requises échoue, on annule tout et aucune écriture n’a d’effet. Dans MongoDB, les transactions multi-documents nécessitent un ensemble de réplicas ou un cluster shardé et s’exécutent via une session client.

    Cependant, les transactions ne constituent pas une solution universelle pour gérer les concurrences. Elles coûtent plus cher, peuvent échouer en cas de conflits d’écriture et nécessitent une logique de tentative répétée. Utilisez-les lorsque l’opération métier exige véritablement une cohérence du type tout ou rien, et préférez une mise à jour conditionnelle unique lorsque cela suffit.

    Choisir l’outil adéquat

    Au lieu de commencer par « devrions-nous utiliser le verrouillage optimiste ? », commencez par « quel échec voulons-nous éviter ? » :

    • Une modification sur un seul champ ou conditionnelle, comme la décrémentation des stocks uniquement lorsqu’ils sont disponibles : utilisez une mise à jour atomique.
    • Des modifications obsolètes provenant d’utilisateurs travaillant sur des données anciennes, comme deux administrateurs modifiant un même produit : utilisez le contrôle de concurrence optimiste.
    • Plusieurs écritures qui doivent réussir ou échouer ensemble, comme les modifications des commandes, des stocks et des comptes : utilisez une transaction.
  • Conflit très élevé sur les mêmes données : envisagez le verrouillage pessimiste, l’enfilement des tâches ou leur partitionnement, en fonction de la charge de travail.
  • Aucun modèle unique ne convient à tous les systèmes, et de nombreuses fonctionnalités réelles combinent deux d’entre eux.

    Réproduire le problème de concurrence dans des tests

    Un test où l’utilisateur A met à jour un produit et reçoit une réponse de succès ne prouve rien concernant la concurrence. Les tests normaux exécutent les opérations une après l’autre, ce qui explique précisément pourquoi ces bugs échappent à leur détection. Il faut créer ce problème de concurrence pour pouvoir le tester.

    Pour le cas du stock, commencez avec une quantité de 1 et lancez simultanément 100 tentatives d’achat, par exemple à l’aide de Promise.all. Le résultat attendu est qu’une seule réservation réussisse et que les 99 autres soient clairement rejetées, le stock se terminant à zéro plutôt qu’à une valeur négative.

    Pour le verrouillage optimiste, définissez la version sur 10 et envoyez plusieurs mises à jour qui indiquent toutes la version 10. Vous devriez voir une d’entre elles réussir et augmenter la version, tandis que les autres rencontreront des conflits au lieu d’écraser les données plus récentes.

    À surveiller en production

    Après le déploiement, assurez-vous que ces indicateurs soient visibles dans vos métriques et journaux :

    • réponses 409 Conflict ;
    • mises à jour conditionnelles qui ne correspondent à rien ;
    • retries et annulations de transactions ;
    • deadlocks et conflits de verrouillage ;
    • mouvements d’inventaire inattendus ;
    • opérations dupliquées ;
    • autres erreurs liées à la concurrence.

    notre guide sur les endpoints POST idempotents.

    Ne vous demandez donc pas si deux requêtes pourraient atteindre le même code en même temps ; supposez qu’elles le feront. Une bonne habitude de révision consiste à se demander, pour chaque mise à jour, quel en serait le résultat si deux copies étaient exécutées simultanément. Si la conception répond clairement à cette question, vous êtes sur de bon chemin. Si la réponse honnête est « espérons que la deuxième requête ne cause aucun problème », le code nécessite un nouvel examen.

    Points clés

    • Une mise à jour perdue se produit lorsque un changement valide est écrasé par un autre issu de données obsolètes, généralement via une opération lecture-modification-écriture.
    • Préférez les mises à jour atomiques et conditionnelles qui intégrent la règle métier dans le filtre.
    • Utilisez un champ de version ainsi qu’une réponse 409 Conflict pour détecter les modifications obsolètes plutôt que de les écraser.
    • N’optez pour des transactions que lorsque plusieurs écritures doivent être confirmées ou annulées ensemble.
  • Testez avec des requêtes véritablement concurrentes et surveillez les conflits en production ; concevez votre système pour faire face aux requêtes qui peuvent se chevaucher, et non seulement à celles que vous prévoyez.
  • Lectures complémentaires