Gostaria de ser notificado sobre novos conteúdos?
Falhas acontecem
Sempre que um serviço ou sistema chama outro, podem ocorrer falhas. Essas falhas podem resultar de vários fatores como servidores, redes, load balancers, software, sistemas operacionais ou até de erros dos operadores do sistema. Projetamos nossos sistemas para reduzir a probabilidade de falhas, mas é impossível construir sistemas que nunca falhem. Portanto, na Amazon, projetamos nossos sistemas para tolerar e reduzir a probabilidade de falhas e evitar que uma pequena porcentagem de falhas se transforme em uma interrupção completa. Para criar sistemas resilientes, empregamos três ferramentas essenciais: tempos limite, novas tentativas e retirada.
Muitos tipos de falhas se tornam aparentes, pois as solicitações demoram mais que o normal e, muitas vezes, nunca são concluídas. Quando um cliente aguarda mais do que o normal pela conclusão de uma solicitação, ele também mantém os recursos que estava usando para essa solicitação por mais tempo. Quando várias solicitações detêm os recursos por muito tempo, o servidor pode ficar sem esses recursos. Esses recursos podem incluir memória, threads, conexões, portas efêmeras ou qualquer outro item limitado. Para evitar essa situação, os clientes definem tempos limite. Tempo limite é a quantidade máxima de tempo que um cliente aguarda a conclusão de uma solicitação.
Muitas vezes, tentar a mesma solicitação novamente faz com que a solicitação tenha êxito. Isso acontece porque os tipos de sistemas que criamos não costumam falhar como uma única unidade. Em vez disso, sofrem falhas parciais ou transitórias. Uma falha parcial ocorre quando uma porcentagem das solicitações tem êxito. Uma falha transitória ocorre quando uma solicitação falha por um curto período. As novas tentativas permitem que os clientes sobrevivam a essas falhas parciais aleatórias e falhas transitórias de curta duração enviando a mesma solicitação novamente.
Nem sempre é seguro fazer uma nova tentativa. Uma nova tentativa pode aumentar a carga no sistema que está sendo chamado, se o sistema já estiver falhando porque está se aproximando de uma sobrecarga. Para evitar esse problema, implementamos nossos clientes para usar a retirada. Isso aumenta o tempo entre tentativas subsequentes, o que mantém a carga no back-end uniforme. O outro problema com as novas tentativas é que algumas chamadas remotas têm efeitos colaterais. Um tempo limite ou falha não significa necessariamente que os efeitos colaterais não tenham acontecido. Se não quiser receber os efeitos colaterais várias vezes, uma melhor prática é criar as APIs para que sejam idempotentes, o que significa que novas tentativas podem ser feitas com segurança.
Finalmente, o tráfego não chega aos serviços da Amazon a uma taxa constante. Em vez disso, a taxa de chegada de solicitações frequentemente apresenta grandes intermitências. Essas intermitências podem ser causadas pelo comportamento do cliente, pela recuperação de falhas e até mesmo por algo simples, como uma tarefa cron periódica. Se os erros forem causados pela carga, as novas tentativas poderão ser ineficazes se todos os clientes tentarem novamente ao mesmo tempo. Para evitar esse problema, usamos o jitter. Trata-se de uma quantidade aleatória de tempo antes de fazer ou tentar novamente uma solicitação para ajudar a evitar grandes intermitências diluindo a taxa de chegada.
Cada uma dessas soluções é discutida nas seções a seguir.
Tempos limite
• Pequeno aumento na latência de back-end, levando a uma interrupção completa porque todas as solicitações começam a passar por uma nova tentativa.
• Essa abordagem também não funciona com serviços com limites de latência apertados, onde p99.9 é próximo a p50. Nesses casos, adicionar algum preenchimento ajuda a evitar pequenos aumentos de latência que causam altos números de tempos limite.
• Encontramos uma armadilha comum ao implementar tempos limite. O Linux SO_RCVTIMEO é poderoso, mas tem algumas desvantagens que o tornam inadequado como um tempo limite de soquete de ponta a ponta. Algumas linguagens, como Java, expõem esse controle diretamente. Outros idiomas, como o Go, fornecem mecanismos de tempo limite mais robustos.
• Também existem implementações em que o tempo limite não cobre todas as chamadas remotas, como DNS ou TLS. Em geral, preferimos usar os tempos limite incorporados em clientes bem testados. Se implementarmos nossos próprios tempos limite, prestamos muita atenção ao significado exato das opções de soquete de tempo limite e ao trabalho que está sendo feito.
Tentativas e retirada
As novas tentativas são "egoístas". Em outras palavras, quando um cliente tenta novamente, ele passa mais tempo do servidor para obter uma chance maior de sucesso. Onde as falhas são raras ou transitórias, isso não é um problema. Isso ocorre porque o número geral de solicitações repetidas é pequeno e a compensação pelo aumento da disponibilidade aparente funciona bem. Quando falhas são causadas por sobrecarga, tentativas que aumentam a carga podem piorar significativamente as coisas. Eles podem até atrasar a recuperação mantendo a carga alta por muito tempo após a resolução do problema original. As novas tentativas são semelhantes a um medicamento poderoso - útil na dose certa, mas pode causar danos significativos quando usado em excesso. Infelizmente, em sistemas distribuídos, quase não há como coordenar todos os clientes para obter o número certo de novas tentativas.
A solução preferida que usamos na Amazon é uma retirada. Em vez de tentar novamente de forma imediata e agressiva, o cliente espera uma quantidade de tempo entre as tentativas. O padrão mais comum é uma retirada exponencial, em que o tempo de espera é aumentado exponencialmente após cada tentativa. A retirada exponencial pode levar a tempos de retirada muito longos, porque as funções exponenciais crescem rapidamente. Para evitar tentar por muito tempo, as implementações normalmente limitam a retirada a um valor máximo. Isso é chamado, previsivelmente, de retirada exponencial limitada. No entanto, isso apresenta outro problema. Agora, todos os clientes estão fazendo novas tentativas constantemente à taxa máxima. Em quase todos os casos, nossa solução é limitar o número de vezes que o cliente tenta novamente e manipular a falha resultante anteriormente na arquitetura orientada a serviços. Na maioria dos casos, o cliente desistirá da chamada de qualquer maneira, porque tem seus próprios tempos limite.
Há outros problemas com novas tentativas, descritas a seguir:
• Sistemas distribuídos geralmente têm várias camadas. Considere um sistema em que a ligação do cliente cause uma pilha de cinco chamadas de serviço. Termina com uma consulta a um banco de dados e três tentativas em cada camada. O que acontece quando o banco de dados começa a falhar nas consultas sob carga? Se cada camada tentar de forma independente, a carga no banco de dados aumentará 243 vezes, tornando improvável a recuperação. Isso ocorre porque as novas tentativas em cada camada se multiplicam: três primeiras tentativas, depois nove tentativas e assim por diante. Por outro lado, tentar novamente na camada mais alta da pilha pode desperdiçar trabalho de chamadas anteriores, o que reduz a eficiência. Em geral, para operações de plano de controle e plano de dados de baixo custo, nossa melhor prática é tentar novamente em um único ponto da pilha.
• Carga. Mesmo com uma única camada de tentativas, o tráfego ainda aumenta significativamente quando os erros são iniciados. Os disjuntores, em que as chamadas para um serviço downstream são totalmente interrompidas quando um limite de erro é excedido, são amplamente promovidas para resolver esse problema. Infelizmente, os disjuntores introduzem um comportamento modal em sistemas que pode ser difícil de testar e pode introduzir um tempo adicional à recuperação. Descobrimos que podemos mitigar esse risco limitando as tentativas localmente usando um bucket de tokens. Isso permite que todas as chamadas tentem novamente desde que existam tokens e, em seguida, tente novamente a uma taxa fixa quando os tokens estiverem esgotados. A AWS adicionou esse comportamento ao AWS SDK em 2016. Portanto, os clientes que usam o SDK têm esse comportamento de controle de utilização incorporado.
• Decidir quando tentar novamente. Em geral, nossa opinião é que APIs com efeitos colaterais não são seguras para tentar novamente, a menos que ofereçam idempotência. Isso garante que os efeitos colaterais ocorram apenas uma vez, não importa quantas vezes você tente novamente. APIs somente leitura geralmente são idempotentes, enquanto APIs de criação de recursos podem não ser. Algumas APIs, como a API RunInstances do Amazon Elastic Compute Cloud (Amazon EC2), fornecem mecanismos explícitos baseados em token para fornecer idempotência e torná-los seguros para tentar novamente. É necessário um bom design de API e cuidado ao implementar clientes, para evitar efeitos colaterais duplicados.
• Saber quais falhas valem a pena tentar novamente. O HTTP fornece uma distinção clara entre erros de cliente e servidor. Isso indica que os erros do cliente não devem ser tentados novamente com a mesma solicitação, porque eles não terão êxito mais tarde, enquanto os erros do servidor podem ter êxito nas tentativas subsequentes. Infelizmente, a consistência eventual nos sistemas desfoca significativamente essa linha. Um erro do cliente em um momento pode se tornar um sucesso no momento seguinte, à medida que o estado se propaga.
Apesar desses riscos e desafios, as tentativas são um mecanismo poderoso para fornecer alta disponibilidade diante de erros transitórios e aleatórios. É necessário julgamento para encontrar a compensação certa para cada serviço. Em nossa experiência, um bom lugar para começar é lembrar que as novas tentativas são egoístas. As novas tentativas são uma maneira de os clientes afirmarem a importância de sua solicitação e exigirem que o serviço gaste mais recursos para lidar com isso. Se um cliente é muito egoísta, pode criar problemas abrangentes.
Jitter
Quando falhas são causadas por sobrecarga ou contenção, recuar frequentemente não ajuda tanto quanto parece. Isso é devido à correlação. Se todas as chamadas com falha voltarem ao mesmo tempo, elas causam contenção ou sobrecarga novamente quando são tentadas novamente. Nossa solução é o jitter. O jitter adiciona uma certa aleatoriedade à retirada para espalhar as novas tentativas no tempo. Para obter mais informações sobre quanto jitter adicionar e as melhores maneiras de adicioná-lo, consulte Retirada exponencial e jitter.
O jitter não é apenas para novas tentativas. A experiência operacional nos ensinou que o tráfego para nossos serviços, incluindo planos de controle e dados, tende a aumentar bastante. Esses picos de tráfego podem ser muito curtos e geralmente são ocultados por métricas agregadas. Ao criar sistemas, consideramos adicionar algum jitter a todos os temporizadores, tarefas periódicas e outros trabalhos atrasados. Isso ajuda a espalhar picos de trabalho e facilita o dimensionamento dos serviços downstream para uma carga de trabalho.
Ao adicionar jitter ao trabalho agendado, não selecionamos o jitter em cada host aleatoriamente. Em vez disso, usamos um método consistente que produz o mesmo número todas as vezes no mesmo host. Dessa forma, se houver um serviço sendo sobrecarregado ou uma condição de corrida, isso acontecerá da mesma maneira em um padrão. Nós, humanos, somos bons em identificar padrões e temos mais chances de determinar a causa raiz. O uso de um método aleatório garante que, se um recurso está sendo sobrecarregado, isso só acontece - bem, aleatoriamente. Isso torna a solução de problemas muito mais difícil.
Nos sistemas em que trabalhei, como o Amazon Elastic Block Store (Amazon EBS) e o AWS Lambda, descobrimos que os clientes enviavam solicitações frequentemente em intervalos regulares, como uma vez por minuto. No entanto, quando um cliente tem vários servidores se comportando da mesma maneira, eles podem se alinhar e disparar suas solicitações ao mesmo tempo. Isso pode ocorrer nos primeiros segundos de um minuto ou nos primeiros segundos após a meia-noite em tarefas diárias. Prestando atenção à carga por segundo e trabalhando com os clientes para adicionar o jitter às cargas de trabalho periódicas deles, realizamos a mesma quantidade de trabalho com menos capacidade do servidor.
Temos menos controle sobre picos no tráfego de clientes. No entanto, mesmo para tarefas acionadas pelo cliente, é uma boa ideia adicionar o jitter onde isso não afeta a experiência do cliente.
Conclusão
Em sistemas distribuídos, falhas transitórias ou latência em interações remotas são inevitáveis. Os tempos limites impedem que os sistemas sejam suspensos excessivamente, as novas tentativas podem mascarar essas falhas e a retirada e o jitter e podem melhorar a utilização e reduzir o congestionamento nos sistemas.
Na Amazon, aprendemos que é importante ter cuidado com as novas tentativas. As novas tentativas podem amplificar a carga em um sistema dependente. Se as chamadas para um sistema estiverem atingindo o tempo limite e esse sistema estiver sobrecarregado, as novas tentativas poderão piorar a sobrecarga, em vez de melhorar. Evitamos essa amplificação tentando novamente apenas quando observamos que a dependência está íntegra. Paramos de tentar novamente quando as novas tentativas não estão ajudando a melhorar a disponibilidade.
Sobre o autor
Marc Brooker é engenheiro-chefe sênior na Amazon Web Services. Ele trabalha na AWS desde 2008 em vários serviços, como EC2, EBS e IoT. Atualmente, ele se concentra no AWS Lambda, que inclui o trabalho em escalabilidade e virtualização. Marc realmente aprecia a leitura de COEs e obras póstumas. Ele tem PhD em engenharia elétrica.