Double réservation : course critique entre deux clics
Le service informatique d'un lycée de 1 400 élèves exploite une application intranet de réservation écrite en PHP 8 et MySQL 8 (InnoDB, niveau d'isolation REPEATABLE READ). Elle gère 25 salles et 40 tablettes, avec une base de 1 200 réservations. Le code vérifie la disponibilité par un SELECT COUNT(*) sur les créneaux demandés, puis exécute un INSERT si le compte est nul ; aucun verrou n'est posé, la table ne porte ni clé étrangère ni contrainte d'unicité. À 8 h, jusqu'à 60 utilisateurs valident un formulaire en quelques secondes. Depuis la rentrée, trois doubles réservations ont été signalées : dans chaque cas, deux INSERT portent la même salle et le même créneau, horodatés à moins de 200 ms d'intervalle. Un professeur s'est retrouvé sans salle devant une classe de BTS. La direction demande une correction avant les conseils de classe, et un élève de BTS SIO doit présenter la démarche en 10 minutes.
1. Expliquez la cause racine du double enregistrement. Pourquoi un contrôle fait en PHP avant l'INSERT ne peut-il pas, par principe, suffire ?
Read the worked answer
Le scénario fautif Deux utilisateurs A et B demandent la même salle au même créneau, à 180 ms d'intervalle :
- A exécute
SELECT COUNT(*) ...→ 0, la salle est libre en lecture. - B exécute la même requête avant que A n'ait inséré → 0 également.
- A exécute
INSERT→ succès (aucune contrainte ne l'en empêche). - B exécute
INSERT→ succès aussi, rien en base ne détecte le chevauchement.
C'est une condition de course (TOCTOU : time-of-check to time-of-use) entre la vérification et l'écriture.
Pourquoi le niveau d'isolation ne sauve pas la situation
En REPEATABLE READ, un SELECT non verrouillant lit un instantané MVCC : il ne pose aucun verrou sur les lignes ni sur les plages, et deux transactions voient donc simultanément un état où la salle est libre. Sans SELECT ... FOR UPDATE ni index permettant un verrou d'intervalle, les insertions fantômes ne sont pas bloquées.
Pourquoi un contrôle applicatif ne peut pas suffire en général Un test conditionnel en PHP est exécuté sur un processus différent de la transaction concurrente : il n'a aucune autorité sur ce que fait l'autre processus entre-temps. Le seul point du système capable de faire respecter un invariant sous concurrence est la base de données elle-même, via une contrainte ou un verrou. Un contrôle applicatif peut améliorer le confort (message d'erreur précoce), jamais garantir l'intégrité.
Pourquoi seulement trois cas sur 1 200 ? La fenêtre de collision dure quelques millisecondes par insertion ; elle est rare mais certaine sur 60 utilisateurs simultanés à 8 h. Trois occurrences en un trimestre sont un signal fort, pas un accident isolé, et chaque occurrence a un coût pédagogique visible.
2. Proposez une correction en deux niveaux : verrouillage applicatif et garantie en base. Justifiez le choix retenu vis-à-vis de MySQL 8 et décrivez le comportement attendu en cas de conflit.
Read the worked answer
Niveau 1 - sérialiser proprement l'accès (correction immédiate) Encadrer la vérification et l'insertion dans une transaction, et verrouiller l'actif réservé :
START TRANSACTION;
SELECT id FROM salle WHERE id = :salle FOR UPDATE;
SELECT COUNT(*) FROM reservation
WHERE salle_id = :salle
AND date_debut < :fin AND date_fin > :debut;
-- si 0, alors seulement :
INSERT INTO reservation (...) VALUES (...);
COMMIT;
Le FOR UPDATE sur la ligne salle crée un point de sérialisation : B attend A jusqu'au COMMIT, puis son COUNT voit la réservation de A et renvoie un refus propre. Coût : un verrou par salle pendant quelques millisecondes, très acceptable à 60 utilisateurs. Risque à maîtriser : verrouillage dans un ordre constant (toujours la salle d'abord) et transaction courte pour éviter l'interblocage.
Niveau 2 - garantie en base (correction durable) MySQL 8 n'offre pas de contrainte d'exclusion sur un intervalle comme PostgreSQL. La solution robuste consiste à matérialiser les créneaux réservables :
CREATE TABLE creneau_reserve (
salle_id INT NOT NULL,
jour DATE NOT NULL,
heure_debut TIME NOT NULL,
reservation_id INT NOT NULL,
PRIMARY KEY (salle_id, jour, heure_debut),
FOREIGN KEY (reservation_id) REFERENCES reservation(id),
FOREIGN KEY (salle_id) REFERENCES salle(id)
);
Une réservation de 10 h à 12 h insère deux lignes (10 h, 11 h). Toute collision devient une violation de clé primaire, donc impossible. Ajouter aussi les clés étrangères manquantes (salle_id, user_id).
Comportement attendu en cas de conflit
L'application capture l'erreur 1062 (duplicate entry) ou détecte l'absence de ligne insérée, annule la transaction et renvoie un HTTP 409 Conflict avec un message explicite : « cette salle vient d'être réservée sur ce créneau, choisissez-en une autre ». L'interface recharge le planning et remet les choix de l'utilisateur. Il ne faut jamais afficher « réservation enregistrée » avant le COMMIT.
Le niveau 1 corrige vite, le niveau 2 rend le bug structurellement impossible : les deux sont nécessaires.
3. Décrivez la démarche de reprise des trois doublons existants et le test de non-régression à présenter en 10 minutes à la direction.
Read the worked answer
1. Détecter exhaustivement Une auto-jointure sur le chevauchement d'intervalles, avec la convention « commence avant la fin de l'autre et finit après son début » :
SELECT a.id, b.id, a.salle_id, a.date_debut, a.date_fin
FROM reservation a
JOIN reservation b
ON a.salle_id = b.salle_id AND a.id < b.id
AND a.date_debut < b.date_fin AND a.date_fin > b.date_debut;
Les trois signalements ne sont qu'une partie du problème : des doublons peuvent exister sur des créneaux jamais utilisés. La requête donne l'inventaire réel, à confronter au planning papier.
2. Reprendre manuellement, avec trace Pas de suppression automatique : pour chaque conflit, appliquer une règle explicite (antériorité de la réservation, contrainte pédagogique, capacité de la salle), réaffecter la réservation perdante, prévenir les enseignants concernés, et consigner la décision dans le ticket. Un script de correction non revu est un risque juridique et relationnel.
3. Verrouiller la structure Nettoyage terminé puis ajout des contraintes (clés étrangères, table de créneaux, clé primaire composite). L'ordre est impératif : une contrainte posée sur des données sales échoue. Prévoir une sauvegarde avant migration et une procédure de retour arrière.
4. Prouver par le test
- Test de non-régression fonctionnel : deux navigateurs, même salle, même créneau, validation simultanée → un succès et un 409.
- Test de charge reproductible : script à 2 puis 10 threads, 50 itérations, sur la base de recette ; vérifier en base
COUNT(*)des chevauchements = 0. - Test de performance : mesurer le temps de réponse du formulaire avant/après, pour montrer que le verrou n'a pas dégradé l'expérience à 8 h.
5. Restitution en 10 minutes Plan : le symptôme constaté et son coût (un professeur sans salle), la cause en une phrase (condition de course), la correction en deux niveaux, la preuve par le test, la date de mise en production. Éviter le jargon non expliqué, garder deux chiffres en tête : 0 doublon après correction, temps de réponse inchangé.