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.
| Situation | Transaction ? | Pourquoi |
|---|---|---|
| Ajouter une ligne à une fiche | Non | Un seul document |
| Clôturer une fiche si elle est en cours | Non | Un seul document, condition dans le filtre |
Mettre en paiement : fiche à MP et création du paiement | Oui | Deux collections qui doivent rester cohérentes |
| Valider une fiche et écrire l'événement dans un journal d'audit | Oui, si le journal fait foi | Deux documents ; ou bien imbriquer l'historique dans la fiche |
Transférer un véhicule d'un visiteur à un autre (deux documents visiteurs) | Oui | Sinon 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
mongodavec l'option--replSet rs0(oureplication.replSetName: rs0dansmongod.conf), puis, une seule fois, dansmongosh: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.yamldémarre un replica set d'un membre et l'initialise grâce au test de santé :
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) :
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.
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.
// 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 €) :
| Appel | Résultat |
|---|---|
| 1er appel | La fiche passe à MP, un paiement de 898,90 € est créé |
| 2e appel, même fiche | Fiche introuvable ou pas à l'état VA : rien n'est modifié |
Fiche remise à VA par erreur, puis 3e appel | La 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
TooManyOpenTransactionsau-delà). - Rejouable.
withTransactionpeut 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.
- Un visiteur ajoute trois frais hors forfait à sa fiche en une seule requête.
- Le comptable valide une fiche : il fixe
montantValideet passe l'état àVA. - 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. - Une nuit par mois, un script passe toutes les fiches
MPà l'étatRBaprès le virement.
Voir la réponseMasquer la réponse de l'exercice 1
- Non : un
$pushavec$eachajoute les trois frais au même document, en une écriture atomique. - Non : deux champs du même document, modifiés par un seul
updateOneavec$set, et la conditionetat: 'CL'dans le filtre. - 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à. - 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êmeupdateManytermine 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.
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é ?