Il y a un an, j’ai imposé la réécriture d’une application composée d’une multitude de serveurs, de composants, de bases de données, et qui au final, ne fait que lancer des traitements simples.
L’architecture de cette application était basée sur un produit Spring, abandonné au moment de la mise en production de l’application, Spring-XD, et patché pour lui permettre de gérer des priorités, ce que ne faisait pas Spring-XD. Bref, une architecture compliquée, lourde, lente, onéreuse à héberger, basée sur un composant non maintenu, et stockant les données dans 3 bases, dont deux non transactionnelles. Le genre de chose dont on rêve tous de voir arriver dans notre TMA.
J’ai mis en place une architecture beaucoup plus simple : un serveur ActiveMQ, un conteneur écrit en Java allant piocher les ordres de traitements à lancer dans les différentes queues JMS avec des priorités entre elles, le lancement du traitement dans un fork de JVM, des messages retour pour notifier les progrès. Simple et basique, le plus possible, et permettant une scalabilité horizontale en multipliant les conteneurs.
Le seul sujet complexe est la gestion des priorités entre les différents traitements. Chaque nature de traitement a une priorité par défaut, mais on doit pouvoir modifier la priorité d’un traitement en attente pour le lancer au plus tôt, ou au plus tard. Du coup, on a créé autant de queues JMS que de niveaux de priorités, de SUPER_LOW
à SUPER_HIGH
, en passant par LOW
, MEDIUM
et HIGH
. Chaque conteneur vient tenter de piocher un message dans les queues, suivant un ordre précis : VERY_HIGH,HIGH,VERY_HIGH,MEDIUM,VERY_HIGH,HIGH,VERY_HIGH,MEDIUM,VERY_HIGH,HIGH,VERY_HIGH,LOW,...
Si au bout d’un tour complet sans avoir trouvé de message à traiter, c’est qu’il n’y a plus rien à traiter, sinon des très basses priorités ; il pioche alors dans VERY_LOW
. Donc, encore simple. Changer la priorité d’un message consiste juste à le changer de queue.
Le conteneur, au démarrage, se connecte sur chacune des queues JMS, et pioche dedans. Quand il trouve un message, il le traite, puis il continue à tenter de piocher dans les queues. Et tout fonctionne très bien. On déploie cette application en PROD, sans aucun problème.
Et un jour, pour faire un test, on lance une publication massive de 750 documents. Soit 750 messages mis en file d’attente, tous avec la même priorité, donc dans la même queue JMS. Chaque traitement prend environ 10 secondes à s’exécuter, on a 4 conteneurs, au bout de 40 minutes, je m’attends à ce que tout soit terminé.
Et là, c’est le drame. 36 messages sont consommés, traités avec succès, et tout s’arrête.
Chaque conteneur est bloqué sans rien traiter. ActiveMQ a des OutOfMemory dans ses logs. Quand je redémarre ActiveMQ, chaque conteneur traite un message, et tout se rebloque. Lorsque je redémarre un conteneur, il traite 9 messages, et il se rebloque.
Sueurs froides, interrogations, recherches fébriles dans Google, Stackoverflow, JIRA d’ActiveMQ. Craintes de m’être trompé sur mon architecture super simple. Crainte d’avoir merdé tout court, et d’avoir imposé au forceps un machin qui ne marche pas…
Et à un moment, je tombe dans un ticket JIRA sur jms.prefetch.all
. Je cherche dans la documentation d’ActiveMQ, et je tombe sur une page qui explique le prefetch.
Le prefetch de messages est une technique qui permet d’éviter la multiplication des requêtes vers le broker, et donc qui permet d’augmenter les performances. Enfin, dans certains cas. Le principe, c’est quand un consumer
récupère un message, le broker lui en envoie 10. Le broker conserve ces messages dans une zone tampon, où ils sont distribués, mais pas acquittés. Donc, en mémoire. Lorsqu’un consumer acquitte un message, il est physiquement supprimé, et lorsque le conteneur en a consommé 5, le broker lui en renvoie 5.
Côté conteneur, lorsqu’on appelle la méthode receiveNoWait()
, on reçoit le premier message en tête de la file, mais les 9 autres sont cachés derrière, en mémoire, et occupent de la place. Et comme le coup suivant, on va consommer dans une autre queue, le buffer qui contient ces neufs messages est vidé, sans pour autant que les messages ne soient ni acquittés – normal, on ne les a pas traité – ni refusés. Et comme à chaque tour de picking, on réinitialise les connections aux queues, on perd la référence à ces messages.
Et tout cela jusqu’à ce que la mémoire du broker soit saturée, et qu’il s’arrête. Et dans ce cas là, les conteneurs se retrouvent au chômage, puisque le broker ne peut plus distribuer de messages.
Bref, dans notre cas d’usage, le prefetch des messages est tout à fait contre-indiqué. Alors que dans une situation normale, où un consumer
reçoit des messages depuis une unique queue
, où le traitement du message est très rapide, le prefetch permet d’économiser beaucoup de trafic réseau. Mais le fait que je pioche dans plusieurs queues est très problématique, et le fait que les traitements soient longs diminue la capacité de load-balancing entre les conteneurs.
J’ai modifié l’URL avec laquelle je me connecte au broker. On est passé de tcp://aaa.bbb.ccc.ddd:61616
à tcp://aaa.bbb.ccc.ddd:61616?jms.prefetchPolicy.all=0
. J’ai relancé, et les messages ont été consommés comme je l’attendais, la queue JMS s’est vidée tranquillement.
Deux erreurs ont conduit à cette situation :
- Utiliser le même consumer pour plusieurs queues est une erreur. J’aurais dû avoir un consumer par queue, et changer de consumer à chaque picking. Ca occupe plus de mémoire, mais en fait, c’est pas grand chose. J’ai ouvert un JIRA sur ce point, pour une évolution future.
- J’avais longuement lu la documentation d’ActiveMQ, et en particulier les paramètres des URLs permettant de se connecter à un broker. Mais il y en a tellement que j’ai abandonné avant d’arriver au bout, et j’ai raté le prefetch. Je connaissais le concept, que j’avais utilisé sur des bases SQL, mais je n’avais pas vu que par défaut, le prefetch size est à 10. Faute d’inattention.
Une conclusion est quand même à tirer de ce problème : l’architecture que j’avais mis en place était bonne, on a corrigé le problème en changeant un paramètre dans un fichier de configuration. Et c’est nettement plus performant que ce qui était là avant. On est passé de 90 secondes à 15, pour le même traitement. Et c’est moins cher. KISS : Keep It Stupid and Simple.