Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1115 minIntermédiaire

Transactions MongoDB : quand et comment les utiliser

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • dire quand une transaction est nécessaire, et quand elle ne l'est pas ;
  • préparer un replica set pour les transactions ;
  • écrire une transaction avec le driver ou avec Mongoose ;
  • éviter le piège de la session oubliée.

3 exercices corrigés

Une transaction MongoDB regroupe plusieurs opérations, sur un ou plusieurs documents et collections, pour qu'elles soient appliquées toutes ou aucune : si l'une échoue, MongoDB annule les autres. MongoDB offre ces transactions ACID multi-documents depuis la version 4.0, à condition que la base tourne en replica set (ou en cluster réparti). Ce chapitre explique pourquoi on en a moins souvent besoin qu'en SQL, puis comment les écrire dans mongosh, avec le driver et avec Mongoose, sans tomber dans le piège de la session oubliée.

Un document est déjà atomique

En SQL, enregistrer une fiche et ses lignes touche trois tables : il faut une transaction pour que la fiche n'existe jamais sans ses lignes (transactions SQL). En MongoDB, la fiche et ses lignes sont un seul document, et toute écriture sur un document est atomique : un updateOne qui ajoute une ligne et change dateModif est appliqué en entier ou pas du tout, même si le serveur tombe au milieu, et aucun autre client ne voit un état intermédiaire.

C'est l'un des intérêts de l'imbrication (chapitre 6) : la plupart des opérations d'AppliFrais (saisir une ligne, clôturer une fiche, la valider) ne touchent qu'un document et n'ont besoin d'aucune transaction. La technique du chapitre 4 complète cette garantie : mettre la condition métier dans le filtre ({ _id, etat: 'CR' }) rend l'opération sûre même si deux requêtes arrivent en même temps.

Quand une transaction devient nécessaire

Une transaction s'impose quand plusieurs documents doivent changer ensemble et qu'un état intermédiaire serait faux. Dans AppliFrais, la mise en paiement en est un exemple : le comptable passe la fiche à l'état MP et crée un document dans une collection paiements, que le service de paie lit pour faire le virement. Sans transaction, une panne entre les deux écritures laisserait une fiche « mise en paiement » sans ordre de paiement : le visiteur ne serait jamais remboursé, et personne ne le verrait.

SituationTransaction ?Pourquoi
Ajouter une ligne à une ficheNonUn seul document
Clôturer une fiche si elle est en coursNonUn seul document, condition dans le filtre
Mettre en paiement : fiche à MP et création du paiementOuiDeux collections qui doivent rester cohérentes
Valider une fiche et écrire l'événement dans un journal d'auditOui, si le journal fait foiDeux documents ; ou bien imbriquer l'historique dans la fiche
Transférer un véhicule d'un visiteur à un autre (deux documents visiteurs)OuiSinon le véhicule peut se retrouver chez les deux, ou chez aucun

La condition : un replica set

MongoDB n'accepte les transactions que sur un replica set (un groupe de serveurs qui se répliquent, réplication des données) ou un cluster réparti. Sur un serveur isolé (standalone), toute transaction échoue avec l'erreur Transaction numbers are only allowed on a replica set member or mongos.

  • Atlas : toutes les bases, y compris l'offre gratuite, sont des replica sets de trois serveurs. Rien à faire.
  • Installation locale : lance mongod avec l'option --replSet rs0 (ou replication.replSetName: rs0 dans mongod.conf), puis, une seule fois, dans mongosh : rs.initiate(). Un replica set d'un seul membre suffit pour développer.
  • Docker : l'image démarre MongoDB seul. Pour le développement local, cette variante du compose.yaml démarre un replica set d'un membre et l'initialise grâce au test de santé :
yaml
services:
  mongo:
    image: mongo:9
    command: ["--replSet", "rs0", "--bind_ip_all"]
    ports:
      - "127.0.0.1:27017:27017"
    volumes:
      - mongo-data:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval",
             "try { rs.status().ok } catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: 'localhost:27017' }] }).ok }"]
      interval: 5s
      start_period: 10s
volumes:
  mongo-data:

Cette variante n'active pas l'authentification (un replica set authentifié exige en plus un fichier de clé partagé entre les membres) : réserve-la à ton poste, avec le port publié sur 127.0.0.1 seulement. Connecte-toi avec mongodb://localhost:27017/appli_frais?directConnection=true.

Une transaction dans mongosh

Une transaction vit dans une session. Dans mongosh, on démarre une session, on récupère la base à travers la session, puis on encadre les opérations par startTransaction et commitTransaction (ou abortTransaction) :

js
const session = db.getMongo().startSession();
const base = session.getDatabase('appli_frais');     // les opérations passent par la session

session.startTransaction();
base.fiches.updateOne(
  { visiteur: ObjectId('6a0000000000000000000002'), mois: '2026-09' },
  { $set: { etat: 'VA', montantValide: 1394.8 } }
);
base.journal.insertOne({ action: 'validation', mois: '2026-09',
                         visiteur: ObjectId('6a0000000000000000000002'), le: new Date() });

base.fiches.findOne({ visiteur: ObjectId('6a0000000000000000000002'), mois: '2026-09' }).etat  // 'VA'
db.fiches.findOne({ visiteur: ObjectId('6a0000000000000000000002'), mois: '2026-09' }).etat    // 'CL'

session.abortTransaction();      // ou session.commitTransaction()
session.endSession();

Pendant la transaction, la fiche est déjà VA dans la session, mais toujours CL pour les autres clients (db, hors session) : c'est l'isolation. Après abortTransaction(), la fiche est restée CL et le journal est vide ; avec commitTransaction(), les deux écritures deviennent visibles en même temps.

Avec le driver : withTransaction

Dans une application, on n'appelle pas startTransaction et commitTransaction à la main : session.withTransaction() les gère, réessaie automatiquement la transaction en cas d'erreur passagère (un conflit d'écriture avec une autre transaction, une élection de serveur principal) et l'annule si ta fonction lance une erreur.

js
const session = client.startSession();
try {
  await session.withTransaction(async () => {
    const fiche = await fiches.findOneAndUpdate(
      { _id: idFiche, etat: 'VA' },
      { $set: { etat: 'MP' } },
      { session, returnDocument: 'after' }          // { session } sur CHAQUE opération
    );
    if (!fiche) throw new Error("Fiche introuvable ou pas à l'état VA");
    await paiements.insertOne(
      { fiche: fiche._id, visiteur: fiche.visiteur, montant: fiche.montantValide, ordonneLe: new Date() },
      { session }
    );
  });
} finally {
  await session.endSession();
}

Avec Mongoose : connection.transaction

Mongoose ajoute connection.transaction(), qui crée la session, appelle withTransaction, et remet les documents Mongoose dans leur état d'origine si la transaction est rejouée.

js
// src/services/paiement.service.js
import mongoose from 'mongoose';
import { Fiche } from '../models/Fiche.js';
import { Paiement } from '../models/Paiement.js';   // fiche (unique), visiteur, montant, ordonnePar

export function mettreEnPaiement(idFiche, idComptable) {
  return mongoose.connection.transaction(async (session) => {
    const fiche = await Fiche.findOneAndUpdate(
      { _id: idFiche, etat: 'VA' },
      { $set: { etat: 'MP' } },
      { session, returnDocument: 'after' },
    );
    if (!fiche) throw new Error("Fiche introuvable ou pas à l'état VA");

    const [paiement] = await Paiement.create(
      [{ fiche: fiche._id, visiteur: fiche.visiteur, montant: fiche.montantValide, ordonnePar: idComptable }],
      { session },          // avec une session, create prend un TABLEAU de documents
    );
    return paiement;
  });
}

Ce qui se passe sur le jeu de données, quand le comptable met en paiement la fiche de septembre de Lucas Martin (898,90 €) :

AppelRésultat
1er appelLa fiche passe à MP, un paiement de 898,90 € est créé
2e appel, même ficheFiche introuvable ou pas à l'état VA : rien n'est modifié
Fiche remise à VA par erreur, puis 3e appelLa fiche passe à MP dans la transaction, puis la création du paiement échoue (E11000 : l'index unique sur fiche interdit un second paiement) ; la transaction est annulée et la fiche reste à VA

Les règles d'une bonne transaction

  • Courte. Une transaction est annulée si elle dure plus de 60 secondes (paramètre transactionLifetimeLimitSeconds). Ne fais jamais d'appel réseau extérieur (un service de paiement, l'envoi d'un courriel) dans une transaction : fais-le après la validation.
  • Rare. Une transaction bloque les documents qu'elle modifie pour les autres transactions et consomme des ressources ; MongoDB 9.0 limite par défaut à 10 000 le nombre de transactions ouvertes en même temps (erreur TooManyOpenTransactions au-delà).
  • Rejouable. withTransaction peut exécuter ta fonction plusieurs fois : elle ne doit avoir aucun effet hors de la base (pas de compteur en mémoire, pas de journal écrit dans un fichier) avant la validation.
  • Avec des garanties fortes. Toutes les lectures d'une transaction voient le même état des données. Avec la confirmation d'écriture w: 'majority', le réglage par défaut depuis MongoDB 5.0, la validation n'est confirmée que lorsque la majorité des membres du replica set l'ont enregistrée : un serveur qui tombe juste après ne la fait pas disparaître.

Exercices

Exercice 1 sur 3 Transaction ou pas ? Réussi À revoir

Pour chaque opération d'AppliFrais, dis si une transaction est nécessaire, et justifie.

  1. Un visiteur ajoute trois frais hors forfait à sa fiche en une seule requête.
  2. Le comptable valide une fiche : il fixe montantValide et passe l'état à VA.
  3. Le comptable refuse un frais hors forfait : il le retire de la fiche et l'ajoute dans une collection fraisRefuses, consultée par le service d'audit.
  4. Une nuit par mois, un script passe toutes les fiches MP à l'état RB après le virement.
Voir la réponseMasquer la réponse de l'exercice 1
  1. Non : un $push avec $each ajoute les trois frais au même document, en une écriture atomique.
  2. Non : deux champs du même document, modifiés par un seul updateOne avec $set, et la condition etat: 'CL' dans le filtre.
  3. Oui : deux documents dans deux collections ; sans transaction, une panne entre les deux écritures ferait disparaître le frais (retiré de la fiche, absent de fraisRefuses) ou le dupliquerait. Une alternative sans transaction : marquer le frais comme refusé dans la fiche (refuse: true, un motif), et laisser l'audit le lire là.
  4. Non, en général : updateMany({ etat: 'MP' }, { $set: { etat: 'RB' } }) est atomique pour chaque document, mais pas pour l'ensemble. Si le script s'arrête au milieu, une partie des fiches est passée à RB ; relancer le même updateMany termine le travail, puisque le filtre ne reprend que les fiches encore à MP. Cette opération rejouable n'a pas besoin de transaction (et une transaction de plusieurs milliers de documents serait à éviter).

Tu avais trouvé ?

Exercice 2 sur 3 Trouver l'erreur Réussi À revoir

Ce service transfère un véhicule de fonction d'un visiteur à un autre (chaque document visiteurs a un champ vehicule). En test, il arrive que les deux visiteurs se retrouvent avec le même véhicule. Pourquoi ? Corrige-le.

js
export function transfererVehicule(immat, idAncien, idNouveau) {
  return mongoose.connection.transaction(async (session) => {
    await Visiteur.updateOne({ _id: idNouveau, vehicule: null }, { $set: { vehicule: immat } });
    const r = await Visiteur.updateOne({ _id: idAncien, vehicule: immat }, { $set: { vehicule: null } }, { session });
    if (r.modifiedCount !== 1) throw new Error("L'ancien utilisateur n'a pas ce véhicule");
  });
}
Voir la réponseMasquer la réponse de l'exercice 2

La première opération n'a pas l'option { session } : elle s'exécute hors de la transaction. Quand la seconde échoue (l'ancien visiteur n'a pas ce véhicule), la transaction annule la seconde… mais pas la première : le nouveau visiteur garde le véhicule. Il faut aussi vérifier que la première modification a bien eu lieu.

export function transfererVehicule(immat, idAncien, idNouveau) {
  return mongoose.connection.transaction(async (session) => {
    const r1 = await Visiteur.updateOne(
      { _id: idNouveau, vehicule: null }, { $set: { vehicule: immat } }, { session });
    if (r1.modifiedCount !== 1) throw new Error('Le nouvel utilisateur a déjà un véhicule');
    const r2 = await Visiteur.updateOne(
      { _id: idAncien, vehicule: immat }, { $set: { vehicule: null } }, { session });
    if (r2.modifiedCount !== 1) throw new Error("L'ancien utilisateur n'a pas ce véhicule");
  });
}

Tu avais trouvé ?

Exercice 3 sur 3 Lire une erreur Réussi À revoir

Un étudiant teste sa transaction avec la base lancée par le compose.yaml du chapitre 3 et obtient MongoServerError: Transaction numbers are only allowed on a replica set member or mongos. Que se passe-t-il, et quelles sont ses deux solutions ?

Voir la réponseMasquer la réponse de l'exercice 3

Le conteneur du chapitre 3 lance MongoDB en serveur isolé (standalone), et les transactions exigent un replica set. Deux solutions : utiliser la variante compose.yaml de ce chapitre, qui démarre un replica set d'un seul membre (sans authentification, pour son poste uniquement), ou utiliser une base Atlas, qui est toujours un replica set. Pour une installation native, l'option --replSet rs0 suivie de rs.initiate() fait de même.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : API REST Node.js et MongoDB : CRUD, validation Zod et erreursRevoir le sommaire du cours