20 000 apprenants inscrits à la même session, tous connectés au même quiz noté sur cinq minutes chrono : c’est le genre de pic que peu d’architectures WordPress encaissent sans grincer. Sur une plateforme de formation professionnelle bâtie autour d’une extension maison, ce scénario s’est produit trois fois en un trimestre, à chaque campagne de certification obligatoire imposée par les entreprises clientes qui achetaient des sièges de formation par centaines.
Le suivi de progression classique — une ligne par apprenant et par leçon, mise à jour au fil de l’eau — tenait très bien la charge. Les quiz notés, eux, posaient un problème différent : plusieurs tentatives possibles par apprenant, un calcul de score qui dépend de l’ordre d’arrivée des réponses, et une obligation de traçabilité horodatée pour les organismes certifiés Qualiopi. Il fallait un modèle de données pensé pour l’écriture concurrente, pas seulement pour la lecture rapide.
Ce que l’écriture concurrente casse en silence
Le premier réflexe, sur ce type de projet, est de stocker les réponses dans une table de métadonnées classique, une ligne par question répondue. Sous faible charge, ça fonctionne. Sous forte charge, deux écritures presque simultanées sur le même apprenant peuvent se chevaucher : un UPDATE qui recalcule un score total à partir d’une lecture précédente écrase la contribution de l’écriture concurrente. Le symptôme observé en production était un score final incohérent avec le détail des réponses enregistrées, visible uniquement en réexportant les données brutes.
La cause profonde n’était pas un bug de code applicatif, mais un choix d’architecture : lire, calculer côté PHP, puis écrire, sans jamais verrouiller la ligne concernée pendant l’opération. MySQL ne protège pas automatiquement contre ce genre de race condition tant qu’on ne le lui demande pas explicitement.
Une table de tentatives plutôt qu’un score recalculé
La correction a consisté à changer de granularité. Plutôt qu’une ligne « score courant » mise à jour en continu, chaque tentative de quiz devient une ligne immuable dans une table dédiée, créée via dbDelta() :
CREATE TABLE {$wpdb->prefix}elearning_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
learner_id BIGINT UNSIGNED NOT NULL,
quiz_id BIGINT UNSIGNED NOT NULL,
answers_json LONGTEXT NOT NULL,
score DECIMAL(5,2) NOT NULL,
submitted_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY learner_quiz (learner_id, quiz_id)
) {$charset_collate};

Le score n’est plus jamais recalculé à partir d’un état partagé mutable : il est figé au moment de la soumission, dans la même transaction que l’enregistrement des réponses. Pour la certification finale, on prend simplement la meilleure tentative (ou la dernière, selon la règle métier du client) via une requête d’agrégation, jamais via une réécriture.
Verrouiller la ligne quand une mise à jour reste nécessaire
Certains cas obligent malgré tout à mettre à jour un compteur partagé, par exemple le nombre de tentatives restantes pour un apprenant. Dans ce cas précis, un simple UPDATE ... SET attempts_left = attempts_left - 1 est en réalité une opération atomique côté MySQL, à condition de ne pas passer par une lecture PHP intermédiaire :
- Éviter
$wpdb->get_var()suivi d’un$wpdb->update()recalculé en PHP. - Préférer une décrémentation SQL directe, qui reste correcte même avec des centaines d’écritures concurrentes.
- Ajouter une contrainte
CHECKou une clauseWHERE attempts_left > 0pour éviter de passer sous zéro.
Quand une véritable exclusion mutuelle est requise — par exemple pour attribuer un badge unique au premier apprenant qui termine un parcours — un verrou nommé via get_lock() côté MySQL, ou une contrainte d’unicité sur une table dédiée aux badges, évite d’avoir à gérer soi-même la synchronisation en PHP.
La traçabilité imposée par la certification
Les organismes Qualiopi exigent de pouvoir reconstituer, a posteriori, le détail exact de ce qu’un apprenant a répondu et à quel moment. Stocker chaque tentative comme une ligne immuable répond directement à cette exigence : il suffit d’exporter la table elearning_attempts filtrée sur une session, sans dépendre d’un état recalculé qui aurait pu être écrasé entre-temps. C’est un effet de bord heureux d’un choix fait d’abord pour la fiabilité technique.
Sur ce type de plateforme, la règle qu’on applique systématiquement maintenant : toute donnée qui sert de preuve doit être écrite une fois et jamais réécrite. Un score recalculable n’est pas une preuve, c’est une estimation.
Ce qu’on retient
Le passage à une table de tentatives immuables a réglé le problème d’incohérence sans complexifier le code applicatif : moins de lectures-modifications-écritures, plus d’insertions simples. Les pics à 20 000 connexions simultanées ne posent plus de souci de cohérence, seulement des questions classiques de dimensionnement serveur, traitées séparément avec de la mise en cache d’objets et un plan de montée en charge sur la base de données. La leçon générale dépasse le cadre de l’e-learning : dès qu’une donnée doit servir de preuve ou de score final, elle mérite sa propre ligne immuable plutôt qu’un compteur partagé recalculé à la volée.