Los errores ocurren

Cada vez que un servicio o un sistema llama a otro, pueden producirse errores. Estos errores pueden tener su origen en varios factores. Entre ellos se incluyen servidores, redes, balanceadores de carga, software, sistemas operativos o incluso errores de los operadores del sistema. Diseñamos nuestros sistemas con el fin de reducir la posibilidad de que ocurran errores, pero es imposible crear sistemas que nunca los cometan. Por lo tanto, en Amazon diseñamos los sistemas para tolerar y reducir la posibilidad de que ocurran errores, y evitamos que el pequeño porcentaje de errores se convierta en una interrupción completa. Para crear sistemas resistentes, utilizamos tres herramientas esenciales: los tiempos de espera, los reintentos y el retardo.

Muchos tipos de errores se vuelven evidentes cuando las solicitudes tardan más tiempo de lo habitual para completarse y posiblemente nunca lo hagan. Cuando un cliente espera más de lo normal para que se complete una solicitud, también retiene los recursos que estaba utilizando para ella durante más tiempo. Cuando cierta cantidad de solicitudes retiene los recursos durante un largo tiempo, el servidor puede agotar dichos recursos. Estos recursos pueden incluir la memoria, los subprocesos, las conexiones, los puertos efímeros o cualquier otro elemento que sea limitado. Para evitar esta situación, los clientes establecen tiempos de espera. Los tiempos de espera son la cantidad máxima de tiempo que un cliente espera para se complete una solicitud.

A menudo, cuando se intenta completar la misma solicitud de nuevo, esta tiene éxito. Esto se debe a que los tipos de sistemas que creamos no suelen presentar errores cuando operan como una sola unidad. En cambio, presentan errores parciales o transitorios. Cuando solo un porcentaje de las solicitudes tiene éxito, existe un error parcial. Cuando una solicitud presenta error durante un periodo corto, existe un error transitorio. Los reintentos permiten que los clientes se sobrepongan a estos errores parciales aleatorios y errores transitorios de corta duración mediante el reenvío de la misma solicitud.

No siempre es seguro llevar a cabo un reintento. Los reintentos pueden aumentar la carga en el sistema al que se está llamando, si es que este ya presenta errores porque está por sobrecargarse. Para evitar este problema, recomendamos a nuestros clientes que utilicen el retardo. Este mecanismo aumenta el tiempo entre los siguientes reintentos, lo que mantiene la carga pareja en el backend. El otro problema que surge con los reintentos es que algunas llamadas remotas generan efectos secundarios. La existencia de tiempo de espera o errores no significa necesariamente que han surgido efectos secundarios. Si no desea que se produzcan efectos secundarios varias veces, una práctica recomendada en estos casos es diseñar las API para que sean idempotentes, es decir, que puedan someterse a reintentos de forma segura.

Finalmente, la entrada de tráfico a los servicios de Amazon no tiene un ritmo constante. En cambio, con frecuencia, la tasa de llegada de las solicitudes presenta grandes aumentos en ráfagas. Estos aumentos en ráfagas pueden deberse al comportamiento del cliente, a la recuperación de errores e, incluso, a algo simple como un trabajo cron periódico. Si la carga provoca errores, es posible que los reintentos no sean efectivos en el caso de que todos los clientes realicen uno al mismo tiempo. Para evitar este problema, utilizamos la fluctuación. Esta consiste en una cantidad de tiempo aleatoria antes de realizar o volver a intentar una solicitud, cuyo fin es ayudar a prevenir grandes aumentos en ráfagas mediante la extensión de la tasa de llegada.

En las próximas secciones, se analizará cada una de estas soluciones.

Tiempos de espera

Una práctica recomendada en Amazon consiste en establecer un tiempo de espera para cualquier llamada remota y, generalmente, para todas las llamadas de los procesos, incluso en la misma casilla. Esto incluye un tiempo de espera de conexión y uno de solicitud. Muchos clientes estándares ofrecen capacidades de tiempo de espera integradas y sólidas.
Por lo general, el problema más complicado es elegir el valor del tiempo de espera que se quiere establecer. Configurar un tiempo de espera demasiado alto limita su utilidad, ya que se siguen consumiendo los recursos mientras el cliente aguarda el tiempo de espera. Configurar un tiempo de espera demasiado bajo implica dos riesgos:
 
• Aumento del tráfico en el backend y aumento de la latencia, ya que se están volviendo a intentar demasiadas solicitudes
• Aumento de la latencia baja del backend que genera una interrupción completa, ya que se inicia el reintento de todas las solicitudes
 
Una práctica recomendada para elegir el tiempo de espera para las llamadas dentro de una región de AWS implica comenzar con las métricas de latencia del servicio posterior. Por lo tanto, en Amazon, cuando ordenamos que un servicio llame a otro, elegimos una tasa aceptable de tiempos de espera falsos (por ejemplo, de 0,1 %). Luego, revisamos el percentil de la latencia correspondiente en el servicio posterior (p99,9 en este ejemplo). Esta estrategia funciona bien en la mayoría de los casos, pero existen algunas dificultades que se describen a continuación:
 
• Esta estrategia no funciona en casos donde los clientes tienen una latencia de red considerable, como, por ejemplo, a través de Internet. En estos casos, incluimos una latencia de red razonable que sea representativa del peor de los casos y tenemos en cuenta que nuestros clientes podrían abarcar todo el mundo.
• Esta estrategia tampoco funciona con los servicios que tienen límites de latencia ajustados, donde p99,9 está cerca de p50. En estos casos, agregar algo de relleno ayuda a evitar el aumento de la latencia baja que provoca una gran cantidad de tiempos de espera.
• Enfrentamos una dificultad común a la hora de implementar los tiempos de espera. SO_RCVTIMEO de Linux es potente, pero presenta algunas desventajas que hacen que no sea adecuado como tiempo de espera del conector completo. Algunos lenguajes de programación, como Java, exponen este control directamente. Otros lenguajes de programación, como Go, ofrecen mecanismos de tiempo de espera más sólidos.
• También existen implementaciones para las que el tiempo de espera no cubre todas las llamadas remotas, como los protocolos de mutuo acuerdo DNS o TLS. Por lo general, preferimos utilizar los tiempos de espera integrados en los clientes que se han probado adecuadamente. Si implementamos nuestros propios tiempos de espera, prestamos mucha atención al significado exacto de las opciones de conector del tiempo de espera y a qué trabajo se está llevando a cabo.
 
En un sistema con el que trabajé en Amazon, observamos una pequeña cantidad de tiempos de espera comunicándose con una dependencia de manera inmediata después de las implementaciones. Se estableció un tiempo de espera muy bajo, de alrededor de 20 milisegundos. Fuera de las implementaciones, incluso con este valor bajo de tiempo de espera, no observamos que los tiempos de espera tuvieran lugar a menudo. Después de investigar, descubrí que el temporizador incluía establecer una nueva conexión segura, que se volvió a utilizar en las solicitudes siguientes. Ya que necesitamos más de 20 milisegundos para establecer la conexión, observamos que una pequeña cantidad de solicitudes caducaron cuando empezó a funcionar un servidor nuevo después de las implementaciones. En algunos casos, las solicitudes se volvieron a intentar y tuvieron éxito. Al comienzo, evitamos este problema mediante el aumento del valor de tiempo de espera en caso de que se estableciera una conexión. Luego, mejoramos el sistema al establecer estas conexiones cuando se iniciaba un proceso, pero antes de recibir tráfico. Esto nos ayudó a resolver el problema del tiempo de espera por completo.

Reintentos y retardo

Los reintentos son “egoístas”. En otras palabras, cuando un cliente vuelve a intentar una solicitud, el servidor tarda más tiempo para alcanzar una mayor oportunidad de éxito. Si los errores son poco comunes o transitorios, esto no representa un problema. Esto se debe a que la cantidad total de solicitudes con reintento es reducida y a que la compensación de aumentar la disponibilidad aparente funciona bien. Cuando la causa de los errores es una sobrecarga, los reintentos que aumentan la carga pueden empeorar la situación considerablemente. Incluso pueden retrasar la recuperación, ya que mantienen la carga alta durante mucho tiempo después de que se resuelve el problema original. Los reintentos se parecen a un medicamento potente; son útiles en su justa dosis, pero pueden causar daño significativo si se consumen en exceso. Desafortunadamente, en los sistemas distribuidos casi no hay manera de coordinar todos los clientes para lograr la cantidad correcta de reintentos.

La solución que se prefiere en Amazon es el retardo. En lugar de llevar a cabo un reintento de inmediato y con intensidad, el cliente espera determinada cantidad de tiempo entre los intentos. El patrón más común es el retardo exponencial, donde el tiempo de espera se incrementa exponencialmente después de cada intento. El retardo exponencial puede llevar a periodos de retardo muy largos, ya que las funciones exponenciales crecen rápidamente. Por lo general, las implementaciones limitan el retardo con un valor máximo para evitar que se extiendan durante demasiado tiempo los reintentos. Esto se denomina, como es de esperar, retardo exponencial limitado. Sin embargo, este mecanismo presenta otro problema. Ahora todos los clientes llevan a cabo reintentos constantemente con la tasa límite. En casi todos los casos, nuestra solución es limitar la cantidad de veces que el cliente realiza reintentos y solucionar el error resultante con anterioridad en la arquitectura orientada a servicios. En la mayoría de los casos, el cliente se dará por vencido con la llamada de todas maneras porque tiene sus propios tiempos de espera.

Existen otros problemas con los reintentos que se describen a continuación:

• Por lo general, los sistemas distribuidos tienen varias capas. Considere un sistema donde la llamada del cliente provoca una pila de cinco capas de llamadas a servicios. Finaliza con una consulta a la base de datos y tres reintentos en cada capa. ¿Qué sucede cuando la base de datos empieza a generar errores en las consultas con carga? Si cada capa realiza reintentos de manera independiente, la carga de la base de datos aumentará 243 veces, lo que convierte a la posibilidad de recuperación en muy remota. Esto se debe a que los reintentos en cada capa se multiplican (primero tres intentos, luego nueve y así sucesivamente). Por el contrario, los reintentos en la capa más alta de la pila podrían significar el desperdicio de trabajo correspondiente a las llamadas anteriores, lo que reduce la eficiencia. En general, para las operaciones de bajo costo en el plano de datos y en el de control, nuestra práctica recomendada es realizar el reintento en un único punto de la pila.
• Carga. Incluso con una sola capa de reintentos, el tráfico continúa creciendo de manera significativa cuando empiezan a aparecer los errores. Para solucionar este problema, se recomiendan con mucha frecuencia los interruptores, por los que se detienen completamente las llamadas a servicios posteriores cuando se supera el límite de error. Desafortunadamente, los interruptores introducen un comportamiento modal en los sistemas que es difícil de probar y pueden ingresar tiempo adicional de recuperación considerable. Hemos descubierto que podemos mitigar este riesgo si limitamos los reintentos a nivel local con un bucket de tokens. Esto permite que se vuelvan a intentar todas las llamadas, siempre que haya tokens, y luego realizar reintentos a una tasa establecida cuando se agoten los tokens. AWS incorporó este comportamiento a los SDK de AWS en 2016. Por lo tanto, los clientes que utilicen el SDK tienen integrado este comportamiento de limitación controlada.
• Decisión sobre cuándo realizar un reintento. En general, nuestra opinión es que las API con efectos secundarios no son seguras para los reintentos a menos que ofrezcan idempotencia. Esto garantiza que los efectos secundarios ocurran solo una vez, sin importar cuántas veces se realicen reintentos. Por lo general, las API de solo lectura son idempotentes, mientras que las API de creación de recursos tal vez no lo sean. Algunas API, como la API RunInstances de Amazon Elastic Compute Cloud (Amazon EC2), ofrecen mecanismos basados en tokens explícitos a fin de brindar idempotencia y preparar su seguridad para los reintentos. Se necesita un buen diseño de la API y cuidado a la hora de implementar los clientes para evitar los efectos secundarios duplicados.
• Conocimiento sobre qué errores vale la pena volver a intentar. HTTP ofrece una distinción clara entre los errores del cliente y los del servidor. Indica que los errores del cliente no deben volver a intentarse con la misma solicitud, ya que no van a tener éxito más tarde, mientras que los errores del servidor pueden llegar a tener éxito en los siguientes intentos. Lamentablemente, la consistencia final en los sistemas dificulta la distinción de manera considerable. A medida que se propaga el estado de error, en cierto momento, el error del cliente puede cambiar y tener éxito.

Más allá de estos riesgos y desafíos, los reintentos son un mecanismo potente para ofrecer alta disponibilidad frente a errores transitorios y aleatorios. Se requiere un juicio certero a la hora de encontrar el equilibrio adecuado para cada servicio. Según nuestra experiencia, un buen punto para comenzar es recordar que los reintentos son egoístas. Representan un mecanismo por el cual los clientes afirman la importancia de sus solicitudes y exigen que el servicio invierta más recursos en administrarlas. Si el cliente es demasiado egoísta, puede crear problemas de gran alcance.

Fluctuación

Cuando la sobrecarga o la contención provocan los errores, a menudo el retardo no ayuda tanto como se piensa que debería. Esto se debe a la correlación. Si todas las llamadas con errores se atrasan al mismo momento, provocan contención o sobrecarga nuevamente cuando se intenta realizarlas de nuevo. Nuestra solución es la fluctuación. La fluctuación incorpora cierta cantidad de aleatoriedad al retardo para distribuir los reintentos en el tiempo. Para obtener más información acerca de la cantidad de fluctuación que se debe agregar y de las mejores maneras de hacerlo, consulte Exponential Backoff and Jitter (Fluctuación y retardo exponencial).

La fluctuación no sirve solo para los reintentos. La experiencia operativa nos ha enseñado que el tráfico hacia nuestros servicios, incluidos los planos de datos y de control, tiende a tener muchos picos. Estos picos de tráfico pueden ser muy cortos y, por lo general, los ocultan las métricas agrupadas. Al momento de crear los sistemas, consideramos incorporar un poco de fluctuación en todos los temporizadores, los trabajos periódicos y demás trabajos con retraso. Esto ayuda a extender los picos de trabajo y facilita el escalado a los servicios posteriores en beneficio de una carga de trabajo.

Si agregamos fluctuación a un trabajo programado, no seleccionamos la fluctuación en cada alojamiento de forma aleatoria. En cambio, utilizamos un método consistente que siempre produce la misma cantidad en el mismo alojamiento. De esta manera, si hay un servicio sobrecargado o una condición de carrera, esto sucede de la misma manera en un patrón. Los humanos somos buenos para identificar patrones y tenemos más posibilidades de determinar la causa raíz. El uso de un método aleatorio garantiza que si se está sobrecargando un recurso, esto solo suceda al azar. Esto hace que la resolución de los problemas sea mucho más difícil.

En sistemas en los que he trabajado, como Amazon Elastic Block Store (Amazon EBS) y AWS Lambda, descubrimos que, a menudo, los clientes envían las solicitudes en intervalos regulares, por ejemplo, de a una por minuto. Sin embargo, cuando un cliente tiene varios servidores que se comportan de la misma manera, puede alinear y activar las solicitudes al mismo tiempo. Esto puede tomar los primeros segundos de un minuto o los primeros segundos después de la medianoche para los trabajos diarios. Después de prestar atención a la carga por segundo y de trabajar con los clientes para fluctuar sus cargas de trabajo periódicas, logramos la misma cantidad de trabajo con menos capacidad de servidor.

Tenemos menos control sobre los picos de tráfico del cliente. Sin embargo, incluso para las tareas activadas por el cliente, es una buena idea agregar fluctuación donde no afecte la experiencia del cliente.

Conclusión

En los sistemas distribuidos, la latencia o los errores transitorios en las interacciones remotas son inevitables. Los tiempos de espera evitan que los sistemas tengan que aguardar durante un periodo ilógicamente largo; los reintentos pueden ocultar esos errores; y el retardo y la fluctuación pueden mejorar la utilización de los sistemas y reducir su congestión.

En Amazon, hemos aprendido que es importante tener cuidado con los reintentos. Estos pueden aumentar la carga en un sistema dependiente. Si el tiempo de espera de las llamadas a un sistema se está agotando, y dicho sistema está sobrecargado, los reintentos pueden empeorar la sobrecarga en lugar de aliviarla. Para evitar este aumento, realizamos reintentos solo cuando observamos que la dependencia está en buen estado. Dejamos de llevar a cabo reintentos cuando esta acción no ayuda a mejorar la disponibilidad.


Acerca del autor

Marc Brooker es ingeniero jefe sénior en Amazon Web Services. Ha trabajado en AWS desde el año 2008 en varios servicios como EC2, EBS e IoT. En la actualidad, se centra en AWS Lambda, incluido el trabajo de escalado y virtualización. Marc disfruta mucho leyendo sobre corrección de errores y análisis post mortem. Tiene un doctorado en ingeniería eléctrica.

Los desafíos de los sistemas distribuidos Uso de la eliminación de carga para evitar la sobrecarga Evitar los planes alternativos en los sistemas distribuidos