Presque tous les logiciels modernes se composent de plusieurs services. Souvent, un long temps de réponse des canaux inter-services devient une source de problèmes de performance. La solution standard à ce type de problème est l'emballage de plusieurs demandes inter-services en un seul paquet, ce qui est appelé le traitement par lot (batching).
Si vous utilisez le traitement par lot, vous pouvez ne pas être satisfait de son résultat en termes de performance ou de lisibilité du code. Cette méthode n'est pas aussi simple pour l'appelant qu'on pourrait le penser. Pour différents objectifs et dans différentes situations, les solutions peuvent varier considérablement. À l'aide d'exemples concrets, je vais montrer les avantages et les inconvénients de plusieurs approches.
Projet de démonstration
Pour illustrer, examinons un exemple d'un des services de l'application sur laquelle je travaille actuellement.
Explication sur le choix de la plateforme pour les exemplesLe problème de mauvaise performance est assez général et ne concerne pas des langages et plateformes spécifiques. Dans cet article, pour démontrer les défis et solutions, des exemples de code en Spring + Kotlin seront utilisés. Kotlin est tout aussi compréhensible (ou incompréhensible) pour les développeurs Java et C#, de plus, le code est plus compact et lisible que celui en Java. Pour faciliter la compréhension des développeurs Java, j'éviterai la magie noire de Kotlin et utiliserai uniquement la magie blanche (dans l'esprit de Lombok). Il y aura quelques méthodes d'extension, mais elles sont en réalité familières à tous les programmeurs Java sous forme de méthodes statiques, donc cela sera un petit plus qui ne dénaturera pas le plat.
Il existe un service d'approbation de documents. Quelqu'un crée un document et le soumet à discussion, au cours de laquelle des modifications sont apportées, et finalement le document est approuvé. Le service d'approbation ne sait rien des documents : c'est juste un chat d'approbation avec quelques fonctions supplémentaires que nous ne traiterons pas ici.
Ainsi, il existe des salles de chat (correspondant aux documents) avec un ensemble prédéfini de participants dans chacune d'elles. Comme dans les chats ordinaires, les messages contiennent du texte et des fichiers et peuvent être des réponses (reply) et des transferts (forward) :
classe de données MessageDeChat(
// nullable так как появляется только после persist
val identifiant : Long? = null,
/** Ссылка на автора */
val auteur : RéférenceUtilisateur,
/** Сообщение */
val message : Chaîne,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: Liste<RéférenceFichier>? = null,
/** Если является ответом, то здесь будет оригинал */
val répondreÀ : MessageDeChat? = null,
/** Если является пересылкой, то здесь будет оригинал */
val transféréDe : MessageDeChat? = null
)
Les liens vers le fichier et vers l'utilisateur sont des liens vers d'autres domaines. Chez nous, cela fonctionne comme suit :
typealias RéférenceFichier = Long
typealias RéférenceUtilisateur = Long
Les données des utilisateurs sont stockées dans Keycloak et récupérées via REST. Il en va de même pour les fichiers : les fichiers et leurs métadonnées résident dans un service de stockage séparé.
Tous les appels de ces services sont des requêtes lourdes. Cela signifie que les frais de transport de ces requêtes sont beaucoup plus élevés que le temps de traitement par le service tiers. Dans nos bancs d'essai, le temps d'appel typique de tels services est de 100 ms, nous utiliserons donc ces chiffres par la suite.
Nous devons créer un simple contrôleur REST pour obtenir les derniers N messages avec toutes les informations nécessaires. En d'autres termes, nous considérons que le modèle de message au frontend est presque identique et que toutes les données doivent être transmises. La différence du modèle pour le frontend est que le fichier et l'utilisateur doivent être représentés de manière légèrement décryptée, afin de les rendre cliquables :
/** В таком виде отдаются ссылки на сущности для фронта */
classe de données RéférenceUI(
/** Идентификатор для url */
val réf : Chaîne,
/** Видимое пользователю название ссылки */
val nom : Chaîne
)
classe de données ChatMessageUI(
val identifiant : Long,
/** Ссылка на автора */
val auteur : RéférenceUI,
/** Сообщение */
val message : Chaîne,
/** Ссылки на аттачи */
val files: Liste<RéférenceUI>,
/** Если являтся ответом, то здесь будет оригинал */
val répondreÀ : ChatMessageUI? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val transféréDe : ChatMessageUI? = null
)
Nous devons mettre en œuvre ce qui suit :
interface ChatRestApi {
fun getLast(n: Int): Liste<ChatMessageUI>
}
Le suffixe UI fait référence aux modèles DTO pour le frontend, c'est-à-dire ce que nous devons retourner via REST.
Il peut sembler surprenant ici que nous ne transmettons aucun identifiant de chat et qu'il n'y en a même pas dans le modèle ChatMessage/ChatMessageUI. Je l'ai fait intentionnellement pour ne pas encombrer le code des exemples (les chats sont isolés, donc nous pouvons supposer qu'il n'y en a qu'un).
Une digression philosophiqueDans la classe ChatMessageUI et dans la méthode ChatRestApi.getLast, le type de données utilisé est List, alors qu'en réalité, il s'agit d'un Set ordonné. Dans le JDK, cela pose problème, donc déclarer l'ordre des éléments au niveau de l'interface (conservation de l'ordre lors de l'ajout et de l'extraction) ne sera pas possible. Ainsi, il est devenu courant d'utiliser List dans les cas où un Set ordonné est nécessaire (il existe également LinkedHashSet, mais ce n'est pas une interface).
Une limitation importante : nous considérerons qu'il n'y a pas de longues chaînes de réponses ou de renvois. C'est-à-dire qu'elles existent, mais leur longueur ne dépasse pas trois messages. Au frontend, la chaîne de messages doit être transmise dans son intégralité.
Pour obtenir des données des services externes, il existe les API suivantes :
interface ChatMessageRepository {
fun findLast(n: Int): Liste<MessageDeChat>
}
classe de données FileHeadRemote(
val identifiant : RéférenceFichier,
val nom : Chaîne
)
interface FileRemoteApi {
fun getHeadById(id: RéférenceFichier): FileHeadRemote
fun getHeadsByIds(id: Set<RéférenceFichier>): Set<FileHeadRemote>
fun getHeadsByIds(id: Liste<RéférenceFichier>): Liste<FileHeadRemote>
fun getHeadsByChat(): Liste<FileHeadRemote>
}
classe de données UserRemote(
val identifiant : RéférenceUtilisateur,
val nom : Chaîne
)
interface UserRemoteApi {
fun getUserById(id: RéférenceUtilisateur): UserRemote
fun getUsersByIds(id: Set<RéférenceUtilisateur>): Set<UserRemote>
fun getUsersByIds(id: Liste<RéférenceUtilisateur>): Liste<UserRemote>
}
Il est clair que dans les services externes, un traitement par lots est initialement prévu, et ce dans les deux cas : via Set (sans conservation de l'ordre des éléments, avec des clés uniques) et via List (des doublons peuvent exister - l'ordre est conservé).
Implémentations simples
Implémentation naïve
La première implémentation naïve de notre contrôleur REST ressemblera dans la plupart des cas à quelque chose comme ça :
class ChatRestController(
val privé messageRepository : ChatMessageRepository,
val privé userRepository : UserRemoteApi,
val privé fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.carte { it.toFrontModel() }
private fun MessageDeChat.toFrontModel(): ChatMessageUI =
ChatMessageUI(
id = id ?: throw IllegalStateException("$ceci doit être persistant"),
author = userRepository.getUserById(author).toFrontReference(),
message = message,
files = files?.let { files ->
fileRepository.getHeadsByIds(files)
.carte { it.toFrontReference() }
} ?: listOf(),
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Tout est extrêmement clair, ce qui est un grand avantage.
Nous utilisons le traitement par lots et obtenons des données d'un service externe par paquets. Mais qu'en est-il de notre performance ?
Pour chaque message, un appel UserRemoteApi sera effectué pour obtenir les données du champ author et un appel FileRemoteApi pour récupérer tous les fichiers joints. En théorie, c'est tout. Supposons que les champs forwardFrom et replyTo pour ChatMessage sont obtenus de manière à ne pas nécessiter d'appels supplémentaires. Cependant, les transformer en ChatMessageUI entraînera une récursion, ce qui signifie que les compteurs d'appels peuvent augmenter considérablement. Comme nous l'avons noté précédemment, supposons que nous n'avons pas de grande profondeur et que la chaîne est limitée à trois messages.
En fin de compte, nous aurons entre deux et six appels aux services externes pour un seul message et un appel JPA pour l'ensemble du paquet de messages. Le nombre total d'appels variera entre 2*N+1 et 6*N+1. Combien cela représente-t-il en termes réels ? Supposons que pour rendre la page, nous avons besoin de 20 messages. Pour les obtenir, il faudra entre 4 et 10 secondes. Horrible ! Nous aimerions nous en tenir à 500 ms. Étant donné que nous souhaitions réaliser un défilement fluide en front-end, les exigences de performance pour ce endpoint pourraient être doublées.
Avantages :
- Le code est concis et auto-documenté (rêve du support).
- Le code est simple, donc il y a presque aucune chance de se tirer une balle dans le pied.
- Le traitement par lots ne semble pas étranger et s'intègre organiquement dans la logique.
- Les modifications logiques seront facilement apportées et seront locales.
Inconvénient :
Une performance horrible, liée au fait que les paquets sont très petits.
Une telle approche peut souvent être observée dans des services simples ou des prototypes. Si la vitesse de mise à jour est importante, il vaut mieux ne pas compliquer le système. En même temps, pour notre très simple service, la performance est très mauvaise, donc la portée d'application d'une telle approche est très étroite.
Traitement parallèle naïf
Il est possible de lancer le traitement de tous les messages en parallèle, ce qui permettra d'éliminer la croissance linéaire du temps en fonction du nombre de messages. Ce n'est pas vraiment la meilleure voie, car cela entraînera une charge de pointe importante sur le service externe.
Il est très simple d'implémenter le traitement parallèle :
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 bien meilleur que dans une implémentation naïve, mais ce n'est toujours pas assez rapide.
Avec cette approche, les requêtes à userRepository et fileRepository seront exécutées de manière synchrone, ce qui n'est pas très efficace. Pour y remédier, il sera nécessaire de modifier considérablement la logique des appels. Par exemple, via CompletionStage (alias CompletableFuture) :
private fun MessageDeChat.toFrontModel(): ChatMessageUI =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).toFrontReference()
}.thenCombine(
files?.let {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).carte { it.toFrontReference() }
}
} ?: CompletableFuture.completedFuture(listOf())
) { author, files ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$ceci doit être persistant"),
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 compréhensible. Cela est dû au fait que nous avons dû séparer les appels aux services externes du lieu où les résultats sont utilisés. En soi, ce n'est pas une mauvaise chose. Mais la combinaison des appels semble peu élégante et rappelle une typique 'nouille' réactive.
Si l'on utilise des coroutines, tout semblera plus propre :
private fun MessageDeChat.toFrontModel(): ChatMessageUI =
joindre(
{ userRepository.getUserById(author).toFrontReference() },
{ files?.let { fileRepository.getHeadsByIds(fichiers)
.carte { it.toFrontReference() } } ?: listOf() }
).let { (auteur, fichiers) ->
ChatMessageUI(
id = id ?: throw IllegalStateException("$ceci doit être persistant"),
auteur = auteur,
message = message,
fichiers = fichiers,
forwardFrom = forwardFrom?.toFrontModel(),
replyTo = replyTo?.toFrontModel()
)
}
Où :
fun <A, B> joindre(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, un tel bon parallélisme n'existe pas, et le coût peut être assez sévère : avec quelques utilisateurs se connectant simultanément aux services, un afflux de requêtes s'abattra qui ne sera de toute façon pas traité en parallèle, donc nous reviendrons à nos tristes 4 s.
Mon résultat en utilisant 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 pas le problème.
Une application alternative des requêtes parallèlesQue faire si les services tiers ne prévoient pas de traitement par lots ? Par exemple, on peut masquer l'absence d'implémentation du traitement par lots à l'intérieur des méthodes des interfaces :
interface UserRemoteApi {
fun getUserById(id: RéférenceUtilisateur): UserRemote
fun getUsersByIds(id: Set<RéférenceUtilisateur>): Set<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
fun getUsersByIds(id: Liste<RéférenceUtilisateur>): Liste<UserRemote> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Cela a du sens s'il y a de l'espoir pour l'apparition d'un traitement par lots dans les versions suivantes.
Avantages :
- Mise en œuvre facile du traitement parallèle des messages.
- Bonne évolutivité.
Inconvénients :
- Nécessité de séparer l'obtention des données de leur traitement lors du traitement parallèle des requêtes vers différents services.
- Charge accrue sur les services tiers.
On peut voir que les limites d'application sont à peu près les mêmes que pour l'approche naïve. Utiliser la méthode des requêtes parallèles a du sens si vous souhaitez multiplier par plusieurs fois les performances de votre service grâce à l'exploitation intensive de ressources externes. Dans notre exemple, les performances ont été multipliées par 2,5, mais c'est clairement insuffisant.
Mise en cache
Il est possible de réaliser une mise en cache dans l'esprit de JPA pour les services externes, c'est-à-dire stocker les objets reçus dans le cadre de la session pour ne pas les récupérer à nouveau (y compris lors du traitement par lots). On peut créer de tels caches soi-même, utiliser Spring avec son @Cacheable, et il est toujours possible d'utiliser un cache prêt à l'emploi comme EhCache manuellement.
Un problème général sera que les caches ne sont utiles que s'il y a des hits. Dans notre cas, des hits sur le champ author sont très probables (disons 50 %), mais il n'y aura pas de hits sur les fichiers du tout. Cette approche apportera certaines améliorations, mais ne changera pas radicalement les performances (et nous avons besoin d'une rupture).
Les caches inter-sessions (longs) nécessitent une logique complexe d'invalidation. En général, plus vous attendrez d'arriver au point où vous devrez résoudre des problèmes de performance à l'aide de caches inter-sessions, mieux ce sera.
Avantages :
- Implémentation de la mise en cache sans modification du code.
- Augmentation des performances de plusieurs fois (dans certains cas).
Inconvénients :
- Possibilité de diminution des performances en cas d'utilisation incorrecte.
- Frais généraux de mémoire importants, surtout avec des caches longs.
- Invalidation complexe, dont les erreurs entraîneront des problèmes difficiles à reproduire en temps réel.
Très souvent, les caches sont utilisés uniquement pour corriger rapidement des problèmes de conception. Cela ne signifie pas qu'ils ne doivent pas être utilisés. Cependant, il est toujours recommandé de les aborder avec prudence et d'évaluer d'abord l'augmentation de performance obtenue, puis de prendre une décision.
Dans notre exemple, la mise en cache apportera un gain de performance d'environ 25 %. Cependant, il y a beaucoup d'inconvénients avec les caches, donc je ne les utiliserais pas ici.
Résultats
Donc, nous avons examiné la mise en œuvre naïve d'un service utilisant le traitement par lots et quelques méthodes simples pour l'accélérer.
Le principal avantage de toutes ces méthodes est la simplicité, dont découlent de nombreux effets positifs.
Un problème commun de ces méthodes est la mauvaise performance, principalement liée à la taille des paquets. Donc, si ces solutions ne vous conviennent pas, il vaut la peine d'envisager des méthodes plus radicales.
Il existe deux axes principaux dans lesquels vous pouvez chercher des solutions :
- le traitement asynchrone des données (nécessite un changement de paradigme, donc ne sera pas abordé dans cet article) ;
- l'agrégation des paquets tout en conservant le traitement synchrone.
L'agrégation des paquets permettra de réduire considérablement le nombre d'appels externes tout en maintenant le code synchrone. Ce sujet sera abordé dans la prochaine partie de l'article.
Source : habr.com
