Hace algún tiempo leí de la cuenta de Twitter de Bryan Hunter una frase (no se puede decir mucho más en Twitter) que aseguraba haber podido migrar un código hecho en C# a Erlang pasando de 30 mil líneas de código a tan solo mil líneas y ejecutarse 18 veces más rápido, ¿es esto escalabilidad?

El tuit en cuestión es el siguiente:

Bryan Hunter tuit

El texto viene a decir lo mismo que enuncié en el primer párrafo de este artículo. Un código migrado a Erlang consigue ser 30 veces más pequeño y 18 veces más rápido. Al igual que este caso, cuando Whatsapp fue vendido a Facebook, uno de los hechos que más atrajo de la empresa fue el bajo número de técnicos de que disponía la empresa, ¿cómo un equipo tan pequeño pudo haber desarrollado y mantenido ese producto?

La respuesta está en la escalabilidad del código. Hay lenguajes que permiten una escalabilidad de código significativa, permiten organizar el código de modo que es fácil de mantener: arreglar y evolucionar. Pero además, Erlang tiene también un sistema escalable, tanto de forma horizontal agregando más nodos al sistema, como de forma vertical pudiendo emplear al máximo los núcleos disponibles en la misma máquina donde se ejecuta el sistema.

¿Cómo se consigue esto? ¿Es único en Erlang o está disponible en otros lenguajes? Vamos por partes.

Escalabilidad en el código

He escrito últimamente mucho sobre calidad interna del código, he hablado de deuda técnica acumulada en proyectos e incluso hice un análisis de un código antiguo, cómo abordarlo y tratar de convertir ese código legado en un código con mayor calidad.

No obstante, queda otro tema bastante interesante a tratar y al que no se suele prestar mucha atención y es la escalabilidad de un código. Un código escalable es aquél que aún creciendo en un alto grado (de miles a decenas o cientos de miles de líneas de código) aún puede ser mantenido, arreglado y sigue evolucionando.

Organizar un código es la forma más rápida de hacer su escalabilidad posible, existen muchas buenas prácticas relacionadas con este aspecto y pero el enfoque que más me gusta es el de la claridad de Saša Jurić. Se basa en muchos aspectos pero principalmente en la separación de intereses de Dijkstra.

Esto puede parecer obvio pero resulta muy complejo cuando no tenemos en cuenta el dominio sobre el que se desarrolla la aplicación. Piensa por ejemplo en una aplicación web, en esa aplicación organizas el código para MVC y creas esa separación creando módulos para el modelo, otros para los controladores y otros para las vistas. Es una separación funcional. Pero si continuamos con esta separación y ahora creamos servicios, tipos de datos para mejorar nuestros esquemas y otros elementos y vamos creando unidades organizativas pensando en estos tipos en lugar de qué hace exactamente el código, podemos encontrarnos con unas cuantas decenas de modelos inconexos, un puñado de controladores mezclados y vistas separadas únicamente por tipo de presentación y no por función que realizan.

No digo que esta separación sea mala, en ciertos desarrollos es importante. Pero debemos mezclar esta organización con una organización por dominio. Conceptual al modelo computacional que queremos resolver. Si queremos una aplicación con acceso para usuarios, está bien crear un borde o frontera donde agregar las funciones a realizar principalmente por los usuarios y organizar modelos y funcionalidad en base a esta funcionalidad a implementar. Al generar esta separación y esa barrera de acceso, permitimos un nivel de aislamiento y separación que facilita modificar esa unidad conceptual sin romper nada mientras mantengamos la interfaz del API (Application Programming Interface, o Interfaz de Programación de Aplicación) sin modificaciones.

Sobre los lenguajes. Está claro que lenguajes que realicen sus actividades con la menor burocracia posible y el código se base en las mecánicas que el propio lenguaje proporciona de forma nativa o que permite implementar de forma fácil, conseguirá una cantidad de código menor que otros lenguajes donde la carga burocrática para crear una unidad nos requiera el empleo de un IDE con ayudantes para generar código.

Escalabilidad vertical

Cuando los procesadores comenzaron a reducir la velocidad de sus relojes y comenzaron a agregar núcleos el juego cambió completamente para muchas de las formas de programación hasta el momento. Muchos sistemas eran desarrollados con la esperanza de obtener un mejor rendimiento tiempo después con la llegada de un nuevo procesador más veloz. Sin embargo, en lugar de hacer los sistemas más rápidos comenzaron agregar núcleos haciendo de la programación paralela y concurrente una necesidad.

El problema era que ningún lenguaje de programación estuvo preparado. Mucho código se ejecutaba y corría en un solo núcleo sin aprovechar la existencia de otro núcleos y por tanto, con la reducción progresiva de los procesadores, llegamos a ver cómo aún ampliando la capacidad de la máquina, el software no conseguía obtener mejor rendimiento.

Obviamente en lenguajes como C o C++ la solución pasó por dejar el trabajo al programador. Crear procesos o hilos para distribuir el trabajo entre estos procesos o hilos con la idea de ejecutar cada uno de ellos en un núcleo diferente. Por lo que la complejidad de estos programas se incrementó.

Por otro lado, Erlang como lenguaje orientado a la concurrencia no tuvo muchos problemas para adaptar su código de modo que sus planificadores se ejecutasen cada uno en un núcleo diferente y asumiesen la ejecución de los procesos repartiendo la carga entre todos los núcleos disponibles. Escribiendo el mismo código, sin cambiar nada, tenemos acceso a todo el potencial de la máquina.

Otros lenguajes como Go, surgiendo en estos tiempos donde la ejecución multi-núcleo es un requisito básico, implementaron el concepto de corutinas e igualmente consiguieron obtener un buen uso de todo el potencial de las máquinas con muchos núcleos.

Lenguajes con más historia como Python, sin embargo, no son tan fáciles porque disponen de multi-hilo y multi-proceso y ambos se emplean en diferentes casos de uso. No obstante, la mayor parte del procesamiento de librerías como numpy, scipy y pytorch se hace en C y por tanto se implementan a más bajo nivel del que se trabaja normalmente con Python.

Escalabilidad horizontal

Una de las grandes problemáticas con la escalabilidad horizontal es la forma de distribuir el trabajo y la información. Si pensamos en una página web estática donde el servidor atiende peticiones de navegadores web y sirve la página HTML, ficheros CSS, JavaScript e imágenes almacenados en el sistema de ficheros, cabe pensar que si disponemos de un balanceador de carga, es muy fácil escalar horizontalmente este servicio. Un servidor sin estado (stateless) siempre es fácil de escalar horizontalmente.

Sin embargo, el desafío llega cuando esas peticiones van a un servidor programado con algún lenguaje de programación y realiza acciones concretas que requieren una toma de datos dinámica, modificar esos datos, crear o destruir una sesión de usuario o incluso realizar un trabajo en segundo plano. En estos casos podemos trasladar la responsabilidad de los datos a una base de datos (p.ej. PostgreSQL), el establecimiento de una sesión a un sistema de clave-valor o caché (p.ej. Redis) y la ejecución de tareas en segundo plano a un bróker de gestión de colas (p.ej. RabbitMQ).

Estos servidores con estado (statefull) son los más difíciles de escalar horizontalmente. Podemos echar un vistazo a Internet para buscar cómo escalar horizontalmente cada uno de estos elementos y encontraremos referencias a CAP o incluso a PACELC. Nos intentarán hacer ver la dificultad de replicar y mantener los datos replicados en diferentes localizaciones a modo de alta disponibilidad e incluso nos darán otras soluciones más elaboradas para mejorar el rendimiento.

Haciendo un resumen, podemos ver la forma de mantener los datos

  • Replicación uno a uno (o master-master), es un modo de replicación donde todos los servidores dentro del mismo clúster pueden leer y escribir. Cada nodo que recibe una escritura debe enviar la modificación a los datos al resto de servidores.
  • Replicación jerarquizada (o master-slave), en este caso los servidores se separan por rangos, normalmente solo dos rangos. El rango superior es el único con permiso para realizar modificaciones en los datos y realiza un envío de estas modificaciones a todos los demás, no se permite ejecutar peticiones de datos complejas en este rango. El rango inferior no tiene permitido realizar modificaciones salvo por las modificaciones solicitadas desde el rango superior y es donde se ejecutarán principalmente las consultas y sobre todo las consultas más pesadas.
  • Replicación distribuida, en este caso un balanceador de carga o una función de hash consistente indica el servidor dueño de los datos de donde se leerá y escribirá. Al mantener los datos fraccionados, la carga se distribuye entre todos los servidores existentes.

Es muy común encontrar en muchas bases de datos distribuidas como Cassandra o CockroachDB la replicación distribuida pero mezclada en esos trozos en los que se parte el conjunto global de datos con una replicación jerarquizada o incluso uno a uno.

Sin embargo, ninguna de las opciones es una panacea, todas tienen sus pros y sus contras y además, cada implementación presenta sus particularidades. Para poder implementar un modelo específico de replicación debes tener en cuenta los requisitos, lo que ganas y lo que perderás.

Otra opción es implementar un sistema de datos distribuido en lenguajes con implementaciones similares al Modelo Actor. En el Volumen II de Erlang/OTP hablo de cómo implementar servidores y máquinas de estado de modo que se minimice la necesidad de emplear fuentes de datos externas simplificando y pudiendo implementar el modelo requerido para cada parte del programa y manteniendo el almacenamiento de datos tan simple como sea posible.

Conclusiones

Escalar siempre es una tarea ardua. Lenguajes como Erlang vienen con mecánicas y buenas prácticas, además del framework OTP para permitir simplificar y poder emplear tanto la escalabilidad vertical como la escalabilidad horizontal sin mucho problema. Sin embargo, requiere que sepamos qué estamos haciendo, qué opciones tenemos disponibles y cómo podemos implementarlas para sacarles el máximo provecho y minimizar los contras.

¿Y tú? ¿Has tenido que pelearte con algún sistema para hacerlo escalar vertical u horizontalmente? ¿tuviste que cambiar de forma de implementar tu código, tu framework o tu lenguaje de programación debido a esto? ¡Déjanos tu comentario!

Comentarios

Hay 2 comentarios. Inicia sesión para enviar un comentario.

  • ¡Excelente articulo! Yo estoy en una aventura en usar Mnesia reemplazando Redis para alojar una estructura de datos de gran tamaño, donde debe replicarse en un cluster de escalado horizontal. Ya logre las dos etapas, pero el desafió es como se comportara en producción.
  • Gracias por el comentario Jorge. La verdad es que con Mnesia siempre tengo sentimientos encontrados. Tengo que escribir acerca de esa base de datos y hacer primero algunas pruebas porque me dijeron que no se comporta bien ante "net-splits" (cuando los nodos de mnesia siguen vivos pero no se ven entre ellos). Si los datos avanzan en ambos clusters, al parecer, no hay forma automática de recuperar esos datos. Ten cuidado y haz muchas pruebas.