La ludification a introduit une pression globalement plus forte à de multiples endroits de la plateforme. Durant les opérations de scoring, étape essentielle du jeu, de nombreuses informations doivent être écrites pour mettre à jours les scores des joueurs, leurs statistiques, les statistiques internes à la plateforme, ainsi que d’autres données concernant l’état du joueur dans le jeu en cours.
Il s’agit d’une matrice importante d’informations: pour chaque joueur, chaque opération de scoring provoque au minimum 97 opération d’écritures en base de données, jusqu’à 120 dans certains cas, et au moins 3 opérations de lecture.
En moyenne, chaque match compte 180 opportunités de prédiction (GuessOpportunity), chacune de celle ci comporte au minimum 100 prédictions (Guess).
La figure suivante représente une partie des opérations de lecture/écriture lors du scoring d'une prédiction d'un joueur.
Avant le début de nos travaux, durant une opération de scoring d’un utilisateur, chaque instruction était immédiatement envoyé au serveur Redis pour être appliqué, et la valeur de cette opération était attendu pour continuer l’exécution du programme, et passer à la seconde instruction (fig. 1).
On voit ici la charge que représente chaque opération de lecture/écriture: l'envoie d'une requête sur le réseau pour exécuter l'opération. En moyenne, et avec notre topologie de réseau, le temps d'exécution est en moyenne 9ms: de 2 à 4 ms pour envoyer la requête, de 2 à 4 ms pour l'exécution et encore 2 à 4 ms pour recevoir la réponse).
Ce temps est appelé round-trip time (RTT).
En l'état actuel de la plateforme, la durée moyenne théorique d'une opération de scoring d'une GuessOpportunity est de 180 * 108,5 * 9ms, soit environ 3 minutes. Un délai inacceptable pour notre plate forme de jeu orienté temps réel.
Notre moteur de base de données statistiques est Redis et nous savions que celui ci intègre une fonctionnalité intéressante appelé pipelining.
Afin de combiner les avantages de ses fonctionnalités et également de différer l’exécution des instructions, nous nous sommes appuyé sur un paradigme informatique vieux de plusieurs années: les promises. Par instruction, nous entendons l’expression d’une opération, la promise est composé d’une ou plusieurs instructions dont le résultat ne sera pas retourné car s’il n’est pas déjà connu, il pourra être soit différée dans le temps.
Le pipelining dans le serveur Redis permet de traiter une suite d'opérations sans que le client ne lise les résultats. Les résultats de l'ensemble des opérations est retourné sous forme d'un tuple une fois la totalité des opérations contenues dans le pipeline (ou Unité de Traitement, ou lot) traitées. À charge pour le client de valider les réponses et/ou les éventuelles erreurs.
Le client open-source Ruby pour Redis appelé redis-rb, ne possédait pas de fonctionnalités pour activer la gestion du pipelining. Il nous fallait donc écrire une librairie adaptée à nos besoins autour du client redis-rb.
Pour répondre à cette problématique concernant la pression exercée sur notre moteur de statistiques, tout en ajoutant de la visibilité à l’ensemble, il nous a semblé essentiel de découpler les instructions et leur application au niveau de la base de données.
Nous avons donc fait en sorte que les opérations de scoring ne génèrent qu’une série d’instructions compréhensibles, leur exécution peut donc être optimisée et éventuellement différée.
Nous avons écrit une bibliothèque open-source, spécifique pour créer des promises avec les commandes Redis: redis-promise. Celle-ci permet de grouper plusieurs commandes et ainsi d’optimiser les opérations d’entrée-sortie entre Redis et nos application et de différer leur exécution.
Notre première version avait pour objectif de pouvoir substituer de manière transparente le client redis-rb pour notre librairie, celle ci reprenant la même API (syntaxe) que le client d'origine. L'utilisation de notre librairie est donc invisible pour le dévelopeur. Lorsqu'une commande Redis est exprimée, l'instruction n'est pas envoyée à Redis, mais stockée dans un accumulateur sous forme d'une promesse et une référence vers celle-ci est retournée. C'est uniquement lorsque la valeur de l'opération (la référence) est utilisée que les promesses stockées dans l'accumulateur (Unit of Work) sont envoyés au serveur Redis et exécutées séquentiellement. On appelle cette opération le flush. Dans un souci de consistance des données lus, notre première version avait pour garantie d'effectuer un flush sur l'accumulateur lorsqu'une instruction d'écriture était ajoutée après une instruction de lecture. L'accumulateur courant est ensuite réinitialisé et prêt stocker de nouvelles instructions.
Après intégration de la librairie dans notre plateforme, le gain de performance était remarquable comme on peut le voir dans le schéma suivant (fig. 2).
Apres plusieurs essais, nous avons finalement réussi à optimiser la librairie pour enlever une limitation importante: le fait qu'une écriture force l'exécution des instructions précédentes. Pour cela, nous avons appliqué un mécanisme d'imbrication de Promises, qui nous permet d'exprimer des instructions plus complexes telle que, mettre dans le même accumulateur deux opérations de lecture et une opération d'écriture ayant comme paramètres les opérations de lecture. L'exécution permet de ressoudre les promesses en une ou plusieurs opération d'entré/sortie, sans que le code du programme n'est d'instruction de retour particulière à gérer.
Dans notre cas, cette amélioration nous a permis dans le meilleur des cas de n'effectuer qu'une seule opération d'entré/sortie sur l'ensemble du scoring d'un joueur (fig. 3).
En se basant sur les mêmes valeurs de calculs que précédemment, nous pouvions théoriquement espérer un net gain de performance pour le scoring d'un lot de 180 guesses de 108,5 instructions en moyenne pour un temps de calcul par instruction de 3ms plus le "cout" de la requête (6ms):
180 * 108,5 * 3ms + 2 * 3ms ~= 1 minute
Le gain de performance pour notre application est en réalité nettement supérieur que les chiffres avancés. En effet, les écritures dans Redis qui composent la plus grande partie de nos instructions durant le scoring sont effectuées de manière orthogonale par rapport à la chaine d'exécution du code: les instructions sont générés sans réaliser d'opération d'entrée/sorties. Celles-ci sont appliquées à la fin du traitement du scoring de l'utilisateur, en même temps que le résultat de sa prédiction lui est envoyé. Le ressenti de l'utilisateur par rapport à l'application est donc quasiment instantané.
L'intérêt de cette librairie est important pour l'ensemble de notre plateforme, et pourra être dans le futur d'une grande utilité pour les projets ayant les mêmes contraintes de temps par rapport aux traitements différes, et donc particulièrement les applications de traitement de données en temps réel. Nous comptons promouvoir cette librairie dans des conférences autour de la communauté Ruby.