Problèmes de traitement par lots des demandes et leurs solutions (partie 1)

Presque tous les produits logiciels modernes se composent de plusieurs services. Souvent, les longs temps de réponse des canaux interservices deviennent une source de problèmes de performances. La solution standard à ce type de problème consiste à regrouper plusieurs demandes interservices dans un seul package, appelé traitement par lots.

Si vous utilisez le traitement par lots, vous ne serez peut-être pas satisfait de son résultat en termes de performances ou de compréhensibilité du code. Cette méthode n'est pas aussi facile pour l'appelant que vous ne le pensez. À des fins différentes et dans des situations différentes, les décisions peuvent varier considérablement. Sur des exemples spécifiques, je montrerai les avantages et les inconvénients de plusieurs approches.

Projet de démonstration


Pour plus de clarté, considérons un exemple de l'un des services de l'application sur laquelle je travaille actuellement.

Une explication du choix de la plateforme pour des exemples
Le problème des performances médiocres est assez général et ne s'applique à aucune langue ni plate-forme spécifique. Cet article utilisera des exemples de code Spring + Kotlin pour illustrer les tâches et les solutions. Kotlin est également compréhensible (ou incompréhensible) pour les développeurs Java et C #, en outre, le code est plus compact et compréhensible qu'en Java. Pour faciliter la compréhension des développeurs Java purs, j'éviterai la magie noire de Kotlin et n'utiliserai que du blanc (dans l'esprit de Lombok). Il y aura quelques méthodes d'extension, mais elles sont en fait familières à tous les programmeurs Java en tant que méthodes statiques, donc ce sera un peu de sucre qui ne gâchera pas le goût du plat.

Il existe un service d'approbation des documents. Quelqu'un crée un document et le soumet à la discussion, au cours de laquelle des modifications sont apportées, et finalement le document est cohérent. Le service de réconciliation lui-même ne sait rien des documents: ce n'est qu'une conversation de coordinateurs avec de petites fonctions supplémentaires, que nous ne considérerons pas ici.

Ainsi, il existe des salles de chat (correspondant à des documents) avec un ensemble prédéfini de participants dans chacun d'eux. Comme dans les chats classiques, les messages contiennent du texte et des fichiers et peuvent être des réponses et des transferts:

data class ChatMessage (
   // nullable persist
   val id : Long ? = null ,
   /** */
   val author : UserReference ,
   /** */
   val message : String ,
   /** */
   // - JPA+ null,
   val files : List < FileReference > ? = null ,
   /** , */
   val replyTo : ChatMessage ? = null ,
   /** , */
   val forwardFrom : ChatMessage ? = null
)


Les liens vers le fichier et l'utilisateur sont des liens vers d'autres domaines. Il vit avec nous comme ceci:

typealias FileReference = Long
typealias UserReference = Long

Les données utilisateur sont stockées dans Keycloak et récupérées via REST. Il en va de même pour les fichiers: les fichiers et les méta-informations les concernant vivent dans un service de stockage de fichiers distinct.

Tous les appels vers ces services sont des demandes lourdes . Cela signifie que le surcoût pour le transport de ces demandes est beaucoup plus important que le temps nécessaire pour qu'elles soient traitées par un service tiers. Sur nos bancs d'essai, le temps d'appel typique pour ces services est de 100 ms, donc à l'avenir, nous utiliserons ces numéros.

Nous devons créer un simple contrôleur REST pour recevoir les N derniers messages avec toutes les informations nécessaires. Autrement dit, nous pensons que dans le frontend, le modèle de message est presque le même et nous devons envoyer toutes les données. La différence entre le modèle frontal est que le fichier et l'utilisateur doivent être présentés sous une forme légèrement décryptée pour en faire des liens:

/** */
data class ReferenceUI (
   /** url */
   val ref : String ,
   /** */
   val name : String
)
data class ChatMessageUI (
   val id : Long ,
   /** */
   val author : ReferenceUI ,
   /** */
   val message : String ,
   /** */
   val files : List < ReferenceUI >,
   /** , */
   val replyTo : ChatMessageUI ? = null ,
   /** , */
   val forwardFrom : ChatMessageUI ? = null
)


Nous devons implémenter les éléments suivants:

interface ChatRestApi {
   fun getLast ( n : Int ) : List < ChatMessageUI >
}


Postfix UI signifie des modèles DTO pour l'interface, c'est-à-dire ce que nous devons donner via REST.

Il peut sembler surprenant ici que nous ne transmettions aucun identifiant de chat et même dans le modèle ChatMessage / ChatMessageUI, ce n'est pas le cas. Je l'ai fait exprès, afin de ne pas encombrer le code des exemples (les chats sont isolés, donc nous pouvons supposer que nous en avons un).

Retraite philosophique
La classe ChatMessageUI et la méthode ChatRestApi.getLast utilisent toutes deux le type de données List, alors qu'il s'agit en fait d'un ensemble ordonné. Dans le JDK, tout cela est mauvais, donc déclarer l'ordre des éléments au niveau de l'interface (maintenir l'ordre lors de l'ajout et de l'extraction) échouera. Il est donc courant d'utiliser List dans les cas où vous avez besoin d'un ensemble ordonné (il existe toujours un LinkedHashSet, mais ce n'est pas une interface).

Une limitation importante: nous supposons qu'il n'y a pas de longues chaînes de réponses ou de transferts. Autrement dit, ils le sont, mais leur longueur ne dépasse pas trois messages. La chaîne de messages front-end doit être transmise dans son intégralité.

Pour recevoir des données de services externes, il existe de telles API:

interface ChatMessageRepository {
   fun findLast ( n : Int ) : List < ChatMessage >
}
data class FileHeadRemote (
   val id : FileReference ,
   val name : String
)
interface FileRemoteApi {
   fun getHeadById ( id : FileReference ) : FileHeadRemote
   fun getHeadsByIds ( id : Set < FileReference > ) : Set < FileHeadRemote >
   fun getHeadsByIds ( id : List < FileReference > ) : List < FileHeadRemote >
   fun getHeadsByChat () : List < FileHeadRemote >
}
data class UserRemote (
   val id : UserReference ,
   val name : String
)
interface UserRemoteApi {
   fun getUserById ( id : UserReference ) : UserRemote
   fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote >
   fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote >
}


On peut voir que le traitement par lots était initialement prévu dans les services externes, et dans les deux cas: via Set (sans conserver l'ordre des éléments, avec des clés uniques) et via List (il peut y avoir des doublons - l'ordre est préservé).

Implémentations simples


Implémentation naïve


La première implémentation naïve de notre contrôleur REST ressemblera à ceci dans la plupart des cas:

class ChatRestController (
   private val messageRepository : ChatMessageRepository ,
   private val userRepository : UserRemoteApi ,
   private val fileRepository : FileRemoteApi
) : ChatRestApi {
   override fun getLast ( n : Int ) =
     messageRepository . findLast ( n )
       . map { it . toFrontModel () }

   private fun ChatMessage . toFrontModel () : ChatMessageUI =
     ChatMessageUI (
       id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
       author = userRepository . getUserById ( author ) . toFrontReference () ,
       message = message ,
       files = files ?. let { files ->
         fileRepository . getHeadsByIds ( files )
           . map { it . toFrontReference () }
} ?: listOf () ,
       forwardFrom = forwardFrom ?. toFrontModel () ,
       replyTo = replyTo ?. toFrontModel ()
)
}

Tout est très clair, et c'est un gros plus.

Nous utilisons le traitement par lots et recevons les données d'un service externe par lots. Mais que se passe-t-il avec la performance?

Pour chaque message, un appel à UserRemoteApi sera effectué pour obtenir des données sur le champ auteur et un appel à FileRemoteApi pour recevoir tous les fichiers joints. Il semble que ce soit tout. Supposons que les champs forwardFrom et replyTo pour ChatMessage sont obtenus afin que cela ne nécessite pas d'appels supplémentaires. Mais les transformer en ChatMessageUI entraînera une récursivité, c'est-à-dire que les performances du nombre d'appels peuvent considérablement augmenter. Comme nous l'avons noté précédemment, disons que nous n'avons pas beaucoup d'imbrication et que la chaîne est limitée à trois messages.

Par conséquent, nous recevons de deux à six appels à des services externes par message et un appel JPA à l'ensemble du paquet de messages. Le nombre total d'appels variera de 2 * N + 1 à 6 * N + 1. Combien est-ce en unités réelles? Supposons que vous ayez besoin de 20 publications pour afficher une page. Pour les obtenir, vous avez besoin de 4 s à 10 s. Horrible Je voudrais rencontrer les 500 ms. Et comme le front-end rêvait de faire un défilement transparent, les exigences de performances de ce point de terminaison peuvent être doublées.

Avantages:

  1. Le code est concis et auto-documenté (rêve du support).
  2. Le code est simple, il n'y a donc pratiquement aucune possibilité de tirer dans la jambe.
  3. Le traitement par lots ne semble pas étranger et s'inscrit naturellement dans la logique.
  4. Les changements de logique seront effectués facilement et seront locaux.

Moins:

Performances terribles dues au fait que les packages sont très petits.

Cette approche se retrouve souvent dans des services simples ou dans des prototypes. Si la vitesse du changement est importante, cela ne vaut guère la peine de compliquer le système. Dans le même temps, pour notre service très simple, les performances sont terribles, de sorte que le champ d'application de cette approche est très étroit.

Traitement parallèle naïf


Vous pouvez commencer à traiter tous les messages en parallèle - cela supprimera une augmentation linéaire du temps en fonction du nombre de messages. Ce n'est pas un moyen particulièrement bon, car cela entraînera une forte charge de pointe sur le service externe.

La mise en œuvre du traitement parallèle est très simple:

override fun getLast ( n : Int ) =
   messageRepository . findLast ( n ) . parallelStream ()
     . map { it . toFrontModel () }
     . collect ( toList ())


En utilisant le traitement parallèle des messages, nous obtenons idéalement 300–700 ms, ce qui est beaucoup mieux qu'avec une implémentation naïve, mais pas encore assez rapide.

Avec cette approche, les requêtes vers userRepository et fileRepository seront exécutées de manière synchrone, ce qui n'est pas très efficace. Pour résoudre ce problème, vous devrez modifier considérablement la logique d'appel. Par exemple, via CompletionStage (alias CompletableFuture):

private fun ChatMessage . toFrontModel () : ChatMessageUI =
   CompletableFuture . supplyAsync {
     userRepository . getUserById ( author ) . toFrontReference ()
   } . thenCombine (
     files ?. let {
       CompletableFuture . supplyAsync {
         fileRepository . getHeadsByIds ( files ) . map { it . toFrontReference () }
}
} ?: CompletableFuture . completedFuture ( listOf ())
) { author , files ->
     ChatMessageUI (
       id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
       author = author ,
       message = message ,
       files = files ,
       forwardFrom = forwardFrom ?. toFrontModel () ,
       replyTo = replyTo ?. toFrontModel ()
)
   } . get () !!


On peut voir que le code de mappage initialement simple est devenu moins clair. En effet, nous avons dû séparer les appels de service externes de l'endroit où les résultats ont été utilisés. Ce n'est pas mauvais en soi. Mais la combinaison des appels n'a pas l'air très élégante et ressemble à une "nouille" réactive typique.

Si vous utilisez des coroutines, tout sera plus décent:

private fun ChatMessage . toFrontModel () : ChatMessageUI =
   join (
     { userRepository . getUserById ( author ) . toFrontReference () } ,
     { files ?. let { fileRepository . getHeadsByIds ( files )
       . map { it . toFrontReference () } } ?: listOf () }
   ) . let { ( author , files ) ->
     ChatMessageUI (
       id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
       author = author ,
       message = message ,
       files = files ,
       forwardFrom = forwardFrom ?. toFrontModel () ,
       replyTo = replyTo ?. toFrontModel ()
)
   }


Où:

fun < A , B > join ( a : () -> A , b : () -> B ) =
   runBlocking ( IO ) {
     awaitAll ( async { a () } , async { b () } )
   } . let {
     it [ 0 ] as A to it [ 1 ] as B
   }


Théoriquement, en utilisant un tel traitement parallèle, nous obtenons 200–400 ms, ce qui est déjà proche de nos attentes.

Malheureusement, une si bonne parallélisation ne se produit pas, et le retour sur investissement est assez cruel: avec seulement quelques utilisateurs travaillant en même temps, une vague de demandes tombera sur les services, qui ne seront toujours pas traités en parallèle, nous allons donc revenir à nos tristes 4 s.

Mon résultat lors de l'utilisation d'un tel service est de 1300-1700 ms pour le traitement de 20 messages. C'est plus rapide que dans la première implémentation, mais cela ne résout toujours pas le problème.

Utilisation alternative des requêtes parallèles
Que faire si le traitement par lots n'est pas fourni dans les services tiers? Par exemple, vous pouvez masquer le manque d'implémentation du traitement par lots dans les méthodes d'interface:

interface UserRemoteApi {
   fun getUserById ( id : UserReference ) : UserRemote
   fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote > =
     id . parallelStream ()
       . map { getUserById ( it ) } . collect ( toSet ())
   fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote > =
     id . parallelStream ()
       . map { getUserById ( it ) } . collect ( toList ())
}


Cela a du sens s'il existe un espoir que le traitement par lots apparaîtra dans les futures versions.

Avantages:

  1. Implémentation facile du traitement simultané des messages.
  2. Bonne évolutivité.

Inconvénients:

  1. La nécessité de séparer la réception des données de leur traitement lors du traitement parallèle des demandes pour différents services.
  2. Augmentation de la charge sur les services tiers.

On peut voir que le champ d'application est approximativement le même que celui de l'approche naïve. L'utilisation de la méthode de requête parallèle est logique si vous souhaitez augmenter les performances de votre service plusieurs fois en raison de l'exploitation impitoyable des autres. Dans notre exemple, la productivité a augmenté de 2,5 fois, mais ce n'est clairement pas suffisant.

Mise en cache


Vous pouvez effectuer une mise en cache de style JPA pour les services externes, c'est-à-dire stocker les objets reçus dans la session afin de ne pas les recevoir à nouveau (y compris pendant le traitement par lots). Vous pouvez faire ces caches vous-même, vous pouvez utiliser Spring avec son @Cacheable, et vous pouvez toujours utiliser manuellement un cache prêt à l'emploi comme EhCache.

Le problème général sera lié au fait que les caches n'ont de bon sens que s'il y a des hits. Dans notre cas, les hits sur le champ auteur (disons, 50%) sont très probables, et il n'y aura aucun hit sur les fichiers. Cette approche apportera quelques améliorations, mais les performances ne changeront pas radicalement (et nous avons besoin d'une percée).

Les caches (longs) d'intersession nécessitent une logique d'invalidation complexe. En général, plus vous arrivez au point de résoudre les problèmes de performances avec des caches intersessions, mieux c'est.

Avantages:

  1. Implémentez la mise en cache sans changer le code.
  2. Les performances augmentent plusieurs fois (dans certains cas).

Inconvénients:

  1. Possibilité de dégradation des performances en cas d'utilisation incorrecte.
  2. Large surcharge de mémoire, en particulier avec de longues caches.
  3. Invalidation complexe, dont les erreurs entraîneront des problèmes difficiles lors de l'exécution.

Très souvent, les caches ne sont utilisés que pour corriger rapidement les problèmes de conception. Cela ne signifie pas qu'ils n'ont pas besoin d'être utilisés. Cependant, il est toujours utile de les traiter avec prudence et d'évaluer d'abord le gain de performances résultant, puis de prendre une décision.

Dans notre exemple, les caches auront une augmentation de performance d'environ 25%. En même temps, les caches ont beaucoup d'inconvénients, donc je ne les utiliserais pas ici.

Résumé


Nous avons donc examiné l'implémentation naïve d'un service qui utilise le traitement par lots et quelques moyens simples pour l'accélérer.

Le principal avantage de toutes ces méthodes est la simplicité, dont les conséquences sont agréables.

Un problème courant avec ces méthodes est de mauvaises performances, principalement en raison de la taille des paquets. Par conséquent, si ces solutions ne vous conviennent pas, il vaut la peine d'envisager des méthodes plus radicales.

Il existe deux principaux domaines dans lesquels vous pouvez rechercher des solutions:

  • Travail asynchrone avec des données (nécessite un changement de paradigme, par conséquent, cet article n'est pas pris en compte)
  • agrandissement des packs tout en maintenant un traitement synchrone.

L'élargissement des offres réduira considérablement le nombre d'appels externes et en même temps gardera le code synchrone. La prochaine partie de l'article sera consacrée à ce sujet.

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


All Articles