Problemi di elaborazione delle query batch e relative soluzioni (parte 1)

Problemi di elaborazione delle query batch e relative soluzioni (parte 1)Quasi tutti i prodotti software moderni sono costituiti da diversi servizi. Spesso i lunghi tempi di risposta dei canali interservizi diventano fonte di problemi di prestazioni. La soluzione standard a questo tipo di problema consiste nel comprimere più richieste interservizi in un unico pacchetto, denominato batching.

Se utilizzi l'elaborazione batch, potresti non essere soddisfatto dei risultati in termini di prestazioni o chiarezza del codice. Questo metodo non è così facile per il chiamante come potresti pensare. Per scopi diversi e in situazioni diverse, le soluzioni possono variare notevolmente. Utilizzando esempi specifici, mostrerò i pro e i contro di diversi approcci.

Progetto dimostrativo

Per chiarezza, diamo un'occhiata ad un esempio di uno dei servizi nell'applicazione su cui sto attualmente lavorando.

Spiegazione della selezione della piattaforma per esempiIl problema delle scarse prestazioni è abbastanza generale e non riguarda linguaggi o piattaforme specifiche. Questo articolo utilizzerà esempi di codice Spring + Kotlin per dimostrare problemi e soluzioni. Kotlin è ugualmente comprensibile (o incomprensibile) per gli sviluppatori Java e C#, inoltre il codice è più compatto e comprensibile rispetto a Java. Per renderlo più facile da comprendere per gli sviluppatori Java puri, eviterò la magia nera di Kotlin e utilizzerò solo la magia bianca (nello spirito di Lombok). Ci saranno alcuni metodi di estensione, ma in realtà sono familiari a tutti i programmatori Java come metodi statici, quindi questo sarà un piccolo zucchero che non rovinerà il gusto del piatto.
Esiste un servizio di approvazione dei documenti. Qualcuno crea un documento e lo sottopone alla discussione, durante la quale vengono apportate le modifiche e, infine, viene concordato il documento. Lo stesso servizio di approvazione non sa nulla di documenti: è solo una chat di approvatori con piccole funzionalità aggiuntive che qui non considereremo.

Esistono quindi chat room (corrispondenti a documenti) con un insieme predefinito di partecipanti in ciascuna di esse. Come nelle chat normali, i messaggi contengono testo e file e possono essere risposte o inoltramenti:

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
)

I collegamenti a file e utenti sono collegamenti ad altri domini. Qui viviamo così:

typealias FileReference Long
typealias UserReference Long

I dati dell'utente vengono archiviati in Keycloak e recuperati tramite REST. Lo stesso vale per i file: i file e le metainformazioni su di essi risiedono in un servizio di archiviazione file separato.

Tutte le chiamate a questi servizi lo sono richieste pesanti. Ciò significa che il sovraccarico derivante dal trasporto di queste richieste è molto maggiore del tempo necessario per l'elaborazione da parte di un servizio di terze parti. Sui nostri banchi di prova, il tempo tipico di chiamata per tali servizi è di 100 ms, quindi utilizzeremo questi numeri in futuro.

Dobbiamo creare un semplice controller REST per ricevere gli ultimi N messaggi con tutte le informazioni necessarie. Cioè, crediamo che il modello di messaggio nel frontend sia quasi lo stesso e che tutti i dati debbano essere inviati. La differenza tra il modello front-end è che il file e l'utente devono essere presentati in una forma leggermente decrittografata per poter creare collegamenti:

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

Dobbiamo implementare quanto segue:

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

Il suffisso dell'interfaccia utente indica i modelli DTO per il frontend, ovvero ciò che dovremmo servire tramite REST.

Ciò che potrebbe sorprendere è che non stiamo trasmettendo alcun ID di chat e anche il modello ChatMessage/ChatMessageUI non ne ha uno. L'ho fatto intenzionalmente per non ingombrare il codice degli esempi (le chat sono isolate, quindi possiamo supporre di averne solo una).

Digressione filosoficaSia la classe ChatMessageUI che il metodo ChatRestApi.getLast utilizzano il tipo di dati List quando in realtà si tratta di un Set ordinato. Questo è negativo nel JDK, quindi dichiarare l'ordine degli elementi a livello di interfaccia (preservando l'ordine durante l'aggiunta e la rimozione) non funzionerà. Quindi è diventata pratica comune utilizzare una List nei casi in cui è necessario un Set ordinato (esiste anche un LinkedHashSet, ma questa non è un'interfaccia).
Limitazione importante: Assumeremo che non ci siano lunghe catene di risposte o trasferimenti. Cioè esistono, ma la loro lunghezza non supera i tre messaggi. L'intera catena di messaggi deve essere trasmessa al frontend.

Per ricevere dati da servizi esterni sono presenti le 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>
}

Si può vedere che i servizi esterni prevedono inizialmente l'elaborazione batch, e in entrambe le versioni: tramite Set (senza preservare l'ordine degli elementi, con chiavi univoche) e tramite List (potrebbero esserci duplicati - l'ordine viene preservato).

Implementazioni semplici

Implementazione ingenua

La prima implementazione ingenua del nostro controller REST sarà simile a questa nella maggior parte dei 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()
    )
}

Tutto è molto chiaro e questo è un grande vantaggio.

Utilizziamo l'elaborazione batch e riceviamo dati da un servizio esterno in batch. Ma cosa succede alla nostra produttività?

Per ogni messaggio verrà effettuata una chiamata a UserRemoteApi per ottenere i dati sul campo autore e una chiamata a FileRemoteApi per ottenere tutti i file allegati. Sembra che sia così. Diciamo che i campi forwardFrom e AnswerTo per ChatMessage sono ottenuti in modo tale che questo non richieda chiamate inutili. Ma trasformarli in ChatMessageUI porterà alla ricorsione, ovvero i contatori delle chiamate possono aumentare in modo significativo. Come abbiamo notato in precedenza, supponiamo di non avere molti annidamenti e che la catena sia limitata a tre messaggi.

Di conseguenza, avremo da due a sei chiamate a servizi esterni per messaggio e una chiamata JPA per l'intero pacchetto di messaggi. Il numero totale di chiamate varierà da 2*N+1 a 6*N+1. Quanto vale in unità reali? Diciamo che sono necessari 20 messaggi per eseguire il rendering di una pagina. Per riceverli ci vorranno dai 4 ai 10 s. Terribile! Vorrei mantenerlo entro 500 ms. E poiché sognavano di realizzare uno scorrimento fluido nel frontend, i requisiti prestazionali per questo endpoint possono essere raddoppiati.

pro:

  1. Il codice è conciso e autodocumentante (il sogno di un team di supporto).
  2. Il codice è semplice, quindi non ci sono quasi opportunità di spararsi sui piedi.
  3. L'elaborazione batch non sembra qualcosa di estraneo ed è organicamente integrata nella logica.
  4. I cambiamenti logici verranno apportati facilmente e saranno locali.

meno:

Prestazioni orribili a causa di pacchetti molto piccoli.

Questo approccio può essere visto abbastanza spesso in servizi semplici o in prototipi. Se la velocità con cui si apportano le modifiche è importante, difficilmente vale la pena complicare il sistema. Allo stesso tempo, per il nostro servizio molto semplice, le prestazioni sono terribili, quindi l'ambito di applicabilità di questo approccio è molto ristretto.

Elaborazione parallela ingenua

Puoi iniziare a elaborare tutti i messaggi in parallelo: questo ti consentirà di eliminare l'aumento lineare del tempo a seconda del numero di messaggi. Questo non è un percorso particolarmente adatto perché comporterà un notevole picco di carico sul servizio esterno.

Implementare l'elaborazione parallela è molto semplice:

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

Usando l'elaborazione parallela dei messaggi, otteniamo idealmente 300–700 ms, che è molto meglio che con un'implementazione ingenua, ma non ancora abbastanza veloce.

Con questo approccio, le richieste a userRepository e fileRepository verranno eseguite in modo sincrono, il che non è molto efficiente. Per risolvere questo problema, dovrai cambiare parecchio la logica della chiamata. Ad esempio, tramite CompletionStage (noto anche come 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()!!

Si può vedere che il codice di mappatura inizialmente semplice è diventato meno comprensibile. Questo perché abbiamo dovuto separare le chiamate ai servizi esterni da cui vengono utilizzati i risultati. Questo di per sé non è male. Ma la combinazione delle chiamate non sembra molto elegante e ricorda un tipico "noodle" reattivo.

Se usi le coroutine, tutto sembrerà più decente:

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, utilizzando tale elaborazione parallela, otterremo 200-400 ms, che è già vicino alle nostre aspettative.

Purtroppo una parallelizzazione così buona non esiste, e il prezzo da pagare è abbastanza crudele: con solo pochi utenti che lavorano contemporaneamente, una raffica di richieste ricadrà sui servizi, che comunque non verranno elaborate in parallelo, quindi dobbiamo torneremo ai nostri tristi 4 s.

Il mio risultato quando utilizzo tale servizio è di 1300–1700 ms per l'elaborazione di 20 messaggi. Questo è più veloce rispetto alla prima implementazione, ma non risolve comunque il problema.

Usi alternativi delle query paralleleCosa succede se i servizi di terze parti non forniscono l'elaborazione batch? Ad esempio, puoi nascondere la mancanza di implementazione dell'elaborazione batch all'interno dei metodi dell'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())
}

Questo ha senso se speri di vedere l'elaborazione batch nelle versioni future.
pro:

  1. Implementa facilmente l'elaborazione parallela basata su messaggi.
  2. Buona scalabilità.

contro:

  1. La necessità di separare l'acquisizione dei dati dal loro trattamento quando si elaborano richieste a diversi servizi in parallelo.
  2. Aumento del carico sui servizi di terze parti.

Si può notare che l’ambito di applicabilità è approssimativamente lo stesso dell’approccio ingenuo. Ha senso utilizzare il metodo della richiesta parallela se desideri aumentare più volte le prestazioni del tuo servizio a causa dello sfruttamento spietato degli altri. Nel nostro esempio, le prestazioni sono aumentate di 2,5 volte, ma chiaramente non è sufficiente.

Caching

È possibile eseguire la memorizzazione nella cache nello spirito di JPA per servizi esterni, ovvero archiviare gli oggetti ricevuti all'interno di una sessione in modo da non riceverli nuovamente (anche durante l'elaborazione batch). Puoi creare tali cache da solo, puoi usare Spring con il suo @Cacheable, inoltre puoi sempre usare manualmente una cache già pronta come EhCache.

Un problema comune sarebbe che le cache sono utili solo se contengono risultati. Nel nostro caso, i risultati sul campo autore sono molto probabili (diciamo il 50%), ma non ci saranno risultati sui file. Questo approccio fornirà alcuni miglioramenti, ma non cambierà radicalmente le prestazioni (e abbiamo bisogno di una svolta).

Le cache di intersessione (lunghe) richiedono una logica di invalidazione complessa. In generale, più tardi si inizia a risolvere i problemi di prestazioni utilizzando le cache di intersessione, meglio è.

pro:

  1. Implementa la memorizzazione nella cache senza modificare il codice.
  2. Aumento della produttività più volte (in alcuni casi).

contro:

  1. Possibilità di prestazioni ridotte se utilizzato in modo errato.
  2. Ampio sovraccarico di memoria, soprattutto con cache lunghe.
  3. Invalidazione complessa, i cui errori porteranno a problemi difficili da riprodurre in fase di esecuzione.

Molto spesso, le cache vengono utilizzate solo per correggere rapidamente i problemi di progettazione. Ciò non significa che non debbano essere utilizzati. Tuttavia, dovresti sempre trattarli con cautela e valutare prima il conseguente miglioramento delle prestazioni, e solo dopo prendere una decisione.

Nel nostro esempio, le cache forniranno un aumento delle prestazioni di circa il 25%. Allo stesso tempo, le cache presentano molti svantaggi, quindi non le utilizzerei qui.

Risultati di

Quindi, abbiamo esaminato un'implementazione ingenua di un servizio che utilizza l'elaborazione batch e alcuni semplici modi per accelerarla.

Il vantaggio principale di tutti questi metodi è la semplicità, da cui derivano molte piacevoli conseguenze.

Un problema comune con questi metodi è la scarsa prestazione, dovuta principalmente alla dimensione dei pacchetti. Pertanto, se queste soluzioni non ti soddisfano, vale la pena considerare metodi più radicali.

Esistono due direzioni principali in cui è possibile cercare soluzioni:

  • lavoro asincrono con i dati (richiede un cambio di paradigma, quindi non è discusso in questo articolo);
  • ampliamento dei lotti mantenendo la sincronia delle lavorazioni.

L'ampliamento dei batch ridurrà notevolmente il numero di chiamate esterne e allo stesso tempo manterrà il codice sincrono. A questo argomento sarà dedicata la parte successiva dell’articolo.

Fonte: habr.com

Aggiungi un commento