Comment couper un monolithe en services et maintenir les performances des caches en mémoire sans perdre en cohérence


Bonjour à tous. Je m'appelle Alexander, je suis développeur Java dans le groupe d'entreprises Tinkoff.

Dans cet article, je souhaite partager mon expérience dans la résolution de problèmes liés à la synchronisation de l'état des caches dans les systèmes distribués. Nous les avons rencontrés, brisant notre application monolithique en microservices . Évidemment, nous parlerons de la mise en cache des données au niveau de la JVM, car avec les caches externes, les problèmes de synchronisation sont résolus en dehors du contexte de l'application.

Dans cet article, je parlerai de notre expérience du passage à une architecture orientée services, accompagnée d'un passage à Kubernetes, et de la résolution des problèmes associés. Nous examinerons l'approche de l'organisation du système de mise en cache distribué IMDG (In-Memory Data Grid), ses avantages et ses inconvénients, raison pour laquelle nous avons décidé d'écrire notre propre solution.

Cet article décrit un projet dont le backend est écrit en Java. Par conséquent, nous parlerons également des normes dans le domaine de la mise en cache temporaire en mémoire. Nous discutons de la spécification JSR-107, de la spécification JSR-347 défaillante et des fonctionnalités de mise en cache dans Spring. Bienvenue au chat!


Et découpons l'application en services ...


Nous allons passer à une architecture orientée services et passer à Kubernetes - c'est ce que nous avons décidé il y a un peu plus de 6 mois. Pendant longtemps, notre projet a été un monolithe, de nombreux problèmes liés à la dette technique se sont accumulés, et nous avons écrit de nouveaux modules d'application comme des services séparés. En conséquence, la transition vers une architecture orientée services et une coupe monolithique était inévitable.

Notre application est chargée, en moyenne 500 rps vient aux services web (en pointe elle atteint 900 rps). Afin de collecter l'intégralité du modèle de données en réponse à chaque demande, vous devez vous rendre plusieurs centaines de fois dans les différents caches.

Nous essayons d'accéder au cache distant pas plus de trois fois par demande, en fonction de l'ensemble de données requis, et sur les caches JVM internes, la charge atteint 90 000 rps par cache. Nous avons environ 30 caches de ce type pour diverses entités et DTO-shki. Sur certains caches chargés, nous ne pouvons même pas nous permettre de supprimer la valeur, car cela peut entraîner une augmentation du temps de réponse des services Web et un plantage de l'application.


Voici à quoi ressemble la surveillance de la charge, supprimée des caches internes sur chaque nœud pendant la journée. Selon le profil de charge, il est facile de voir que la plupart des demandes sont des données lues. Une charge d'écriture uniforme est due à la mise à jour des valeurs dans les caches à une fréquence donnée.

Les temps d'arrêt ne sont pas valables pour notre application. Par conséquent, dans le but d'un déploiement transparent, nous avons toujours équilibré tout le trafic entrant sur deux nœuds et déployé l'application à l'aide de la méthode Rolling Update. Kubernetes est devenu notre solution d'infrastructure idéale lors du passage aux services. Ainsi, nous avons résolu plusieurs problèmes à la fois.

Le problème de la commande et de la mise en place permanente d'infrastructures pour de nouveaux services


On nous a donné un espace de noms dans le cluster pour chaque circuit, que nous avons trois: dev - pour les développeurs, qa - pour les testeurs, prod - pour les clients.

Avec l'espace de noms en surbrillance, l'ajout d'un nouveau service ou d'une nouvelle application revient Ă  Ă©crire quatre manifestes: DĂ©ploiement, Service, Ingress et ConfigMap.

Tolérance de charge élevée


L'entreprise est en expansion et en croissance constante - il y a un an, la charge moyenne était deux fois inférieure à celle actuelle.

La mise à l'échelle horizontale dans Kubernetes vous permet de niveler les économies d'échelle avec l'augmentation de la charge de travail du projet développé.

Maintenance, collecte de journaux et surveillance


La vie devient beaucoup plus facile lorsqu'il n'est pas nécessaire d'ajouter des journaux au système de journalisation lors de l'ajout de chaque nœud, de configurer la clôture des métriques (sauf si vous avez un système de surveillance push), d'effectuer les paramètres réseau et d'installer simplement le logiciel nécessaire au fonctionnement.

Bien sûr, tout cela peut être automatisé en utilisant Ansible ou Terraform, mais au final, l'écriture de plusieurs manifestes pour chaque service est beaucoup plus facile.

Haute fiabilité


Le mécanisme intégré k8s des échantillons Liveness et Readiness vous permet de ne pas vous inquiéter du fait que l'application a commencé à ralentir ou à cesser complètement de répondre.

Kubernetes contrôle désormais le cycle de vie des modules de foyer contenant des conteneurs d'applications et le trafic qui leur est acheminé.

Avec les équipements décrits, nous devions résoudre un certain nombre de problèmes afin de rendre les services adaptés à une mise à l'échelle horizontale et à l'utilisation d'un modèle de données commun pour de nombreux services. Il fallait résoudre deux problèmes:

  1. L'état de l'application. Lorsque le projet est déployé dans le cluster k8s, des pods contenant des conteneurs de la nouvelle version de l'application commencent à être créés et ne sont pas liés à l'état des pods de la version précédente. De nouveaux modules d'application peuvent être créés sur des serveurs de cluster arbitraires qui satisfont aux restrictions spécifiées. De plus, désormais, chaque conteneur d'application exécuté dans le pod Kubernetes peut être détruit à tout moment si la sonde Liveness indique qu'il doit être redémarré.
  2. Cohérence des données. Il est nécessaire de maintenir la cohérence et l'intégrité des données les uns avec les autres à tous les nœuds. Cela est particulièrement vrai si plusieurs nœuds fonctionnent dans un même modèle de données. Il est inacceptable que lorsque des demandes sont adressées à différents nœuds de l'application dans la réponse, des données incohérentes parviennent au client.

Dans le développement moderne de systèmes évolutifs, l'architecture sans état est la solution aux problèmes ci-dessus. Nous nous sommes débarrassés du premier problème en déplaçant toutes les statistiques vers le stockage cloud S3.

Cependant, en raison de la nécessité d'agréger un modèle de données complexe et d'économiser le temps de réponse de nos services Web, nous ne pouvions pas refuser de stocker les données dans des caches en mémoire. Pour résoudre le deuxième problème, ils ont écrit une bibliothèque pour synchroniser l'état des caches internes des nœuds individuels.

Nous synchronisons les caches sur des nœuds séparés


Comme données initiales, nous avons un système distribué composé de N nœuds. Chaque nœud possède environ 20 caches en mémoire, dont les données sont mises à jour plusieurs fois par heure.

La plupart des caches ont une politique d'actualisation des données TTL (durée de vie), certaines données sont mises à jour avec une opération CRON toutes les 20 minutes en raison de la charge élevée. La charge de travail sur les caches varie de plusieurs milliers de rps la nuit à plusieurs dizaines de milliers pendant la journée. La charge de pointe, en règle générale, ne dépasse pas 100 000 rps. Le nombre d'enregistrements dans un stockage temporaire ne dépasse pas plusieurs centaines de milliers et est placé dans le tas d'un nœud.

Notre tâche consiste à assurer la cohérence des données entre le même cache sur différents nœuds, ainsi que le temps de réponse le plus court possible. Réfléchissez aux moyens de résoudre généralement ce problème.

La première et la plus simple solution qui vient à l'esprit est de mettre toutes les informations dans un cache distant. Dans ce cas, vous pouvez vous débarrasser complètement de l'état de l'application, ne pas penser aux problèmes de cohérence et disposer d'un point d'accès unique à un entrepôt de données temporaire.


Cette méthode de stockage temporaire des données est assez simple et nous l'utilisons. Nous mettons en cache une partie des données dans Redis , qui est un stockage de données NoSQL en RAM. Dans Redis, nous enregistrons généralement un cadre de réponse de service Web, et pour chaque demande, nous devons enrichir ces données avec des informations pertinentes, ce qui nécessite d'envoyer plusieurs centaines de demandes au cache local.

Évidemment, nous ne pouvons pas extraire les données des caches internes pour le stockage à distance, car le coût de transmission d'un tel volume de trafic sur le réseau ne nous permettra pas de respecter le temps de réponse requis.

La deuxième option consiste à utiliser une grille de données en mémoire (IMDG), qui est un cache distribué en mémoire. Le schéma d'une telle solution est le suivant:


L'architecture IMDG est basée sur le principe du partitionnement des données des caches internes des nœuds individuels. En fait, cela peut être appelé une table de hachage distribuée sur un cluster de nœuds. IMDG est considéré comme l'une des implémentations les plus rapides du stockage distribué temporaire.

Il existe de nombreuses implémentations IMDG, la plus populaire étant Hazelcast . Le cache distribué vous permet de stocker des données dans la RAM sur plusieurs nœuds d'application avec un niveau acceptable de fiabilité et de préservation de la cohérence, ce qui est obtenu par la réplication des données.

La tâche de construire un tel cache distribué n'est pas facile, mais l'utilisation d'une solution IMDG prête à l'emploi pour nous pourrait devenir un bon remplacement pour les caches JVM et éliminer les problèmes de réplication, de cohérence et de distribution des données entre tous les nœuds d'application.

La plupart des fournisseurs IMDG pour les applications Java implémentent JSR-107 , l'API Java standard pour travailler avec des caches internes. En général, cette norme a une histoire assez importante, dont je parlerai plus en détail ci-dessous.

Il était une fois des idées pour implémenter votre interface pour interagir avec IMDG - JSR 347 . Mais la mise en œuvre d'une telle API n'a pas reçu un soutien suffisant de la communauté Java, et nous avons maintenant une interface unique pour interagir avec les caches en mémoire, quelle que soit l'architecture de notre application. Bon ou mauvais est une autre question, mais cela nous permet d'ignorer complètement toutes les difficultés de mise en œuvre d'un cache In-memory distribué et de travailler avec lui comme cache d'une application monolithique.

Malgré les avantages évidents de l'utilisation d'IMDG, cette solution est toujours plus lente que le cache JVM standard, en raison de la surcharge nécessaire pour assurer la réplication continue des données réparties entre plusieurs nœuds JVM, ainsi que la sauvegarde de ces données. Dans notre cas, la quantité de données pour le stockage temporaire n'était pas si grande, les données avec une marge s'inscrivaient dans la mémoire d'une application, donc leur allocation à plusieurs JVM semblait une solution inutile. Et le trafic réseau supplémentaire entre les nœuds d'application soumis à de lourdes charges peut avoir un impact considérable sur les performances et augmenter le temps de réponse des services Web. Finalement, nous avons décidé d'écrire notre propre solution à ce problème.

Nous avons laissé des caches en mémoire comme stockage temporaire de données et pour maintenir la cohérence, nous avons utilisé le gestionnaire de files d'attente RabbitMQ. Nous avons adopté le modèle de conception comportementale «Publisher - Subscriber» et maintenu la pertinence des données en supprimant l'enregistrement modifié du cache de chaque nœud. Le schéma de solution est le suivant:


Le diagramme montre un cluster de N nœuds, chacun ayant un cache en mémoire standard. Tous les nœuds utilisent un modèle de données commun et doivent être cohérents. Lors du premier accès au cache par une clé arbitraire, la valeur dans le cache est absente et nous y mettons la valeur réelle de la base de données. Avec tout changement - supprimez l'enregistrement.

Les informations réelles dans la réponse de cache ici sont fournies en synchronisant la suppression d'une entrée lorsqu'elle est modifiée sur l'un des nœuds. Chaque nœud du système a une file d'attente dans le gestionnaire de files d'attente RabbitMQ. L'enregistrement dans toutes les files d'attente s'effectue via un point d'accès de type Sujet commun. Cela signifie que les messages envoyés au sujet tombent dans toutes les files d'attente qui lui sont associées. Ainsi, lors de la modification de la valeur sur n'importe quel nœud du système, cette valeur sera supprimée du stockage temporaire de chaque nœud, et l'accès suivant lancera l'écriture de la valeur actuelle dans le cache à partir de la base de données.

Soit dit en passant, un mécanisme PUB / SUB similaire existe dans Redis. Mais, à mon avis, il est toujours préférable d'utiliser le gestionnaire de files d'attente pour travailler avec les files d'attente, et RabbitMQ était parfait pour notre tâche.

Norme JSR 107 et sa mise en Ĺ“uvre


L'API Java Cache standard pour le stockage temporaire des données en mémoire (spécification JSR-107 ) a une histoire assez longue; elle a été développée pendant 12 ans.

Pendant si longtemps, les approches de développement de logiciels ont changé, les monolithes ont été remplacés par une architecture de microservices. En raison d'un manque de spécifications aussi long pour l'API Cache, il y a même eu des demandes pour développer des caches API pour les systèmes distribués JSR-347 (Data Grids for the Java Platform). Mais après la sortie tant attendue de JSR-107 et la sortie de JCache, la demande de créer une spécification distincte pour les systèmes distribués a été retirée.

Au cours des 12 longues années sur le marché, le lieu de stockage temporaire des données est passé de HashMap à ConcurrentHashMap avec la sortie de Java 1.5, et plus tard, de nombreuses implémentations open source prêtes à l'emploi de la mise en cache en mémoire sont apparues.

Après la sortie de JSR-107, les solutions des fournisseurs ont commencé à implémenter progressivement la nouvelle spécification. Pour JCache, il existe même des fournisseurs spécialisés dans la mise en cache distribuée - les mêmes grilles de données, dont la spécification n'a jamais été implémentée.

Considérez en quoi consiste le package javax.cache et comment obtenir une instance de cache pour notre application:
CachingProvider provider = Caching.getCachingProvider("org.cache2k.jcache.provider.JCacheProvider"); CacheManager cacheManager = provider.getCacheManager(); CacheConfiguration<Integer, String> config = new MutableConfiguration<Integer, String>() .setTypes(Integer.class, String.class) .setReadThrough(true) . . .; Cache<Integer, String> cache = cacheManager.createCache(cacheName, config); 

Ici, Caching est un chargeur de démarrage pour CachingProvider.

Dans notre cas, JCacheProvider, qui est l'implémentation cache2k du fournisseur JSR-107 SPI , sera chargé à partir de ClassLoader. Pour le chargeur, vous n'aurez peut-être pas à spécifier l'implémentation du fournisseur, mais il essaiera alors de charger l'implémentation qui se trouve dans
META-INF / services / javax.cache.spi.CachingProvider

Dans tous les cas, dans ClassLoader, il devrait y avoir une seule implémentation de CachingProvider.

Si vous utilisez la bibliothèque javax.cache sans aucune implémentation, une exception sera levée lorsque vous essayez de créer JCache. Le but du fournisseur est de créer et de gérer le cycle de vie de CacheManager, qui, à son tour, est responsable de la gestion et de la configuration des caches. Ainsi, pour créer un cache, vous devez procéder comme suit:


Les caches standard créés à l'aide de CacheManager doivent avoir une configuration compatible avec l'implémentation. La CacheConfiguration paramétrée standard fournie par javax.cache peut être étendue à une implémentation CacheProvider spécifique.

Aujourd'hui, il existe des dizaines d'implémentations différentes de la spécification JSR-107: Ehcache , Guava , caféine , cache2k . De nombreuses implémentations sont des grilles de données en mémoire dans les systèmes distribués - Hazelcast , Oracle Coherence .

Il existe également de nombreuses implémentations de stockage temporaire qui ne prennent pas en charge l'API standard. Pendant longtemps dans notre projet, nous avons utilisé Ehcache 2, qui n'est pas compatible avec JCache (l'implémentation de la spécification est apparue avec Ehcache 3). La nécessité d'une transition vers une implémentation compatible JCache est apparue avec la nécessité de surveiller l'état des caches en mémoire. À l'aide de MetricRegistry standard, il a été possible de sécuriser la surveillance uniquement à l'aide de l'implémentation JCacheGaugeSet, qui collecte des métriques à partir de JCache standard.

Comment choisir l'implémentation du cache en mémoire appropriée pour votre projet? Vous devriez peut-être faire attention aux points suivants:

  1. Avez-vous besoin d'une assistance pour la spécification JSR-107?
  2. Il convient également de prêter attention à la rapidité de la mise en œuvre sélectionnée. Sous de fortes charges, les performances des caches internes peuvent avoir un impact significatif sur le temps de réponse de votre système.
  3. Support au printemps. Si vous utilisez le framework bien connu dans votre projet, il convient de prendre en compte le fait que toutes les implémentations de cache JVM n'ont pas de CacheManager compatible dans Spring.

Si vous, comme nous, utilisez activement Spring dans votre projet, alors pour la mise en cache des données, vous adhérez très probablement à l'approche orientée aspect (AOP) et utilisez l'annotation @Cacheable. Spring utilise son propre SPI CacheManager pour que les aspects fonctionnent. Le bean suivant est requis pour que les caches de printemps fonctionnent:
 @Bean public org.springframework.cache.CacheManager cacheManager() { CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); return new JCacheCacheManager(cacheManager); } 

Pour travailler avec des caches dans le paradigme AOP, des considérations transactionnelles doivent également être prises en compte. Le cache de printemps doit nécessairement prendre en charge la gestion des transactions. À cette fin, spring CacheManager hérite des propriétés AbstractTransactionSupportingCacheManager, qui peuvent être utilisées pour synchroniser les opérations put- / expict effectuées dans une transaction et les exécuter uniquement après qu'une transaction réussie a été validée.

L'exemple ci-dessus montre l'utilisation du wrapper JCacheCacheManager pour le gestionnaire de spécifications de cache. Cela signifie que toute implémentation JSR-107 est également compatible avec Spring CacheManager. C'est une autre raison de choisir un cache en mémoire avec prise en charge de la spécification JSR pour votre projet. Mais si ce support n'est toujours pas nécessaire, mais je veux vraiment utiliser @Cacheable, alors vous avez le support pour deux autres solutions de cache interne: EhCacheCacheManager et CaffeineCacheManager.

Lors du choix de l'implémentation du cache en mémoire, nous n'avons pas pris en compte la prise en charge d'IMDG pour les systèmes distribués, comme mentionné précédemment. Pour maintenir les performances des caches JVM sur notre système, nous avons écrit notre propre solution.

Effacement des caches dans un système distribué


Les IMDG modernes utilisés dans les projets avec une architecture de microservices vous permettent de répartir les données en mémoire entre tous les nœuds de travail du système en utilisant un partitionnement de données évolutif avec le niveau de redondance requis.

Dans ce cas, de nombreux problèmes sont associés à la synchronisation, à la cohérence des données, etc., sans parler de l'augmentation du temps d'accès au stockage temporaire. Un tel schéma est redondant si la quantité de données utilisées tient dans la RAM d'un nœud, et pour maintenir la cohérence des données, il suffit de supprimer cette entrée sur tous les nœuds pour toute modification de la valeur du cache.

Lors de la mise en œuvre d'une telle solution, l'idée d'utiliser certains EventListener vient d'abord à l'esprit, dans JCache, il existe un CacheEntryRemovedListener pour l'événement de suppression d'une entrée du cache. Il semble qu'il suffit d'ajouter votre propre implémentation d'écouteur, qui enverra des messages au sujet lorsque l'enregistrement est supprimé, et le cache eutectique sur tous les nœuds est prêt - à condition que chaque nœud écoute les événements de la file d'attente associée au sujet général, comme le montre le diagramme ci-dessus.

Lors de l'utilisation de cette solution, les données sur différents nœuds seront incohérentes en raison du fait que les listes d'événements dans tout processus d'implémentation JCache après qu'un événement se soit produit. Autrement dit, s'il n'y a pas d'enregistrement dans le cache local pour la clé donnée, mais qu'il existe un enregistrement pour la même clé sur un autre nœud, l'événement ne sera pas envoyé au sujet.


Examinez les autres moyens disponibles pour intercepter l'événement d'une valeur supprimée du cache local.

Dans le package javax.cache.event, à côté d'EventListeners, il y a également un CacheEntryEventFilter, qui, selon JavaDoc, est utilisé pour vérifier tout événement CacheEntryEvent avant de passer cet événement à CacheEntryListener, qu'il s'agisse d'un enregistrement, d'une suppression, d'une mise à jour ou d'un événement lié à l'expiration de l'enregistrement en cache. Lors de l'utilisation du filtre, notre problème restera, car la logique sera exécutée après l'enregistrement de l'événement CacheEntryEvent et après l'exécution de l'opération CRUD dans le cache.

Néanmoins, il est possible de rattraper le déclenchement d'un événement pour supprimer un enregistrement du cache. Pour ce faire, utilisez l'outil intégré dans JCache qui vous permet d'utiliser les spécifications de l'API pour écrire et charger des données à partir d'une source externe, si elles ne sont pas dans le cache. Il existe deux interfaces pour cela dans le package javax.cache.integration:

  • CacheLoader - pour charger les donnĂ©es demandĂ©es par la clĂ©, s'il n'y a aucune entrĂ©e dans le cache.
  • CacheWriter - pour utiliser l'Ă©criture, la suppression et la mise Ă  jour des donnĂ©es sur une ressource externe lors de l'appel des opĂ©rations de cache correspondantes.

Pour garantir la cohérence, les méthodes CacheWriter sont atomiques par rapport à l'opération de cache correspondante. Nous semblons avoir trouvé une solution à notre problème.

Nous pouvons maintenant maintenir la cohérence de la réponse des caches en mémoire sur les nœuds lors de l'utilisation de notre implémentation de CacheWriter, qui envoie des événements à la rubrique RabbitMQ chaque fois qu'il y a un changement dans l'enregistrement dans le cache local.

Conclusion


Lors du développement de tout projet, lors de la recherche d'une solution adaptée aux problèmes émergents, il faut tenir compte de sa spécificité. Dans notre cas, les caractéristiques du modèle de données du projet, le code hérité hérité et la nature de la charge n'ont permis d'utiliser aucune des solutions existantes au problème de mise en cache distribuée.

Il est très difficile de rendre une implémentation universelle applicable à tout système développé. Pour chacune de ces implémentations, il existe des conditions optimales d'utilisation. Dans notre cas, les spécificités du projet ont conduit à la solution décrite dans cet article. Si quelqu'un a un problème similaire, nous serons heureux de partager notre solution et de la publier sur GitHub.

Source: https://habr.com/ru/post/fr474994/


All Articles