Probleme de procesare a interogărilor pe lot și soluțiile acestora (partea 1)

Probleme de procesare a interogărilor pe lot și soluțiile acestora (partea 1)Aproape toate produsele software moderne constau din mai multe servicii. Adesea, timpii lungi de răspuns ai canalelor interservicii devin o sursă de probleme de performanță. Soluția standard pentru acest tip de problemă este de a împacheta mai multe cereri inter-servicii într-un singur pachet, care se numește loturi.

Dacă utilizați procesarea în lot, este posibil să nu fiți mulțumit de rezultate în ceea ce privește performanța sau claritatea codului. Această metodă nu este atât de ușoară pentru apelant pe cât ați putea crede. Pentru scopuri diferite și în situații diferite, soluțiile pot varia foarte mult. Folosind exemple specifice, voi arăta avantajele și dezavantajele mai multor abordări.

Proiect demonstrativ

Pentru claritate, să ne uităm la un exemplu de unul dintre serviciile din aplicația la care lucrez în prezent.

Explicație privind selecția platformei pentru exempleProblema performanței slabe este destul de generală și nu se referă la niciun limbaj sau platforme specifice. Acest articol va folosi exemple de cod Spring + Kotlin pentru a demonstra problemele și soluțiile. Kotlin este la fel de înțeles (sau de neînțeles) pentru dezvoltatorii Java și C#; în plus, codul este mai compact și mai ușor de înțeles decât în ​​Java. Pentru a face lucrurile mai ușor de înțeles pentru dezvoltatorii Java pur, voi evita magia neagră a lui Kotlin și voi folosi doar magia albă (în spiritul Lombok). Vor exista câteva metode de extensie, dar acestea sunt de fapt familiare tuturor programatorilor Java ca metode statice, așa că acesta va fi un mic zahăr care nu va strica gustul felului de mâncare.
Există un serviciu de aprobare a documentelor. Cineva creează un document și îl trimite spre discuție, timp în care se fac modificări și, în cele din urmă, documentul este convenit. Serviciul de omologare în sine nu știe nimic despre documente: este doar un chat al aprobatorilor cu mici funcții suplimentare pe care nu le vom lua în considerare aici.

Deci, există camere de chat (corespunzătoare documentelor) cu un set predefinit de participanți în fiecare dintre ele. Ca și în chat-urile obișnuite, mesajele conțin text și fișiere și pot fi răspunsuri sau redirecționate:

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
)

Legăturile de fișiere și de utilizator sunt legături către alte domenii. Aici trăim așa:

typealias FileReference Long
typealias UserReference Long

Datele utilizatorului sunt stocate în Keycloak și primite prin REST. Același lucru este valabil și pentru fișiere: fișierele și metainformațiile despre ele trăiesc într-un serviciu separat de stocare a fișierelor.

Toate apelurile către aceste servicii sunt cereri grele. Aceasta înseamnă că suprasolicitarea transportului acestor solicitări este mult mai mare decât timpul necesar pentru ca acestea să fie procesate de un serviciu terță parte. Pe bancurile noastre de testare, timpul tipic de apel pentru astfel de servicii este de 100 ms, așa că vom folosi aceste numere în viitor.

Trebuie să facem un controler REST simplu pentru a primi ultimele N mesaje cu toate informațiile necesare. Adică, credem că în frontend modelul de mesaj este aproape același și toate datele trebuie trimise. Diferența dintre modelul front-end este că fișierul și utilizatorul trebuie să fie prezentate într-o formă ușor decriptată pentru a le face linkuri:

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

Trebuie să implementăm următoarele:

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

Postfix UI înseamnă modele DTO pentru frontend, adică ceea ce trebuie să servim prin REST.

Ceea ce poate fi surprinzător aici este că nu transmitem niciun identificator de chat și chiar și în modelul ChatMessage/ChatMessageUI nu există niciunul. Am făcut asta intenționat pentru a nu aglomera codul exemplelor (chaturile sunt izolate, așa că putem presupune că avem doar unul).

Digresiune filosoficăAtât clasa ChatMessageUI cât și metoda ChatRestApi.getLast folosesc tipul de date List, când de fapt este un Set ordonat. În JDK totul este rău, așa că declararea ordinii elementelor la nivel de interfață (păstrarea ordinii la adăugarea și preluarea) nu va funcționa. Prin urmare, a devenit o practică obișnuită să folosiți List în cazurile în care este nevoie de un set ordonat (există și LinkedHashSet, dar aceasta nu este o interfață).
Limitare importantă: Vom presupune că nu există lanțuri lungi de răspunsuri sau transferuri. Adică există, dar lungimea lor nu depășește trei mesaje. Întregul lanț de mesaje trebuie transmis către front-end.

Pentru a primi date de la servicii externe există următoarele API-uri:

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

Se poate observa că serviciile externe prevăd inițial procesarea în lot, și în ambele variante: prin Set (fără păstrarea ordinii elementelor, cu chei unice) și prin Listă (pot exista duplicate - ordinea se păstrează).

Implementări simple

Implementare naivă

Prima implementare naivă a controlerului nostru REST va arăta cam așa în majoritatea cazurilor:

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

Totul este foarte clar, iar acesta este un mare plus.

Utilizăm procesarea în loturi și primim date de la un serviciu extern în loturi. Dar ce se întâmplă cu productivitatea noastră?

Pentru fiecare mesaj, se va face un apel către UserRemoteApi pentru a obține date din câmpul autor și un apel către FileRemoteApi pentru a obține toate fișierele atașate. Se pare că asta este. Să presupunem că câmpurile forwardFrom și replyTo pentru ChatMessage sunt obținute în așa fel încât să nu necesite apeluri inutile. Dar transformarea lor în ChatMessageUI va duce la recursivitate, adică contoarele de apeluri pot crește semnificativ. După cum am menționat mai devreme, să presupunem că nu avem prea multe cuibări și lanțul este limitat la trei mesaje.

Ca rezultat, vom primi de la două până la șase apeluri către servicii externe per mesaj și un apel JPA pentru întregul pachet de mesaje. Numărul total de apeluri va varia de la 2*N+1 la 6*N+1. Cât costă în unități reale? Să presupunem că este nevoie de 20 de mesaje pentru a reda o pagină. Pentru a le obține, veți avea nevoie de 4 s până la 10 s. Teribil! Aș dori să-l mențin în termen de 500 ms. Și din moment ce visau să facă derulare fără întreruperi pe front-end, cerințele de performanță pentru acest punct final pot fi dublate.

Pro-uri:

  1. Codul este concis și auto-documentat (visul unei echipe de asistență).
  2. Codul este simplu, așa că aproape că nu există ocazii să te împuști în picior.
  3. Procesarea în loturi nu arată ca ceva străin și este integrată organic în logică.
  4. Modificările logice vor fi ușor de făcut și vor fi locale.

Mai puțin:

Performanță groaznică din cauza pachetelor foarte mici.

Această abordare poate fi văzută destul de des în servicii simple sau în prototipuri. Dacă viteza de a face modificări este importantă, nu merită să complicați sistemul. În același timp, pentru serviciul nostru foarte simplu, performanța este îngrozitoare, astfel încât domeniul de aplicare al acestei abordări este foarte îngust.

Procesare paralelă naivă

Puteți începe să procesați toate mesajele în paralel - acest lucru vă va permite să scăpați de creșterea liniară a timpului în funcție de numărul de mesaje. Aceasta nu este o cale deosebit de bună, deoarece va avea ca rezultat o sarcină de vârf mare pentru serviciul extern.

Implementarea procesării paralele este foarte simplă:

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

Folosind procesarea paralelă a mesajelor, obținem în mod ideal 300–700 ms, ceea ce este mult mai bun decât cu o implementare naivă, dar încă nu suficient de rapidă.

Cu această abordare, cererile către userRepository și fileRepository vor fi executate sincron, ceea ce nu este foarte eficient. Pentru a remedia acest lucru, va trebui să schimbați destul de mult logica apelurilor. De exemplu, prin 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()!!

Se poate observa că inițial codul de mapare simplu a devenit mai puțin de înțeles. Acest lucru se datorează faptului că a trebuit să separăm apelurile către serviciile externe de unde sunt utilizate rezultatele. Acest lucru în sine nu este rău. Dar combinarea apelurilor nu arată deosebit de elegantă și seamănă cu un „taței” reactiv tipic.

Dacă utilizați coroutine, totul va arăta mai 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()
    )
  }

În cazul în care:

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

Teoretic, folosind o astfel de procesare paralelă, vom obține 200–400 ms, ceea ce este deja aproape de așteptările noastre.

Din păcate, o paralelizare atât de bună nu are loc, iar prețul de plătit este destul de crud: cu doar câțiva utilizatori lucrând în același timp, serviciile vor fi lovite de un val de solicitări care oricum nu vor fi procesate în paralel, așa că va reveni la cei 4 tristi.

Rezultatul meu când folosesc un astfel de serviciu este de 1300–1700 ms pentru procesarea a 20 de mesaje. Acest lucru este mai rapid decât în ​​prima implementare, dar tot nu rezolvă problema.

Utilizări alternative ale interogărilor paraleleCe se întâmplă dacă serviciile terță parte nu oferă procesare în loturi? De exemplu, puteți ascunde lipsa implementării procesării loturilor în cadrul metodelor de interfață:

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

Acest lucru are sens dacă sperați să vedeți procesarea loturilor în versiunile viitoare.
Pro-uri:

  1. Implementați cu ușurință procesarea paralelă bazată pe mesaje.
  2. Scalabilitate bună.

Contra:

  1. Necesitatea de a separa achiziția datelor de prelucrarea acesteia atunci când procesează cererile către diferite servicii în paralel.
  2. Sarcină crescută a serviciilor terță parte.

Se poate observa că domeniul de aplicare este aproximativ același cu cel al abordării naive. Este logic să utilizați metoda de solicitare paralelă dacă doriți să creșteți de mai multe ori performanța serviciului dvs. din cauza exploatării nemiloase a altora. În exemplul nostru, productivitatea a crescut de 2,5 ori, dar acest lucru nu este suficient.

stocarea în cache

Puteți face cache în spiritul JPA pentru servicii externe, adică stocați obiectele primite într-o sesiune pentru a nu le primi din nou (inclusiv în timpul procesării în lot). Puteți face singuri astfel de cache-uri, puteți utiliza Spring cu @Cacheable-ul său, plus puteți utiliza întotdeauna un cache gata făcut, cum ar fi EhCache, manual.

O problemă comună ar fi aceea că cache-urile sunt utile numai dacă au accesări. În cazul nostru, accesările în câmpul autor sunt foarte probabile (să spunem, 50%), dar nu vor exista accesări pe fișiere. Această abordare va oferi unele îmbunătățiri, dar nu va schimba radical productivitatea (și avem nevoie de o descoperire).

Cache-urile de intersesiune (lungi) necesită o logică complexă de invalidare. În general, cu cât rezolvați mai târziu problemele de performanță folosind cache-urile intersesiune, cu atât mai bine.

Pro-uri:

  1. Implementați memorarea în cache fără a modifica codul.
  2. Productivitate crescută de mai multe ori (în unele cazuri).

Contra:

  1. Posibilitatea de performanță redusă dacă este utilizat incorect.
  2. Overhead de memorie mare, mai ales cu cache lungi.
  3. Invalidare complexă, erori în care vor duce la probleme greu de reprodus în timpul de execuție.

Foarte des, cache-urile sunt folosite doar pentru a rezolva rapid problemele de proiectare. Acest lucru nu înseamnă că nu ar trebui folosite. Cu toate acestea, ar trebui să le tratați întotdeauna cu prudență și să evaluați mai întâi câștigul de performanță rezultat și abia apoi să luați o decizie.

În exemplul nostru, cache-urile vor oferi o creștere a performanței de aproximativ 25%. În același timp, cache-urile au destul de multe dezavantaje, așa că nu le-aș folosi aici.

Rezultatele

Așadar, ne-am uitat la o implementare naivă a unui serviciu care utilizează procesarea în loturi și câteva modalități simple de a o accelera.

Principalul avantaj al tuturor acestor metode este simplitatea, din care există multe consecințe plăcute.

O problemă comună cu aceste metode este performanța slabă, legată în primul rând de dimensiunea pachetelor. Prin urmare, dacă aceste soluții nu vi se potrivesc, atunci merită să luați în considerare metode mai radicale.

Există două direcții principale în care puteți căuta soluții:

  • lucru asincron cu datele (necesită o schimbare de paradigmă, deci nu este discutată în acest articol);
  • mărirea loturilor menținând în același timp procesarea sincronă.

Mărirea loturilor va reduce foarte mult numărul de apeluri externe și, în același timp, va menține codul sincron. Următoarea parte a articolului va fi dedicată acestui subiect.

Sursa: www.habr.com

Adauga un comentariu