Papers
Using load shedding to avoid overload
David Yanacek · Amazon Builders' Library (artigo técnico online, publicado em 5 dez 2019; não é paper com revisão por pares), 2019
Por que a sobrecarga se alimenta sozinha: latência, timeout e goodput
O artigo parte de uma observação de testes de carga: a latência de um servidor com pouca utilização é menor do que com muita utilização, porque contenção de threads, trocas de contexto, coleta de lixo e disputa por I/O pesam mais sob carga. Em algum ponto o desempenho passa a degradar ainda mais rápido. O autor liga isso à Universal Scalability Law, derivada da lei de Amdahl: paralelizar aumenta a vazão, mas ela é limitada pelos pontos de serialização. Além disso, a vazão costuma cair quando o sistema recebe mais trabalho do que seus recursos suportam, pois o computador aceita o trabalho mas fica lento demais para ser útil. Quando a latência passa do timeout do cliente, as requisições começam a falhar; se a latência mediana iguala o timeout, metade das requisições expira e a disponibilidade percebida é de 50%. Aí um problema de latência vira um problema de disponibilidade. Para descrever isso, o autor distingue throughput, o total de requisições por segundo enviadas ao servidor, de goodput, o subconjunto tratado sem erro e rápido o bastante para o cliente aproveitar a resposta. A sobrecarga se amplifica em ciclo: quando o cliente expira, todo o progresso do servidor naquela requisição é desperdiçado, e os clientes costumam tentar de novo, multiplicando a carga. Com uma cadeia profunda de serviços em que cada camada faz retentativas, uma sobrecarga na camada de baixo provoca retentativas em cascata que amplificam a carga de forma exponencial. O resultado é uma sobrecarga que se torna estado estável.
A ideia do load shedding e a necessidade de testar até quebrar
Na superfície, o load shedding é simples: quando o servidor se aproxima da sobrecarga, ele começa a rejeitar o excesso de requisições para se concentrar nas que decidiu aceitar. O objetivo é manter baixa a latência das requisições aceitas, de modo que a resposta chegue antes do timeout do cliente. Assim o servidor mantém alta disponibilidade para o que aceitou, e só a disponibilidade do tráfego excedente é afetada. Nos gráficos de disponibilidade geral a linha ainda desce, o que parece ruim, mas a chave é que as requisições aceitas continuam disponíveis porque são atendidas rápido. O load shedding permite ao servidor sustentar o goodput enquanto o throughput oferecido cresce. Porém, rejeitar também não é grátis, e por isso o servidor acaba cedendo à lei de Amdahl e o goodput cai. O autor insiste em testar: quem não levou o serviço de carga até quebrar, e bem além disso, deve supor que ele falhará da pior maneira possível. Se em um teste de sobrecarga a disponibilidade cai rapidamente a zero conforme o throughput sobe, é sinal de que faltam mecanismos de load shedding. O resultado ideal é o goodput formar um platô perto da utilização plena e permanecer plano mesmo com mais carga aplicada. Os testes devem medir a disponibilidade e a latência percebidas pelo cliente, e não só as do servidor, e é dos testes que se descobre a combinação de proteções necessária, já que cada mecanismo adiciona complexidade e tem eficácia limitada.
Não desperdiçar trabalho: prazos, trabalho limitado e prioridades
Uma parte central do artigo é evitar que trabalho seja jogado fora. Se o servidor percebe no meio de uma requisição que o cliente já expirou, pode abandonar o restante em vez de entregar uma resposta que ninguém vai ouvir. Para isso os clientes podem enviar dicas de timeout, expressas como horário absoluto ou como duração. O horário absoluto exige relógios sincronizados entre máquinas, e a duração exige saber quando começar o cronômetro: em sobrecarga extrema, muitas requisições ficam em buffers TCP e, quando o servidor as lê, o cliente já desistiu. O autor também relata que a Amazon procura aplicar as dicas de forma transitiva, propagando o tempo restante a cada salto da cadeia. Ele diz que, mesmo com as complicações, impor no servidor o prazo do cliente é melhor do que a alternativa, descartando requisições condenadas. Outra prática é projetar serviços para fazer trabalho limitado: APIs que podem retornar muitos dados são paginadas, o que torna mais fácil estimar a carga de cada requisição, e o controle de admissão fica muito difícil quando o servidor não sabe quanto custará processar algo. A priorização também ajuda: o pedido mais importante que o servidor recebe é o ping do balanceador de carga, pois sem resposta o balanceador deixa de enviar requisições e o servidor fica ocioso. Se uma API depende de duas chamadas para o cliente concluir seu trabalho, deve-se priorizar a que finaliza o trabalho já iniciado, e as da primeira página de uma paginação ficam atrás das páginas seguintes, porque um cliente que vê uma falha após a página N-1 e descarta os resultados desperdiça N-2 chamadas e as retentativas feitas no caminho.
Filas ocultas e proteção em camadas
O autor dedica atenção às filas. Muitas arquiteturas modernas têm filas em memória ligando pools de threads, e todo serviço baseado em TCP tem um buffer do sistema operacional por socket, onde pode haver um enorme volume de requisições acumuladas. Ao tirar trabalho de uma fila, a equipe examina quanto tempo ele ficou esperando e, no mínimo, registra essa duração em métricas. Além de limitar o tamanho das filas, o autor considera importante limitar o tempo que uma requisição pode ficar nelas, descartando as velhas demais para liberar o servidor para as mais novas, que têm mais chance de dar certo. Numa versão extrema, usa-se uma fila LIFO (último a entrar, primeiro a sair) quando o protocolo permite; ele cita que o pipelining do HTTP/1.1 não suporta, mas o HTTP/2 geralmente sim. Balanceadores também podem enfileirar conexões com a chamada fila de surto (surge queue), e então o servidor não sabe quanto tempo a requisição esperou. Um padrão geralmente seguro é o spillover, que falha rápido em vez de enfileirar. O autor conta que o Classic Load Balancer usava surge queue, e o Application Load Balancer rejeita o tráfego excedente. Sobre camadas, proxies como o NGINX têm opção de máximo de conexões, útil, mas usada como último recurso, porque é difícil priorizar tráfego importante e a contagem bruta de requisições em andamento pode enganar. A recomendação final da equipe de frameworks foi configurar o máximo de conexões do balanceador e do proxy em valor alto e deixar o servidor aplicar um load shedding mais preciso com informação local, sem exceder o número de threads, processos ou descritores de arquivo do servidor, para sobrar recurso aos health checks.
Efeitos colaterais, visibilidade e alternativas serverless
O load shedding tem armadilhas descritas no artigo. Quando uma rejeição ocorre apesar de haver capacidade, é um falso positivo, e a meta da Amazon é manter a taxa de falsos positivos de um serviço em zero. Se ela é regularmente diferente de zero, o serviço está sensível demais ou hosts estão constantemente sobrecarregados, o que pode indicar problema de escala ou de balanceamento. É preciso instrumentar quem era o cliente e qual operação ele chamava, além de alarmes para volume relevante de rejeições. Também não se deve poluir as métricas de latência com requisições que falharam rápido: se o serviço rejeita 60% do tráfego, a latência mediana pode parecer ótima mesmo com a latência das requisições bem-sucedidas péssima. Outro risco é desativar o escalonamento automático reativo: se o load shedding rejeita requisições em um alvo de CPU parecido com o do escalonamento, este nunca recebe o sinal, ou o recebe atrasado, para lançar instâncias. Além disso, a frota pode estar rodando bem mais perto do ponto de rejeição do que a CPU indica e não ter folga para a perda de uma Zona de Disponibilidade, então é preciso testar até quebrar. O artigo cita também o uso para economizar, moldando tráfego não crítico como o de crawlers, e recomenda cuidado e que essa moldagem ocorra o mais cedo possível. Por fim, ambientes serverless dão isolamento de recursos: no AWS Lambda cada chamada roda em seu próprio ambiente e atende uma só requisição por vez, de modo que o goodput permanece plano em vez de cair, embora dependências lentas ainda possam elevar a concorrência.
Veja as ideias em ação
Pratique na escola de System Design os desenhos que usam este paper.