1. Les couches d'accès aux composants
Java utilise différentes couches normalisées d'accès aux composants. Des API permettent de communiquer avec l'extérieur, sans avoir connaissance de l'implémentation effective utilisée. Ces bibliothèques, jouant le rôle de drivers, doivent être installées dans des répertoires particuliers des serveurs d'applications, afin d'être disponibles pour les composants applicatifs.
Les serveurs d'applications doivent exposer des ressources, compatibles avec les différentes API. Ainsi, les composants applicatifs n'ont pas connaissance des versions spécifiques des implémentations utilisées (type de la base de données, de file JMS, type de messagerie, etc.).
Les composants doivent être indépendants des implémentations spécifiques des API d'accès aux ressources.
1.1 JDBC
JDBC est l'acronyme de Java DataBase Connectivity. C'est une API d'accès aux bases de données SQL. Différentes architectures sont possibles pour implémenter un driver compatible JDBC.
Les drivers de type 1 sont des ponts vers la technologie équivalente ODBC. Ils ont permis une prise en compte rapide de tous les drivers ODBC par les applications Java.
Les drivers de type 2 utilisent une bibliothèque OS partagée spécifique pour accéder à la base de données. Ils ne sont pas portables d'un système à un autre, à cause de la présence de la DLL. Par contre, ils sont souvent plus rapides que d'autres approches, par l'exploitation d'un code C compilé. Avec l'évolution des performances des JVM, l'écart n'est pas très important.
Les drivers de type 3 sont des drivers intermédiaires, codés en Java, et communiquant avec un composant présent sur le serveur de BD servant de relais vers la base de données. Ils ont vocation à franchir les pare-feu lors d'une utilisation embarquée dans une applet (composant Java exécuté dans un navigateur).
Les drivers de type 4 sont codés entièrement en Java. Ils manipulent des sockets pour communiquer directement avec la base de données, avec le protocole de communication correspondant.
Les drivers JDBC savent gérer les transactions suivant différentes stratégies, permettant plus ou moins efficacement à la base de données de gérer des requêtes en parallèle. Chaque stratégie possède ses avantages et ses risques. Cela va de la lecture d'un enregistrement non encore validé (et qui peut finalement être annulé) à l'impossibilité de relire deux fois de suite les mêmes objets en ayant les mêmes résultats.
1.2 JMS
JMS est l'acronyme de Java Message Service. C'est une API d'accès à des files de messages. Cela permet aux applications de publier des messages dans une file (Queue), ou à destination de tous destinataires en écoute (Topic).
Les messages possèdent une notion de priorité, de durée de vie et de type.
Cette technologie est un modèle d'architecture asynchrone permettant entre autres :
- de lisser la charge en retardant le traitement des requêtes lors des pics de charge ;
- de servir de tampon entre deux sous-systèmes pour permettre l'arrêt d'un des sous-systèmes, sans devoir interrompre toute la chaîne de traitement.
Les consommateurs des messages peuvent utiliser un ou plusieurs threads pour traiter les messages. Au-delà d'un seul thread de consommation, l'ordre de traitement des messages n'est pas garanti. L'alimentation et la consommation des messages sont transactionnelles. Ainsi, il y a garantie que le traitement est terminé lorsque le message est enlevé d'une file.
Cela coupe la transaction entre le dépôt du message et sa consommation. Deux transactions sont nécessaires : l'une pour le dépôt du message, l'autre pour sa consommation. Il est possible de garantir le dépôt d'un message dans une transaction, en cohérence avec une manipulation d'une autre source de donnée transactionnelle. Pour cela, la base de données doit être compatible XA. Mais cela ne garantit pas la bonne exécution du message quand il sera lu.
Par exemple, si une demande de virement est effectuée à l'aide d'un message JMS, le premier compte peut être débité et un message déposé. Le compte destinataire sera crédité lorsque le message sera traité, mais il n'est pas possible de savoir quand.
Le nombre de traitements en parallèle pour consommer les messages a un impact sur les performances et sur l'ordre de consommation des messages.
Si un message n'a pas été traité au-delà de sa durée de vie, il est déplacé dans une queue spéciale mémorisant les messages morts. Il est pertinent de consulter régulièrement la taille de cette queue. Elle ne devrait contenir aucun message dans les cas habituels.
1.3 Javamail
Javamail est une API qui permet de communiquer avec un serveur de mails et un serveur d'envoi de mails. Différents protocoles de communication sont possibles : POP3 avec ou sans SSL, IMAP4 avec ou sans SSL, SMTP, etc. D'autres drivers peuvent permettre à l'application de communiquer avec un serveur Lotus ou d'autres technologies de messagerie de l'entreprise.
1.4 JNDI
JNDI est l'acronyme de Java Naming and Directory Interface. C'est une API d'accès à des annuaires d'objets. Les annuaires peuvent être de tous types : CORBA, RMI, LDAP, répertoires, etc.
On trouve généralement dans ces annuaires les ressources que publie le serveur d'applications, telles que les datasources d'accès aux bases de données, aux serveurs de mails, aux files de messages, etc.
L'annuaire JNDI est un élément majeur des architectures J2EE, car il sert de fondation au concept « Écrit une fois, exécuté partout » préconisé par Oracle. En respectant toutes les bonnes pratiques de développement, un composant peut découvrir tous les paramètres de déploiement dans l'annuaire.
Chaque serveur d'applications publie un annuaire qui lui est propre. Les objets qu'il possède peuvent ou non être visibles à d'autres JVM. Il est également possible d'utiliser un annuaire centralisé, pour partager les paramètres entres différents serveurs d'applications dans une architecture en cluster, par exemple. Cela réduit les risques d'avoir un serveur qui fonctionne, alors que son miroir ne fonctionne pas par un écart dans les paramètres. Souvent, les annuaires sont dupliqués dans tous les membres du cluster.
1.5 EJB
EJB est l'acronyme d'Entreprise Java Beans. Il s'agit de classes Java respectant des spécifications précises. Le cycle de vie des instances est sous le contrôle du serveur J2EE. Il se charge de gérer les transactions, la sécurité, la persistance des objets, etc.
Initialement, les EJB ont été conçus pour rendre les objets Java transactionnels. La difficulté est de permettre un rollback lorsqu'une transaction est abandonnée. Cela entraîne qu'il faut également restituer les objets Java dans leurs états initiaux. Pour pouvoir faire cela, les instances avec état sont généralement persistantes.
Plusieurs types d'EJB existent : BMP, CMP, Session, MDB, et Starter.
Les BMP et les CMP sont des EJB permettant d'accéder à des données persistantes. Dans un cas (Bean Manage Persistance), c'est l'objet qui est en charge de sa persistance (des méthodes sont invoquées par le conteneur d'EJB lorsque c'est nécessaire), dans l'autre cas (Container Manage Persistance), le conteneur se charge de sauver l'état de l'EJB dans une base de données relationnelle. Ces approches de persistances des EJB sont peu utilisées vu leur complexité et leurs limitations.
Les EJB permettent également de gérer un état conversationnel (EJB Session). Le serveur J2EE se charge de limiter le nombre de sessions en mémoire, le nombre de traitements simultanés, etc. Si les traitements dans les sessions doivent mémoriser des informations, les EJB sont appelés « EJB Session statefull ». Cela est peu utilisé. Si les traitements dans les sessions n'ont pas besoin de mémoriser d'information entre deux invocations, les EJB sont appelés « EJB Session stateless ». C'est la très grande majorité des EJB utilisés dans les serveurs d'applications.
La dernière famille d'EJB sont des EJB de messages (MDB). Ces EJB sont invoqués à l'aide de messages déposés dans une queue JMS. Le conteneur écoute les files et invoque les MDB correspondants.
Tous les EJB peuvent être invoqués à distance. Un ORB (Object Request Broker) permet d'invoquer un traitement sur un EJB distant. Comme cela a un impact très important sur les performances, ce n'est pratiquement jamais utilisé. Des raccourcis techniques existent dans les serveurs J2EE pour permettre l'invocation d'un autre EJB, au sein de la même JVM, sans impact sur les performances.
La nouvelle génération des EJB est beaucoup plus efficace. La persistance des objets est beaucoup plus riche et plus simple, mais nécessite l'utilisation de JDK5+ et des annotations.
2. Les caches
Nous allons survoler les différents caches disponibles dans un serveur J2EE. Cela nous permettra de comprendre le comportement du serveur d'application.
Un serveur J2EE est conçu pour traiter des milliers d'utilisateurs simultanément. Bien entendu, cela est une vue de l'esprit. Il n'y a pas mille threads s'exécutant en même temps.
2.1 Taille de la queue TCP des ouvertures de connexions
Dans un premier temps, il y a une limite sur le nombre de demandes d'ouverture de connexion en attente. Au-delà de cette limite, un paquet réseau est retourné à l'utilisateur par la pile TCP/IP de l'OS, pour lui signaler qu'il n'est pas possible d'obtenir une connexion, que le serveur est surchargé.
Cette taille est paramétrable dans les serveurs d'applications. Pour Tomcat, il s'agit du paramètre acceptCount d'un <Connector/> présent dans le fichier server.xml.
Il est préférable de maintenir les connexions en dehors du serveur si celui-ci n'est pas capable de les satisfaire. L'impact d'une augmentation de ce paramètre est négligeable en termes d'optimisation de ressources par rapport aux approches que nous allons survoler.
La commande netstat -s permet d'avoir l'état statistique des connexions sur le serveur et des erreurs rencontrées.
2.2 Nombre de threads
Ensuite, il y a une limite du nombre de threads pouvant être exécutés en même temps (paramètre maxThreads de Tomcat présent également dans l'intégration de JBoss). Cette limite doit être judicieusement choisie pour gérer la charge, sans pénaliser les performances. En effet, en multipliant le nombre de threads, on multiplie les changements de contextes par le système d'exploitation et la consommation mémoire.
S'il n'y avait pas d'attente de ressources, l'idéal serait de n'avoir jamais aucun changement de contexte. Ainsi, la CPU est utilisée à 100 % et uniquement pour des traitements efficaces. L'utilisation d'API asynchrone permettrait une meilleure utilisation de la CPU.
Les traitements passent une grande partie de leur temps à attendre des ressources, la lecture ou l'écriture d'un fichier, un paquet réseau, l'exécution d'une requête SQL, etc. En parallélisant les traitements, il est possible d'exploiter la CPU pendant ces temps morts. C'est l'objectif principal de l'ajout de nouveaux threads. Il faut trouver un juste équilibre entre le nombre de threads et l'impact négatif du changement de contexte lors du passage d'un thread à un autre. L'expérience montre qu'il est préférable de laisser les connexions en dehors du serveur, le temps de terminer les threads en cours.
Les requêtes supplémentaires ne pouvant être traitées par les threads en cours sont mises en attente. Comme indiqué ci-dessus, une liste de requêtes est gérée par la pile TCP/IP. Tant qu'il n'existe aucun thread disponible, l'ouverture de connexion est suspendue. Dès qu'un thread est disponible, il accepte la connexion et obtient le flux de la requête. Il doit s'en charger le plus rapidement possible. Si le traitement est trop long, car il attend de nombreuses ressources ou des ressources lentes, il y a consommation excessive d'un thread, au détriment des requêtes en attente. Au pire, tous les threads peuvent être en attente de ressources, la CPU ne fait plus rien, pourtant, il existe d'autres requêtes en attente de traitement qui ne sont pas encore sorties de la pile IP.
Il est important de traiter chaque requête le plus vite possible ou d'augmenter le nombre de threads pour compenser une programmation peu optimisée et augmenter ainsi la consommation CPU.
2.3 Connexions à la base de données
Une fois qu'un traitement est démarré, il demande généralement à communiquer avec une base de données. En théorie, il faudrait, pour chaque requête, ouvrir une connexion à la base de données, s'identifier avec le nom de l'utilisateur, et enfin, envoyer la requête SQL avant d'attendre la réponse.
Ces étapes prennent du temps. Pour éviter de les effectuer à chaque requête, les serveurs J2EE utilisent un « pool » de connexions. Ce pool est transparent pour le développeur. Lorsqu'un traitement demande l'ouverture d'une connexion à la base de données, la demande est capturée par le serveur J2EE. Il regarde dans le pool de connexions s'il n'en existe pas déjà ayant strictement les mêmes paramètres de connexion. Si c'est le cas, il recycle la connexion et la retourne au processus. Si ce n'est pas le cas, il ouvre une nouvelle connexion et l'ajoute au pool.
Lorsque le processus n'a plus besoin de la connexion, il la ferme. Cette fermeture est également capturée par le serveur J2EE qui se charge de replacer la connexion dans le pool de connexions, sans la fermer.
Il n'est pas pertinent d'ouvrir des connexions avec des noms d'utilisateurs différents. En effet, si chaque connexion porte le nom de l'utilisateur, chacune est différente et il n'est plus possible d'utiliser le pool de connexions.
Les applications J2EE utilisent généralement des noms d'utilisateurs de bases de données banalisés.
En général, la plupart des requêtes cherchent à communiquer avec la base de données. Il est donc théoriquement pertinent d'indiquer au départ une taille de pool de connexions équivalent au nombre de threads acceptés par le serveur J2EE. Sinon, la plupart des traitements sont en attente d'une connexion à la base de données.
Que faire si la base de données ne peut supporter le nombre de connexions voulu ? Il faut réduire le nombre de threads et augmenter la taille de la file d'attente TCP/IP. En effet, à quoi sert de traiter une requête si c'est pour être immédiatement suspendu car le processus n'arrive pas à communiquer avec la base de données ? L'ajout de threads entraîne une surconsommation de la mémoire (jusqu'à 1024K pour la pile de chaque thread). L'impact mémoire est au contraire négligeable si le paquet réseau est gardé dans la pile TCP/IP, le temps de libérer un thread.
En pratique, les projets préfèrent augmenter le nombre de threads, même avec un pool de connexions réduit. Il semble pertinent de tester une autre stratégie : augmenter la taille de la pile TCP/IP et réduire le nombre de threads. Un test en charge permettra de sélectionner la meilleure approche.
2.4 Requêtes préparées
Une fois la connexion établie ou obtenue implicitement par le pool J2EE, l'applicatif cherche à exécuter des requêtes à la base de données. JDBC propose deux approches : exécuter directement la requête ou préparer l'exécution de la requête d'une part, et l'exécuter d'autre part. En effet, pour traiter une requête, la base de données doit, dans un premier temps, analyser la syntaxe et préparer un plan d'exécution. Ensuite, la base de données peut exécuter le plan pour retourner ou apporter des modifications aux données. L'étape de préparation du plan est coûteuse en termes de performance, mais il peut être réutilisé entre différentes requêtes basées sur le même modèle.
La demande de données avec une clause WHERE nom='moi' utilise strictement le même plan d'exécution que pour la clause WHERE nom='toi'. Seule la valeur 'moi' ou 'toi' change. Elle peut être portée par une variable de requête SQL. Il est alors possible de préparer les requêtes en ajoutant quelques variables (WHERE nom='?'). Lors de l'exécution proprement dite, le programme doit valoriser les variables et demander l'exécution du plan.
Les serveurs J2EE savent maintenir un cache des requêtes préparées (prepared statement en anglais). Comme chaque plan d'exécution est dépendant de l'utilisateur connecté, (privilèges ou visibilités des données différents suivant les utilisateurs), le cache est associé à chaque connexion. Certains drivers ou implémentations du cache savent partager le cache des requêtes préparées entre plusieurs connexions, mais ce n'est pas le comportement standard. Cela dépend des capacités des bases de données et des drivers.
Il faut avoir à l'esprit que la taille du cache des requêtes préparées doit être multipliée par la taille du cache des connexions à la base de données pour connaître l'impact mémoire.
Si le développeur n'utilise que des requêtes préparées, il est possible d'initialiser, dès le lancement du programme, toutes les requêtes possibles pour l'application, de préparer tous les plans d'exécution possibles. Cela entraîne qu'il n'y a jamais de requêtes calculées par le programme. Pour cela, il ne doit exister aucun code s'occupant de construire dynamiquement une clause WHERE, ORDER BY ou autre.
Si les développeurs respectent bien les bonnes pratiques, en analysant le code, il est possible de calculer le nombre précis de requêtes différentes que peut exécuter le programme. La taille du cache de requêtes préparées doit correspondre à ce calcul. Ainsi, toutes les requêtes seront théoriquement optimisées.
Mais la consommation mémoire correspondante étant souvent très importante, il est préférable d'utiliser une valeur faible, laissant la base de données optimiser les requêtes. Elle possède généralement les caches nécessaires pour gérer ces situations.
2.5 Cache métier
Les applications utilisent généralement leurs propres caches, pour mémoriser une fois pour toutes les données de références ou d'autres objets métiers complexes à construire. Soit le programme se charge d'initialiser ses caches lors du démarrage, soit le cache est alimenté au fur et à mesure des besoins de l'application.
Attention, ces caches doivent obligatoirement avoir une taille limitée et maîtrisée. Cette taille correspond au nombre fini d'objets à charger en mémoire pour les données de référence, ou à une taille limite arbitraire. Dans ce cas, il faut utiliser un mécanisme de LRU (Last Recent Used). C'est-à-dire que les objets dans le cache n'ayant pas été sollicités depuis longtemps peuvent être sacrifiés au bénéfice des objets fraîchement chargés en mémoire. Cela limite le nombre d'instances en mémoire. Sans cette limite, la consommation mémoire de votre applicatif n'est pas maîtrisée, et vous vous exposez à de graves déconvenues. Au fur et à mesure que votre applicatif survit en production, la mémoire est consommée.
Pour contourner cela, l'approche naïve consiste à augmenter la taille mémoire de la JVM, entraînant par effet de bord une charge de travail plus importante pour le ramasse-miettes. Cela bloque alors l'application et tous les utilisateurs pendant plusieurs secondes. Le problème doit être réglé dans le code, et non en modifiant un paramètre de la JVM.
En production, il n'est pas possible d'avoir la main sur les caches métiers.
2.6 Les sessions
Le protocole HTTP est un protocole sans état. C'est-à-dire que contrairement aux autres protocoles permettant de télécharger des fichiers (FTP par exemple), il n'y a pas de connexion maintenue ouverte entre le client et le serveur. C'est cette particularité qui permet à ce protocole de servir des milliers d'utilisateurs, sans saturation de la pile IP ou de la mémoire du serveur. Après chaque requête d'un utilisateur, la connexion est coupée.
Pour contourner cette limitation, lors de la première requête d'un utilisateur, un ticket lui est associé. Ce ticket est livré à l'utilisateur généralement sous forme de cookie.
Pour les requêtes suivantes, l'utilisateur doit présenter le ticket afin que le serveur puisse relier les différentes requêtes d'un même utilisateur. Le serveur doit alors maintenir la session de l'utilisateur, avec les différentes informations que ce dernier a saisies au fur et à mesure de sa navigation sur le site.
Comme il n'existe pas de connexion entre l'utilisateur et le serveur, ce dernier ne peut pas savoir si l'utilisateur va cliquer encore sur un lien ou s'il est parti vers d'autres occupations. Il est impossible de savoir quand la session doit être détruite. Pour contourner la difficulté, les serveurs d'applications considèrent qu'une session qui n'a pas été utilisée depuis trente minutes ne sera plus utilisée. Elle peut être détruite de la mémoire.
Nous avons donc en mémoire une collection d'objets mémorisant les sessions de tous les utilisateurs. Pouvant potentiellement avoir des milliers d'utilisateurs en même temps, même s'ils ne demandent pas tous simultanément de nouvelles pages, il y a un risque de saturer la mémoire du serveur. Pour éviter cela, seul un nombre limité de sessions est gardé en mémoire.
Suivant le paramétrage du serveur de servlet, au-delà de la limite, les nouvelles sessions sont refusées ou les sessions sont déchargées sur disque.
Les sessions étant en mémoire, il est fortement conseillé d'avoir des sessions les plus petites possible.
Souvent, les développeurs ne peuvent connaître la taille de la session, car elle mémorise le résultat d'une requête dont on ne connaît pas le nombre d'éléments. Cela peut avoir des impacts négatifs en production. La mémoire peut ainsi être saturée.
Un nettoyage à l'intérieur de la session avec un algorithme de LRU (Last Rescent Used) permet de libérer de la mémoire des processus applicatifs n'étant plus nécessaires. C'est le moment de supprimer les objets des cas d'utilisations n'étant plus utilisés. Souvent, les développeurs ne nettoient pas la session à la fin d'un processus. Il est préférable de nettoyer les objets ayant permis un virement si l'utilisateur est parti vérifier ses portefeuilles boursiers.
Si les sessions ont une taille raisonnable, la sauvegarde et la lecture sur disque prennent un temps négligeable. Il n'est alors pas nécessaire d'avoir beaucoup de sessions en mémoire. En effet, les sessions sont des objets à durée de vie longue. Il y a donc un effet lors de l'exécution du ramasse-miettes complet (Full GC).
Si le dépôt des sessions sur disque entraîne une dégradation des performances, il est envisageable d'augmenter leur nombre en mémoire, en cohérence avec le nombre d'utilisateurs simultanés attendus. En cas de pic de charge, les sessions complémentaires seront gérées par le disque. Mais il faut bien avoir conscience que cela a un impact sur le ramasse miettes et les performances.
Lors de l'arrêt de Tomcat, les sessions courantes sont mémorisées sur disque. Ainsi, lors du prochain redémarrage, elles peuvent être récupérées en limitant les impacts sur les utilisateurs. Si entre temps les classes ont évolué, les sessions mémorisées peuvent ne plus être valides. Il est alors préférable de supprimer le fichier SESSIONS.ser. Un paramètre de contexte permet d'activer cette fonctionnalité.
Pour réduire la taille mémoire de la JVM, il peut être judicieux de réduire la mémoire de la JVM et d'utiliser un RamDisk pour la sauvegarde des sessions. Cela est à vérifier lors d'un test de charge.
2.7 La gestion des ressources J2EE
Pour résumer, tout est organisé dans les serveurs J2EE pour réduire au maximum l'empreinte mémoire. Le nombre de threads est limité ; le nombre de sessions en mémoire également ; tous les caches ont une taille limite. Une application en production devrait respecter ces principes.
En amont, la chaîne de traitement doit être cohérente avec le paramétrage des serveurs J2EE. De nombreuses approches permettent de gérer la répartition de charge dès l'ouverture des sockets (Altéons), par le serveur Apache s'il est nécessaire en amont (mod_jk), etc. Un serveur J2EE n'est qu'un maillon d'une chaîne de traitement plus globale.