Problemi di elaborazione dei pacchetti e loro risoluzioni (parte 1)

Problemi di elaborazione dei pacchetti e loro risoluzioni (parte 1)Praticamente tutti i moderni prodotti software sono composti da diversi servizi. Spesso, un lungo tempo di risposta tra i canali di servizio diventa fonte di problemi di prestazioni. La soluzione standard a questo tipo di problemi è raggruppare più richieste tra servizi in un unico pacchetto, conosciuta come elaborazione batch (batching).

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

Progetto dimostrativo

Per rendere chiaro, consideriamo l'esempio di uno dei servizi nell'applicazione su cui sto attualmente lavorando.

Spiegazione sulla scelta della piattaforma per gli esempiIl problema delle scarse prestazioni è piuttosto comune e non è legato a particolari linguaggi o piattaforme. In questo articolo, per dimostrare compiti e soluzioni, verranno utilizzati esempi di codice in Spring + Kotlin. Kotlin è altrettanto comprensibile (o incomprensibile) per gli sviluppatori Java e C#, inoltre, il codice risulta più compatto e comprensibile rispetto a Java. Per rendere più semplice la comprensione per gli sviluppatori Java puri, eviterò la magia nera di Kotlin e utilizzerò solo quella 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 saranno solo una piccola dolcezza che non rovinerà il gusto del piatto.
C'è un servizio di approvazione dei documenti. Qualcuno crea un documento e lo porta in discussione, durante il quale vengono apportate modifiche e, alla fine, il documento viene approvato. Il servizio di approvazione non sa nulla dei documenti: è semplicemente una chat tra approvatori con alcune piccole funzioni aggiuntive che non tratteremo qui.

Quindi, ci sono stanze di chat (corrispondenti ai documenti) con un insieme predeterminato di partecipanti in ciascuna di esse. Come nelle normali chat, i messaggi contengono testo e file e possono essere risposte (reply) e inoltri (forward):

classe di dati MessaggioChat(
  // nullable так как появляется только после persist
  val id: Lungo? = null,
  /** Ссылка на автора */
  val autore: RiferimentoUtente,
  /** Сообщение */
  val messaggio: String,
  /** Ссылки на аттачи */
  // из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
  val files: Elenco<RiferimentoFile>? = null,
  /** Если является ответом, то здесь будет оригинал */
  val rispondiA: MessaggioChat? = null,
  /** Если является пересылкой, то здесь будет оригинал */
  val inoltraDa: MessaggioChat? = null
)

I legami a file e utenti sono legami ad altri domini. Da noi funziona così:

tipoalias RiferimentoFile Lungo
tipoalias RiferimentoUtente Lungo

I dati sugli utenti sono archiviati in Keycloak e vengono ottenuti tramite REST. Lo stesso vale per i file: i file e le relative informazioni vivono in un servizio di archiviazione file separato.

Tutte le chiamate a questi servizi sono richieste pesanti. Questo significa che i costi di trasporto di queste richieste sono molto maggiori rispetto al tempo necessario per elaborarle da parte di un servizio esterno. Nei nostri banchi di prova, il tempo tipico per la chiamata di tali servizi è di 100 ms, quindi in seguito utilizzeremo questi numeri.

Dobbiamo creare un semplice controller REST per ottenere gli ultimi N messaggi con tutte le informazioni necessarie. In altre parole, consideriamo che nel frontend il modello dei messaggi sia praticamente lo stesso e dobbiamo inoltrare tutti i dati. La differenza rispetto al modello per il frontend è che il file e l'utente devono essere presentati in una forma leggermente decrittata, in modo da farli diventare link:

/** В таком виде отдаются ссылки на сущности для фронта */
classe di dati Interfaccia di riferimento(
  /** Идентификатор для url */
  val riferimento: String,
  /** Видимое пользователю название ссылки */
  val nome: String
)
classe di dati InterfacciaMessaggioChat(
  val id: Lungo,
  /** Ссылка на автора */
  val autore: Interfaccia di riferimento,
  /** Сообщение */
  val messaggio: String,
  /** Ссылки на аттачи */
  val files: Elenco<Interfaccia di riferimento>,
  /** Если являтся ответом, то здесь будет оригинал */
  val rispondiA: InterfacciaMessaggioChat? = null,
  /** Если являтся пересылкой, то здесь будет оригинал */
  val inoltraDa: InterfacciaMessaggioChat? = null
)

Dobbiamo implementare quanto segue:

interfaccia ChatRestApi {
  fun getLast(nInt): Elenco<InterfacciaMessaggioChat>
}

Postfix UI si riferisce ai modelli DTO per il frontend, cioè ciò che dobbiamo restituire tramite REST.

Può sembrare sorprendente che non stiamo passando alcun identificativo della chat e nemmeno nel modello ChatMessage/ChatMessageUI è presente. L'ho fatto intenzionalmente per non appesantire il codice degli esempi (le chat sono isolate, quindi possiamo considerare che ne abbiamo solo una).

Un'escursione filosoficaSia nella classe ChatMessageUI che nel metodo ChatRestApi.getLast viene utilizzato il tipo di dato List, mentre in realtà si tratta di un Set ordinato. In JDK la situazione è problematica, quindi non è possibile dichiarare l'ordine degli elementi a livello di interfaccia (mantenendo l'ordine durante l'aggiunta e il recupero). Pertanto, è diventata prassi comune utilizzare List nei casi in cui è necessario un Set ordinato (c'è anche LinkedHashSet, ma quello non è un'interfaccia).
Una limitazione importante: consideriamo che non ci siano catene di risposte lunghe o inoltri. In altre parole, ci sono, ma la loro lunghezza non supera i tre messaggi. Nel frontend, la catena di messaggi deve essere trasmessa integralmente.

Per ottenere dati da servizi esterni, ci sono queste API:

interfaccia ChatMessageRepository {
  fun findLast(nInt): Elenco<MessaggioChat>
}
classe di dati FileHeadRemote(
  val id: RiferimentoFile,
  val nome: String
)
interfaccia FileRemoteApi {
  fun getHeadById(idRiferimentoFile): FileHeadRemote
  fun getHeadsByIds(idImposta<RiferimentoFile>): Imposta<FileHeadRemote>
  fun getHeadsByIds(idElenco<RiferimentoFile>): Elenco<FileHeadRemote>
  fun getHeadsByChat(): Elenco<FileHeadRemote>
}
classe di dati UserRemote(
  val id: RiferimentoUtente,
  val nome: String
)
interfaccia UserRemoteApi {
  fun getUserById(idRiferimentoUtente): UserRemote
  fun getUsersByIds(idImposta<RiferimentoUtente>): Imposta<UserRemote>
  fun getUsersByIds(idElenco<RiferimentoUtente>): Elenco<UserRemote>
}

È evidente che nei servizi esterni è inizialmente prevista l'elaborazione in batch, sia nel caso di Set (senza mantenere l'ordine degli elementi, con chiavi uniche) sia nel caso di List (possono esserci duplicati - l'ordine è mantenuto).

Implementazioni semplici

Implementazione naive

La prima implementazione naive del nostro REST controller apparirà per lo più così:

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 MessaggioChat.toFrontModel(): InterfacciaMessaggioChat =
    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 è estremamente chiaro, e questo è un grande vantaggio.

Usiamo l'elaborazione batch e otteniamo dati da un servizio esterno in blocchi. Ma cosa succede con le prestazioni?

Per ogni messaggio verrà effettuata una chiamata a UserRemoteApi per ottenere i dati dal campo author e una chiamata a FileRemoteApi per ottenere tutti i file allegati. Sembra tutto. Supponiamo che i campi forwardFrom e replyTo per ChatMessage vengano ottenuti in modo da non richiedere ulteriori chiamate. Ma la trasformazione in ChatMessageUI porterà a una ricorsione, cioè i conteggi delle chiamate potrebbero aumentare notevolmente. Come abbiamo notato in precedenza, supponiamo che non ci siano profondità annidate e che la catena sia limitata a tre messaggi.

In totale avremo da due a sei chiamate a servizi esterni per un 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 sono queste in unità reali? Supponiamo che per il rendering della pagina siano necessari 20 messaggi. Per ottenerli, servirebbero da 4 a 10 secondi. Terribile! Ci si aspetterebbe di rimanere entro i 500 ms. E poiché nel frontend si desidera ottenere uno scroll senza soluzione di continuità, i requisiti di prestazione per questo endpoint possono raddoppiare.

Pro:

  1. Il codice è conciso e auto-documentato (il sogno del supporto).
  2. Il codice è semplice, quindi ci sono poche possibilità di auto-sabotaggio.
  3. L'elaborazione batch non appare come qualcosa di estraneo ed è organicamente integrata nella logica.
  4. Le modifiche alla logica saranno facili da apportare e saranno localizzate.

Contro:

Prestazioni terribili, dovute al fatto che i pacchetti sono molto piccoli.

Questo approccio può essere visto abbastanza frequentemente in servizi semplici o prototipi. Se la velocità di implementazione delle modifiche è importante, probabilmente non vale la pena complicare il sistema. Tuttavia, per il nostro servizio molto semplice, le prestazioni sono terribili, quindi i confini di applicabilità di tale approccio sono molto ristretti.

Elaborazione parallela naive

È possibile avviare l'elaborazione di tutti i messaggi in parallelo, il che consentirà di eliminare la crescita lineare del tempo in base al numero di messaggi. Questo non è un metodo particolarmente valido, perché porterà a un carico di picco elevato sul servizio esterno.

Implementare l'elaborazione parallela è molto semplice:

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

Utilizzando l'elaborazione parallela dei messaggi, possiamo ottenere 300–700 ms in ideale, che è molto meglio rispetto all'implementazione naïve, ma comunque non 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, sarà necessario modificare radicalmente la logica delle chiamate. Ad esempio, tramite CompletionStage (alias CompletableFuture):

private fun MessaggioChat.toFrontModel(): InterfacciaMessaggioChat =
  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ò notare che il codice di mapping, inizialmente semplice, è diventato meno comprensibile. Questo è dovuto al fatto che abbiamo dovuto separare le chiamate ai servizi esterni dall'uso dei risultati. Di per sé, questo non è male. Ma la combinazione delle chiamate appare piuttosto poco elegante e ricorda la tipica 'pasta' reattiva.

Se utilizziamo le coroutine, tutto apparirà più pulito:

private fun MessaggioChat.toFrontModel(): InterfacciaMessaggioChat =
  upgrade(
    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 <ABupgrade(a: () -> Ab: () -> B) =
  runBlocking(IO{
    awaitAll(async a() }async b() })
  }.let {
    it[0as to it[1as B
  }

Teoricamente, utilizzando questa elaborazione parallela, otterremo 200–400 ms, il che è già vicino alle nostre aspettative.

Sfortunatamente, una tale buona paralelizzazione non esiste, e il prezzo da pagare è piuttosto alto: con il lavoro simultaneo di pochi utenti, i servizi saranno sommersi da un'ondata di richieste, che comunque non verranno elaborate in parallelo, quindi torneremo ai nostri tristi 4 s.

Il mio risultato usando tale servizio è di 1300–1700 ms per elaborare 20 messaggi. Questo è più veloce della prima implementazione, ma non risolve comunque il problema.

Applicazione alternativa delle richieste paralleleE se nei servizi esterni non è prevista l'elaborazione batch? Ad esempio, possiamo nascondere l'assenza dell'implementazione di elaborazione batch all'interno dei metodi delle interfacce:

interfaccia UserRemoteApi {
  fun getUserById(idRiferimentoUtente): UserRemote
  fun getUsersByIds(idImposta<RiferimentoUtente>): Imposta<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toSet())
  fun getUsersByIds(idElenco<RiferimentoUtente>): Elenco<UserRemote> =
    id.parallelStream()
      .map getUserById(it}.collect(toList())
}

Questo ha senso se c'è speranza per il rilascio dell'elaborazione batch nelle versioni successive.
Pro:

  1. Facile implementazione dell'elaborazione parallela per messaggi.
  2. Buona scalabilità.

Contro:

  1. Necessità di separare la ricezione dei dati dalla loro elaborazione durante l'elaborazione parallela delle richieste ai diversi servizi.
  2. Maggiore carico sui servizi esterni.

È evidente che i limiti di applicazione sono sostanzialmente gli stessi di un approccio naive. Utilizzare il metodo delle richieste parallele ha senso se desideri aumentare le prestazioni del tuo servizio moltiplicando l'utilizzo di risorse esterne. Nel nostro esempio, le prestazioni sono aumentate di 2,5 volte, ma questo è evidentemente insufficiente.

Caching

È possibile implementare la memorizzazione nella cache in stile JPA per i servizi esterni, ossia memorizzare gli oggetti ottenuti all'interno della sessione, così da non doverli recuperare nuovamente (incluso durante l'elaborazione batch). Puoi creare queste cache manualmente oppure utilizzare Spring con la sua annotazione @Cacheable, e puoi sempre ricorrere a una cache pronta come EhCache.

Il problema principale sarà legato al fatto che la cache è utile solo se ci sono risultati positivi. Nel nostro caso, è abbastanza probabile avere risultati per il campo autore (supponiamo 50%), mentre non ci saranno risultati per i file. Alcuni miglioramenti questo approccio apporterà, ma non cambierà radicalmente le prestazioni (e noi abbiamo bisogno di una svolta).

Le cache inter-sessione (lunghe) richiedono una logica complessa di invalidazione. In generale, più a lungo aspetti prima di affrontare i problemi di prestazioni tramite cache inter-sessione, meglio è.

Pro:

  1. Implementazione della memorizzazione nella cache senza modifiche al codice.
  2. Incremento delle prestazioni di diverse volte (in alcuni casi).

Contro:

  1. Possibilità di riduzione delle prestazioni se utilizzata in modo errato.
  2. Alti costi in termini di memoria, specialmente con cache lunghe.
  3. La complessità dell'invalidazione, con errori che possono portare a problemi difficili da riprodurre a runtime.

Molto spesso, le cache vengono utilizzate solo per risolvere rapidamente i problemi di progettazione. Ciò non significa che non debbano essere utilizzate. Tuttavia, è sempre opportuno trattarle con cautela e prima valutare il guadagno di prestazioni ottenuto, per poi prendere una decisione.

Nel nostro esempio, la cache porterà a un incremento delle prestazioni attorno al 25%. Tuttavia, gli svantaggi delle cache sono piuttosto numerosi, quindi non le utilizzerei qui.

Conclusioni

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

Il principale pregio di tutti questi metodi è la loro semplicità, da cui derivano molte conseguenze positive.

Un problema comune di questi metodi è la scarsa efficienza, legata principalmente alla dimensione dei pacchetti. Pertanto, se queste soluzioni non sono adatte a voi, è opportuno considerare metodi più radicali.

Ci sono due principali direzioni in cui si possono cercare soluzioni:

  • lavoro asincrono con i dati (richiede un cambiamento di paradigma, quindi non sarà trattato in questo articolo);
  • unificazione dei pacchetti mantenendo l'elaborazione sincrona.

L'unificazione dei pacchetti consente di ridurre significativamente il numero di chiamate esterne, mantenendo comunque il codice sincrono. Questa tematica sarà trattata nella prossima parte dell'articolo.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster