Problèmes de traitement des requêtes par lots et leurs solutions (partie 1)

Problèmes de traitement des requêtes par lots 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 requêtes interservices dans un seul package, appelé traitement par lots.

Si vous utilisez le traitement par lots, vous risquez de ne pas être satisfait des résultats en termes de performances ou de clarté du code. Cette méthode n’est pas aussi simple pour l’appelant qu’on pourrait le penser. Selon les objectifs et les situations, les solutions peuvent varier considérablement. À l’aide d’exemples précis, je montrerai les avantages et les inconvénients de plusieurs approches.

Projet de démonstration

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

Explication de la sélection de la plateforme pour des exemplesLe problème des mauvaises performances est assez général et n’affecte aucun langage ou plateforme spécifique. Cet article utilisera des exemples de code Spring + Kotlin pour démontrer les problèmes et les solutions. Kotlin est également compréhensible (ou incompréhensible) pour les développeurs Java et C#, de plus, le code est plus compact et compréhensible qu'en Java. Pour que ce soit plus facile à comprendre pour les développeurs Java purs, j'éviterai la magie noire de Kotlin et n'utiliserai que la magie blanche (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, ce sera donc un petit 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 pour discussion, au cours de laquelle des modifications sont apportées, et finalement le document est accepté. Le service d'approbation lui-même ne connaît rien aux documents : il s'agit simplement d'un chat d'approbateurs avec de petites fonctions supplémentaires que nous n'aborderons pas ici.

Il existe donc des salons de discussion (correspondant à des documents) avec un ensemble prédéfini de participants dans chacun d'eux. Comme dans les discussions classiques, les messages contiennent du texte et des fichiers et peuvent être des réponses ou 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 des fichiers et des utilisateurs sont des liens vers d'autres domaines. Ici, nous vivons ainsi :

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étainformations les concernant se trouvent dans un service de stockage de fichiers distinct.

Tous les appels vers ces services sont demandes lourdes. Cela signifie que les frais liés au transport de ces requêtes sont bien supérieurs au temps nécessaire à leur traitement par un service tiers. Sur nos bancs de tests, le temps d'appel typique pour de tels services est de 100 ms, nous utiliserons donc ces numéros à l'avenir.

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 le modèle de message dans le frontend est presque le même et que toutes les données doivent être envoyées. La différence entre le modèle front-end est que le fichier et l'utilisateur doivent être présentés sous une forme légèrement décryptée afin d'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 mettre en œuvre les éléments suivants :

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

Le suffixe UI signifie les modèles DTO pour le frontend, c'est-à-dire ce que nous devons servir via REST.

Ce qui peut être surprenant ici, c'est que nous ne transmettons aucun identifiant de chat et même le modèle ChatMessage/ChatMessageUI n'en a pas. J'ai fait cela intentionnellement pour ne pas encombrer le code des exemples (les chats sont isolés, on peut donc supposer que nous n'en avons qu'un).

Digression philosophiqueLa 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é. C'est mauvais dans le JDK, donc déclarer l'ordre des éléments au niveau de l'interface (en préservant l'ordre lors de l'ajout et de la suppression) ne fonctionnera pas. Il est donc devenu courant d'utiliser une List dans les cas où un Set ordonné est nécessaire (il existe également un LinkedHashSet, mais ce n'est pas une interface).
Limite importante : Nous supposerons qu’il n’y a pas de longues chaînes de réponses ou de transferts. Autrement dit, ils existent, mais leur longueur ne dépasse pas trois messages. L’ensemble de la chaîne de messages doit être transmis au frontend.

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

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

On constate que les services externes prévoient dans un premier temps un traitement par lots, et dans les deux versions : 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 conservé).

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(nInt) =
    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 qu’arrive-t-il à notre productivité ?

Pour chaque message, un appel à UserRemoteApi sera effectué pour obtenir des données sur le champ auteur et un appel à FileRemoteApi pour obtenir tous les fichiers joints. Il semble que ce soit ça. Disons que les champs forwardFrom et ReplyTo pour ChatMessage sont obtenus de telle manière que cela ne nécessite pas d'appels inutiles. Mais les transformer en ChatMessageUI entraînera une récursion, c'est-à-dire que les compteurs d'appels peuvent augmenter considérablement. Comme nous l’avons noté plus tôt, supposons que nous n’avons pas beaucoup d’imbrication et que la chaîne est limitée à trois messages.

En conséquence, nous recevrons de deux à six appels vers des services externes par message et un appel JPA pour l'ensemble du paquet de messages. Le nombre total d'appels variera de 2*N+1 à 6*N+1. Combien cela représente-t-il en unités réelles ? Disons qu'il faut 20 messages pour afficher une page. Pour les recevoir, il faudra compter de 4 s à 10 s. Terrible! Je voudrais le garder dans les 500 ms. Et comme ils rêvaient de réaliser un défilement transparent dans le frontend, les exigences de performances pour ce point final peuvent être doublées.

Avantages:

  1. Le code est concis et auto-documenté (le rêve d'une équipe de support).
  2. Le code est simple, il n'y a donc presque aucune possibilité de se tirer une balle dans le pied.
  3. Le traitement par lots ne ressemble pas à quelque chose d'étranger et est organiquement intégré à la logique.
  4. Les changements de logique se feront facilement et seront locaux.

Moins:

Performances horribles en raison de très petits paquets.

Cette approche se retrouve assez souvent dans des services simples ou dans des prototypes. Si la rapidité des changements 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, le champ d'application de cette approche est donc très étroit.

Traitement parallèle naïf

Vous pouvez commencer à traiter tous les messages en parallèle - cela vous permettra de vous débarrasser de l'augmentation linéaire du temps en fonction du nombre de messages. Ce n’est pas une solution particulièrement efficace car elle entraînera une charge de pointe importante sur le service externe.

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

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

En utilisant le traitement de messages parallèle, nous obtenons idéalement 300 à 700 ms, ce qui est bien mieux qu'avec une implémentation naïve, mais toujours pas 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 considérablement modifier 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())
  ) authorfiles ->
    ChatMessageUI(
      id = id ?: throw IllegalStateException("$this must be persisted"),
      author = author,
      message = message,
      files = files,
      forwardFrom = forwardFrom?.toFrontModel(),
      replyTo = replyTo?.toFrontModel()
    )
  }.get()!!

On peut constater que le code de mappage initialement simple est devenu moins compréhensible. En effet, nous avons dû séparer les appels aux services externes de ceux où les résultats sont utilisés. En soi, ce n’est pas mauvais. 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 aura l'air plus correct :

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 <ABjoin(a: () -> Ab: () -> B) =
  runBlocking(IO{
    awaitAll(async a() }async b() })
  }.let {
    it[0as to it[1as B
  }

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

Malheureusement, une telle parallélisation n'existe pas, et le prix à payer est assez cruel : avec seulement quelques utilisateurs travaillant en même temps, un déluge de requêtes s'abattra sur les services, qui ne seront de toute façon pas traitées en parallèle, donc nous reviendra à nos tristes 4 s.

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

Utilisations alternatives des requêtes parallèlesQue se passe-t-il si les services tiers ne proposent pas de traitement par lots ? Par exemple, vous pouvez masquer le manque d'implémentation du traitement par lots dans les méthodes d'interface :

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

Cela est logique si vous espérez voir un traitement par lots dans les futures versions.
Avantages:

  1. Implémentez facilement un traitement parallèle basé sur les messages.
  2. Bonne évolutivité.

Inconvénients:

  1. La nécessité de séparer l'acquisition des données de leur traitement lors du traitement parallèle des demandes adressées à différents services.
  2. Charge accrue sur les services tiers.

On constate que le champ d’applicabilité est à peu près le même que celui de l’approche naïve. Il est logique d'utiliser la méthode des requêtes parallèles si vous souhaitez augmenter plusieurs fois les performances de votre service en raison de l'exploitation impitoyable des autres. Dans notre exemple, les performances ont été multipliées par 2,5, mais ce n'est clairement pas suffisant.

mise en cache

Vous pouvez faire du cache dans l'esprit JPA pour les services externes, c'est-à-dire stocker les objets reçus au sein d'une session afin de ne plus les recevoir (y compris lors du traitement par lots). Vous pouvez créer de tels 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.

Un problème courant serait que les caches ne sont utiles que s'ils ont des hits. Dans notre cas, les résultats dans le champ de l'auteur sont très probables (disons 50 %), mais il n'y aura aucun résultat dans les fichiers. Cette approche apportera quelques améliorations, mais elle ne changera pas radicalement les performances (et nous avons besoin d’une avancée décisive).

Les caches d’intersession (longs) nécessitent une logique d’invalidation complexe. En général, plus tard vous résolvez les problèmes de performances à l'aide des caches intersessions, mieux c'est.

Avantages:

  1. Implémentez la mise en cache sans modifier le code.
  2. Augmentation de la productivité plusieurs fois (dans certains cas).

Inconvénients:

  1. Possibilité de performances réduites en cas de mauvaise utilisation.
  2. Surcharge de mémoire importante, en particulier avec des caches longs.
  3. Invalidation complexe, dont les erreurs entraîneront des problèmes d'exécution difficiles à reproduire.

Très souvent, les caches sont utilisés uniquement pour résoudre rapidement des problèmes de conception. Cela ne veut pas dire qu’ils ne doivent pas être utilisés. Cependant, vous devez toujours les traiter avec prudence et évaluer d'abord le gain de performances qui en résulte, puis prendre une décision seulement.

Dans notre exemple, les caches apporteront une augmentation des performances d'environ 25 %. Dans le même temps, les caches présentent de nombreux inconvénients, je ne les utiliserais donc pas ici.

Les résultats de

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

Le principal avantage de toutes ces méthodes est la simplicité, qui entraîne de nombreuses conséquences agréables.

Un problème courant avec ces méthodes est la mauvaise performance, principalement due à la taille des paquets. Par conséquent, si ces solutions ne vous conviennent pas, cela vaut la peine d'envisager des méthodes plus radicales.

Il existe deux directions principales dans lesquelles vous pouvez rechercher des solutions :

  • travail asynchrone avec les données (nécessite un changement de paradigme, ce n'est donc pas abordé dans cet article) ;
  • agrandissement des lots tout en conservant un traitement synchrone.

L'élargissement des lots réduira considérablement le nombre d'appels externes tout en gardant le code synchrone. La prochaine partie de l'article sera consacrée à ce sujet.

Source: habr.com

Ajouter un commentaire