Problemes de processament de consultes per lots i les seves solucions (part 1)

Problemes de processament de consultes per lots i les seves solucions (part 1)Gairebé tots els productes de programari moderns consisteixen en diversos serveis. Sovint, els llargs temps de resposta dels canals interserveis esdevenen una font de problemes de rendiment. La solució estàndard per a aquest tipus de problemes és empaquetar múltiples sol·licituds entre serveis en un sol paquet, que s'anomena lots.

Si utilitzeu el processament per lots, és possible que no estigueu satisfet amb els resultats en termes de rendiment o claredat del codi. Aquest mètode no és tan fàcil per a la persona que truca com podríeu pensar. Per a diferents propòsits i en diferents situacions, les solucions poden variar molt. Utilitzant exemples específics, mostraré els pros i els contres de diversos enfocaments.

Projecte demostratiu

Per a més claredat, mirem un exemple d'un dels serveis de l'aplicació en què estic treballant actualment.

Explicació de la selecció de la plataforma per exemplesEl problema del mal rendiment és força general i no afecta cap llenguatge o plataforma específic. Aquest article utilitzarà exemples de codi Spring + Kotlin per demostrar problemes i solucions. Kotlin és igualment comprensible (o incomprensible) per als desenvolupadors de Java i C#, a més, el codi és més compacte i comprensible que a Java. Perquè sigui més fàcil d'entendre per als desenvolupadors de Java pur, evitaré la màgia negra de Kotlin i només utilitzaré la màgia blanca (a l'esperit de Lombok). Hi haurà uns quants mètodes d'extensió, però en realitat són familiars per a tots els programadors de Java com a mètodes estàtics, de manera que aquest serà un petit sucre que no farà malbé el gust del plat.
Hi ha un servei d'aprovació de documents. Algú crea un document i el sotmet a discussió, durant el qual es fan edicions i, finalment, s'acorda el document. El propi servei d'aprovació no sap res de documents: només és un xat d'aprovadors amb petites funcions addicionals que aquí no tindrem en compte.

Així doncs, hi ha sales de xat (corresponents als documents) amb un conjunt predefinit de participants en cadascun d'ells. Com en els xats habituals, els missatges contenen text i fitxers i poden ser respostes o reenviaments:

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
)

Els enllaços de fitxers i d'usuari són enllaços a altres dominis. Aquí vivim així:

typealias FileReference Long
typealias UserReference Long

Les dades de l'usuari s'emmagatzemen a Keycloak i es recuperen mitjançant REST. El mateix passa amb els fitxers: els fitxers i la metainformació sobre ells viuen en un servei d'emmagatzematge de fitxers independent.

Totes les trucades a aquests serveis són grans peticions. Això significa que la sobrecàrrega de transport d'aquestes sol·licituds és molt més gran que el temps que triga a ser processades per un servei de tercers. Als nostres bancs de proves, el temps de trucada típic d'aquests serveis és de 100 ms, de manera que utilitzarem aquests números en el futur.

Hem de fer un simple controlador REST per rebre els últims N missatges amb tota la informació necessària. És a dir, creiem que el model de missatge a la interfície és gairebé el mateix i s'han d'enviar totes les dades. La diferència entre el model de front-end és que el fitxer i l'usuari s'han de presentar en una forma lleugerament desxifrada per tal de fer-los enllaços:

/** В таком виде отдаются ссылки на сущности для фронта */
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
)

Hem d'implementar el següent:

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

El postfix de la IU significa models DTO per a la interfície, és a dir, el que hauríem de servir mitjançant REST.

El que pot sorprendre aquí és que no estem passant cap identificador de xat i fins i tot el model ChatMessage/ChatMessageUI no en té cap. Ho he fet intencionadament per no desordenar el codi dels exemples (els xats estan aïllats, així que podem suposar que només en tenim un).

Digressió filosòficaTant la classe ChatMessageUI com el mètode ChatRestApi.getLast utilitzen el tipus de dades List quan en realitat és un conjunt ordenat. Això és dolent al JDK, de manera que declarar l'ordre dels elements a nivell d'interfície (preservar l'ordre en afegir i eliminar) no funcionarà. Per tant, s'ha convertit en pràctica habitual utilitzar una llista en els casos en què es necessita un conjunt ordenat (també hi ha un LinkedHashSet, però aquesta no és una interfície).
Limitació important: Assumirem que no hi ha cadenes llargues de respostes o transferències. És a dir, existeixen, però la seva extensió no supera els tres missatges. Tota la cadena de missatges s'ha de transmetre al frontend.

Per rebre dades de serveis externs hi ha les API següents:

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>
}

Es pot veure que els serveis externs preveuen inicialment el processament per lots, i en ambdues versions: a través de Set (sense conservar l'ordre dels elements, amb claus úniques) i a través de List (pot haver-hi duplicats - es conserva l'ordre).

Implementacions senzilles

Implementació ingènua

La primera implementació ingènua del nostre controlador REST tindrà un aspecte semblant a això en la majoria dels casos:

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()
    )
}

Tot està molt clar, i això és un gran avantatge.

Utilitzem el processament per lots i rebem dades d'un servei extern per lots. Però què passa amb la nostra productivitat?

Per a cada missatge, es farà una trucada a UserRemoteApi per obtenir dades al camp de l'autor i una trucada a FileRemoteApi per obtenir tots els fitxers adjunts. Sembla que això és tot. Suposem que els camps forwardFrom i replyTo per a ChatMessage s'obtenen de tal manera que això no requereix trucades innecessàries. Però convertir-los en ChatMessageUI provocarà recursivitat, és a dir, els comptadors de trucades poden augmentar significativament. Com hem assenyalat anteriorment, suposem que no tenim massa nidificació i que la cadena es limita a tres missatges.

Com a resultat, rebrem de dues a sis trucades a serveis externs per missatge i una trucada JPA per a tot el paquet de missatges. El nombre total de trucades variarà de 2*N+1 a 6*N+1. Quant és això en unitats reals? Suposem que es necessiten 20 missatges per representar una pàgina. Per rebre'ls, es trigarà entre 4 s i 10 s. Terrible! M'agradaria mantenir-lo en 500 ms. I com que van somiar amb fer un desplaçament perfecte a la interfície, els requisits de rendiment d'aquest punt final es poden duplicar.

Pros:

  1. El codi és concís i s'autodocumenta (el somni d'un equip de suport).
  2. El codi és senzill, de manera que gairebé no hi ha oportunitats per disparar-se al peu.
  3. El processament per lots no sembla una cosa aliena i està orgànicament integrat a la lògica.
  4. Els canvis lògics es faran fàcilment i seran locals.

Menys:

Rendiment horrible a causa dels paquets molt petits.

Aquest enfocament es pot veure amb força freqüència en serveis senzills o en prototips. Si la velocitat de fer canvis és important, no val la pena complicar el sistema. Al mateix temps, per al nostre servei molt senzill el rendiment és terrible, de manera que l'àmbit d'aplicabilitat d'aquest enfocament és molt reduït.

Processament paral·lel ingenu

Podeu començar a processar tots els missatges en paral·lel; això us permetrà desfer-vos de l'augment lineal de temps en funció del nombre de missatges. Aquest no és un camí especialment bo perquè donarà lloc a una gran càrrega màxima al servei extern.

Implementar el processament paral·lel és molt senzill:

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

Utilitzant el processament de missatges paral·lel, obtenim 300-700 ms idealment, que és molt millor que amb una implementació ingènua, però encara no és prou ràpid.

Amb aquest enfocament, les sol·licituds a userRepository i fileRepository s'executaran de manera sincrònica, cosa que no és molt eficient. Per solucionar-ho, haureu de canviar força la lògica de trucada. Per exemple, mitjançant CompletionStage (també conegut com 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()!!

Es pot veure que el codi de mapatge inicialment simple s'ha tornat menys comprensible. Això es deu al fet que hem hagut de separar les trucades a serveis externs d'on s'utilitzen els resultats. Això en si mateix no és dolent. Però combinar trucades no sembla gaire elegant i s'assembla a un típic "fideu" reactiu.

Si feu servir corrutines, tot semblarà més decent:

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()
    )
  }

On:

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

Teòricament, utilitzant aquest processament paral·lel, obtindrem 200-400 ms, que ja s'acosta a les nostres expectatives.

Malauradament, una paral·lelització tan bona no existeix, i el preu a pagar és bastant cruel: amb només uns quants usuaris treballant al mateix temps, caurà una pluja de peticions sobre els serveis, que de totes maneres no es processaran en paral·lel, així que tornarà als nostres tristos 4 s.

El meu resultat quan faig servir aquest servei és de 1300 a 1700 ms per processar 20 missatges. Això és més ràpid que en la primera implementació, però encara no resol el problema.

Usos alternatius de consultes paral·lelesQuè passa si els serveis de tercers no proporcionen processament per lots? Per exemple, podeu amagar la manca d'implementació del processament per lots dins dels mètodes d'interfície:

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())
}

Això té sentit si espereu veure processament per lots en futures versions.
Pros:

  1. Implementeu fàcilment el processament paral·lel basat en missatges.
  2. Bona escalabilitat.

Contres:

  1. La necessitat de separar l'adquisició de dades del seu tractament quan es tracten peticions a diferents serveis en paral·lel.
  2. Augment de la càrrega dels serveis de tercers.

Es pot veure que l'àmbit d'aplicabilitat és aproximadament el mateix que el de l'enfocament ingenu. Té sentit utilitzar el mètode de sol·licitud paral·lel si voleu augmentar el rendiment del vostre servei diverses vegades a causa de l'explotació despietada dels altres. En el nostre exemple, el rendiment va augmentar 2,5 vegades, però és evident que això no és suficient.

memòria cau

Podeu fer la memòria cau amb l'esperit de JPA per a serveis externs, és a dir, emmagatzemar objectes rebuts dins d'una sessió per no rebre'ls de nou (inclòs durant el processament per lots). Podeu fer aquests cachés vosaltres mateixos, podeu utilitzar Spring amb el seu @Cacheable, a més sempre podeu utilitzar manualment una memòria cau preparada com EhCache.

Un problema comú seria que les memòria cau només són útils si tenen visites. En el nostre cas, les visites al camp de l'autor són molt probables (per exemple, un 50%), però no hi haurà visites als fitxers. Aquest enfocament aportarà algunes millores, però no canviarà radicalment el rendiment (i necessitem un avenç).

Les memòria cau d'intersessió (llargues) requereixen una lògica d'invalidació complexa. En general, com més tard us agafeu a resoldre problemes de rendiment mitjançant memòria cau entre sessions, millor.

Pros:

  1. Implementeu la memòria cau sense canviar el codi.
  2. Augment de la productivitat diverses vegades (en alguns casos).

Contres:

  1. Possibilitat de reduir el rendiment si s'utilitza incorrectament.
  2. Sobrecàrrega de memòria gran, especialment amb memòria cau llarga.
  3. Invalidació complexa, errors en els quals provocaran problemes difícils de reproduir en temps d'execució.

Molt sovint, les memòries cau només s'utilitzen per solucionar ràpidament problemes de disseny. Això no vol dir que no s'hagin d'utilitzar. Tanmateix, sempre hauríeu de tractar-los amb precaució i primer avaluar el guany de rendiment resultant i només després prendre una decisió.

En el nostre exemple, la memòria cau proporcionarà un augment del rendiment d'un 25%. Al mateix temps, els cachés tenen molts desavantatges, així que no els utilitzaria aquí.

Resultats de

Per tant, vam analitzar una implementació ingènua d'un servei que utilitza el processament per lots i algunes maneres senzilles d'accelerar-lo.

El principal avantatge de tots aquests mètodes és la simplicitat, de la qual hi ha moltes conseqüències agradables.

Un problema comú amb aquests mètodes és el baix rendiment, principalment a causa de la mida dels paquets. Per tant, si aquestes solucions no us convén, val la pena considerar mètodes més radicals.

Hi ha dues direccions principals en les quals podeu buscar solucions:

  • treball asíncron amb dades (requereix un canvi de paradigma, per la qual cosa no es tracta en aquest article);
  • ampliació de lots mantenint el processament sincrònic.

L'ampliació dels lots reduirà molt el nombre de trucades externes i alhora mantindrà el codi sincrònic. La següent part de l'article estarà dedicada a aquest tema.

Font: www.habr.com

Afegeix comentari