L’un des aspects fondamentaux de l’architecture des microservices est la propriété des données. L’encapsulation des données et de la logique empêche le couplage étroit des services. Puisqu’ils n’exposent les informations que via des interfaces publiques (comme l’API REST stable) et masquent les détails d’implémentation internes du stockage de données, ils peuvent faire évoluer leur schéma indépendamment les uns des autres.
Un microservice doit être une unité autonome qui peut remplir la plupart de ses missions avec ses propres données. Il peut également demander à d’autres microservices des informations manquantes nécessaires pour accomplir ses tâches et, éventuellement, les stocker sous forme de copie dénormalisée dans son stockage.
Inévitablement, les services doivent aussi échanger des messages. Habituellement, il est essentiel de s’assurer que le message envoyé atteint sa destination, et le perdre pourrait entraîner de graves conséquences commerciales. La mise en œuvre correcte des modèles de communication entre les services peut être l’un des aspects les plus critiques lors de l’application de l’architecture des microservices. Il est assez facile de laisser tomber la balle en introduisant un couplage indésirable ou une livraison de message peu fiable.
Qu’est-ce qui peut mal tourner ?
Considérons un scénario simple de service UN vient de terminer le traitement de certaines données. Il a validé la transaction qui a enregistré quelques lignes dans une base de données relationnelle. Maintenant, il doit notifier le service B qu’il a terminé sa tâche et que de nouvelles informations sont disponibles pour la récupération.
La solution la plus simple serait simplement d’envoyer une requête REST synchrone (probablement POST ou PUT) au service B directement après la validation d’une transaction.
Cette approche présente certains inconvénients. Le plus important est sans doute un couplage étroit entre les services causé par la nature synchrone du protocole REST. Si l’un des services est en panne en raison d’une maintenance ou d’une panne, le message ne sera pas délivré. Ce type de relation s’appelle couplage temporel car les deux nœuds du système doivent être disponibles pendant toute la durée de la requête.
L’introduction d’une couche supplémentaire – le courtier de messages – dissocie les deux services. Maintenant service UN n’a pas besoin de connaître l’emplacement exact du réseau de B pour envoyer la requête, juste l’emplacement du courtier de messages. Le courtier est responsable de la livraison d’un message au destinataire. Si B est en panne, c’est le travail du courtier de conserver le message aussi longtemps que nécessaire pour le transmettre avec succès.
Si nous y regardons de plus près, nous remarquerons peut-être que le problème de couplage temporel persiste même avec la couche de messagerie. Cette fois, cependant, c’est le courtier et le service UN qui utilisent la communication synchrone et sont couplés ensemble. Le service qui envoie le message ne peut supposer qu’il a été correctement reçu par le courtier que s’il reçoit une réponse ACK. Si le courtier de messages n’est pas disponible, il ne peut pas obtenir le message et ne répondra pas par un accusé de réception. Les systèmes de messagerie sont très souvent des systèmes distribués sophistiqués et durables, mais des temps d’arrêt se produiront encore de temps en temps.
Les échecs sont très souvent de courte durée. Par exemple, un nœud instable peut être redémarré et redevenir opérationnel après une courte période d’indisponibilité. Par conséquent, le moyen le plus simple d’augmenter les chances de transmission du message consiste simplement à réessayer la demande. De nombreux clients HTTP peuvent être configurés pour réessayer les demandes ayant échoué.
Mais il y a un hic. Étant donné que nous ne savons jamais si notre message a atteint sa destination (peut-être que la demande a été reçue, mais que seule la réponse a été perdue ?), une nouvelle tentative de la demande peut entraîner la livraison de ce message plus d’une fois. Il est donc crucial de dédupliquer les messages côté destinataire.
Le même principe peut être appliqué à la communication asynchrone. Par exemple, les producteurs de Kafka peuvent réessayer la livraison du message au courtier en cas d’erreurs récupérables comme NotEnoughReplicasException
. Nous pouvons également configurer le producteur comme idempotent, et Kafka dédupliquera automatiquement les messages répétés.
Malheureusement, il y a une mauvaise nouvelle : même le fait de réessayer des événements ne garantit pas que le message atteindra son service cible ou le courtier de messages. Étant donné que le message est stocké uniquement en mémoire, alors si le service UN plante avant qu’il ne puisse transférer le message avec succès, il sera irrémédiablement perdu.
Une telle situation peut laisser notre système dans un état incohérent. D’une part, la transaction sur service UN a été engagé avec succès, mais d’un autre côté, le service B ne sera jamais informé de cet événement.
Un bon exemple des conséquences d’un tel échec pourrait être la communication entre deux services lorsque le premier a déduit des points de fidélité d’un compte utilisateur, et maintenant il doit faire savoir à l’autre service qu’il doit envoyer un certain prix au client. Si le message n’atteint jamais l’autre service, l’utilisateur ne recevra jamais son cadeau.
Alors peut-être que la solution à ce problème sera d’abord d’envoyer un message, d’attendre ACK, et ensuite seulement de valider la transaction ? Malheureusement, cela ne servirait pas à grand-chose. Le système peut toujours échouer après l’envoi du message, mais juste avant le commit. La base de données détectera que la connexion au service est perdue et abandonnera la transaction. Néanmoins, le service de destination recevra toujours une notification indiquant que les données ont été modifiées.
Ce n’est pas tout. La validation peut être bloquée pendant un certain temps s’il existe d’autres transactions simultanées détenant des verrous sur des objets de base de données que la transaction tente de modifier. Dans la plupart des bases de données relationnelles, les données modifiées au sein d’une transaction avec un niveau d’isolement égal ou supérieur au lire engagé (par défaut dans Postgres) ne sera visible qu’à la fin d’une transaction. Si le service cible reçoit un message avant que la transaction ne soit validée, il peut essayer de récupérer de nouvelles informations mais n’obtiendra que des données obsolètes.
De plus, en faisant une demande avant le commit, service UN prolonge la durée de sa transaction, ce qui peut potentiellement bloquer d’autres transactions. Cela peut aussi parfois poser problème, par exemple si le système est soumis à une charge élevée.
Sommes-nous donc condamnés à vivre avec le fait que notre système entrera occasionnellement dans un état incohérent ? L’approche à l’ancienne pour assurer la cohérence entre les services utiliserait un modèle comme la transaction distribuée (par exemple, 2PC), mais il y a aussi une autre astuce que nous pourrions utiliser.
Boîte d’envoi transactionnelle
Le problème auquel nous sommes confrontés est lié à un problème dans lequel nous ne pouvons pas à la fois effectuer un appel externe (au courtier de messages, à un autre service, etc.) et valider la transaction ACID. Dans le scénario du chemin heureux, les deux tâches réussiront, mais les problèmes commencent lorsque l’une d’entre elles échoue pour une raison quelconque. je vais essayer d’expliquer comment nous pouvons surmonter ces problèmes en introduisant un modèle de boîte d’envoi transactionnel.
Dans un premier temps, nous devons introduire une table qui stocke tous les messages destinés à la livraison – c’est notre boîte d’envoi des messages. Ensuite, au lieu de faire directement des requêtes, nous enregistrons simplement le message sous forme de ligne dans la nouvelle table. Faire un INSÉRER dans la table de la boîte d’envoi des messages est une opération qui peut faire partie d’une transaction de base de données normale. Si la transaction échoue ou est annulée, aucun message ne sera conservé dans la boîte d’envoi.
Dans la deuxième étape, nous devons créer un processus de travail en arrière-plan qui, à intervalles réguliers, interrogera les données de la table de la boîte d’envoi. Si le processus trouve une ligne contenant un message non envoyé, il doit maintenant le publier (l’envoyer à un service externe ou à un courtier) et le marquer comme envoyé. Si la livraison échoue pour une raison quelconque, le travailleur peut réessayer la livraison au tour suivant.
Marquer le message comme délivré implique l’exécution de la requête, puis une transaction de base de données (pour mettre à jour la ligne). Cela signifie que nous sommes toujours aux prises avec les mêmes problèmes qu’avant. Après une requête réussie, la transaction peut échouer et la ligne de la table de la boîte d’envoi ne sera pas modifiée. Étant donné que le statut du message est toujours en attente (il n’a pas été marqué), il sera renvoyé et la cible recevra un message deux fois. Cela signifie que le modèle de boîte d’envoi n’empêche pas les demandes en double – celles-ci doivent toujours être gérées du côté du destinataire (ou du courtier de messages).
La principale amélioration de la boîte d’envoi transactionnelle est que l’intention d’envoyer le message est désormais conservée dans un stockage durable. Si le service meurt avant d’avoir pu effectuer une livraison réussie, le message restera dans la boîte d’envoi. Après le redémarrage, le processus d’arrière-plan récupère le message et envoie à nouveau la demande. Finalement, le message atteindra sa destination.
La livraison de messages assurée avec d’éventuelles demandes en double signifie que nous avons un garantie de traitement au moins une fois, et les destinataires ne perdront aucune notification (sauf en cas de pannes catastrophiques entraînant une perte de données dans la base de données). Soigné!
Sans surprise, cependant, ce modèle présente quelques points faibles.
Tout d’abord, la mise en œuvre du modèle nécessite l’écriture d’un code passe-partout. Le code pour stocker le message dans la boîte d’envoi doit être caché sous une couche d’abstraction, afin qu’il n’interfère pas avec l’ensemble de la base de code. De plus, nous aurions besoin de mettre en œuvre un processus planifié dans lequel nous recevrons des messages de la boîte d’envoi.
Deuxièmement, l’interrogation de la table de la boîte d’envoi peut parfois exercer une pression importante sur votre base de données. La requête pour récupérer les messages est généralement aussi simple qu’un simple SÉLECTIONNER déclaration. Néanmoins, il doit être exécuté à un intervalle élevé (généralement inférieur à 1 s, très souvent bien inférieur). Pour réduire la charge, la fréquence de vérification peut être diminuée, mais si l’interrogation se produit trop rarement, cela aura un impact sur la latence de livraison des messages. Vous pouvez également réduire le nombre d’appels à la base de données en augmentant simplement la taille du lot. Néanmoins, avec un grand nombre de messages sélectionnés, si la requête échoue, aucun d’entre eux ne sera marqué comme livré.
Le débit de la boîte d’envoi peut être augmenté en augmentant le parallélisme. Plusieurs threads ou instances du service peuvent chacun récupérer un groupe de lignes de la boîte d’envoi et les envoyer simultanément. Pour éviter que différents lecteurs ne reprennent le même message et…