El modelo reactivo y la antifragilidad
Contexto
El modelo reactivo surge como una respuesta frente a las limitaciones del modelo tradicional de concurrencia basado en hilos:
- Existe un estado mutable compartido.
- Pueden aparecer interbloqueos (deadlocks).
- Livelocks (similar a un deadlock, excepto que el estado de los dos procesos envueltos en el livelock constantemente cambia con respecto al otro, provocando inanición en ambos).
- Condiciones de carrera.
Características del modelo reactivo
El modelo reactivo es la respuesta a las necesidades de las nuevas aplicaciones. Necesitamos construir sistemas que:
- Reaccionen frente a eventos (event-driven).
- Reaccionen frente a la carga (elasticidad).
- Reaccionen frente a fallos (resiliencia).
- Reaccionen a los usuarios (capacidad de respuesta).
Event-driven
Los sistemas reactivos se basan en el paso de mensajes de forma asíncrona para establecer los límites entre componentes que aseguren un bajo acoplamiento entre los mismos, el aislamiento y la transparencia de la ubicación (location transparency).
Este límite también proporciona los medios para delegar fallos como mensajes.
Emplear el paso explícito de mensajes permite la gestión de la carga, la elasticidad y el control de flujo al configurar y supervisar las colas de mensajes en el sistema y aplicar mecanismos de contrapresión (back-pressure) cuando sea necesario.
Elasticidad
El sistema sigue siendo capaz de responder ante una carga de trabajo variable. Los sistemas reactivos reaccionan frente a los cambios en el volumen de datos de entrada, aumentando o disminuyendo los recursos asignados para atender las peticiones. Esto implica diseños que no tienen puntos de contención o cuellos de botella, dando como resultado la capacidad de fragmentar o replicar componentes y distribuir la carga entre ellos.
Resiliencia
El sistema permanece activo ante fallos. La resiliencia se logra mediante la replicación, contención, aislamiento y delegación. Los fallos están contenidos dentro de cada componente, aislando componentes entre sí y asegurando así que las diferentes partes del sistema pueden fallar y recuperarse sin comprometer el sistema como un todo.
Capacidad de respuesta
El sistema responde de manera oportuna si es posible.
La capacidad de respuesta es la piedra angular de la usabilidad, pero más que eso, significa que los problemas pueden detectarse rápidamente y tratarse con eficacia.
Los sistemas responsivos se enfocan en proporcionar tiempos de respuesta rápidos y consistentes, estableciendo límites superiores confiables para que ofrezcan una calidad de servicio consistente.
Antifragilidad
La noción de antifragilidad proviene de un libro de Nassim Nicholas Taleb titulado Antifragile. La antifragilidad es una propiedad de los sistemas, ya sea natural o artificial: un sistema es antifrágil si prospera y mejora cuando se enfrenta a errores. Taleb propone una definición amplia de "error": puede ser volatilidad (por ejemplo, para sistemas financieros), ataques y choques (por ejemplo, para sistemas inmunológicos), muerte (por ejemplo, para sistemas humanos), etc. Existen ya varios ensayos donde se analizan las relaciones entre los conceptos tradicionales de ingeniería de software y la antifragilidad. Por un lado, se relaciona la antifragilidad del software con la tolerancia a fallos clásica. Por otro, se puede ver el vínculo entre la antifragilidad y aspectos relacionados con la resiliencia en la programación reactiva.
Location transparency
Esta característica se torna esencial cuando se considera el trabajo en entornos distribuidos. Básicamente, consiste en que la plataforma reactiva proporcione los medios para que la comunicación entre diferentes componentes se realice de una forma que no requiera identificar la ubicación de cada uno de ellos, el runtime se ocupa de proporcionar los mecanismos necesarios que la comunicación se establezca de forma fiable y transparente para los diferentes intervinientes.
Control de latencia
El control de latencia se centra en la regulación del flujo de intercambio de mensajes entre nodos de una arquitectura distribuida.
Es la solución natural al problema del productor rápido – consumidor lento, y también a la inversa. Mediante el intercambio de primitivas, el consumidor solicita al productor el número de ítems que es capaz de consumir en cada momento, evitando saturaciones y errores.
Aislamiento
El aislamiento entre componentes tiene por objetivo evitar caídas en cadena. Si un componente falla, esto no tiene que derivar en una caída generalizada de todos los nodos.
Monitorización
Los errores en las aplicaciones actuales (complejas, distribuidas e interconectadas) no son la excepción: son el caso normal; no son predecibles ni son evitables.
El siguiente diagrama resume los puntos esenciales en los que se debe focalizar el diseño del sistema de monitorización para que cumpla las necesidades mínimas requeridas para este tipo de aplicaciones.
Patrones de Antifragilidad
Implementar sistemas antifrágiles es posible, si se emplean los mecanismos adecuados. En este caso, una colección de patrones.
Bulkheads
Los mamparos de un barco le permiten mantener la flotabilidad aun teniendo varias secciones inundadas. El patrón de bulkheads (mamparos) en el mundo del Software se traduce en mecanismos para mantener la estanqueidad de los diferentes componentes de un sistema, de forma que pueda seguir parcialmente operativo, aunque algunas de sus partes no lo hagan. Es un patrón clave en el aislamiento de los componentes de una arquitectura. También se conoce como unidades de fallo, o unidades de mitigación.
Verificación de los parámetros
Este patrón, que se resume en su propio enunciado, se basa en la conocida Ley de Postel: “Se conservador en lo que hagas, y liberal en lo que recibas”. Traducido al diseño de interfaces se puede interpretar como la recomendación de asegurar que una operación invocada recibe toda la información que necesita para realizar su función correctamente, e ignora aquella que no necesita. Es importante hacer un buen uso de los tipos de datos para garantizar que se reciben exactamente los valores que se necesitan. Es mucho mejor, por ejemplo, definir una interfaz que recibe valores del tipo Cliente, Producto y Precio, que de tipos genéricos como String o Double.
Relajar las restricciones temporales
En un entorno distribuido, mantener una consistencia estricta exige un fuerte acoplamiento de los componentes involucrados. Cualquier fallo individual puede comprometer la disponibilidad del conjunto del sistema. Si se relajan las restricciones de consistencia, es posible reducir el acoplamiento entre los componentes.
En muchos casos, las aplicaciones distribuidas se ajustan a un modelo BASE, en lugar del modelo ACID. En BASE se verifican las propiedades:
- Basic Availability: es decir, que la base de datos parece funcionar la mayor parte del tiempo.
- Soft-state: los almacenes de datos no tienen que ser consistentes en escritura, ni las diferentes réplicas de los mismos tienen que ser mutuamente consistentes todo el tiempo.
- Eventual consistency: los almacenes de datos muestran información consistente en algún momento, en tiempo de lectura, pero no instantáneamente.
Idempotencia
La ídempotencia define como la propiedad de una función de devolver siempre el mismo resultado frente a invocaciones repetidas. Matemáticamente hablando, se puede expresar como:
idempotente(f) ⬄ f(f(x)) = f(x)
Garantizar esta propiedad es fundamental para lograr el deseado desacoplamiento en sistemas distribuidos. La ya mencionada falta de fiabilidad de la red fácilmente puede conducir a la repetición de invocaciones a una operación. La mejor forma de evitar comportamientos erráticos y garantizar la robustez del conjunto es que las invocaciones repetidas no tengan efecto alguno.
Patrones para control de latencia: timeout, circuit breaker y fail-fast
El control de latencia podría considerarse un mundo aparte en materia de resiliencia. Como ya se ha indicado, se refiere al control del flujo de petición-respuesta entre dos sistemas o componentes.
Asegurar el control de este flujo requiere de mecanismos que ayuden a regular el comportamiento de los dos extremos de la comunicación cuando se produzcan divergencias fuertes en los tiempos de las diferentes etapas de la comunicación.
El patrón Timeout es el más sencillo. En cada intercambio de petición-respuesta se establece un tiempo máximo para la parte que debe esperar a la otra. Si no se obtiene el resultado en dicho tiempo, se da como fallida la comunicación y se lleva a cabo una acción alternativa.
El patrón Circuit breaker se ha popularizado mucho, especialmente debido a su uso en arquitecturas de microservicios. Este patrón básicamente funciona desde el lado del cliente. Si el servidor no está disponible, o tarda demasiado en responder, se activa el circuit breaker y genera una respuesta alternativa para el cliente. Existen varias posibilidades a la hora de implementarlo: que detecte de forma anticipada si el servidor no está disponible, que lo marque como no disponible sólo después de cierto número de peticiones fallidas, que reintente la comunicación pasado un tiempo…
Finalmente, el concepto de Fail-fast principalmente viene a decir que, en un sistema distribuido es mucho peor un nodo que tarda en responder correctamente, que uno que directamente responde con un error. Un nodo que opera correctamente, pero es muy lento es imposible de distinguir de uno que está caído (especialmente si salta antes el timeout). Si la lentitud en la respuesta se debe a que el nodo tiene problemas, entonces es preferible que falle directamente y provoque un rearranque o el disparo del circuit breaker.