Cómo mejorar el rendimiento de nuestras aplicaciones

Pool de conexiones http (Elixir, Go y Java)

John Mauricio Carmona Escobar
Bancolombia Tech
12 min readOct 28, 2022

--

En el siguiente artículo revisaremos cómo podemos mejorar el rendimiento de nuestras aplicaciones a partir de optimizar uno de los puntos importantes dentro del desarrollo de software, como lo es la comunicación con servicios externos.

Es común encontrarnos casos de uso donde requerimos comunicarnos con diferentes servicios mediante distintos protocolos. Uno de los más usados es HTTP donde, por ejemplo, podemos apoyarnos de dicho protocolo para comunicarnos con una aplicación financiera o hasta algo simple cómo la consulta de información.

Veremos a continuación algunos de los aspectos que debemos tener en cuenta al momento de realizar dicha comunicación, con el fin de sacar el mayor provecho de los recursos y obtener un mejor rendimiento.

Antes de iniciar veamos un poco cómo funciona HTTP/HTTPS y las principales diferencias de su versiones 1 y 2.

La siguiente imagen nos dará claridad desde el entendimiento de lo que involucra una petición HTTP y los diferentes bloques que la componen.

(https://blog.bytebytego.com/)

En la anterior imagen podemos ver a grandes rasgos los siguientes pasos:

  1. Negociación TCP (handshake),
  2. Verificación de certificado
  3. Intercambio de llaves.
  4. Transmisión de datos

Puedes ampliar más información de cómo funciona HTTPS aquí.

HTTP/1 y HTTP/2

Una de las principales diferencias que hay entre la versión 1 y 2 del protocolo HTTP es que en HTTP/2 las conexiones pueden ser inmediatamente reusadas pero en HTTP/1 la petición debe ser completada antes de que la conexión pueda ser reusada.

Por otro lado, HTTP/2 nos permite el uso de protocolo binario en la transmisión de información.

A continuación iremos explorando cómo podemos hacer uso de dichos protocolos en diferentes lenguajes, revisando cada una de sus particularidades y encontrando similitudes que nos permitan ayudar a entender mejor.

Las pruebas a continuación serán ejecutadas en ambientes aislados usando instancias c5.large. Puedes consultar el código usado en el siguiente link.

Elixir

Para Elixir no escribiremos desde cero nuestra conexión, por el contrario usaremos Mint cómo cliente HTTP.

Mint se basa en una estructura de datos funcional e inmutable que representa una conexión HTTP.

Al usar Mint tendríamos un código cómo este:

(Ver código completo)

Podemos resumir el código anterior en los siguientes pasos:

  1. Iniciar la conexión
  2. Realizar petición
  3. Manejar la respuesta
  4. Cerrar la conexión

Note como en el anterior código no hacemos uso de un pool de conexiones, por el contrario cada petición crea su respectiva conexión.

Resultados

Las gráficas a continuación nos mostrarán el resultado de las transacciones por segundo (tps) soportadas por el servicio durante 5 minutos de carga (Más alto mejor).

Primero veamos cómo se comporta nuestra aplicación al conectarse con un servicio por http (no https), donde podríamos resumir que solo se realizará la Negociación TCP y la transmisión de datos sin cifrar.

Latencia de 10 ms
Latencia de 100 ms

En las gráficas anteriores observamos como la latencia del servicio no afecta los resultados. En promedio unas casi 3.000 transacciones por segundo. Esto se debe a que cada petición es independiente y no tiene que interactuar dentro de un pool de conexiones.

Pero qué pasa si la conexión debe ser por HTTPS, y por lo tanto ejecutar pasos adicionales como la verificación de certificado, intercambio de llaves y cifrado de información.

Latencia de 100 ms
Latencia de 1.000 ms

Igual que en el caso anterior la latencia del servicio no afecta el comportamiento, pero por otro lado el rendimiento baja considerablemente a en promedio 300 transacciones por segundo (casi 10 veces menos que con http).

Realmente los temas relacionados con la seguridad no son negociables y, aunque a veces estamos en la misma red privada que el servicio que consumimos y podríamos usar http, esto no aplica en la mayoría de casos.

Pero entonces ¿Cómo podemos mejorar nuestro rendimiento si debemos comunicarnos por HTTPS?

Pool de conexiones

Un pool de conexiones es un conjunto limitado de conexiones ,de forma tal que dichas conexiones pueden ser reutilizadas y no tener que ser creadas cada vez que sean requeridas.

Un pool de conexiones http nos ayuda a mejorar el rendimiento de nuestras aplicaciones ya que las conexiones son rehusadas, evitándonos en cada ocasión tener que realizar la negociación TCP, la verificación de certificado, intercambio de llaves y solo centrarnos en la transmisión de datos.

Ahora realicemos las mismas pruebas pero esta vez usando un pool de conexiones y veamos qué ocurre.

Para estos escenarios de uso de Pool de conexiones nos apoyaremos en Finch.

Finch es un cliente HTTP centrado en el rendimiento y construido sobre Mint y Nimble Pool. Nos proporciona estrategias eficientes de agrupación de conexiones evitando la copia siempre que sea posible.

Su forma de uso es bastante sencilla. Debemos iniciar Finch dentro del árbol de supervisión de nuestra aplicación asignándole un nombre; para nuestro ejemplo HttpFinch.

(Configuración adicional para Finch)

Finch nos permite además manejar diferentes configuraciones

Y ahora podemos usarlo fácilmente a través del nombre que asignamos anteriormente.

El tipo de conexión de Finch por defecto es HTTP/1

Ejecutemos nuevamente nuestras pruebas y veamos los nuevos resultados

Pool de 100 conexiones y latencia de 10 ms.

En la gráfica anterior vemos cómo usando un pool de 100 conexiones logramos un aumento del rendimiento de casi 10 veces, pasando de 400 a 4.000 transacciones por segundo sobre HTTPS.

Lastimosamente no todo es tan sencillo cómo crear un pool con un número de conexiones fijo, hay otras cosas que debemos tener presentes.

Pero… ¿y si la latencia aumenta?

Aumentemos la latencia de 10 ms a 100 ms y veamos qué ocurre

Latencia 100 ms

Bueno, seguimos teniendo un rendimiento mejor al que veíamos al no usar un pool de conexiones sobre HTTPS, y si tenemos latencias inferiores a 100 ms estaríamos ganando un mayor rendimiento.

Continuemos aumentando la latencia:

Latencia 500ms

Y ahora un poco más de latencia:

1.000 ms

Hasta aquí vemos cómo realmente el comportamiento se vuelve fácil de predecir cuando tenemos aumento de latencia.

Si tenemos un pool de N conexiones (N = 100), y cada conexión tarda en liberarse X tiempo (X = 1 segundo), tendremos N transacciones en X tiempo (100 transacciones por segundo).

Otro ejemplo: Si la petición tarda 500 ms (½ segundo), con un pool de 100 conexiones tendremos un rendimiento de 200 transacciones por segundo, y así sucesivamente.

Ahora aumentemos el número de conexiones y veamos qué ocurre:

Pool de 200 conexiones
Pool de 500 conexiones
Pool de 1.000 conexiones
Pool de 2.000 conexiones

En este punto podríamos concluir que, si tenemos latencias bajas usando un pool de conexiones logramos aumentar nuestro rendimiento. Además, después de un cierto número de conexiones dentro del pool vemos cómo no ganamos rendimiento en bajas latencias, pero si las latencias aumentan tendremos un mayor número de conexiones para atender.

Aumentar nuestro pool nos puede incurrir en aumentos de memoria y uso de más recursos y si tenemos un número muy alto de conexiones dentro del pool podríamos afectar otras partes de nuestra aplicación.

HTTP/2

Cómo mencionábamos anteriormente en HTTP/2 las conexiones pueden ser inmediatamente reusadas (multiplexación) pero para usar dicho tipo de conexión debemos especificarlo de forma explícita a Flinch por medio del atributo ‘protocol’

Ejecutemos nuevamente nuestras pruebas

Aquí debemos tener en cuenta que si el servicio que estamos consumiendo no soporta HTTP/2 obtendremos un error.

Una de las ventajas de usar HTTP/2 en Elixir es que nos podemos despreocupar de la latencia del servicio que estamos consumiendo obteniendo un rendimiento considerablemente bueno.

Solución de errores HTTP/2 con Finch

Si tenemos una alta concurrencia puede ser que nos encontremos con el siguiente error:

Puede solucionar dicho error aumentando el atributo count:

Hasta aquí la información es clara, pero ¿esto ocurre sólo en Elixir?

Antes de pasar a las conclusiones veamos que ocurre en otros stacks tecnológicos.

Java reactivo (Webflux)

El cliente web reactivo para webfux ‘WebClient’ por defecto nos entrega un pool de conexiones configurado. Veremos un poco más adelante estos valores por defecto y cómo podemos cambiar su configuración.

Seguiremos la misma ruta de pruebas que ejecutamos en Elixir con el fin de tener una estructura de resultados similar.

Como mencionamos hace un momento, ‘WebClient’ viene por defecto con un pool de conexiones donde, para probar escenarios de no uso de pool de conexiones y por cada petición crear una nueva conexión, debemos de apoyarnos en ‘ConnectionProvider.newConnection()’ de la siguiente forma:

Ahora estamos listos para ejecutar el primer escenario donde nos conectaremos a un servicio http sin hacer uso del pool de conexiones.

Y ahora al mismo servicio sobre HTTPS:

Hagamos un paréntesis aquí para ver que los resultados tienen un comportamiento igual que los resultados que veíamos en Elixir.

Ahora hagamos uso del pool de conexiones:

Pool de 100 conexiones
Pool de 500 conexiones
Pool de 1.000 conexiones

Continuamos observando un comportamiento similar al visto en Elixir.

HTTP/2

Para hacer usos de HTTP2 al momento de crear nuestro HttpCliente debemos especificar dicho protocolo.

Cabe anotar que además podemos asignar ambos protocolos, y si en la etapa de negociación el servicio externo no soporta uno, el otro será usado.

En este momento no nos detendremos a hablar mucho de estos resultados, ya que, como vemos tenemos el mismo comportamiento que Elixir y por tanto las mismas conclusiones que veíamos anteriormente.

Pool de conexiones por defecto

Hace un momento decíamos que WebClient nos entrega un pool de conexiones por defecto. Veamos cuál es su configuración.

El tamaño del pool por defecto es de 500 conexiones y podemos comprobar esto en la clase TcpResources.java en la llamada del método ConnectionProvider.create

reactor.netty.tcp.TcpResources.java

Aquí es importante anotar algo, hay dos valores importantes a tener en cuenta en la configuración. El primero es ‘maxConnectionsy el segundo pendingAcquireMaxCount.

Cómo veíamos anteriormente maxConnections por defecto es de 500 y pendingAcquireMaxCount es por defecto (‘maxConnections’ * 2) = 1000.

pendingAcquireMaxCount: Es el número máximo de solicitudes registradas para adquirir para mantener en una cola pendiente. Cuando se invoca con -1, la cola pendiente no tendrá límite superior.

Lo anterior nos indica que podemos tener un total de 500 peticiones en ejecución y otras 1000 más esperando a ser atendidas. A partir de ese límite las peticiones serán rechazadas con el error PoolAcquirePendingLimitException.

El anterior problema puede ser solucionado aumentando el tamaño del valor de pendingAcquireMaxCount o asignando un -1 que indicará que no tendrá un límite.

Recuerde que en estos escenarios estará usando más memoria y lo que puede implicar el tener que tener una cola más grande de procesos en espera.

Go

Ahora revisaremos que pasa en Go Lang.

En Go nos apoyaremos del paquete net http de la librería estándar. El paquete http nos proporciona implementaciones de servidor y cliente HTTP.

En Go las cosas son un poco más sencillas ya que al usar el paquete http tendremos un pool de conexiones y además por defecto en la etapa de negociación si el servidor soporta HTTP/2, dicha versión será usada.

El código se vería algo más o menos así:

Ahora veamos los resultados:

HTTP/2

Go es uno de los lenguajes que nos ofrece un mejor rendimiento al trabajar con HTTP/2 y además facilidad de uso ya que el paquete http hace todo por nosotros.

HTTP/1

Cuando nuestro servicio externo no soporta HTTP/2 debemos tener en cuenta los siguientes valores que podemos configurar dentro del pool de conexiones.

MaxIdleConns: Controla la cantidad máxima de conexiones inactivas (keep-alive) en todos los hosts. Cero significa sin límite.

MaxConnsPerHost: Limita opcionalmente la cantidad total de conexiones por host, incluidas las conexiones en los estados de marcado, activo e inactivo. En caso de violación del límite, los diales se bloquearán. Cero significa sin límite.

MaxIdleConnsPerHost: Si no es cero, controla el máximo de conexiones inactivas (keep-alive) para mantener por host. Si es cero, se usa DefaultMaxIdleConnsPerHost. = 2

IdleConnTimeout: Es la cantidad máxima de tiempo que una conexión inactiva (keep-alive) permanecerá inactiva antes de cerrarse. Cero significa sin límite.

Con la siguiente configuración tendríamos el mismo comportamiento que en Elixir y Java reactivo, teniendo un pool de conexiones con un tamaño fijo.

Pool de 200 conexiones
Pool de 500 conexiones

En Go adicional podríamos hacer uso del pool de conexiones de una forma diferente, configurando un número de conexiones inactivas que pueden ser reusadas y adicionalmente no asignar un límite al número de conexiones por host de la siguiente forma:

(También podríamos configurar un valor alto en dicho atributo)

HTTPS/1 - MaxIdleConnsPerHost 100 y MaxConnsPerHost 0
HTTPS/1 - MaxIdleConnsPerHost500 y MaxConnsPerHost 0

Aquí debemos anotar que aunque la configuración anterior nos entrega un buen rendimiento, en entornos de bajas latencias con un pool con un número fijo de conexiones podríamos obtener un mejor rendimiento.

HTTPs/1 — MaxIdleConnsPerHost 500 y MaxConnsPerHost 500

Java spring boot (Tomcat)

Java en sus stacks bloqueantes es un poco diferente, en estos escenarios lo realmente importante no es tanto el pool de conexiones http que manejamos en nuestro cliente web; en este caso es más importante el número de threads que nuestro servidor tenga configurados y que representa el número máximo de subprocesos que se pueden ejecutar en un momento dado.

Para el caso de Tomcat (maxThreads) 200 es su valor por defecto, pero podemos modificarlos de la siguiente forma:

Realmente el tema no es tan sencillo cómo solo aumentar el valor de nuestro maxThreads, esto ya que dependerá de varios factores como por ejemplo los recursos que la máquina tenga y las operaciones que nuestros servicios deba realizar.

En los ejemplos a continuación estaremos haciendo uso de okhttp como cliente http quién se encargara de manejar las conexiones externas.

maxThreads = 200
maxThreads = 500

Pero veamos qué pasa si continuamos aumentando el número de conexiones (pool) soportadas por nuestro servidor Tomcat.

Latencia de 500 ms

Podemos ver como al aumentar el número de peticiones que podemos atender, nuestro rendimiento empieza a disminuir lo que además nos permite comprobar los principios planteados por la ley de escalabilidad.

Conclusiones

  • Una de las primeras cosas que debemos recordar es que, aunque los resultados anteriores nos entregan unos datos base, no existe una solución general y conocer las características que tienen sus servicios externos llevarán a una mejor solución.
  • Así como para la base de datos, usar un pool de conexiones nos entrega un mejor rendimiento pero la latencia del servicio externo es un factor importante a tener en cuenta y que puede cambiar la estrategia a utilizar.
  • Si el servicio con el que debe comunicarse ofrece latencias altas o un bajo rendimiento podría optar por estrategias asíncronas donde puede apoyarse de una arquitectura orientada a eventos.
  • Separar en diferentes pool de conexiones dependiendo del servicio que estemos consumiendo nos permitirá trabajar fácilmente con diferentes requerimientos.
  • Un buen monitoreo nos puede entregar información valiosa que nos ayude a configurar mejor nuestras aplicaciones.
  • Los escenarios descritos anteriormente describen escenarios de alta concurrencia. Si su solución no necesita soportar una gran cantidad de transacciones por segundo puede optar fácilmente por la solución más sencilla.

Gracias.

--

--