Problemas del procesamiento de consultas por lotes y sus soluciones (parte 1)

Problemas del procesamiento de consultas por lotes y sus soluciones (parte 1)Casi todos los productos de software modernos constan de varios servicios. A menudo, los largos tiempos de respuesta de los canales entre servicios se convierten en una fuente de problemas de rendimiento. La solución estándar a este tipo de problema es agrupar varias solicitudes entre servicios en un solo paquete, lo que se denomina procesamiento por lotes.

Si utiliza el procesamiento por lotes, es posible que no esté satisfecho con los resultados en términos de rendimiento o claridad del código. Este método no es tan fácil para la persona que llama como podría pensar. Para diferentes propósitos y en diferentes situaciones, las soluciones pueden variar mucho. Utilizando ejemplos específicos, mostraré los pros y los contras de varios enfoques.

Proyecto de demostración

Para mayor claridad, veamos un ejemplo de uno de los servicios de la aplicación en la que estoy trabajando actualmente.

Explicación de la selección de plataforma para ejemplos.El problema del bajo rendimiento es bastante general y no afecta a ningún idioma o plataforma específica. Este artículo utilizará ejemplos de código Spring + Kotlin para demostrar problemas y soluciones. Kotlin es igualmente comprensible (o incomprensible) para los desarrolladores de Java y C#; además, el código es más compacto y comprensible que en Java. Para que sea más fácil de entender para los desarrolladores de Java puro, evitaré la magia negra de Kotlin y solo usaré la magia blanca (en el espíritu de Lombok). Habrá algunos métodos de extensión, pero en realidad son familiares para todos los programadores de Java como métodos estáticos, por lo que será un poco de azúcar que no estropeará el sabor del plato.
Existe un servicio de aprobación de documentos. Alguien crea un documento y lo envía para discusión, durante el cual se realizan modificaciones y, finalmente, se acuerda el documento. El servicio de aprobación en sí no sabe nada sobre documentos: es solo un chat de aprobadores con pequeñas funciones adicionales que no consideraremos aquí.

Así, existen salas de chat (correspondientes a documentos) con un conjunto predefinido de participantes en cada una de ellas. Como en los chats normales, los mensajes contienen texto y archivos y pueden ser respuestas o 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
)

Los enlaces de archivos y usuarios son enlaces a otros dominios. Aquí vivimos así:

typealias FileReference Long
typealias UserReference Long

Los datos del usuario se almacenan en Keycloak y se recuperan mediante REST. Lo mismo ocurre con los archivos: los archivos y la metainformación sobre ellos se encuentran en un servicio de almacenamiento de archivos independiente.

Todas las llamadas a estos servicios son solicitudes pesadas. Esto significa que la sobrecarga de transportar estas solicitudes es mucho mayor que el tiempo que lleva procesarlas un servicio de terceros. En nuestros bancos de pruebas, el tiempo de llamada típico para dichos servicios es de 100 ms, por lo que utilizaremos estos números en el futuro.

Necesitamos hacer un controlador REST simple para recibir los últimos N mensajes con toda la información necesaria. Es decir, creemos que el modelo de mensaje en el frontend es casi el mismo y es necesario enviar todos los datos. La diferencia entre el modelo front-end es que el archivo y el usuario deben presentarse en un formato ligeramente descifrado para poder convertirlos en enlaces:

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

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

El sufijo UI significa modelos DTO para el frontend, es decir, lo que debemos servir vía REST.

Lo que puede resultar sorprendente aquí es que no estamos pasando ningún ID de chat e incluso el modelo ChatMessage/ChatMessageUI no tiene uno. Hice esto intencionalmente para no saturar el código de los ejemplos (los chats están aislados, por lo que podemos asumir que solo tenemos uno).

Digresión filosóficaTanto la clase ChatMessageUI como el método ChatRestApi.getLast utilizan el tipo de datos Lista cuando en realidad es un Conjunto ordenado. Esto es malo en el JDK, por lo que declarar el orden de los elementos en el nivel de la interfaz (preservar el orden al agregar y eliminar) no funcionará. Por lo tanto, se ha convertido en una práctica común usar una Lista en los casos en que se necesita un Conjunto ordenado (también existe un LinkedHashSet, pero no es una interfaz).
Limitación importante: Supondremos que no existen largas cadenas de respuestas o transferencias. Es decir, existen, pero su extensión no supera los tres mensajes. Toda la cadena de mensajes debe transmitirse al frontend.

Para recibir datos de servicios externos existen las siguientes 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>
}

Se puede ver que los servicios externos prevén inicialmente el procesamiento por lotes, y en ambas versiones: a través de Set (sin guardar el orden de los elementos, con claves únicas) y a través de List (puede haber duplicados, el orden se conserva).

Implementaciones simples

Implementación ingenua

La primera implementación ingenua de nuestro controlador REST se verá así en la mayoría de los 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á muy claro y esto es una gran ventaja.

Utilizamos procesamiento por lotes y recibimos datos de un servicio externo en lotes. Pero ¿qué pasa con nuestra productividad?

Para cada mensaje, se realizará una llamada a UserRemoteApi para obtener datos sobre el campo de autor y una llamada a FileRemoteApi para obtener todos los archivos adjuntos. Parece que eso es todo. Digamos que los campos forwardFrom y AnswerTo para ChatMessage se obtienen de tal manera que no requieren llamadas innecesarias. Pero convertirlos en ChatMessageUI generará recursividad, es decir, los contadores de llamadas pueden aumentar significativamente. Como señalamos anteriormente, supongamos que no tenemos muchos anidamientos y que la cadena está limitada a tres mensajes.

Como resultado, obtendremos de dos a seis llamadas a servicios externos por mensaje y una llamada JPA para todo el paquete de mensajes. El número total de llamadas variará de 2*N+1 a 6*N+1. ¿Cuánto es esto en unidades reales? Digamos que se necesitan 20 mensajes para representar una página. Para recibirlos se necesitarán de 4 s a 10 s. ¡Horrible! Me gustaría mantenerlo dentro de los 500 ms. Y dado que soñaron con lograr un desplazamiento fluido en la interfaz, los requisitos de rendimiento para este punto final se pueden duplicar.

Pros:

  1. El código es conciso y autodocumentado (el sueño de un equipo de soporte).
  2. El código es simple, por lo que casi no hay oportunidades de dispararse en el pie.
  3. El procesamiento por lotes no parece algo extraño y está integrado orgánicamente en la lógica.
  4. Los cambios lógicos se realizarán fácilmente y serán locales.

Menos:

Rendimiento horrible debido a paquetes muy pequeños.

Este enfoque se puede ver con bastante frecuencia en servicios simples o en prototipos. Si la velocidad con la que se realizan los cambios es importante, no vale la pena complicar el sistema. Al mismo tiempo, para nuestro servicio tan simple, el rendimiento es terrible, por lo que el alcance de aplicabilidad de este enfoque es muy limitado.

Procesamiento paralelo ingenuo

Puede comenzar a procesar todos los mensajes en paralelo; esto le permitirá deshacerse del aumento lineal en el tiempo dependiendo de la cantidad de mensajes. Este no es un camino particularmente bueno porque resultará en un gran pico de carga en el servicio externo.

Implementar el procesamiento paralelo es muy simple:

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

Al utilizar el procesamiento de mensajes paralelo, idealmente obtenemos entre 300 y 700 ms, lo cual es mucho mejor que con una implementación ingenua, pero aún no lo suficientemente rápido.

Con este enfoque, las solicitudes a userRepository y fileRepository se ejecutarán sincrónicamente, lo cual no es muy eficiente. Para solucionar este problema, deberá cambiar bastante la lógica de llamada. Por ejemplo, a través de CompletionStage (también conocido 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()!!

Se puede ver que el código de mapeo inicialmente simple se ha vuelto menos comprensible. Esto se debe a que tuvimos que separar las llamadas a servicios externos desde donde se utilizan los resultados. Esto en sí mismo no es malo. Pero la combinación de llamadas no parece muy elegante y se parece a un típico "fideo" reactivo.

Si usas corrutinas, todo se verá más 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()
    )
  }

Donde:

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

Teóricamente, utilizando dicho procesamiento paralelo, obtendremos entre 200 y 400 ms, lo que ya se acerca a nuestras expectativas.

Desafortunadamente, no existe una paralelización tan buena y el precio a pagar es bastante cruel: con sólo unos pocos usuarios trabajando al mismo tiempo, una avalancha de solicitudes caerá sobre los servicios, que de todos modos no se procesarán en paralelo, por lo que Volveremos a nuestros tristes 4s.

Mi resultado al utilizar un servicio de este tipo es de 1300 a 1700 ms para procesar 20 mensajes. Esto es más rápido que en la primera implementación, pero aún no resuelve el problema.

Usos alternativos de consultas paralelas¿Qué pasa si los servicios de terceros no proporcionan procesamiento por lotes? Por ejemplo, puede ocultar la falta de implementación del procesamiento por lotes dentro de los métodos de la interfaz:

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

Esto tiene sentido si espera ver el procesamiento por lotes en versiones futuras.
Pros:

  1. Implemente fácilmente el procesamiento paralelo basado en mensajes.
  2. Buena escalabilidad.

Contras:

  1. La necesidad de separar la adquisición de datos de su procesamiento cuando se procesan solicitudes a diferentes servicios en paralelo.
  2. Mayor carga en servicios de terceros.

Se puede ver que el alcance de aplicabilidad es aproximadamente el mismo que el del enfoque ingenuo. Tiene sentido utilizar el método de solicitud paralela si desea aumentar varias veces el rendimiento de su servicio debido a la explotación despiadada de otros. En nuestro ejemplo, el rendimiento se multiplicó por 2,5, pero esto claramente no es suficiente.

almacenamiento en caché

Puede realizar el almacenamiento en caché en el espíritu de JPA para servicios externos, es decir, almacenar los objetos recibidos dentro de una sesión para no volver a recibirlos (incluso durante el procesamiento por lotes). Puede crear dichos cachés usted mismo, puede usar Spring con su @Cacheable y, además, siempre puede usar un caché listo para usar como EhCache manualmente.

Un problema común sería que los cachés sólo son útiles si tienen resultados. En nuestro caso, es muy probable que haya aciertos en el campo de autor (digamos, 50%), pero no habrá ningún acierto en los archivos. Este enfoque proporcionará algunas mejoras, pero no cambiará radicalmente el rendimiento (y necesitamos un gran avance).

Los cachés entre sesiones (largos) requieren una lógica de invalidación compleja. En general, cuanto más tarde se dedique a resolver los problemas de rendimiento utilizando cachés entre sesiones, mejor.

Pros:

  1. Implemente el almacenamiento en caché sin cambiar el código.
  2. Aumento de la productividad varias veces (en algunos casos).

Contras:

  1. Posibilidad de rendimiento reducido si se usa incorrectamente.
  2. Gran sobrecarga de memoria, especialmente con cachés largos.
  3. Invalidación compleja, cuyos errores provocarán problemas difíciles de reproducir en tiempo de ejecución.

Muy a menudo, los cachés se utilizan sólo para solucionar rápidamente problemas de diseño. Esto no significa que no deban usarse. Sin embargo, siempre debe tratarlos con precaución y primero evaluar el aumento de rendimiento resultante, y sólo entonces tomar una decisión.

En nuestro ejemplo, las cachés proporcionarán un aumento de rendimiento de alrededor del 25%. Al mismo tiempo, los cachés tienen muchas desventajas, por lo que no los usaría aquí.

resultados

Entonces, analizamos una implementación ingenua de un servicio que utiliza procesamiento por lotes y algunas formas simples de acelerarlo.

La principal ventaja de todos estos métodos es la simplicidad, de la que se derivan muchas consecuencias agradables.

Un problema común con estos métodos es el bajo rendimiento, principalmente debido al tamaño de los paquetes. Por lo tanto, si estas soluciones no le convienen, entonces debería considerar métodos más radicales.

Hay dos direcciones principales en las que puede buscar soluciones:

  • trabajo asincrónico con datos (requiere un cambio de paradigma, por lo que no se analiza en este artículo);
  • Ampliación de lotes manteniendo el procesamiento sincrónico.

La ampliación de lotes reducirá en gran medida la cantidad de llamadas externas y al mismo tiempo mantendrá el código sincrónico. La siguiente parte del artículo estará dedicada a este tema.

Fuente: habr.com

Añadir un comentario