Prácticamente todos los productos de software modernos consisten en múltiples servicios. A menudo, un gran tiempo de respuesta entre los canales de los servicios se convierte en una fuente de problemas de rendimiento. La solución estándar a este tipo de problemas es agrupar múltiples solicitudes entre servicios en un solo paquete, conocido como procesamiento por lotes (batching).
Si utilizas el procesamiento por lotes, es posible que no estés satisfecho con el resultado en términos de rendimiento o claridad del código. Este método no es tan sencillo para la parte llamadora como podría parecer. Para diferentes propósitos y en diversas situaciones, las soluciones pueden variar considerablemente. En ejemplos concretos, mostraré los pros y los contras de varios enfoques.
Proyecto demostrativo
Para ilustrar, consideremos el ejemplo de uno de los servicios en la aplicación en la que estoy trabajando actualmente.
Explicación sobre la elección de la plataforma para los ejemplosEl problema del bajo rendimiento es bastante común y no se limita a lenguajes o plataformas específicos. En este artículo, se utilizarán ejemplos de código en Spring + Kotlin para demostrar tareas y soluciones. Kotlin es igualmente comprensible (o incomprensible) para desarrolladores de Java y C#, además, el código es más compacto y legible que en Java. Para facilitar la comprensión a los desarrolladores puramente de Java, evitaré la magia negra de Kotlin y utilizaré solo la blanca (al estilo de Lombok). Habrá algunos métodos de extensión, pero en realidad son conocidos por todos los programadores de Java como métodos estáticos, así que esto será un pequeño dulce que no desvirtuará el sabor del plato.
Hay un servicio de aprobación de documentos. Alguien crea un documento y lo presenta para discusión, durante el proceso se realizan correcciones, y finalmente el documento es aprobado. El servicio de aprobación en sí no sabe nada sobre los documentos: es simplemente un chat de aprobadores con algunas funciones adicionales que no abordaremos aquí.
Entonces, existen salas de chat (que corresponden a documentos) con un conjunto predefinido de participantes en cada una de ellas. Al igual que en los chats normales, los mensajes contienen texto y archivos, y pueden ser respuestas (reply) y reenvíos (forward):
clase de datos MensajeDeChat(
// nullable так как появляется только после persist
val id: Largo? = null,
/** Ссылка на автора */
val autor: ReferenciaDeUsuario,
/** Сообщение */
val mensaje: String,
/** Ссылки на аттачи */
// из-за особенностей связки JPA+СУБД проще поддерживать и null, и пустые списки
val files: Lista<ReferenciaDeArchivo>? = null,
/** Если является ответом, то здесь будет оригинал */
val responderA: MensajeDeChat? = null,
/** Если является пересылкой, то здесь будет оригинал */
val reenviadoDe: MensajeDeChat? = null
)
Los enlaces al archivo y al usuario son enlaces a otros dominios. Así es como funciona esto:
tipoalias ReferenciaDeArchivo = Largo
tipoalias ReferenciaDeUsuario = Largo
Los datos de los usuarios se almacenan en Keycloak y se obtienen a través de REST. Lo mismo ocurre con los archivos: los archivos y su metainformación viven en un servicio de almacenamiento de archivos separado.
Todas las llamadas a estos servicios son solicitudes pesadas. Esto significa que los costos de transporte de estas solicitudes son mucho mayores que el tiempo que tarda en procesarlas un servicio externo. En nuestros entornos de prueba, el tiempo típico para invocar estos servicios es de 100 ms, así que usaremos estas cifras más adelante.
Necesitamos crear un controlador REST simple para obtener los últimos N mensajes con toda la información necesaria. Es decir, asumimos que en el frontend el modelo de mensajes es casi el mismo y necesitamos enviar todos los datos. La diferencia del modelo para el frontend es que el archivo y el usuario deben representarse en una forma un poco descifrada, para convertirlos en enlaces:
/** В таком виде отдаются ссылки на сущности для фронта */
clase de datos Interfaz de referencia(
/** Идентификатор для url */
val ref: String,
/** Видимое пользователю название ссылки */
val nombre: String
)
clase de datos Interfaz de mensaje de chat(
val id: Largo,
/** Ссылка на автора */
val autor: Interfaz de referencia,
/** Сообщение */
val mensaje: String,
/** Ссылки на аттачи */
val files: Lista<Interfaz de referencia>
/** Если являтся ответом, то здесь будет оригинал */
val responderA: Interfaz de mensaje de chat? = null,
/** Если являтся пересылкой, то здесь будет оригинал */
val reenviadoDe: Interfaz de mensaje de chat? = null
)
Necesitamos implementar lo siguiente:
interface ChatRestApi {
fun getLast(n: Int): Lista<Interfaz de mensaje de chat>
}
El sufijo UI implica modelos DTO para el frontend, es decir, lo que debemos devolver a través de REST.
Aquí puede parecer sorprendente que no estemos pasando ningún identificador de chat y que ni siquiera en el modelo ChatMessage/ChatMessageUI esté presente. Hice esto intencionalmente para no sobrecargar el código de los ejemplos (los chats están aislados, así que se puede considerar que solo tenemos uno).
Una reflexión filosóficaTanto en la clase ChatMessageUI como en el método ChatRestApi.getLast se utiliza el tipo de datos List, mientras que en realidad es un Set ordenado. En JDK esto es problemático, así que no es posible declarar el orden de los elementos a nivel de interfaz (manteniendo el orden al agregar y extraer). Por lo tanto, se ha convertido en una práctica común usar List en aquellos casos en que se necesita un Set ordenado (también hay LinkedHashSet, pero este no es una interfaz).
Una limitación importante: asumamos que no hay largas cadenas de respuestas o reenvíos. Es decir, existen, pero su longitud no excede los tres mensajes. En el frontend, la cadena de mensajes debe ser transmitida en su totalidad.
Para obtener datos de servicios externos, existen estas API:
interface RepositorioDeMensajesDeChat {
fun encontrarÚltimo(n: Int): Lista<MensajeDeChat>
}
clase de datos EncabezadoDeArchivoRemoto(
val id: ReferenciaDeArchivo,
val nombre: String
)
interface ApiDeArchivoRemoto {
fun obtenerEncabezadoPorId(id: ReferenciaDeArchivo): EncabezadoDeArchivoRemoto
fun obtenerEncabezadosPorIds(id: Establecer<ReferenciaDeArchivo>: Establecer<EncabezadoDeArchivoRemoto>
fun obtenerEncabezadosPorIds(id: Lista<ReferenciaDeArchivo>: Lista<EncabezadoDeArchivoRemoto>
fun obtenerEncabezadosPorChat(): Lista<EncabezadoDeArchivoRemoto>
}
clase de datos UsuarioRemoto(
val id: ReferenciaDeUsuario,
val nombre: String
)
interface ApiDeUsuarioRemoto {
fun obtenerUsuarioPorId(id: ReferenciaDeUsuario): UsuarioRemoto
fun obtenerUsuariosPorIds(id: Establecer<ReferenciaDeUsuario>: Establecer<UsuarioRemoto>
fun obtenerUsuariosPorIds(id: Lista<ReferenciaDeUsuario>: Lista<UsuarioRemoto>
}
Se observa que en los servicios externos se prevé originalmente el procesamiento por lotes, tanto en ambas variantes: a través de Set (sin mantener el orden de los elementos, con claves únicas) y a través de List (pueden haber duplicados, el orden se mantiene).
Implementaciones simples
Implementación ingenua
La primera implementación ingenua de nuestro controlador REST se verá en la mayoría de los casos algo así:
class ChatRestController(
private val messageRepository: RepositorioDeMensajesDeChat,
private val userRepository: ApiDeUsuarioRemoto,
private val fileRepository: ApiDeArchivoRemoto
) : ChatRestApi {
override fun getLast(n: Int) =
messageRepository.findLast(n)
.map { it.toFrontModel() }
private fun MensajeDeChat.toFrontModel(): Interfaz de mensaje de chat =
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, y eso es un gran punto a favor.
Utilizamos procesamiento por lotes y obtenemos datos de un servicio externo en paquetes. Pero, ¿qué pasa con nuestro rendimiento?
Para cada mensaje se realizará una llamada a UserRemoteApi para obtener datos del campo author y una llamada a FileRemoteApi para obtener todos los archivos adjuntos. Parece que eso es todo. Supongamos que los campos forwardFrom y replyTo para ChatMessage se obtienen de tal manera que no requieren llamadas adicionales. Pero convertirlos en ChatMessageUI provocará recursión, es decir, los contadores de llamadas pueden aumentar significativamente. Como mencionamos antes, asumamos que no tenemos mucha anidación y que la cadena está limitada a tres mensajes.
En total, obtendremos de dos a seis llamadas a servicios externos por un 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 eso en unidades reales? Supongamos que para renderizar la página se necesitan 20 mensajes. Para obtenerlos, se necesitará de 4 a 10 segundos. ¡Es horrible! Nos gustaría hacerlo en 500 ms. Y dado que en el frontend se quería implementar un desplazamiento sin costuras, las exigencias de rendimiento de este endpoint pueden duplicarse.
Pros:
- El código es conciso y autodescriptivo (el sueño del soporte).
- El código es simple, por lo que hay muy pocas posibilidades de cometer un error.
- El procesamiento por lotes no parece algo ajeno y se integra orgánicamente en la lógica.
- Los cambios en la lógica se realizarán de manera sencilla y serán locales.
Desventaja:
Un rendimiento terrible, asociado a que los paquetes son muy pequeños.
Este enfoque se puede ver con bastante frecuencia en servicios simples o en prototipos. Si la velocidad de los cambios es importante, difícilmente vale la pena complicar el sistema. Al mismo tiempo, para nuestro servicio muy simple, el rendimiento resulta ser horrible, por lo que el ámbito de aplicabilidad de este enfoque es muy limitado.
Procesamiento paralelo ingenuo
Se puede iniciar el procesamiento de todos los mensajes en paralelo, lo que permitirá evitar el crecimiento lineal del tiempo en función del número de mensajes. Este no es un camino especialmente bueno, ya que conducirá a una gran carga máxima en el servicio externo.
Implementar el procesamiento paralelo es muy sencillo:
override fun getLast(n: Int) =
messageRepository.findLast(n).parallelStream()
.map { it.toFrontModel() }
.collect(toList())
Usando el procesamiento paralelo de mensajes, obtendremos 300–700 ms en ideal, lo cual es mucho mejor que con la implementación naive, pero aún así no es lo suficientemente rápido.
Con este enfoque, las solicitudes a userRepository y fileRepository se ejecutarán de manera sincrónica, lo cual no es muy eficiente. Para corregir esto, será necesario cambiar bastante la lógica de las llamadas. Por ejemplo, a través de CompletionStage (también conocido como CompletableFuture):
private fun MensajeDeChat.toFrontModel(): Interfaz de mensaje de chat =
CompletableFuture.supplyAsync {
userRepository.getUserById(author).toFrontReference()
}.thenCombine(
files?.let {
CompletableFuture.supplyAsync {
fileRepository.getHeadsByIds(files).map { it.toFrontReference() }
}
} ?: CompletableFuture.completedFuture(listOf())
) { author, files ->
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, que al principio era sencillo, se vuelve menos claro. Esto se debe a que tuvimos que separar las llamadas a servicios externos del lugar donde se utilizan los resultados. En sí mismo, esto no es malo. Pero la combinación de llamadas no se ve particularmente elegante y recuerda a una 'pasta' reactiva típica.
Si utilizamos corutinas, todo se verá mucho mejor:
private fun MensajeDeChat.toFrontModel(): Interfaz de mensaje de chat =
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 <A, B> join(a: () -> A, b: () -> B) =
runBlocking(IO) {
awaitAll(async { a() }, async { b() })
}.let {
it[0] as A a it[1] as B
}
Teóricamente, usando este procesamiento paralelo, obtendremos 200–400 ms, lo cual ya está cerca de nuestras expectativas.
Desafortunadamente, tal paralelización no ocurre tan fácilmente y el costo es bastante alto: con el trabajo simultáneo de solo unos pocos usuarios, los servicios serán bombardeados con una avalancha de solicitudes, que aún no se procesarán de manera paralela, así que volveremos a nuestros tristes 4 s.
Mi resultado al utilizar este servicio es de 1300–1700 ms para procesar 20 mensajes. Esto es más rápido que en la primera implementación, pero aún así no resuelve el problema.
Aplicación alternativa de solicitudes paralelas¿Qué pasa si los servicios externos no ofrecen el procesamiento por lotes? Por ejemplo, se puede ocultar la falta de implementación del procesamiento por lotes dentro de los métodos de las interfaces:
interface ApiDeUsuarioRemoto {
fun obtenerUsuarioPorId(id: ReferenciaDeUsuario): UsuarioRemoto
fun obtenerUsuariosPorIds(id: Establecer<ReferenciaDeUsuario>: Establecer<UsuarioRemoto> =
id.parallelStream()
.map { getUserById(it) }.collect(toSet())
fun obtenerUsuariosPorIds(id: Lista<ReferenciaDeUsuario>: Lista<UsuarioRemoto> =
id.parallelStream()
.map { getUserById(it) }.collect(toList())
}
Esto tiene sentido si se tiene la esperanza de que aparezca el procesamiento por lotes en versiones futuras.
Pros:
- Fácil implementación del procesamiento paralelo por mensajes.
- Buena escalabilidad.
Desventajas:
- Necesidad de separar la obtención de datos de su procesamiento en el procesamiento paralelo de solicitudes a diferentes servicios.
- Mayor carga en los servicios externos.
Es evidente que los límites de aplicabilidad son aproximadamente los mismos que en el enfoque ingenuo. Utilizar el método de solicitudes paralelas tiene sentido si desea aumentar la productividad de su servicio varias veces a expensas de la explotación implacable de recursos ajenos. En nuestro ejemplo, la productividad aumentó 2.5 veces, pero esto es claramente insuficiente.
Cacheo
Se puede implementar un almacenamiento en caché al estilo de JPA para servicios externos, es decir, almacenar los objetos obtenidos durante la sesión para no tener que volver a obtenerlos (incluyendo al procesar en lotes). Se pueden crear esos cachés por uno mismo, se puede utilizar Spring con su @Cacheable, además de que siempre se puede usar un caché predefinido como EhCache de manera manual.
El problema general estará relacionado con el hecho de que los cachés solo son útiles si hay aciertos. En nuestro caso, es muy probable que haya aciertos en el campo author (supongamos, 50 %), mientras que no habrá aciertos en los archivos. Este enfoque dará alguna mejora, pero no cambiará drásticamente la productividad (y necesitamos un avance).
Los cachés intersessionales (largos) requieren una lógica de invalidación compleja. En general, cuanto más tarde llegue a tener que resolver problemas de productividad con cachés intersessionales, mejor.
Pros:
- Implementación de almacenamiento en caché sin modificar el código.
- Incremento de productividad varias veces (en algunos casos).
Desventajas:
- Posibilidad de disminución del rendimiento en caso de un uso incorrecto.
- Grandes sobrecargas de memoria, especialmente con cachés largos.
- Invalidación compleja, cuyos errores conducirán a problemas difíciles de reproducir en tiempo de ejecución.
Con mucha frecuencia, los cachés se utilizan solo para solucionar rápidamente problemas de diseño. Esto no significa que no deban usarse. Sin embargo, siempre se debe tener precaución y primero evaluar el aumento de rendimiento obtenido, antes de tomar una decisión.
En nuestro ejemplo, habrá un aumento de rendimiento de alrededor del 25 % gracias a los cachés. A pesar de ello, hay muchas desventajas asociadas a los cachés, por lo que no los consideraría aquí.
Resultados
Así que hemos revisado la implementación ingenua de un servicio que utiliza procesamiento por lotes y varias formas sencillas de acelerarlo.
La principal virtud de todos estos métodos es su simplicidad, de la que hay muchas consecuencias agradables.
Un problema común de estos métodos es el bajo rendimiento, relacionado principalmente con el tamaño de los paquetes. Por lo tanto, si estas soluciones no son adecuadas para ti, vale la pena considerar métodos más radicales.
Hay dos enfoques principales donde se pueden buscar soluciones:
- trabajo asíncrono con datos (requiere un cambio de paradigma, por lo que no se explora en este artículo);
- aumento de los paquetes manteniendo el procesamiento sincrónico.
El aumento de los paquetes permitirá reducir significativamente la cantidad de llamadas externas y al mismo tiempo mantener el código sincrónico. Esta temática será abordada en la siguiente parte del artículo.
Fuente: habr.com
