Le WordPress d'aujourd'hui, décodé pour les développeurs

Extensions

Une extension e-learning à l’échelle : 20 000 apprenants et des quiz fiables

Comment stocker les réponses de quiz notés d'une plateforme de formation sans perdre de données quand des milliers d'apprenants répondent en même temps.

Par Clément Hadrot • 17 décembre 2023 • 5 min de lecture • Aucun commentaire
Une extension e-learning à l'échelle : 20 000 apprenants et des quiz fiables

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};
L'essentiel à retenir : Verrouillage pessimiste plutôt qu'un simple update ; Une table dédiée aux tentatives, pas aux réponses individuelles ; Traçabilité horodatée pour les organismes certifiés

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 CHECK ou une clause WHERE attempts_left > 0 pour é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.

Partager :

À propos de l'auteur

Clément Hadrot

Développeur WordPress, passionné par Elementor, le FSE et l’automatisation par IA.

Voir tous ses articles

Dans la même veine

À lire aussi