El desarrollo de servicios y la búsqueda de crear soluciones de forma sencilla y que tenga un gran desempeño son algunos de los objetivos a la hora de buscar un lenguaje de programación. Siempre hemos estado tratando de comparar las principales categorías que encontramos entre los lenguajes de programación: compilados e interpretados. Cada uno de estos enfoques tiene sus propias características y ventajas, y comprender las diferencias entre ellos es fundamental para los desarrolladores. Pero en el día de hoy vamos a indagar un poco más sobre los lenguajes compilados, ya que en knowmad mood la construcción de servicios con este tipo de lenguaje es muy común y es necesario conocer un poco más sobre el rendimiento que puede tener este tipo de soluciones compiladas desarrollada en diferentes lenguajes de programación.
Aunque existen varios lenguajes compilados, en el día de hoy nos centraremos en 3 de ellos: Java (Nativo), Golang y Rust. Con todo el “hype” de estos 2 últimos lenguajes y la posibilidad de crear aplicaciones nativas Java con GraalVM se ha decidido realizar un ejercicio donde se pueda observar el desempeño de cada solución implementada en cada lenguaje bajo las mismas condiciones.
Para dar un poco de contexto, primero es importante hablar de cada uno de ellos. Spring Native, que nace como solución para generar aplicaciones nativas Java desarrolladas con Spring Boot y compiladas a un artefacto nativo con GraalVM, es la opción escogida para crear el artefacto desarrollado en el lenguaje Java, con lo que a priori tendríamos mejores desempeños en el consumo de recursos e inicio de la aplicación. La segunda opción en nuestra comparativa es Golang o Go, que es un lenguaje de programación de opensource creado por Google. Diseñado para ser eficiente, seguro y fácil de usar, Go ofrece una sintaxis concisa y características poderosas, lo que lo convierte en una opción popular para el desarrollo de aplicaciones escalables y de alto rendimiento. La última, y no menos importante, se trata de Rust, lenguaje de programación moderno y seguro, diseñado para garantizar la seguridad y el rendimiento, Rust combina la concurrencia y el paralelismo con un control riguroso de la memoria, lo que lo hace ideal para aplicaciones de alto rendimiento y críticas en términos de seguridad.
Construcción Servicio
Características
Con el fin de equiparar los más posible y disminuir las variables que pueden involucrarse durante las pruebas, la funcionalidad implementada fue la misma para todos los lenguajes. Se implementó un API Rest que exponía servicios para las operaciones CRUD de un objeto de tipo Alerta, y que conectaba a una base de datos Mongodb para manejar la persistencia de los datos. La fuente de los datos es única para todos los servicios para descartar también este factor.
Se adjunta el API spefication para más detalles:
Construcción
En lo que concierne a la implementación del servicio en cada lenguaje, se puede decir que la dificultad depende directamente con la funcionalidad a implementar, experiencia del desarrollador y complejidad del lenguaje. Es importante recordar que el paso a paso o detalles de la implementación de cada servicio en los diferentes lenguajes no serán abordados en este capítulo ya que no es el objetivo de este, solo se darán algunos comentarios de la experiencia para este artículo.
En mi caso, mi experiencia me permite construir este tipo de funcionalidad de forma rápida en Java con Spring Boot. Para este ejercicio se utilizó Spring Boot 3.0 con las dependencias de starter web y Spring Native; GraalVM para la construcción del ejecutable nativo.
Para ver detalles de la implementación Java ir al siguiente enlace: Repositorio Java | Github.
La construcción del artefacto en Golang no fue complicada a pesar de no tener una experiencia previa en este lenguaje de programación, aunque es un lenguaje nuevo para mi la documentación encontrada en internet fue suficiente para implementar la funcionalidad sin mayor inconveniente. La configuración del entorno de desarrollo es sencilla y existen imágenes públicas para crear contenedores para desplegar este tipo de soluciones.
Para ver detalles de la implementación Go ir al siguiente enlace: Repositorio Go | Github.
En cuanto a Rust, para mí fue algo más complicado de implementar que los demás. La documentación no es muy amigable y tampoco es abundante. Encontrar un ejemplo tan sencillo como puede ser una API Rest que exponga un CRUD que persista en una base de datos mongodb no es sencillo. Los errores no soy específicos o claros, lo que dificulta encontrar una solución. La configuración del entorno de desarrollo es sencilla y existen imágenes públicas para crear contenedores para este tipo de soluciones.
Para ver detalles de la implementación Rust ir al siguiente enlace: Repositorio Rust | Github.
Entorno de pruebas
Para las pruebas de cada solución se utilizo un laptop con las siguientes características: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz y 32GB de RAM. Esta máquina cuenta con Docker Desktop para el manejo de contenedores y Ubuntu en WSL2 para ejecutar alguna utilidad. Para cada servicio se construyó una imagen Docker y se levantó un contenedor exponiendo el puerto respectivo de cada servicio.
Para la base de datos, se levantó un contenedor de la imagen Docker mongo:4.2 y se precargaron algunos datos de prueba a través de un script durante su inicialización. Aunque la base de datos era la misma para los 3 servicios, el acceso a esta base datos era exclusivo, esto quiere decir que para no afectar la prueba de rendimiento de cada servicio sus prueban se ejecutan en instante de tiempo diferentes y así no alterar los resultados por la capacidad del motor de base de datos.
Se utilizaron las herramientas de JMeter 5.5 y Apache Benchmark (ab) para ejecutar las pruebas de carga. Las pruebas con ab
fueron ejecutadas desde un WSL Ubuntu hacía el contenedor de Docker con el servicio a probar.
A continuación, se ilustra la disposición de los componentes en el Docker host:
Para seguir eliminando factores que puedan afectar la prueba cada contenedor es iniciado asignándole la misma cantidad de recursos, tanto de memoria como de CPU. La asignación de cores la mantendremos en 0.2
para el contenedor, en este caso indicar que el contenedor se ejecutará con el 20%
de la capacidad total de CPU.
Para cada contenedor se asignan los siguientes recursos:
- Memoria:
128 MB
- Cores:
0.2
Plan de carga
Se utilizaron las herramientas de JMeter 5.5 y Apache Benchmark (ab) para lograr escenarios de usuarios concurrentes manteniendo sesiones HTTP con el servidor y otras donde se terminen las conexiones una vez finalizada la petición. Los parámetros de configuración de los planes de carga de las 2 herramientas se escogen sin tener algún criterio base con estimaciones reales para el servicio Rest, solo se escoge una base donde los 3 servicios respondan correctamente o de forma optima y a partir de allí se realizan incrementos progresivo para analizar el comportamiento de errores, peticiones por segundos atendidas y consumos de recursos de los servicios.
JMeter
El plan de carga diseñado contiene dos solicitudes HTTP para probar el API de Alertas. Aquí está el detalle de cada solicitud:
-
Obtener todas los Alertas disponibles:
- Método HTTP:
GET
- URL:
http://localhost:4000/alerts
- Método HTTP:
-
Obtener una Alerta específica por su ID:
- Método HTTP:
GET
- URL:
http://localhost:4000/alerts/{id}
- Método HTTP:
Los valores utilizados durante las pruebas fueron los siguientes:
- Número de hilos: Define el número de usuarios que serán simulados para la prueba.
- Rump up (segundos): Define el tiempo en segundos durante el cual se distribuirán la ejecución de las llamadas
- Loop Count: Número de veces que se ejecutará cada hilo.
- Performance Target (segundos): Muestras por minuto objetivo en la prueba.
- Timeout: Tiempo de espera antes de dar las peticiones con error. (2 segundos para todos)
Se adjunta el plan de carga para más detalles:
AB
Para la ejecución de la herramienta se escogieron las siguientes opciones:
[c
] concurrency: Número de peticiones múltiples a ejecutar de forma concurrente.
[n
] requests: Número de peticiones a ejecutar durante la prueba.
[s
] timeout: Número máximo de segundos a esperar antes de arrojar un error de timeout.
La prueba no especificará que se mantenga abierta la conexión HTTP persistente (keep-alive
) durante la ejecución de las solicitudes.
Ejecución De Pruebas
Pruebas Rendimiento Servicio Java
▶ JMeter
Prueba 1
Parámetro | Valor |
---|---|
Número de hilos | 100 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 100 |
Prueba 2
Parámetro | Valor |
---|---|
Número de hilos | 500 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 200 |
Prueba 3
Parámetro | Valor |
---|---|
Número de hilos | 1000 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 500 |
En esta prueba ya el servicio empieza a reportar timeouts en casi el 70% de las peticiones. El consumo de memoria evidencia que llegó a su máximo asignado.
▶ AB
Prueba 1
Se empieza de forma agresiva con 1000
peticiones concurrentes hasta completar 10000
peticiones con un timeout para las peticiones de máximo 2
segundos. Después de realizar algunas pruebas, este es el número máximo de petición (117 aprox.
) por segundo que puede atender el servicio Java.
Parámetro | Valor |
---|---|
Concurrencia | 1000 |
Peticiones | 10000 |
Tiempo de espera | 2 segundos |
Prueba 2
En este punto el rendimiento del servicio empieza a verse afectado, bajando así la tasa de atención de peticiones por segundos:
Parámetro | Valor |
---|---|
Concurrencia | 1200 |
Peticiones | 5000 |
Tiempo de espera | 2 segundos |
Aquí vemos que el servicio empieza a degradarse bajando así la frecuencia de peticiones atendidas.
Prueba 3
Parámetro | Valor |
---|---|
Concurrencia | 1500 |
Peticiones | 5000 |
Tiempo de espera | 2 segundos |
En este punto la carga ha afectado al servicio totalmente degradando gravemente su desempeño.
Pruebas Rendimiento Servicio Go
▶ JMeter
Prueba 1
Parámetro | Valor |
---|---|
Número de hilos | 100 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 100 |
Prueba 2
Para esta segunda prueba vamos a empezar con valores de la prueba 3 donde Java empezó a tener problemas.
Parámetro | Valor |
---|---|
Número de hilos | 1000 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 500 |
Maneja carga y responde sin problemas.
Prueba 3
Parámetro | Valor |
---|---|
Número de hilos | 2500 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 100 |
En esta última prueba solo tuvo
1
fallo por timeout para esta configuración, recibiendo más de162
transacciones por segundo. El consumo de memoria no superó nunca el35%
y la CPU llegó a alcanzar un consumo del30%
.
▶ AB
Prueba 1
Empezaremos, como de costumbre, donde el servicio Java empezó a degradarse:
Parámetro | Valor |
---|---|
Concurrencia | 1200 |
Peticiones | 5000 |
Tiempo de espera | 2 segundos |
Aquí se observa que el tiempo de procesamiento y las peticiones por segundos atendidas aumentan notablemente en el servicio Go y no tiene ningún problema en resolverlas.
Prueba 2
Parámetro | Valor |
---|---|
Concurrencia | 1350 |
Peticiones | 5000 |
Tiempo de espera | 2 segundos |
Hemos encontrado su máximo valor de rendimiento de
650
peticiones por segundo y ya empieza a degradar su rendimiento.
Pruebas Rendimiento Servicio Rust
▶ JMeter
Prueba 1
Parámetro | Valor |
---|---|
Número de hilos | 100 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 100 |
Prueba 2
Para esta segunda prueba vamos a empezar con valores de la prueba 3 donde Java empezó a tener problemas.
Parámetro | Valor |
---|---|
Número de hilos | 1000 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 500 |
Maneja carga y responde sin problemas.
Prueba 3
Seguimos con la configuración donde el servicio GO empezó a tener problemas:
Parámetro | Valor |
---|---|
Número de hilos | 2500 |
Rump up (segundos) | 30 |
Loop Count | 1 |
Performance Target (segundos) | 100 |
Se observa que en este punto no pudo soportar la carga igual que GO, rechazando más del
80%
por ciento de las peticiones.
▶ AB
Prueba 1
Empezaremos, como de costumbre, donde el servicio Java empezó a degradarse:
Parámetro | Valor |
---|---|
Concurrencia | 1200 |
Peticiones | 5000 |
Tiempo de espera | 2 segundos |
Responde sin problema, pero no con el mismo rendimiento del líder hasta ahora Go, eso sí, con mucho menos consumo de memoria.
Prueba 2
Ejecutamos la misma prueba de Go cuando se empezó a notar que bajaba su rendimiento.
Parámetro | Valor |
---|---|
Concurrencia | 1350 |
Peticiones | 5000 |
Tiempo de espera | 2 segundos |
En este punto llama la atención que el servicio no se degradó tanto y el consumo de memoria sigue siendo controlado, aunque Go sigue teniendo el mejor rendimiento.
Prueba 3
Parámetro | Valor |
---|---|
Concurrencia | 2000 |
Peticiones | 5000 |
Tiempo de espera | 2 segundos |
El consumo de memoria se incrementa un poco, pero llama bastante la atención que su rendimiento se mantiene estable sin afectar el consumo de memoria significativamente frente a grandes picos de peticiones. Por cierto, en este punto la misma prueba se hizo con GO y bajó su rendimiento:
Análisis de Resultados
JMeter Consolidado
Servicio | No. Prueba | Número de Hilos | Transacciones por segundo | Tiempo de Respuesta Promedio | Observaciones |
---|---|---|---|---|---|
Java | 1 | 100 | 6.60 | 14.73 ms | - |
2 | 500 | 33.05 | 20.75 ms | Degradación en rendimiento | |
3 | 1000 | 58.85 | 1531.37 ms | Degradación en rendimiento-1200 Errors | |
Go | 1 | 100 | 6.72 | 15.71 ms | - |
2 | 1000 | 66.21 | 80.86 ms | - | |
3 | 2500 | 162.898 | 467.007 ms | - | |
Rust | 1 | 100 | 6.73 | 18.54 ms | - |
2 | 1000 | 66.59 | 31.17 ms | - | |
3 | 2500 | 147.03 | 1849.45 ms | Degradación en rendimiento |
AB Consolidado
Servicio | No. Prueba | Peticiones Concurrentes | Peticiones por segundo | Tiempo Prueba (segundos) | Observaciones |
---|---|---|---|---|---|
Java | 1 | 1000 -> 10000 | 116.63 | 85.74 | - |
2 | 1200 -> 5000 | 74.08 | 67.49 | Degradación en rendimiento | |
3 | 1500 -> 5000 | 98.81 | 50.60 | Degradación en rendimiento | |
Go | 1 | 1200 -> 5000 | 644.35 | 7.76 | - |
2 | 1350 -> 5000 | 537.82 | 9.3 | Degradación en rendimiento | |
3 | 2000 -> 5000 | 290.45 | 17.21 | Degradación en rendimiento | |
Rust | 1 | 1200 -> 5000 | 438.11 | 11.41 | - |
2 | 1350 -> 5000 | 427.35 | 11.70 | - | |
3 | 2000 -> 5000 | 426.46 | 11.72 | - |
Conclusiones
Antes de saltar directamente a las conclusiones, es fundamental resaltar que los resultados obtenidos por las implementaciones de cada solución no reflejan la realidad absoluta del lenguaje. Es crucial tener en cuenta que, para agilizar el proceso de construcción, se seleccionaron librerías y dependencias, o se realizaron implementaciones que podrían afectar el rendimiento del lenguaje y son variables que no podemos eliminar de los resultados y puede que tengan algún impacto.
En general, al analizar las pruebas realizadas con JMeter, se observa que los servicios desarrollados en Go han obtenido los mejores resultados en términos de rendimiento y tiempo de respuesta en comparación con los servicios en Java y Rust. Los servicios en Go exhibieron tiempos de respuesta promedio más bajos y una mayor tasa de transacciones por segundo en todas las pruebas. A su vez, el servicio Go se mantuvo estable y no mostró una degradación significativa en ninguna de las pruebas realizadas. Por otro lado, el servicio Java experimentó una degradación en su rendimiento en la tercera prueba con 1000 peticiones, mientras que el servicio Rust mostró una degradación en la tercera prueba con 5000 peticiones, pero esto ya es una prueba bien extrema no realizada en GO.
En cuanto a las pruebas en AB, tanto el servicio Go como el servicio Rust mostraron un rendimiento alto, resolviendo las peticiones con tiempos de respuesta rápidos y consistentes. Por otro lado, el servicio Java comenzó a experimentar una degradación en su rendimiento a partir de la segunda prueba con 1200 solicitudes concurrentes.
En resumen, el servicio desarrollado en Go se destacó como el que demostró el mejor rendimiento en todas las pruebas realizadas con ambas herramientas. No obstante, es esencial mencionar que el lenguaje Rust se distingue por su eficiencia en el consumo de recursos. Aunque su tasa de resolución de peticiones por segundo no fue tan elevada como la de Go, el consumo de memoria se mantuvo estable durante las pruebas, incluso durante picos de carga significativos.
Por último, como conclusión, observamos que Java, junto con su versión nativa, sigue siendo la solución tradicional y popular entre la competencia. Su soporte, frameworks y comunidad la hacen una solución fiable y muy válida. Sin embargo, si priorizamos la construcción de servicios con un alto procesamiento de solicitudes frente a altas cargas, Go es la opción más resalta del trío. Por otro lado, Rust se destaca por su capacidad de mantener un consumo de recursos estable en escenarios de alta demanda sin comprometer el rendimiento global de la solución y esto debido que es el que está a nivel más bajo o cercano a la máquina de los 3, esto facilita la gestión y la protección de la memoria. Go y Rust ya vienen pensados para trabajar de base con la concurrencia, e incluyen canales para la gestión de los hilos.
Con estas conclusiones, podemos apreciar cómo cada lenguaje de programación presenta ventajas y consideraciones específicas según los requerimientos del proyecto, brindando a los desarrolladores una variedad de opciones para elegir la mejor herramienta para sus necesidades.