Prublemi di l'elaborazione di e dumande in batch è e so suluzione (parte 1)

Prublemi di l'elaborazione di e dumande in batch è e so suluzione (parte 1)Quasi tutti i prudutti di u software mudernu sò custituiti da parechji servizii. Spessu, i tempi di risposta longu di i canali interservizi diventanu una fonte di prublemi di rendiment. A suluzione standard à stu tipu di prublema hè di imballà parechje richieste interservizi in un pacchettu, chì hè chjamatu batching.

Sè vo aduprate u prucessu di batch, pudete micca esse felice cù i risultati in quantu à u rendiment o a chiarità di codice. Stu metudu ùn hè micca cusì faciule per u chjamante cum'è pudete pensà. Per diversi scopi è in diverse situazioni, i suluzioni ponu varià assai. Utilizendu esempi specifichi, vi mustraraghju i pro è i contra di parechji approcci.

Prughjettu di dimostrazione

Per a chiarezza, fighjemu un esempiu di unu di i servizii in l'applicazione chì aghju travagliatu.

Spiegazione di a selezzione di a piattaforma per esempiU prublema di scarsa prestazione hè abbastanza generale è ùn tocca à alcuna lingua o piattaforma specifica. Questu articulu hà da utilizà esempi di codice Spring + Kotlin per dimustrà prublemi è suluzione. Kotlin hè ugualmente comprensibile (o incomprensibile) per i sviluppatori Java è C#, in più, u codice hè più compactu è cumprendi più cà in Java. Per fà più faciule per capiscenu per i sviluppatori Java puri, eviteraghju a magia negra di Kotlin è solu aduprà a magia bianca (in u spiritu di Lombok). Ci saranu uni pochi di metudi di estensione, ma sò in realtà familiarizati per tutti i programatori Java cum'è metudi statichi, cusì serà un picculu zuccaru chì ùn sguassate micca u gustu di u platu.
Ci hè un serviziu di appruvazioni di documenti. Qualchissia crea un documentu è u sottumette per discussione, durante u quale l'edizioni sò fatte, è in fine u documentu hè accunsentutu. U serviziu di appruvazioni stessu ùn sapi nunda di documenti: hè solu una chat di appruvazioni cù picculi funzioni supplementari chì ùn avemu micca cunsideratu quì.

Dunque, ci sò chat room (currispondenu à i ducumenti) cù un inseme predefinitu di participanti in ognunu di elli. Cum'è in i chats regulari, i missaghji cuntenenu testu è fugliali è ponu esse risposti o rinviati:

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
)

File è ligami d'utilizatori sò ligami à altri domini. Quì campemu cusì:

typealias FileReference Long
typealias UserReference Long

I dati di l'utilizatori sò guardati in Keycloak è recuperati via REST. U stessu passa per i schedarii: i schedarii è i metainformazioni nantu à elli campanu in un serviziu di almacenamentu separatu.

Tutte e chjama à questi servizii sò richieste pesanti. Questu significa chì l'overhead di u trasportu di sti dumande hè assai più grande di u tempu chì ci vole per esse trattatu da un serviziu di terzu. In i nostri banchi di prova, u tempu tipicu di chjama per tali servizii hè 100 ms, cusì useremu questi numeri in u futuru.

Avemu bisognu di fà un semplice controller REST per riceve l'ultimi N missaghji cù tutte l'infurmazioni necessarii. Vale à dì, avemu cridutu chì u mudellu di missaghju in u frontend hè quasi u listessu è tutti i dati deve esse mandatu. A diffarenza trà u mudellu front-end hè chì u schedariu è l'utilizatori anu da esse presentati in una forma ligeramente decifrata per fà ligami:

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

Avemu bisognu di implementà i seguenti:

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

U postfix UI significa mudelli DTO per u frontend, vale à dì ciò chì duvemu serve via REST.

Ciò chì pò esse surprisante quì hè chì ùn avemu micca passatu alcunu ID di chat è ancu u mudellu ChatMessage / ChatMessageUI ùn hà micca unu. Aghju fattu questu intenzionalmente per ùn impastà u codice di l'esempii (i chats sò isolati, cusì pudemu suppone chì avemu solu unu).

Digressione filosoficaTramindui a classa ChatMessageUI è u mètudu ChatRestApi.getLast aduprà u tipu di dati Lista quandu in fattu hè un Set urdinatu. Questu hè male in u JDK, cusì dichjarà l'ordine di l'elementi à u livellu di l'interfaccia (priservà l'ordine quandu aghjunghje è sguassà) ùn viaghja micca. Cusì hè diventatu una pratica cumuni per utilizà una Lista in i casi induve un Set urdinatu hè necessariu (ci hè ancu un LinkedHashSet, ma questu ùn hè micca una interfaccia).
Limitazione impurtante: Assumiremu chì ùn ci sò micca catene longu di risposte o trasferimenti. Vale à dì, esistenu, ma a so lunghezza ùn trapassa trè missaghji. L'intera catena di missaghji deve esse trasmessa à u frontend.

Per riceve dati da i servizii esterni, ci sò e seguenti API:

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

Pò esse vistu chì i servizii esterni inizialmente furnisce u prucessu di batch, è in i dui versioni: à traversu Set (senza priservà l'ordine di elementi, cù chjavi unichi) è attraversu Lista (ci pò esse duplicati - l'ordine hè cunsirvatu).

Implementazioni simplici

Implementazione ingenua

A prima implementazione ingenua di u nostru controller REST parerà cusì cusì in a maiò parte di i casi:

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

Tuttu hè assai chjaru, è questu hè un grande plus.

Avemu aduprà u prucessu di batch è riceve dati da un serviziu esternu in batch. Ma chì succede à a nostra produtividade?

Per ogni missaghju, una chjama à UserRemoteApi serà fatta per uttene dati nantu à u campu di l'autore è una chjama à FileRemoteApi per uttene tutti i fugliali attaccati. Chì pare esse. Diciamu chì i campi forwardFrom è replyTo per ChatMessage sò ottenuti in tale manera chì questu ùn hè micca bisognu di chjama inutili. Ma trasfurmà in ChatMessageUI portarà à a recursione, vale à dì, i contatori di chjama ponu aumentà significativamente. Comu avemu nutatu prima, supponemu chì ùn avemu micca assai nidificazione è a catena hè limitata à trè missaghji.

In u risultatu, riceveremu da duie à sei chjamate à servizii esterni per missaghju è una chjama JPA per tuttu u pacchettu di missaghji. U numeru tutale di chjamate varierà da 2 * N + 1 à 6 * N + 1. Quantu hè questu in unità reali? Diciamu chì ci vole 20 missaghji per rende una pagina. Per riceveli, ci vole da 4 s à 10 s. Terribile! Mi piacerebbe mantene in 500 ms. E siccomu sunnianu di fà un scrolling senza saldatura in u frontend, i requisiti di prestazione per questu endpoint ponu esse radduppiati.

Pros:

  1. U codice hè cuncisu è autodocumentatu (sognu di un squadra di supportu).
  2. U codice hè simplice, cusì ùn ci hè quasi nisuna opportunità per sparà in u pede.
  3. U prucessu di batch ùn pare micca qualcosa di straneru è hè integratu organicamente in a logica.
  4. I cambiamenti logici seranu fatti facilmente è seranu lucali.

Minus:

Prestazione terribile per via di pacchetti assai chjuchi.

Stu approcciu pò esse vistu abbastanza spessu in servizii simplici o in prototipi. Se a rapidità di fà cambiamenti hè impurtante, ùn vale a pena cumplicà u sistema. À u listessu tempu, per u nostru serviziu assai simplice u rendiment hè terribili, cusì u scopu di l'applicabilità di questu approcciu hè assai strettu.

Trattamentu parallelu ingenu

Pudete principià a trasfurmazioni di tutti i missaghji in parallelu - questu vi permetterà di sguassà l'aumentu lineale in u tempu secondu u numeru di missaghji. Questu ùn hè micca un percorsu particularmente bonu perchè resultarà in una grande carica di punta nantu à u serviziu esternu.

Implementà u prucessu parallelu hè assai simplice:

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

Utilizendu u prucessu di messagiu parallelu, avemu 300-700 ms idealmente, chì hè assai megliu cà cù una implementazione ingenua, ma ancu micca abbastanza veloce.

Cù questu approcciu, e dumande à l'UserRepository è u fileRepository seranu eseguite in modu sincronu, chì ùn hè micca assai efficace. Per riparà questu, avete da cambià assai a logica di a chjama. Per esempiu, via CompletionStage (aka 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()!!

Pò esse vistu chì u codice di mapping inizialmente simplice hè diventatu menu comprensibile. Questu hè perchè avemu avutu a separà e chjama à i servizii esterni da induve i risultati sò usati. Questu in sè stessu ùn hè micca male. Ma cumminendu e chjama ùn pare micca assai eleganti è s'assumiglia à un tipicu "noodle" reattivu.

Sè vo aduprate coroutines, tuttu sarà più decentu:

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

Dove:

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

Teoricamente, aduprendu tali prucessione parallele, uttene 200-400 ms, chì hè digià vicinu à e nostre aspettative.

Sfurtunatamente, una tale bona parallelizazione ùn esiste micca, è u prezzu à pagà hè abbastanza crudele: cù solu uni pochi d'utilizatori chì travaglianu à u stessu tempu, un barrage di dumande cascarà nantu à i servizii, chì ùn saranu micca trattati in parallelu in ogni modu, cusì avemu tornerà à i nostri tristi 4 s.

U mo risultatu quandu si usa un tali serviziu hè 1300-1700 ms per processà 20 missaghji. Questu hè più veloce chì in a prima implementazione, ma ancu ùn risolve u prublema.

Usi alternativu di e dumande paralleleE s'è i servizii di terzu ùn furnisce micca un prucessu batch? Per esempiu, pudete ammuccià a mancanza di implementazione di trasfurmazioni batch in i metudi di l'interfaccia:

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

Questu hè sensu s'è vo sperate di vede u prucessu di batch in versioni future.
Pros:

  1. Implementa facilmente u trattamentu parallelu basatu in messagi.
  2. Bona scalabilità.

Cons:

  1. A necessità di separà l'acquistu di dati da u so trasfurmazioni quandu si tratta di richieste à diversi servizii in parallelu.
  2. Aumentu di a carica nantu à i servizii di terze parti.

Pò esse vistu chì u scopu di l'applicabilità hè apprussimatamente uguale à quellu di l'approcciu ingenu. Hè sensu di utilizà u metudu di dumanda parallela se vulete aumentà a prestazione di u vostru serviziu parechje volte per via di a sfruttamentu senza pietà di l'altri. In u nostru esempiu, u rendiment hà aumentatu da 2,5 volte, ma questu hè chjaramente micca abbastanza.

caching

Pudete fà caching in u spiritu di JPA per i servizii esterni, vale à dì, almacenà l'uggetti ricevuti in una sessione per ùn riceve micca di novu (cumpresu durante u processu batch). Pudete fà tali cache voi stessu, pudete aduprà Spring cù u so @Cacheable, in più pudete sempre aduprà una cache pronta cum'è EhCache manualmente.

Un prublema cumuni seria chì i cache sò utili solu s'ellu anu successu. In u nostru casu, i colpi nantu à u campu di l'autore sò assai prubabile (per esempiu, 50%), ma ùn ci sarà micca successu in i schedari. Stu approcciu darà qualchì migliuramentu, ma ùn cambierà micca radicalmente u rendiment (è avemu bisognu di un avanzu).

I cache di intersessione (longu) necessitanu una logica cumplessa di invalidazione. In generale, più tardi si scende à risolve i prublemi di rendiment usendu cache di intersessione, u megliu.

Pros:

  1. Implementa caching senza cambià u codice.
  2. A produtividade aumentata parechje volte (in certi casi).

Cons:

  1. Possibilità di prestazioni ridotte s'ellu hè adupratu in modu incorrectu.
  2. Grande memoria overhead, soprattuttu cù cache longu.
  3. Invalidazione cumplessa, errori in quale portanu à prublemi difficiuli di ripruduce in runtime.

Moltu spessu, i cache sò usati solu per patch rapidamente i prublemi di disignu. Questu ùn significa micca chì ùn deve micca esse usatu. In ogni casu, duvete sempre trattà cun prudenza è prima evaluà u guadagnu di rendiment risultatu, è solu dopu piglià una decisione.

In u nostru esempiu, i caches furnisceranu un aumentu di rendiment di circa 25%. À u listessu tempu, i caches anu assai disadvantages, perchè ùn l'aghju micca aduprà quì.

Risultati

Dunque, avemu vistu una implementazione ingenua di un serviziu chì usa u processu batch, è qualchi modi simplici per accelerà.

U vantaghju principali di tutti sti metudi hè a simplicità, da quale ci sò assai cunsiquenzi piacevuli.

Un prublema cumuni cù questi metudi hè un rendimentu poviru, principalmente per via di a dimensione di i pacchetti. Dunque, se queste suluzioni ùn vi cunvene micca, allora vale a pena cunsiderà i metudi più radicali.

Ci hè dui direzzione principali in quale pudete circà suluzioni:

  • travagliu asincronu cù dati (esige un cambiamentu di paradigma, cusì ùn hè micca discututu in questu articulu);
  • allargamentu di batch mantenendu u prucessu sincronu.

L'allargamentu di lotti riducerà assai u numeru di chjamati esterni è à u stessu tempu mantene u codice sincronu. A prossima parte di l'articulu serà dedicata à stu tema.

Source: www.habr.com

Add a comment