Problemas do procesamento de consultas por lotes e as súas solucións (parte 1)

Problemas do procesamento de consultas por lotes e as súas solucións (parte 1)Case todos os produtos de software modernos constan de varios servizos. Os tempos de resposta lentos entre os servizos adoitan converterse nunha fonte de problemas de rendemento. Unha solución estándar para este problema é empaquetar varias solicitudes entre servizos nun único paquete, un proceso coñecido como procesamento por lotes.

Se estás a usar o procesamento por lotes, pode que non esteas satisfeito co seu rendemento ou coa claridade do código. Este método non é tan sinxelo para a persoa que chama como poderías pensar. As solucións para diferentes propósitos e situacións poden variar moito. Usando exemplos específicos, ilustrarei as vantaxes e as desvantaxes de varias abordaxes.

Proxecto de demostración

Para ilustrar isto, vexamos un exemplo dun dos servizos da aplicación na que estou a traballar actualmente.

Explicación da selección de plataformas para exemplosO problema do baixo rendemento é bastante xeral e non afecta a ningunha linguaxe ou plataforma específica. Este artigo empregará exemplos de código Spring e Kotlin para demostrar os desafíos e as solucións. Kotlin é igualmente comprensible (ou incomprensible) para os desenvolvedores de Java e C#, e o código resultante é máis compacto e comprensible que Java. Para facilitarlles a comprensión aos desenvolvedores puros de Java, evitarei a maxia negra de Kotlin e empregarei só maxia branca (no espírito de Lombok). Haberá algúns métodos de extensión, pero en realidade son familiares para todos os programadores de Java como métodos estáticos, polo que este será un pequeno edulcorante que non estragará o prato.
Existe un servizo de aprobación de documentos. Alguén crea un documento e envíao para o seu debate, durante o cal se fan edicións e, finalmente, o documento é aprobado. O servizo de aprobación en si non sabe nada sobre os documentos: é simplemente unha sala de chat para os aprobadores con algunhas características adicionais que non imos comentar aquí.

Entón, existen salas de chat (correspondentes a documentos) cun conxunto predefinido de participantes en cada unha. Do mesmo xeito que nos chats normais, as mensaxes conteñen texto e ficheiros e poden ser respostas ou reenvíos:

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
)

As ligazóns a un ficheiro e a un usuario son ligazóns a outros dominiosAsí é como funciona para nós:

typealias FileReference Long
typealias UserReference Long

Os datos do usuario almacénanse en Keycloak e recupéranse mediante REST. O mesmo aplícase aos ficheiros: os ficheiros e os seus metadatos residen nun servizo de almacenamento de ficheiros separado.

Todas as chamadas a estes servizos son solicitudes difícilesIsto significa que a sobrecarga de transportar estas solicitudes é moito maior que o tempo que tarda en procesalas o servizo de terceiros. Nos nosos bancos de probas, o tempo de chamada típico para estes servizos é de 100 ms, polo que usaremos estas cifras de agora en diante.

Necesitamos crear un controlador REST sinxelo para recuperar as últimas N mensaxes con toda a información necesaria. Noutras palabras, asumimos que o modelo de mensaxes frontend é case o mesmo e que todos os datos deben enviarse. A diferenza no modelo frontend é que o ficheiro e o usuario deben representarse nunha forma lixeiramente descifrada para convertelos en ligazóns:

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

Necesitamos implementar o seguinte:

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

O sufixo da IU significa modelos DTO para o frontend, é dicir, o que deberiamos devolver mediante REST.

O que pode resultar sorprendente aquí é que non pasamos ningún ID de chat, e nin sequera hai un no modelo ChatMessage/ChatMessageUI. Fixen isto intencionadamente para manter o código de exemplo sinxelo (os chats están illados, polo que podemos considerar que só temos un).

Digresión filosóficaTanto a clase ChatMessageUI como o método ChatRestApi.getLast empregan o tipo de datos List, pero en realidade son un conxunto ordenado. O JDK non o admite, polo que non é posible declarar a orde dos elementos a nivel de interface (preservando a orde durante a inserción e a recuperación). Polo tanto, é unha práctica común usar List cando se necesita un conxunto ordenado (LinkedHashSet tamén está dispoñible, pero non é unha interface).
Limitación importante: Supoñamos que non existen cadeas longas de respostas ou reenvíos. É dicir, existen, pero a súa lonxitude non supera as tres mensaxes. A cadea completa de mensaxes debe transmitirse ao frontend.

Para obter datos de servizos externos, existen as seguintes 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>
}

É evidente que os servizos externos proporcionan inicialmente procesamento por lotes e en ambas variantes: mediante Set (sen preservar a orde dos elementos, con claves únicas) e mediante List (pode haber duplicados; a orde consérvase).

Implementacións sinxelas

Implementación inxenua

A primeira implementación inxenua do noso controlador REST terá un aspecto semellante na maioría dos 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()
    )
}

Todo está extremadamente claro, e iso é unha gran vantaxe.

Usamos o procesamento por lotes e recibimos datos dun servizo externo en lotes. Pero que está a suceder co rendemento?

Para cada mensaxe, realizarase unha chamada a UserRemoteApi para recuperar o campo de autor e unha chamada a FileRemoteApi para recuperar todos os ficheiros adxuntos. Iso parece ser todo. Supoñamos que os campos forwardFrom e replyTo para ChatMessage se xeran de tal xeito que non requiren ningunha chamada adicional. Non obstante, convertelos a ChatMessageUI levará á recursividade, o que significa que os contadores de chamadas poderían aumentar significativamente. Como sinalamos anteriormente, supoñamos que non temos moita aniñación e que o fío está limitado a tres mensaxes.

Como resultado, acabaremos con dúas a seis chamadas de servizo externo por mensaxe e unha chamada JPA por lote completo de mensaxes. O número total de chamadas variará de 2 * N + 1 a 6 * N + 1. Canto é iso en unidades reais? Digamos que unha páxina necesita 20 mensaxes para renderizar. Recuperalas levará entre 4 e 10 segundos. Horrible! Queremos mantelo por debaixo dos 500 ms. E como o equipo do frontend quería conseguir un desprazamento sen problemas, os requisitos de rendemento para este punto final pódense duplicar.

Pros:

  1. O código é curto e autodocumentado (o soño dunha persoa de apoio).
  2. O código é sinxelo, polo que case non hai oportunidades de dispararte no pé.
  3. O procesamento por lotes non parece alleo e encaixa perfectamente na lóxica.
  4. Os cambios na lóxica serán fáciles de facer e serán locais.

Menos:

Pésimo rendemento debido a que os paquetes son moi pequenos.

Esta estratexia é bastante común en servizos ou prototipos sinxelos. Se a velocidade de cambio é importante, non paga a pena complicar o sistema. Non obstante, para o noso servizo tan sinxelo, o rendemento é terrible, polo que a aplicabilidade desta estratexia é moi limitada.

Procesamento paralelo inxenuo

Podes executar todo o procesamento de mensaxes en paralelo; isto eliminará o aumento lineal do tempo de procesamento dependendo do número de mensaxes. Esta non é unha estratexia particularmente boa, xa que provocará unha carga máxima significativa no servizo externo.

Implementar o procesamento paralelo é moi sinxelo:

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

Usando o procesamento de mensaxes en paralelo, obtemos idealmente entre 300 e 700 ms, o que é moito mellor que a implementación inxenua, pero aínda non é o suficientemente rápido.

Con esta estratexia, as solicitudes a userRepository e fileRepository executaranse de forma síncrona, o que resulta ineficiente. Para solucionar isto, a lóxica de chamada terá que modificarse significativamente. Por exemplo, a través de CompletionStage (tamén coñecido como 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ódese ver que o código de mapeo inicialmente sinxelo volveuse menos claro. Isto débese a que tivemos que separar as chamadas a servizos externos de onde se usan os resultados. Isto en si mesmo non é malo. Pero combinar as chamadas non parece elegante e parécese aos típicos "fideos" reactivos.

Se usas corutinas, todo terá un aspecto máis 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()
    )
  }

En que:

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

Teoricamente, usando este procesamento paralelo, obtemos entre 200 e 400 ms, o que xa está preto das nosas expectativas.

Desafortunadamente, unha paralelización tan boa non existe, e o prezo é bastante elevado: con só uns poucos usuarios traballando simultaneamente, os servizos inundaranse de solicitudes que non se procesarán en paralelo de todos os xeitos, polo que volveremos aos nosos tristes 4 segundos.

O meu resultado usando este servizo é de 1300–1700 ms para procesar 20 mensaxes. Isto é máis rápido que a primeira implementación, pero aínda así non resolve o problema.

Uso alternativo de consultas paralelasQue ocorre se os servizos de terceiros non admiten o procesamento por lotes? Por exemplo, podes ocultar a falta de implementación do procesamento por lotes dentro dos métodos da interface:

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

Isto ten sentido se existe a esperanza de que o procesamento por lotes apareza en versións futuras.
Pros:

  1. Implementación sinxela do procesamento paralelo baseado en mensaxes.
  2. Boa escalabilidade.

Contras:

  1. A necesidade de separar a adquisición de datos do seu procesamento durante o procesamento paralelo de solicitudes a diferentes servizos.
  2. Maior carga en servizos de terceiros.

Claramente, o ámbito de aplicabilidade é aproximadamente o mesmo que para a abordaxe inxenua. Empregar consultas paralelas ten sentido se queres aumentar o rendemento do teu servizo varias veces explotando sen piedade o dos demais. No noso exemplo, o rendemento aumentou 2,5 veces, pero isto claramente non é suficiente.

caché

Podes implementar o almacenamento en caché ao estilo JPA para servizos externos, almacenando os obxectos recuperados dentro dunha sesión para evitar ter que recuperalos de novo (incluído durante o procesamento por lotes). Podes implementar estas cachés ti mesmo, usar Spring co seu @Cacheable ou sempre podes usar unha caché xa preparada como EhCache manualmente.

O problema xeral é que as cachés só son útiles se hai resultados. No noso caso, os resultados no campo autor son moi probables (digamos un 50%), pero non haberá ningún resultado nos ficheiros. Esta estratexia proporcionará algunhas melloras, pero non mellorará radicalmente o rendemento (e necesitamos un gran avance).

As cachés entre sesións (longas) requiren unha lóxica de invalidación complexa. En xeral, canto máis se espere para recorrer ás cachés entre sesións para resolver problemas de rendemento, mellor.

Pros:

  1. Implementar o almacenamento en caché sen modificar o código.
  2. Aumento da produtividade varias veces (nalgúns casos).

Contras:

  1. Posibilidade de degradación do rendemento se non se usa correctamente.
  2. Gran sobrecarga de memoria, especialmente con cachés longas.
  3. Invalidación complexa, cuxos erros provocarán problemas difíciles de reproducir en tempo de execución.

As memorias caché adoitan empregarse simplemente como unha solución rápida para problemas de deseño. Isto non significa que non se deban usar. Non obstante, sempre paga a pena abordalas con precaución e avaliar as melloras de rendemento resultantes antes de tomar unha decisión.

No noso exemplo, as cachés proporcionarán un aumento do rendemento de arredor do 25 %. Non obstante, as cachés teñen bastantes inconvenientes, polo que non as usaría aquí.

Resultados de

Entón, analizamos unha implementación inxenua dun servizo que usa o procesamento por lotes e algunhas formas sinxelas de aceleralo.

A principal vantaxe de todos estes métodos é a súa simplicidade, que ten moitas consecuencias agradables.

Un problema común con estes métodos é o baixo rendemento, principalmente debido ao tamaño dos paquetes. Polo tanto, se estas solucións non che funcionan, deberías considerar enfoques máis radicais.

Hai dúas direccións principais nas que se poden buscar solucións:

  • traballo asíncrono con datos (require un cambio de paradigma, polo que non se trata neste artigo);
  • Ampliación de lotes mantendo o procesamento síncrono.

A análise por lotes reducirá significativamente o número de chamadas externas e manterá o código síncrono. Este tema tratarase na seguinte sección do artigo.

Fonte: www.habr.com

Compre hospedaxe fiable para sitios con protección DDoS, servidores VPS VDS 🔥 Compra aloxamento web fiable con protección DDoS, servidores VPS VDS | ProHoster