Development

Benchmark de protocolos RPC

Realizamos dos pruebas separadas para comparar prolos RPC y encontrar la solución ideal para nuestra arquitectura de microservicio.

January 26 2016

La plataforma de Infobip no es un solo gran monolito. En un ejemplo típico de arquitectura de microservicio, consiste en una multitud de servicios pequeños que representan diferentes partes de la plataforma. Cada servicio tiene un propósito único y bien definido, y solo se ocupa de hacer su trabajo de la mejor manera posible.

Tener un gran conjunto de servicios pequeños facilita el manejo de la plataforma en su totalidad. Cualquier servicio puede actualizarse en cualquier momento sin perturbar a los demás. Los servicios pueden estar escritos (de hecho, lo están) en diferentes lenguajes de programación, según cuál se adecúe mejor al problema que está intentando resolver. Las partes de la plataforma que manejan grandes cantidades de tráfico se pueden escalar independientemente de aquellas con una carga liviana. Esas son las ventajas de una arquitectura de microservicios, que hace que desarrollar la plataforma de Infobip se convierta en una gran experiencia.

Pero ningún servicio vive en el vacío. Ser responsable de solo una pequeña parte significa que los servicios que trabajan en conjunto necesitan un medio de comunicación. Por ejemplo, lanzar una campaña de SMS en nuestro Infobip Portal estará a cargo del servicio destinado a las campañas. Si una campaña incluye contactos o grupos del cliente, estos serán recuperados desde un servicio diferente que está a cargo del manejo de contactos. Y además, hay otro servicio que está a cargo de programar los mensajes, y otros para direccionar, facturar y entregar mensajes a los suscriptores móviles.

Elecciones, elecciones

Nuestos servicios se comunican entre ellos a través de diferentes protocolos de llamada a procedimiento remoto (RPC). Se probaron varios de estos protocolos durante estos años, pero solo quedan tres en uso en este momento.

  • El viejo amigo HTTP/1.1. Existen múltiples implementaciones de cliente y servidor en prácticamente cualquier lenguaje de programación. Esto lo convierte en una buena opción para nuestra plataforma políglota. Generalmente, enviamos JSON planos a este protocolo. Esto simplifica la eliminación de fallos de los servicios, ya que solo necesitamos un navegador web o un cliente REST.
  • El segundo protocolo también es HTTP, pero esta vez enviamos objetos serializados Java de un lado a otro. Este protocolo era la opción predeterminada cuando dos servicios escritos en lenguaje de programación Java se estaban comunicando porque pueden leer y escribir este tipo de datos de manera nativa.
  • MML, las siglas de “lenguaje de máquina a máquina”, es nuestro procotolo RPC propio diseñado para obtener gran rendimiento. Tiene similitudes con el HTTP/2, que todavía no existía cuando comenzamos a trabajar en MML. Utiliza una conexión TCP persistente entre el cliente y el servidor, y permite a los clientes cargar de manera balanceada los servidores y generar tolerancia frente a fallos cuando sea necesario. MML utiliza typed JSON como carga. Se parece al JSON común pero cada objeto está catalogado con el tipo java. Debido a ello, MML solo se usa entre servicios Java.

Contar con tres maneras diferentes de comunicarse hace que elegir la correcta sea un poco complicado. Cada protocolo tiene sus propias ventajas y desventajas en lo referido a rendimiento, eliminación de fallos e interoperabilidad con servicios que no sean Java. Se da por sentado que MML es el mejor en rendimiento, pero está limitado a comunicaciones entre dos servicios Java. Cuando participan otros lenguajes de programación, nos queda una única opción: JSON plano sobre HTTP.

Contar con tres soluciones para un mismo problema está lejos de ser lo ideal. Cualquier esfuerzo para mejorar la comunicación entre los servicios se debe realizar tres veces para cada protocolo, y cada uno tiene diferentes características que generan un desafío a la hora de implementar las mismas características en cada uno de ellos. Lo ideal sería tener solo un protocolo, que sea eficiente en cuanto a rendimiento, que facilite la corrección de problemas y que pueda ser utilizado desde diferentes lenguajes de programación. Quedarse con el pan y con la torta.

Necesitábamos una manera de comparar diversos parámetros para ver cuál es "el mejor" y en qué situación. Elegimos el rendimiento como nuestra principal prioridad. Siempre podemos construir herramientas para protocolos de eliminación de fallos y escribir nuestra propia implementación si no hay una para un lenguaje específico. Pero si un protocolo es fundamentalmente lento en cuanto a rendimiento, no hay nada que se pueda hacer.

También necesitábamos un benchmark. Y es fácil escribir uno que pueda hacer varias llamadas en bucle y medir el rendimiento. Y es incluso más fácil cometer errores con estos benchmarks, así que no estamos interesados en las mediciones ni en los resultados, que pueden ser engañosos e inútiles.

Escribir un (buen) benchmark

Dos de estos tres protoclos se limitan al lenguaje de programación Java, así que tiene sentido escribir el benchmark completo en Java. Pero es difícil escribir benchmarks que se ejecuten en una máquina virtual Java (JVM) sin que causen problemas. Hay muchísimos detalles que tener en cuenta:

  • Se necesita tiempo para que la JVM perfile y optimice el código generado. No tiene sentido medir antes de que la JVM finalice con las optimizaciones.
  • La JVM va a perfilar y a optimizar el código para el patrón de uso real. Si comparamos dos protocolos uno después del otro, puede que el segundo obtenga peores resultados porque la JVM ya especializó el código del primero. Para evitar esto, cada benchmark debe ejecutarse en un proceso nuevo y limpio.
  • En caso de benchmarks de multitareas, se debe tener especial cuidado para que cada tarea comience y finalice la medición al mismo tiempo.
  • Y eso no es ni siquiera la mitad de los problemas potenciales. Por suerte, hay herramientas que pueden resolverlos por nosotros. Una de ellas es el Java Microbenchmark Harness (JMH). Como fue escrito por desarrolladores de JVM, podemos asumir que saben lo que hacen.

Escribir benchmarks con JMH es fácil. Solo necesitamos escribir el código que queramos medir y el JMH se hará cargo de todo lo demás: ejecutar el código en bucle para medir el rendimiento, separar los procesos uno por uno para cada benchmark, darle a la JVM suficiente tiempo para "entrar en calor" y optimizar el código de manera apropiada, y otros detalles tediosos (pero importantes).

El esqueleto de nuestro benchmark se verá así:

public class ProtocolBenchmark {
 
    @Param({"http_json", "http_java", "mml"})
    private String protocol;
 
    @Param({"0", "10", "100"})
    private int payloadSize;
 
    @Setup
    public void setup() {
        // Spin-up the server and the clients
    }
 
    @Benchmark
    @Threads(1)
    public Object threads_1() {
        // Perform a RPC call
        // This will be invoked from one thread (client) only
    }
 
    @Benchmark
    @Threads(32)
    public Object threads_32() {
        // Perform a RPC call
        // This will be concurrently invoked from 32 threads (clients)
    }

}

El JHM es bastante flexible y se puede adaptar a varios escenarios. Las opciones que fueron más útiles para nuestro benchmark fueron los parámetros y la cantidad de hilos. Los parámetros nos permiten reutilizar el mismo benchmark para diferentes escenarios. No hay diferencia en la lógica del benchmark cuando se miden diferentes protocolos o se usan diferentes cargas de distintos tamaños, así que con los parámetros podemos evitar copiar y pegar el mismo código. Y al variar la cantidad de hilos que se ejecutan simultáneamente, podemos medir cómo los protocolos logran manejar un número creciente de clientes simultáneos.

Funcionamiento del benchmark

¡Es hora de hacer nuestro pequeño benchmark! En realidad, realizamos dos pruebas separadas en condiciones diferentes.

Durante la primera prueba, el servidor y los clientes estaban ejecutándose en la misma máquina y se comunicaban en una interfaz localhost. Utilizamos este escenario para medir la carga pura del protocolo cuando no hay factores externos que interfieran con las mediciones.

En la segunda prueba, sometimos a nuestros protocolos a condiciones más realistas. Ejecutamos el servidor en una máquina diferente de la de los clientes, y se comunicaron a través de una red real. Y, en el caso de los protocolos HTTP, todo el tráfico se direccionó a través de HAProxy, ya que así es como en realidad lo utilizamos en un ambiente de producción. Como MML es compatible con el balance de carga del lado del cliente, un cliente puede contectarse directamente al servidor sin necesidad de un intermediario.

Hicimos que el lado del cliente de la comparación sincronizara una cierta cantidad de clientes, y que cada cliente realizara varias veces una llamada remota con la carga de un cierto tamaño y esperara que el servidor hiciera eco de la misma carga. La carga era una simple lista de objetos con un tamaño igual a la cantidad de objetos en la lista.

La cantidad de clientes y el tamaño de la carga fueron parametrizados para cubrir los patrones de comunicación típicos de nuestros servicios. Medimos con 1, 32, 64 y 128 clientes simultáneos y con tamaños de carga que variaban entre 0 (lista vacía), 10 y 100 elementos.

Resultados

Luego de una prueba completa, el JHM recolectó datos tabulares para cada combinación de ciertos parámetros. Visualizamos esos resultados en simples gráficos de barras para que la comparación fuera más sencilla.

En primer lugar, cuando medimos la comunicación con un locahost, obtuvimos algo así:

Engineering

Para cargas pequeñas y medianas, el protocolo MML es el ganador obvio sin importar la cantidad de clientes simultáneos. Pero eso cambió cuando optamos por tamaños grandes de carga. En ese caso, los protocolos basados en HTTP igualaron al MML en cuanto a rendimiento. Otra observación interesante es que utilizar objetos serializados Java es, de hecho, más lento que usar JSON. Parece que Jackson, la librería de JSON que estamos utilizando, está más optimizada para nuestro caso de uso.

En la segunda prueba con una red real, los resultados fueron los siguientes:

Engineering

MML sigue liderando en los tamaños de carga pequeños y medianos, pero con un margen menor. Y para cargas grandes, la tabla se invirtió. Transferir JSON plano sobre HTTP tiene, de hecho, un mejor rendimiento que MML por un margen significante. Typed JSON es, probablemente, la causa de este resultado. Todas estas etiquetas de tipo que agrega aumentan notablemente la cantidad de bytes que deben enviarse por el cable, lo que causa que el rendimiento sufra.

Conclusión

¿Qué hemos aprendido de este experimento? Por un lado, nuestro protocolo propio, aunque es excelente para cargas pequeñas y medianas, no es tan buena opción cuando los servicios que enviamos son solicitudes y respuestas grandes. Por otro lado, utilizar JSON sobre HTTP tiene un rendimiento decente y puede ser utilizado por servicios escritos en cualquier lenguaje, lo cual es, definitivamente, una ventaja. Por último, utilizar serialización de Java sobre HTTP fue inferior en todos los escenarios de prueba, así que, definitivamente, este protocolo es el que vamos a quitar.

A fin de cuentas, seguimos sin un ganador claro. Nos gustaría investigar sobre la optimización de JSON sobre HTTP - o quizás crear un prototipo de solución basada en HTTP/2 y ver qué sucede. Todavía hay mucho trabajo para hacer. ¡Quédense en línea!

By Hrvoje Ban, Software Engineer