Hystrix: primer contacto
En la conferencia de Software Guru de este año, Agustín Ramos dio una charla acerca de sistemas tolerantes a fallas, en la cual mencionó un software que me llamó mucho la atención, llamado Hystrix, desarrollado por Netflix.
La idea de Hystrix es que en sistemas que se comunican mucho con otros sistemas por medio de red, poder aislar todas esas llamadas a servicios externos y permitir que sean administradas de forma robusta, es decir, que haya un control de conexiones salientes, mantener buenos tiempos de respuesta, con tolerancia a fallas integrada.
Esto suena muy bien: si tengo un sistema que hace llamadas constantes a un web service externo, generalmente el funcionamiento de ese web service afecta el funcionamiento de mi sistema: Si de repente se pone lento, se tarda mucho en contestar, mi sistema empieza a sentirse lento, porque está esperando respuesta del sistema externo. Luego empiezan los problemas porque resulta que mi sistema encola las llamadas a dicho web service, precisamente para no saturarlo, pero pues está lento y eso está fuera de mi control pero resulta que las llamadas encoladas ya se tardan mucho tiempo en ejecutarse, es decir, la tardanza del web service no solamente se convierte en esperar respuesta del mismo, sino que hay llamadas que se quedan mucho tiempo encoladas y entonces puede que ya salgan muy tarde. ¿Y si hay un usuario en línea esperando la respuesta? ¿Y si el sistema ya le respondió error, incluso antes de que siquiera se realice la llamada al servicio externo?
Esto ya me pasó un par de veces, y la solución fue tomar el tiempo en que la llamada es encolada en el thread pool. Después, justo antes de realizar la llamada, hay que revisar cuánto tiempo estuvo encolada; si ya tiene un tiempo considerable, seguramente es porque el servicio que vamos a invocar está saturado y por lo tanto es muy alta la probabilidad de que se tarde en contestar esta llamada. Por lo tanto, lo mejor puede ser cancelarla, y devolver un error sin siquiera realizarla.
Esto no es realmente muy difícil de implementar, pero sí es bastante tedioso, engorroso y sobre todo es complicado probarlo: hay que simular el servicio externo, hacer que se tarde en contestar, y ver si realmente las llamadas encoladas se contestan con error sin realizarse.
Y esto es precisamente lo que hace Hystrix. Bueno, esto y más: puede administrar la llamada que vamos a realizar de modo que si se tarda mucho en contestar el servicio, interrumpe la llamada y devuelve un error. Esto puede ser útil en ciertos casos no tan útil en otros: Por ejemplo si se corta una llamada a un servicio externo para efectuar un pago, es mejor esperar un tiempo a cortarlo de inmediato, especialmente porque si no se recibe respuesta, hay que enviar un mensaje de anulación y ese tiene que ser reenviado hasta que se reciba una respuesta satisfactoria. Estos servicios generalmente tienen timeouts largos (30 segundos y a veces más).
Benchmark
Pero bueno. Estas cosas siempre suenan maravillosas, pero no es recomendable entrarle de lleno e integrar algo así a un sistema existente. Primero, una prueba con algo simple. Para ello hice un esquema cliente-servidor bastante sencillo: Un típico eco, un servidor que recibe una línea de texto, la devuelve y cierra la conexión.
Vamos a meter algunos casos especiales: si se recibe una cadena que empieza con A, el servidor esperará 2 segundos antes de contestar (para simular un sistema cargado y que responde lento). Si se recibe una cadena que empieza con B, el servidor no va a contestar nada; esto para simular un sistema completamente saturado, que no contesta a tiempo y se causa un timeout de lectura en el cliente.
Implementar esto no tiene mayor ciencia, de hecho es un simple script en Groovy:
Socket socket
void run() {
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.inputStream))
PrintStream out = new PrintStream(socket.outputStream)
String line = reader.readLine()
if (line.startsWith('A')) {
//retraso intencional
Thread.sleep(2000)
out.println("LATE ${line.reverse()}")
} else if (line.startsWith('B')) {
//timeout intencional
socket.inputStream.read()
} else {
out.println(line.reverse())
}
println "Closing ${socket.inetAddress}"
socket.close()
}
}
int port = (args[0]?:1234) as int
ServerSocket ss = new ServerSocket(port)
println "Listening on port ${port}"
while (true) {
Socket sock = ss.accept()
println "New Connection from ${sock.inetAddress}"
new Thread(new Conn(socket:sock)).start()
}
Ahora, lo primero para probar este tipo de cosas pues es tener un punto de comparación. Lo más simple es implementar un objeto que se conecte al server, envíe una línea de texto, espere respuesta y la guarde o devuelva. Un Callable
se presta muy bien para esto. Este sí lo hice en Java porque Groovy 2.1 todavía no soporta try-with-resources:
java.util.concurrent.Callable<String> {
private final String line;
private final String host;
private final int port;
public Client(String line, String host, int port) {
this.line = line;
this.host = host;
this.port = port;
}
public String call() throws Exception {
try (Socket sock = new Socket(host, port);
PrintStream pout = new PrintStream(sock.getOutputStream());
BufferedReader reader = new BufferedReader(
new InputStreamReader(sock.getInputStream()))) {
sock.setSoTimeout(3000);
pout.println(line);
return reader.readLine();
} finally {
}
}
}
Le puse un timeout de 3 segundos en la lectura, que es mayor que el retraso en el servidor (2 segundos), de modo que este timeout solamente ocurrirá cuando el servidor no contesta.
Como implementé un Callable, para realizar una conexión al server simplemente habría que hacer:
Pero, eso no puede ser invocado en un hilo porque los hilos aceptan Runnables, no Callables. Hacer un envoltorio es fácil:
Ahora sí, podemos preparar la prueba. Vamos a implementar 3 esquemas: primero, el de hilo por conexión, usando 100 hilos para ejecutar 100 llamadas simultáneas al servidor.
El segundo será un thread pool en el cual meteremos directamente 100 Callables; esto es un esquema más similar a lo que se hace (o se debería hacer) en sistemas reales de este tipo (conexiones host-to-host por medio de un web service).
El tercer esquema es el interesante: implementar esto con Hystrix. Hystrix ofrece una clase central, que es el HystrixCommand. Esta clase tiene un par de métodos que hay que implementar: uno para ejecutar la tarea y devolver un resultado, y otro para devolver un resultado en caso de falla. Es tan fácil como esto:
private final Client worker
public HystrixClient(Client c) {
super(HystrixCommandGroupKey.Factory.asKey("test"));
this.worker=c;
}
protected String run() throws Exception {
return worker.call();
}
protected String getFallback() {
return "ERROR";
}
}
Y eso es todo. Simplemente envolvemos al componente que hace todo el trabajo, en un comando de Hystrix. El comando puede ser ejecutado de varias maneras: síncrona, asíncrona, o reactiva.
Para llamada síncrona, simplemente se invoca el método execute
. Esto ejecutará internamente el método run
, y si hay algún problema (excepción, toma demasiado tiempo, etc) se devolverá en vez de eso el resultado del método getFallback
.
Para llamada asíncrona, se invoca el método queue
, que devuelve un Future
normal de Java.
Y para llamada reactiva, se invoca observe
, el cual devuelve un objeto de tipo Observable
(este tipo está definido en otra biblioteca de Netflix, llamada RxJava).
El código que hice para las pruebas fue sencillo. Para la prueba que usa un hilo por conexión:
100.times {
clients.add(new RunnableClient(
client:new Client(randomName(),
host, port)))
}
List<Thread> threads = clients.collect { new Thread(it) }
threads.each { it.start() }
while (threads.find { it.alive }) {
println "${100-threads.count{it.alive}} pending..."
threads.find { it.alive }?.join()
}
List<String> strings = clients.collect { it.result }
Para la prueba con un thread pool:
100.times {
clients.add(new Client(randomName(), host, port))
}
ExecutorService tpool = Executors.newFixedThreadPool(16)
List<Future<String>> results = clients.collect { tpool.submit(it) }
tpool.shutdown()
while (!tpool.terminated) {
println "${100-results.count{it.done}} pending..."
tpool.awaitTermination(1000,TimeUnit.MILLISECONDS)
}
List<String> strings = results.collect { it.get() }
Y finalmente para la prueba con Hystrix:
100.times {
clients.add(new HystrixClient(randomName(), host, port))
}
println "Invoking all clients..."
List<Future<String>> results = clients.collect { it.queue() }
while (results.find { !it.done }?.get()) {
println "${100-results.count{it.done}} pending..."
}
List<String> strings = results.collect { it.get() }
El código no varía mucho entre implementaciones, sobre todo entre la que usa thread pool y la que usa Hystrix, lo cual puede significar que integrar Hystrix a un proyecto existente que utiliza ese esquema para sus llamadas a servicios externos, puede ser relativamente sencillo.
Adicionalmente a esto, hice código para tomar el tiempo total de ejecución y presentar los resultados, pero no lo incluyo aquí porque no es tan relevante (incluye warmup, es decir, ejecutar una tarea normal y una de Hystrix primero, para activar el JIT y "calentar" la JVM).
Primero ejecuté esto con puras cadenas que NO empiecen con A ni con B, para que todo salga bien. Todo debe salir bien en los 3 esquemas, y estos fueron los resultados:
********** RUNNING Thread per connection ************* Thread per connection DONE in 92 millis 100 total 100 OK ********** RUNNING Thread pool ************* Thread pool DONE in 55 millis 100 total 100 OK ********** RUNNING Hystrix ************* Hystrix DONE in 41 millis 100 total 53 OK
Eso NO me lo esperaba... solamente 53 de las 100 peticiones se realizaron bien; sin embargo, el tiempo total de ejecución es el triple con Hystrix que con el thread pool (que, curiosamente, es menor que el tiempo del otro esquema usando 100 hilos, siendo un pool de 16 hilos).
Obviamente ejecuté la prueba varias veces. Los tiempos variaron un poco pero la diferencia entre thread pool y hystrix fue consistente: el triple de tiempo con Hystrix, y entre 50-55 operaciones exitosas, mientras que los otros dos esquemas resultaron en 100% exitosas.
Sin investigar más a fondo, sospecho que ese tiempo adicional en Hystrix es porque la primera invocación a mi subclase de HystrixCommand, causa que se inicialice la infraestructura de Hystrix (thread pools, circuit breakers, monitores, recabadores de estadísticas, etc).
Duh, es necesario hacer una ejecución previa de un comando Hystrix, para luego que se haga el benchmark ya se obtengan datos más fidedignos. Una vez que incluí el warmup de Hystrix, su tiempo bajó a ~50ms, las operaciones exitosas fluctuaron más, entre 50 y 90. Esto indica que seguramente hay parámetros en Hystrix, como los que mencioné muy al principio de tiempo que una tarea está encolada antes de ejecutarse, que probablemente tienen un default demasiado bajo.
Investigando un poco más encontré que efectivamente hay una manera de configurar varios parámetros de Hystrix, por distintos medios; el más sencillo por ahora es un archivo properties que debe estar en el classpath, llamado config.properties
. Estuve jugando con muchos de sus parámetros, pero al final descubrí que bastaba con mover los del thread pool de Hystrix para obtener consistentemente un 100% de llamadas exitosas:
hystrix.threadpool.default.queueSizeRejectionThreshold=100
hystrix.threadpool.default.maxQueueSize=100
No me gustó mucho tener que ajustar el tamaño de la cola del thread pool, pero pues fue la única manera que encontré de que siempre me diera 100%, al igual que los otros dos esquemas.
Segunda parte: sistema lento
Hasta ahora, todo bien. Hystrix no es algo transparente, pero tampoco se ve tan instrusivo como para que integrarlo a un proyecto existente vaya a ser una pesadilla. Lo único delicado es tener que afinar muy bien los parámetros para que funcione como uno espera.
Pero bueno, justamente la idea de Hystrix es lo de la tolerancia a fallas, por tanto, vamos a ver qué pasa cuando el sistema externo se porta lento. Para ello, empezamos a enviar algunos nombres que empiecen con A, para que haya un retraso en el sistema remoto. En los dos esquemas tradicionales, el resultado debe ser 100% de operaciones exitosas pero con un tiempo de ejecución mayor; en Hystrix, esas operaciones deben fallar, puesto que el timeout por default es de 1 segundo (sí, bastante primermundista. Esto demuestra que los de Netflix jamás han tenido que invocar un web service hecho al aventón para recarga de tiempo aire celular).
Y eso es exactamente lo que ocurre al ejecutar las pruebas. Dado que el número de cadenas empezando con A es aleatorio, los resultados pueden variar mucho entre ejecuciones, aunque modifiqué el código para que las tres pruebas utilicen exactamente las mismas 100 cadenas.
Y efectivamente el resultado es el esperado: los primeros dos esquemas se tardan más (alrededor de 2 a 4 segundos) pero no tienen fallas, mientras que Hystrix se tarda ~1050ms y procesa entre 60 y 90 operaciones. Nuevamente, se nota que estas operaciones no fueron enviadas nunca al servidor, porque del lado del servidor no aparecen errores. Y lo que es notorio es que mientras el tiempo de ejecución de los otros dos esquemas varía mucho aunque siempre es obtiene el mismo resultado, el tiempo de Hystrix es constante y lo que varía es el resultado.
Si subimos el timeout a Hystrix, ajustando parámetros en el archivo properties, entonces podemos ya obtener 100 operaciones igual que con los otros dos esquemas. Pero aquí ya podemos ver cómo en esta parte, Hystrix nos puede ayudar a que el mal funcionamiento del sistema externo no afecte el desempeño del nuestro.
Tercera parte: Sistema saturado
Finalmente, vamos a incluir nombres que empiecen con B, para ver qué ocurre en los tres esquemas. Esta vez, los tres deben tener errores, pero vamos a ver cómo se manejan y cuánto tiempo tardan. Lo esperado es que los dos esquemas tradicionales tengan exactamente el mismo número de errores, que será el número de cadenas enviadas que empiezan con B.
Pero Hystrix puede tener más errores aún, porque las peticiones en que el servidor no contesta, retrasan a las peticiones encoladas y por lo tanto ya puede haber algunas que rebasen el timeout y se cancelen sin haberse enviado.
Y eso es exactamente lo que pasa. Después de subir el timeout de Hystrix a 3 segundos y realizar varias ejecuciones, los resultados son de la siguiente manera:
********** RUNNING Thread per connection ************* 70 pending... 71 pending... Exception in thread "Thread-5" Exception in thread "Thread-10" java.net.SocketTimeoutException: Read timed out 88 pending... [excepciones y más excepciones, de los timeouts de lectura] Thread per connection DONE in 3053 millis 100 total 86 OK 14 errors ********** RUNNING Thread pool ************* Exception in thread "Thread-43" java.net.SocketTimeoutException: Read timed out [excepciones y más excepciones] 69 pending... 67 pending... 49 pending... 14 pending... 8 pending... 4 pending... Thread pool DONE in 6044 millis 100 total 86 OK 14 errors ********** RUNNING Hystrix ************* 66 pending... 48 pending... 40 pending... 32 pending... 10 pending... 9 pending... 3 pending... 2 pending... 1 pending... 0 pending... Hystrix DONE in 3051 millis 100 total 77 OK 23 errors
Los dos esquemas tradicionales tienen siempre el mismo número de errores, pero el tiempo total de ejecución puede variar mucho (en algunos casos supera los 6 segundos). Pero nuevamente, el tiempo en Hystrix es de 3 segundos (el timeout que definimos) y el número de errores a veces es el mismo que en los otros dos esquemas, pero a veces es mayor. Cuando es mayor, es porque algunas peticiones se cancelaron. Nuevamente, del lado del servidor no se ven errores.
Características avanzadas
Hystrix tiene características bastante sofisticadas, que ya salen del alcance de esta introducción:
- Cache de peticiones, bastante sencillo de manejar: las peticiones deben tener un identificador, la primera con dicho identificador se ejecuta y si llegan más con el mismo identificador, los resultados se obtienen de cache en vez de ejecutarse.
- Colapsar peticiones: Se pueden implementar comandos que reciben una lista de otros comandos para ejecutarlos todos juntos. Hystrix se encarga de reunir todos estos comandos para ejecutarlos agrupados; esto es útil porque puede determinarse ya a nivel aplicación si unos cancelan a otros, o si pueden realizarse menos llamadas (por ejemplo se agrupan tres pagos a una misma cuenta y se termina haciendo una sola invocación al servicio de pago, por el monto de las tres).
- Configuración dinámica, a través de la biblioteca Archaius
- Estadísticas y monitoreo. La recabación de estadísticas es algo ya integrado en Hystrix, y para poder verlas se puede tener acceso desde el mismo código, o bien desde una aplicación web que sirve a manera de tablero de mando, donde se pueden monitorear los thread pools, los distintos comandos, el estado de los eventos, tiempos de ejecución, etc así como activar los "circuit breakers" y modificar algunos parámetros de configuración.
Pero el simple hecho de que pueda aislar/proteger a una aplicación de las fallas de aplicaciones externas es bastante útil. Por esa sola razón vale la pena echarle un ojo y tenerlo muy cuenta para proyectos nuevos. El proyecto se encuentra hospedado en github y tiene un wiki muy completo.
El código completo de las pruebas que hice para escribir este artículo está aquí.
- ezamudio's blog
- Inicie sesión o regístrese para enviar comentarios
Comentarios
Este aporte hizo que me diera
Este aporte hizo que me diera de topes... he tenido muchos problemas similares a los que resuelve Hystrix pero en mi caso he tenido que implementar cosas menos sofisticadas y obviamente menos resilentes.
En todo caso, esto me llama la atención:
Si me permite preguntar, ¿Como es que se realizo esto?
Pensándolo un poco pues a mi se me ocurre crear un decorador para un
ExecutorService
que solamente tome métricas envolviendo losRunnable
/Callable
con llamadas aSystem.currentTimeMillis()
... pero eso es para saber cuanto tiempo paso desde que entro a la cola (si es que entro) mas no cuanto estuvo encolado realmente.Saludos.
Fue complicado
Tengo un componente que maneja el thread pool y recibe Runnables, los cuales encola en el mismo. Antes los metía directo, pero para tomar métricas y determinar si lo ejecutaba o no, tuve que hacer esto:
Primero, crear un Runnable que recibe otro Runnable como parámetro en su constructor. Dentro de su constructor toma el tiempo (porque lo construyo cuando lo meto al thread pool) y luego en su método run toma otra vez tiempo; con eso ya puedo saber cuánto tiempo estuvo encolado, y se compara este tiempo contra un límite definido en el componente externo. Si el tiempo es mayor, ya no se ejecuta el runnable, sino se que maneja un error (eso ya es algo específico de la aplicación).
Pero en caso que no haya pasado mucho tiempo encolado, entonces se invoca el run de su Runnable interno y luego vuelve a tomar tiempo. Con eso ya sé cuánto se tardó la ejecución. Esos dos tiempos se los pasa a otro componente que lleva la cuenta para sacar promedios y totales.
Todo eso lo hace Hystrix.
Muy interesante
Está muy interesante. Lo voy a tener en cuenta.
Con respecto a eso de cancelar el llamado si está tardando mucho... me hizo acordar a la resolución de colisiones del protocolo Ethernet, el CSMA/CD, que en algunos casos puede ser más adecuada (y en otros, simplemente descartar el llamado como ya dijeron). En fin, cuando Ethernet detecta colisiones, espera un tiempo con un poco de azar... y si vuelve a colisionar, duplica el tiempo, y así hasta que se descongestiona. La descongestión se produce al separar los pedidos en el tiempo cada vez más. Por ahí lo explico demasiado simplificado, no se si se entiende, pero pensaba que puede ser otra alternativa cuando el envío de datos es de forma asíncrona y encolada, para resolver el problema de que momentáneamente el servidor no responda o tarde mucho en hacerlo. Igualmente, hacer estas cosas a mano, es reinventar la rueda y un dolor de aquellos... , así que bienvenida una librería que enfocada en estos temas. Habrá que investigarla :)
es diferente
Lo que dices de TCP es a nivel red, no a nivel aplicativo. Es algo totalmente distinto.
La combinacion CircuitBreaker
La combinacion CircuitBreaker Pattern / Rx es el plus de esta libreria, pues creo que es mas sencillo manejar observables que directamente primitivas como runnables, threadpools, etc.
Integración con Spring
Por si les interesa integrarlo a aplicaciones que usan Spring, aquí un ejemplo de cómo hacerlo: http://www.mirkosertic.de/doku.php/architecturedesign/springhystrix
Netty
Yo lo que quiero ver (y no he encontrado una sola referencia) es cómo usar Hystrix en conjunto con Netty, si es que se puede... aunque Netty está más chido para server y Hystrix es más para cliente, pero pues sí se pueden hacer clientes con Netty (sirve mucho para conexiones asíncronas host-to-host).