Optimisation de traitements batch

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 :

  1. Lecture des données d’une base de données source ;
  2. Lecture des données d’un fichier fourni par une application back-office ;
  3. 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 ;
  4. 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.

10 Responses

  • Est ce qu’un rapport AWR ne pouvait pas montrer des le départ quelles étaient les requêtes posant problèmes ?

  • Dans notre cas, nous ne savions pas encore si le problème venait des requêtes ou d’ailleurs. Ainsi, nous avons décidé d’analyser séparément chaque étape pour ensuite se concentrer sur les celles qui posaient des problèmes. Nous avons travaillé majoritairement sur l’étape de lecture (la moins performante). Cette étape attaquait la base en JDBC et utilisait une seule requête.
    Autre point important était le contexte. Le projet n’ayant plus de budget, le but était d’améliorer rapidement les perfs pour ne pas mettre la MEP en danger.

  • 7000000 lignes divisé par 3600*2,5

    777 lignes par secondes.

    Cela semble un débit plutôt faible pour un traitement qui se passe en mémoire.

    Comment se décomposent à présent les 2h30 ?

  • Je n’ai plus les chiffres. Nous n’avons pas gagné beaucoup de temps sur la première étape. Le temps de mise en cache a diminué, mais la lecture du fichier (qui faisait plusieurs gigas) se faisait encore sentir sur les performances. La deuxième étape a énormément gagné, premièrement au niveau de la requête SQL, après avec la mise en place du multithreading et finalement avec l’optimisation du cache. La validation des données, qui se passait entièrement en mémoire, était quasi-instantanée. L’écriture avait à peu près la même durée que celle de la deuxième étape.

  • Bonjour,

    Je me souviens être intervenu (avec un archi Xebia) chez SFR.
    Nous avions la même problématique de perf pour un traitement faisant à peu de chose prêt la même chose : export d’un DWH de 15 millions de lignes a croiser avec une DB oracle.
    Nous avions opté pour un mapping du fichier en table oracle et un traitement PLSQL.
    Beaucoup moins sexy qu’un sping batch + hibernate mais peut être plus conçu pour les perfs ?

    Ciao

  • @Julien
    À la BNP, sur notre projet, pour charger des gros fichiers sur Oracle, nous les convertissons en fichier CSV ou equivalent et chargeons ce fichier comme une table externe.
    Et nous avons intégré en parti ce mécanisme dans un Spring Intégration maison base sur Spring.
    Comme toi, nous l’avons fait par souci de performance…

  • bonjour, je suis nouveau dans l’utilisation de spring batch et j’espère avoir des reponses avec vous;
    je travaille sur un projet actuellement et je dois utiliser spring batch pour la gestion du backup. ce qui me derange, c’est recuperer les données de toutes les tables de la base de donnée.pourriez vous m’aider??je n’arrive qu’a recuperer que les données d’une seule table a la fois. est il possible qu’avec le nom de la base de donnée en parametre on puisse en recuperer toutes les données????

  • « 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. »

    Attention une HashMap ou un HashSet ne sont pas Thread-safe!!!
    Si vous aviez écrit votre test en multi-threadé, vous auriez vu que ces collections n’étaient pas utilisables et surtout que ehcache supporte très bien les accès concurrents, c’est étonnant que vous parliez de multithreading, qu’apparemment vous avez parallélisé les tâches et non pas les traitements proprement dits.

  • @Komi
    Dans ton contexte tu es vraiment contrait d’utiliser Spring Batch ? Beaucoup de bases de données proposent des utilitaires pour faire des dumps de la base. Ca me semble un bon compromis pour effectuer une sauvegarde. Sinon, avec Spring Batch peut-être essayer de interroger la base pour savoir quelles sont les tables et ensuite boucler sur le résultat pour sauvegarder les données. Une autre option serait de d’utiliser le schema de données pour lire et sauvegarder les données en parallèle. Jusqu’où je connais il n’y a pas de solution prête dans Spring Batch, donc, il faut coder.
    @Aurelien
    Tu as raison, un HashSet n’est pas thread-safe. Nous avons utilisé une ‘HashMap concurrente’ issue d’un Collections.synchronizedMap. Le but étant d’optimiser, le cache Ehcache a été remplacé par un HashSet concurrent après avoir constaté que : 1. le HashSet était plus performant ; 2. nous n’avions pas besoin d’un Ehcache. Les traitements étaient multithreadés, les task-executors étaient placés au niveau du tasklet (les steps n’étant pas parallélisés).

  • L’utilisation d’un ETL n’était-elle pas pertinente dans un tel cas ?

Laisser un commentaire