Il y a très peu de temps chez l’un de nos clients, nous avons été confrontés à une problématique typique dans le quotidien de la plupart des développeurs : la performance. Au sein du projet, nous avions des traitements batch responsables de l’intégration d’une importante quantité de données. Le problème : les traitements étaient trop lents.
Il s’agissait d’une nouvelle application qui devait être déployée en production pour la première fois. Le client utilisait une méthodologie Cycle en V classique et ces problèmes ont été détectés pendant les tests de performance en pré-production. Comme les temps d’exécution étaient élevés, le passage en production était compromis. Dans ce contexte, un collègue et moi-même sommes intervenus pour analyser le problème et essayer d’optimiser les traitements.
Le contexte
Le traitement batch qui posait des problèmes était divisé en 4 étapes :
- Lecture des données d’une base de données source ;
- Lecture des données d’un fichier fourni par une application back-office ;
- Validation de ces données en mémoire afin de supprimer les doublons et vérifier que les données respectent les règles métiers définies par la MOA ;
- Enfin, consolidation des données traitées dans la base de données de notre application.
Après avoir analysé le code et s’être renseigné sur l’historique du projet, nous avons pu mieux comprendre les choix qui ont été faits. La donnée à intégrer était en fait un objet dont la clef primaire en base était basée sur quatre champs. Il était possible aussi de retrouver le même objet dans la base de données source et dans le fichier fourni par le back-office. Cependant, nous ne devions l’intégrer qu’une seule fois.
La solution qui était en place utilisait un cache du type Ehcache pour monter en mémoire et traiter les données intégrées de la base et du fichier. De cette façon, la coûteuse validation des données effectuée par la base en cas de doublons a été remplacée par une validation au niveau de la Heap. Ainsi, le coût d’aller/retour en base a été remplacé par le coût d’une recherche en mémoire. Par contre, malgré la stratégie mise en place, les traitements étaient encore très longs. Environ 10 heures pour 7 millions d’éléments.
L’analyse
Pour bien comprendre le problème, nous sommes partis sur une analyse du type diviser pour régner. Ainsi, nous avons isolé chaque étape du batch pour pouvoir mesurer quel était l’étape qui prenait le plus de temps.
Le batch était structuré de la façon suivante :
Pour isoler l’étape qui nous posait problème, nous avons tout simplement ajouté des compteurs et des appels System.currentTimeMillis() (à ce propos il est possible d’utiliser un framework de microbenchmark comme Caliper ou même une StopWatch, selon le besoin). Nous avons également limité le périmètre d’intégration à quelques milliers d’objets au lieu de 7 millions, ce qui nous a permis d’avoir des feedbacks sur chaque étape très rapidement. Grâce à cela, nous avons constaté que les temps de traitement partiels se dégradaient très rapidement. À chaque itération, le batch mettait plus de temps pour traiter des lots de 100 éléments.
Après avoir implémenté les modifications et lancé les traitements en local, nous avons observé que l’étape qui prenait le plus de temps était la lecture des données, soit de la base, soit du fichier. Comme le temps des étapes de lecture était bien supérieur à celui de la sauvegarde en base, nous nous sommes concentrés sur ces deux premiers.
Par réflexe, nous avons commencé par regarder la requête SQL qui définissait le périmètre en base à intégrer. Après analyse de la requête et de la logique du batch, nous avons modifié la requête pour enlever des ORDER BYs et des OUTER JOINs dont nous n’avions pas besoin. En analysant le plan de la requête sur notre client SQL, nous avons constaté que cette modification a fait diminuer considérablement le coût de notre requête : de 14.906 opérations Oracle à 982. Déjà un gain très visible au niveau des lancements effectués à partir de mon poste.
Ensuite, à l’aide d’un outil de profiling, nous nous sommes aperçus que l’application ne faisait pas une bonne utilisation du processeur. Ainsi, notre premier réflexe a été de paralléliser la lecture des données. Comme le projet utilisait Spring Batch, cette étape a été accomplie très rapidement car Spring Framework facilite énormément la mise en place du multithreading grâce aux task-executors. Nous ne rentrerons pas plus en détail sur les traitements parallélisés car ce n’est pas le sujet principal de cet article.
La parallélisation, nous a permis encore une fois de diminuer notre temps estimé pour les 7 millions d’éléments, car maintenant nous étions capable d’exploiter de manière optimale la capacité de calcul de la machine. Par contre, nous savions déjà à l’avance que même si le multithreading nous permettait de gagner en temps d’exécution, il n’était pas la source de notre problème.
Les deux étapes de chargement de données avaient la même structure et étaient divisés en trois sous-étapes. La première s’occupait du chargement des données. La deuxième, faisait une validation de données en fonction de certains critères. Et la troisième, écrivait les objets traités dans le cache en mémoire. Une structure classique proposée par Spring Batch.
En poursuivant notre enquête, nous avons mis en place JAMon. JAMon nous a permis d’avoir des statistiques plus fines sur toute la stack d’exécution, comme le temps d’exécution de chaque méthode appelée, le nombre de fois que la méthode a été appelée, le temps global passé dans la méthode, la déviation, entre autres. Avec JAMon, nous avons été capables d’identifier la méthode dans notre application qui prenait le plus de temps. Cette méthode était la méthode qui écrivait dans le cache Ehcache, donc notre étape writer.
Après analyse de la configuration du cache, nous nous sommes interrogés sur l’utilisation d’Ehcache dans notre contexte. D’une part parce que nous n’avions ni besoin d’utiliser un cache distribué (dans notre cas une application/une JVM) ni besoin de persister notre cache. D’autre part, nous n’avions pas non plus besoin de gérer le temps de vie de l’objet dans le cache, le besoin étant juste de garder les éléments en mémoire pour éviter des aller-retours en base. Ainsi, nous avons décidé de coder un simple test unitaire pour vérifier la performance d’un cache Ehcache face à une simple structure comme une HashMap.
Voici la sortie de notre cas de test :
HashMapTest - Generating for base count = 70000 ... HashMapTest - 70000 objects generated. HashMapTest - Time to write 70000 elements into the HashMap: 110ms HashMapTest - Time to read 7000000 elements from HashMap : 16140ms HashMapTest - Time to write 70000 elements into ehcache: 187ms HashMapTest - Time to read 7000000 elements from ehcache : 17078ms
Voilà le constat, manipuler une simple structure comme HashMap coûte moins cher qu’une structure plus complexe comme un cache Ehcache. Ensuite, nous avons refactoré le code pour utiliser une HashMap concurrente au lieu du cache Ehcache. Enfin, nous avons remplacé notre HashMap par un HashSet, et nous avons laissé notre Set gérer les doublons grâce aux méthodes hashCode et equals. Ainsi, nous avons encore diminué le temps d’exécution global estimé.
Le fait d’avoir remplacé notre HashMap par un HashSet, nous a permis aussi d’économiser de la place dans notre Heap, ce que nous avons observé avec notre outil de profiling. Puisque avec le HashSet nous n’avions plus besoin de générer des clefs à partir de chaînes de caractères, le coût de notre garbage collector est également devenu moins important, ce qui nous a permis de charger les 7 millions d’éléments en mémoire sans que notre JVM ne soit saturée. Grâce à cela, nous avons pu considérablement diminuer la dégradation des traitements en fonction de la quantité d’éléments en mémoire.
Pendant que nous faisions des essais d’optimisation de performance, nous avons changé d’autres paramètres qui n’ont pas été présentés dans cet article, comme le fetch et le batch_size d’Hibernate (utilisés au niveau de l’écriture en base), le nombre de threads, etc. Ces essais ne sont pas décrits dans cet article car pendant toute l’analyse ils n’ont pas apporté d’amélioration notable. À chaque modification et à chaque tir, nous mettions à jour un tableau excel avec les temps partiels et le temps global estimé. Cela nous a ensuite permis de savoir quelles étaient les modifications les plus significatives et combien de temps nous avions gagné jusqu’à présent.
Conclusion
À la fin de l’optimisation, nous sommes passés d’environ 10h à 2h30. En analysant les améliorations que nous avions apportées, nous nous sommes rendu compte que nous n’avons rien fait d’extraordinaire ou compliqué. Au contraire, nous avons surtout enlevé des choses qu’il y avait en trop et dont les traitements n’avaient pas besoin. En revanche, nous avons ajouté ou modifié des éléments que nous avons jugé plus adaptés à la problématique en question.
Finalement, il est clair qu’il n’y a pas de gagnant entre une HashMap et un cache Ehcache. Tout dépend du contexte et de ce que l’on veut faire. Dans notre contexte en particulier, nous n’avions pas besoin d’une solution plus complexe de cache comme Ehcache. Un simple HashSet a suffi.