Comment j’ai codé un jeu de pronostics en temps réel pour 800 000 utilisateurs

Baptiste GARCIN
L’Actualité Tech — Blog CBTW
7 min readJun 24, 2021

Dans la vie, il y a des petits rituels immuables qui se répètent et les compétitions internationales de football en font partie. En 2018, mon entreprise a décidé de développer un jeu de pronostics en prévision de la Coupe du monde en Russie. À l’époque, j’étais développeur back et avec l’équipe technique, nous avons parié sur le nombre de personnes qui s’inscriraient. Le consensus était autour de 500 000 inscriptions. Trois mois plus tard, c’étaient 800 000 joueurs qui avaient rejoint notre plateforme. Je vous propose un petit retour d’expérience sur les défis techniques et fonctionnels qui ont dû être relevés pour l’aspect back-end de ce projet.

Pronostics en temps réel — Photo de Riho Kroll sur Unsplash

Un jeu de pronos ? Mais qu’est-ce que c’est ?

Pour comprendre les soucis techniques que nous avons rencontrés, il faut d’abord avoir une idée des fonctionnalités de notre application et du parcours utilisateur.

Une fois inscrit, ce dernier pouvait saisir ses prévisions pour les 48 matchs de la phase de poules. Il pouvait aussi créer ou rejoindre une ou plusieurs ligues. Ces regroupements de joueurs permettaient de se comparer à ses amis grâce à un classement interne.

Par ailleurs, un classement général de tous les utilisateurs inscrits était maintenu. Si pour l’instant, les enjeux de charge semblent assez limités, c’était sans compter l’ambition de notre responsable produit. Celui-ci a insisté pour que les scores de chacun soient mis à jour à chaque but et ainsi, les classements recalculés. Ce dernier choix a fait peser des contraintes assez lourdes sur notre architecture.

D’autant plus que nous ne pouvions nous reposer sur un système de cache. En effet, toutes les données du site étant mises à jour régulièrement, il était difficile d’opter pour cette solution.

Pour récapituler, voici les principaux points de complexité :

  • Une saisie des pronostics économe en ressources.
  • Un classement général : À un instant T, l’application devait charger tous les utilisateurs en RAM.
  • Une validation des pronos très efficaces : Il fallait que la mise à jour prenne moins de 2 minutes.
  • Un processus réversible : Les fans de foot connaissent bien les facéties de l’arbitrage à l’heure de la VAR (l’assistance vidéo). Un but peut être accordé puis refusé quelques minutes après.
  • Une gestion fine de la concurrence en bases de données.
  • La nécessité de valider les pronostics en série : Il se peut qu’une équipe marque deux buts en moins de 2 minutes et il ne faut pas que plusieurs tâches de validation s’exécutent en même temps.

Environnement technique

Environnement technique : Node.js et Hapi
Environnement technique

Assez parlé de produit, parlons de stack technique ! Dans ma boîte, nous utilisions Node.js et Hapi comme framework pour l’API. Ce dernier est méconnu, mais je pense qu’il vaut le détour.

Pour le stockage, nous nous reposions sur Couchbase. Il s’agit d’une base noSQL qui a la particularité de permettre à l’utilisateur de modifier ou de ne récupérer qu’un seul champ du document. Les avantages sont nombreux et les deux plus évidents sont :

  • Une économie des ressources réseau entre l’API et la base.
  • Une gestion simplifiée des risques de concurrence entre plusieurs modifications simultanées d’un même document.

Par ailleurs, nous avons aussi utilisé Redis et Kue (un système de job-queuing très simple aujourd’hui déprécié) pour assurer l’exécution en série des tâches.

Comment ça s’organise ?

Le code a été séparé en deux parties. D’un côté, il y avait une API REST classique qui supportait les requêtes envoyées par le front et l’application mobile. De l’autre, il y avait un système de scripts qui avait pour responsabilité d’attribuer les points aux joueurs, mais aussi de mettre à jour les classements des ligues ainsi que le classement général.

Ce dernier élément est le plus complexe. Un watcher écoutait un dossier. Lorsque notre fournisseur de statistiques y déposait un fichier, un document de référence contenant les scores de tous les matchs passés et en cours, était mis à jour dans la base de données. Ensuite, une tâche était inscrite dans Redis. Et finalement, un worker se chargeait de distribuer les points aux pronostiqueurs.

Schéma de base de données

Notre schéma devait permettre de relever plusieurs défis :

  • Éviter le risque de concurrence,
  • Garantir l’intégrité transactionnelle, c’est-à-dire permettre de revenir en arrière,
  • Limiter au maximum le nombre de requêtes nécessaires.
En bleu, les documents modifiés par les scripts, en rouge, ceux modifiés par l’utilisateur

Le choix a été fait de séparer d’une part les pronostics saisis par l’utilisateur et les points attribués par l’application. Plus largement, nous avons pris le soin d’éviter que les scripts puissent muter des documents que l’utilisateur pouvait lui aussi modifier.

Cette façon de faire nous a permis d’écraser tous les pronos à chaque modification côté utilisateur.

Si nous avions opté pour la solution, certes plus élégante d’une mise à jour après chaque saisie, chaque utilisateur aurait envoyé une requête par prono. C’est-à-dire, pour le dernier jour avant le début de la compétition 300 000 (le nombre de nouveaux inscrits) x 48 (le nombre de matchs) soit plus de 14 millions d’opérations en base de données.

De plus, cette séparation entre les documents joueurs et les documents système nous a permis de nous passer facilement d’une logique incrémentale. Plutôt que d’ajouter les nouveaux points au score total, tout était recalculé à chaque but. Il était ainsi possible de relancer le script à l’infini sans risque.

Comme toutes les solutions, celles-ci ont entraîné de nouveaux problèmes. L’abandon de la logique incrémentale a fait peser une charge plus lourde sur la puissance de calcul du serveur.

Par ailleurs, il fallait 3 opérations en base de données pour valider une fiche de pronostics :

  • Récupération du document de référence, celui qui contient les scores,
  • Récupération du document utilisateur contenant les pronos de l’utilisateur,
  • Mutation du document contenant le score de l’utilisateur.

Optimisation du script de classement

Nos différents arbitrages, concernant l’architecture, ont épargné au maximum notre base de données au prix de scripts plus gourmands en ressource processeur.

Nous avons donc dû relever deux challenges :

  • Des scripts qui s’exécutent en série,
  • Des scripts qui s’exécutent en moins de 2 minutes.

Le premier point a été réglé avec l’implémentation d’un système de job queuing grâce à Kue. Avec cette librairie, nous avons pu nous assurer que plusieurs traitements de score ne se lançaient pas en même temps.

Comme vous le savez peut-être, Node.js est mono-thread, ça veut dire qu’un processus ne prend en charge qu’une tâche à la fois. Pour pouvoir exécuter des opérations de calcul en parallèle, nous avons opté pour une utilisation extensive des child_process.

Node.js permet de démarrer des sous-processus. Si cette action est faite à l’aide de la fonction fork(), un canal de communication est ouvert entre l’enfant et le parent. Sur un serveur possédant 8 cœurs, il est possible de démarrer sept moteurs Node en plus du processus principal et donc d’exécuter 7 tâches en même temps.

Dans le détail, cela se passe comme ça :

Et pour le sous-processus :

En utilisant cette façon de faire, le temps d’exécution a été réduit de plusieurs minutes.

Qu’est-ce qu’on aurait pu faire de mieux ?

Les années ont passé et j’ai eu le temps de réfléchir à des améliorations qui auraient pu nous permettre de gagner encore en efficacité.

On a vu que notre utilisation de Kue avait pour but de nous assurer une exécution en série. Grâce à ce système, notre script de calcul de points ne pouvait pas être lancé tant que le précédent n’était pas fini.

On aurait aussi pu se servir de cet outil pour paralléliser cette tâche. Il aurait suffit de diviser cette grosse opération en unité indépendante, la feuille de pronostics par exemple et ensuite de les distribuer équitablement à un groupe de workers. Cette façon de faire nous aurait permis une scalabilité horizontale avec l’ajout de worker. Avec la première version, la seule solution pour améliorer les performances était d’augmenter la puissance du serveur (et son nombre de cœur).

Et pourquoi ça n’a pas marché ?

Rappelez-vous, le consensus dans l’entreprise était que 500 000 pronostiqueurs allaient rejoindre la plateforme. Les tests ont donc été faits pour 500 000 joueurs. Lorsque nous avons dû valider 800 000 feuilles de pronostics, les performances ne suivaient plus. À cause d’une lourdeur de code, deux tableaux contentant tous les utilisateurs cohabitaient en RAM et occupaient ainsi l’intégralité de la mémoire vive. Il a donc fallu re-coder pendant le premier match de la Coupe du monde.

L’autre problème que nous avons rencontré n’était pas vraiment de notre fait. En voyant l’ampleur du trafic sur nos serveurs, notre hébergeur a cru à une attaque par déni de service et a fini par couper le service.

Vous pouvez également retrouver mon retour d’expérience en format vidéo, à l’occasion d’un talk donné sur Twitch :

Nous publions régulièrement des articles sur des sujets de développement produit web et mobile, data et analytics, sécurité, cloud, hyperautomatisation et digital workplace.
Suivez-nous pour être notifié des prochains articles et réaliser votre veille professionnelle.

Retrouvez aussi nos publications et notre actualité via notre newsletter, ainsi que nos différents réseaux sociaux : LinkedIn, Twitter, Youtube, Twitch et Instagram

Vous souhaitez en savoir plus ? Consultez notre site web et nos offres d’emploi.

--

--