Os algoritmos imitam a vida

Desde meu primeiro curso de ciência da computação na faculdade, tenho grande interesse em como os algoritmos funcionam no mundo real. Quando pensamos sobre qualquer coisa que aconteça no mundo real, criamos algum algoritmo que imita essas coisas. Costumo fazer isso quando estou preso em uma fila, como no supermercado, no trânsito ou no aeroporto. Descobri que o momento em que estou entediado esperando em uma fila é uma ótima oportunidade para refletir sobre a teoria das filas.

Cerca de uma década atrás, passei um dia trabalhando em um centro de abastecimento da Amazon. Fui guiado por um algoritmo, coletando os itens das prateleiras, os transportando de uma caixa para outra e movendo caixotes de um lado para o outro. Durante o trabalho paralelo com tantas outras pessoas, percebi a beleza de ser parte do que é essencialmente uma orquestração física impressionante de merge sort, ou ordenação por mistura.

Na teoria das filas, o comportamento das filas quando elas estão pequenas é relativamente sem graça. Afinal, quando a fila está pequena, todos estão felizes. É só quando a fila está acumulada, quando a fila de um evento está dobrando a esquina, que as pessoas começam a pensar sobre vazão, ou throughput, e priorização.

Neste artigo, vou discutir estratégias que usamos na Amazon para lidar com cenários de backlog de filas, abordagens de design que usamos para diminuir as filas rapidamente e priorizar cargas de trabalho. E o mais importante, vou descrever como evitar o backlog de filas antes de qualquer coisa. Na primeira metade, vou descrever cenários que levaram ao backlog, e na segunda metade, vou descrever os diversos meios utilizados pela Amazon para evitar backlogs ou para lidar com eles com desenvoltura.

A natureza ambígua das filas

As filas são ferramentas importantes para desenvolver sistemas assíncronos confiáveis. As filas permitem que um sistema aceite a mensagem de outro sistema, e repasse a mensagem até que ela seja totalmente processada, mesmo diante de longas interrupções, falhas no servidor ou problemas com sistemas dependentes. Quando ocorre uma falha, em vez de descartar a mensagem, a fila reencaminha as mensagens até que elas sejam processadas com êxito. No fim das contas, uma fila aumenta a durabilidade e a disponibilidade de um sistema às custas de latência ocasional devido a tentativas repetidas.
 
Na Amazon, desenvolvemos vários sistemas assíncronos que aproveitam as vantagens das filas. Alguns desses sistemas processam fluxos de trabalho que podem ser demorados e que envolvem a movimentação de itens físicos no mundo, como o atendimento de pedidos feitos no site amazon.com. Outros sistemas coordenam etapas que podem levar uma quantidade de tempo não trivial. Por exemplo, o Amazon RDS solicita instâncias do EC2, aguarda a execução delas e depois configura seus bancos de dados para você. Outros sistemas aproveitam o agrupamento em lotes. Por exemplo, os sistemas envolvidos na ingestão de métricas e logs do CloudWatch recebem muitos dados para depois agregá-los e os “achatar” em fragmentos.
 
Embora seja fácil perceber os benefícios de uma fila no processamento de mensagens de modo assíncrono, os riscos de usar uma fila são mais sutis. Descobrimos com o passar dos anos que as filas que deveriam melhorar a disponibilidade podem gerar o efeito contrário. Elas podem aumentar drasticamente o tempo de recuperação depois de uma interrupção.
 
Em um sistema baseado em filas, quando o processamento é interrompido, mas as mensagens continuam chegando, o déficit de mensagens pode se tornar um grande backlog, aumentando o tempo de processamento. O trabalho pode ser concluído tarde demais para que os resultados sejam úteis, causando, essencialmente, o impacto na disponibilidade que a fila supostamente ajudaria a evitar.
 
Dito de outra forma, um sistema baseado em filas tem dois modos de operação, ou comportamento bimodal. Quando não há backlog na fila, a latência do sistema é baixa, e o sistema está no modo rápido. Mas se ocorre uma falha ou um padrão de carga inesperado faz com que a taxa de chegada exceda a taxa de processamento, o sistema passa para um modo de operação muito mais sinistro. Neste modo, a latência de ponta a ponta aumenta cada vez mais, e demora bastante tempo para resolver o backlog e retornar para o modo rápido.

Sistemas baseados em filas

Para ilustrar os sistemas baseados em filas neste artigo, vou falar um pouco sobre como dois produtos da AWS funcionam por trás das cortinas: o AWS Lambda, um serviço que executa seu código em resposta a eventos sem precisar se preocupar com a infraestrutura em que ele é executado, e o AWS IoT Core, um serviço gerenciado que permite a conexão de dispositivos facilmente e a interação segura com aplicativos em nuvem e outros dispositivos.

Com o AWS Lambda, você faz upload de seu código de função e depois invoca duas funções de duas maneiras possíveis:

• De modo síncrono: onde a saída de sua função é devolvida para você na resposta HTTP
• De modo assíncrono: onde a resposta HTTP é devolvida imediatamente, e sua função é executada e repetida nos bastidores

O Lambda garante que sua função seja executada, mesmo diante de várias falhas, logo, ele precisa de uma fila duradoura na qual armazenar suas solicitações. Com uma fila duradoura, sua solicitação pode ser redirecionada se sua função falhar na primeira vez.

Com o AWS IoT Core, seus dispositivos e aplicativos são conectados e podem assinar tópicos de mensagem do PubSub. Quando um dispositivo ou aplicativo publica uma mensagem, os aplicativos com assinaturas correspondentes recebem sua própria cópia da mensagem. A maioria dessas mensagens do PubSub ocorre de modo assíncrono, porque um dispositivo IoT restrito não deseja gastar seus recursos limitados aguardando para garantir que todos os dispositivos, aplicativos e sistemas com assinatura recebam uma cópia. Isso é especialmente importante porque um dispositivo com assinatura pode estar offline quando outro dispositivo publicar uma mensagem que seja de seu interesse. Quando o dispositivo offline se reconectar, primeiro ele vai esperar até retomar a velocidade, e depois vai receber as mensagens (para obter informações sobre como codificar o sistema para gerenciar a entrega de mensagens após a reconexão, consulte Sessões persistentes do MQTT no Guia de desenvolvedor do AWS IoT). Existem diversas opções de persistência e processamento assíncrono que seguem acontecendo nos bastidores para que isso seja possível.

Sistemas baseados em fila como esse, com frequência, são implementados com uma fila duradoura. O SQS oferece semântica de entrega de mensagem duradoura, escalável e do tipo “pelo menos uma vez”, de modo que as equipes da Amazon que incluem o Lambda e o IoT o usam frequentemente ao desenvolver seus sistemas assíncronos escaláveis. Em sistemas baseados em fila, um componente produz dados colocando mensagens na fila, e outro componente consome aqueles dados ao, periodicamente, pedir mensagens, processar mensagens e, por fim, excluir mensagens depois de concluir.

Falhas em sistemas assíncronos

No AWS Lambda, se uma invocação de sua função estiver mais lenta que o normal (por exemplo, por causa de uma dependência), ou se ela falhar de modo temporário, nenhum dado será perdido e o Lambda repetirá a tentativa de sua função. O Lambda forma uma fila de suas chamadas de invocação, e quando a função começa a operar novamente, o Lambda resolve o backlog de suas funções. Mas vamos considerar quanto tempo leva para resolver todo o backlog e voltar ao normal.

Imagine um sistema que passa por uma interrupção de uma hora durante o processamento de mensagens. Independentemente da taxa e da capacidade de processamento, a recuperação de uma interrupção exige o dobro da capacidade do sistema por mais uma hora após a recuperação. Na prática, o sistema precisa mais do que o dobro da capacidade disponível, especialmente com serviços elásticos como o Lambda, e a recuperação pode ocorrer mais rápido. Por outro lado, outros sistemas com os quais sua função interage podem não estar preparados para lidar com um grande aumento no processamento conforme você resolve o backlog. Quando isso acontece, a estabilização pode demorar mais tempo. Os serviços assíncronos acumulam backlogs durante as interrupções, o que leva a tempos mais longos de recuperação, ao contrário de serviços síncronos, que reduzem as solicitações durante interrupções, mas têm tempo de recuperação mais rápido.

Ao longo dos anos, quando refletíamos sobre filas, às vezes fomos tentados a pensar que a latência não era importante para sistemas assíncronos. Os sistemas assíncronos, muitas vezes, são desenvolvidos para durabilidade, ou para isolar o chamador imediato da latência. No entanto, na prática, vimos que o tempo de processamento real é importante e, frequentemente, é esperado que os sistemas assíncronos tenham latência de fração de segundos ou até melhor. Quando as filas são introduzidas para durabilidade, é fácil perder a troca que causa o aumento da latência de processamento diante do backlog. O risco oculto com sistemas assíncronos ocorre ao lidar com grandes backlogs.

Como medimos a disponibilidade e a latência

A discussão sobre as trocas entre latência e disponibilidade levanta algumas perguntas interessantes: como medimos e definimos objetivos de latência e disponibilidade para um serviço assíncrono? A medição de taxas de erro do ponto de vista do produtor nos dá parte da visão geral sobre disponibilidade, mas não tudo. A disponibilidade do produtor é proporcional à disponibilidade da fila do sistema que estamos usando. Então, quando criamos no SQS, nossa disponibilidade de produtor corresponde à disponibilidade do SQS.

Por outro lado, se medirmos a disponibilidade do lado do consumidor, isso pode tornar a disponibilidade do sistema pior do que realmente é, pois as falhas podem causar repetições e depois êxito na próxima tentativa.

Também obtemos medidas de disponibilidade das filas de mensagens não entregues (DLQ). Se uma mensagem esgota todas as novas tentativas, ela é descartada ou colocada em uma DLQ. Uma DLQ é simplesmente uma fila separada usada para armazenar mensagens que não podem ser processadas para investigação e intervenção posteriores. A taxa de mensagens ignoradas ou de DLQ é uma boa medida de disponibilidade, mas pode detectar o problema tarde demais. Embora seja uma boa ideia alertar a respeito de volumes de DLQ, as informações de DLQ chegariam muito tarde de modo que não poderíamos confiar exclusivamente nisso para detectar problemas.

E a latência? Novamente, a latência observada pelo produtor reflete a latência do serviço de fila em si. Portanto, focamos mais na medição de tempo das mensagens que estão na fila. Isso detecta rapidamente quando o sistema está em atraso, ou quando estão ocorrendo erros frequentes e novas tentativas. Serviços como o SQS fornecem um registro de data e hora de quando cada mensagem chegou na fila. Com as informações de data e hora, toda vez que uma mensagem sai da fila, podemos registrar e produzir métricas sobre o quão atrasado está nosso sistema.

No entanto, o problema de latência pode ser um pouco mais complexo. Afinal, backlogs são esperados, e não são um problema para algumas mensagens. Por exemplo, no AWS IoT, algumas vezes é esperado que o dispositivo fique offline ou fique mais lento para ler suas mensagens. Isso ocorre porque vários dispositivos IoT têm baixa energia e conexão de internet intermitente. Como operadores do AWS IoT Core, precisamos ser capazes de diferenciar entre um pequeno backlog esperado causado pelo status offline dos dispositivos ou por escolher ler mensagens lentamente, e um backlog geral do sistema e inesperado.

No AWS IoT, equipamos o serviço com outra métrica: AgeOfFirstAttempt. Esta medida registra o tempo agora menos o tempo de fila da mensagem, mas somente se essa for a primeira vez que o AWS IoT tentou entregar a mensagem a um dispositivo. Desse modo, quando os dispositivos passam por backup, temos uma métrica clara que não é poluída com as mensagens de novas tentativas de entrega ou de enfileiramento. Para tornar a métrica ainda mais clara, emitimos uma segunda métrica: AgeOfFirstSubscriberFirstAttempt. Em um sistema PubSub como o AWS IoT, não existem limites práticos em relação a quantos dispositivos ou aplicativos podem assinar determinado tópico, então a latência é mais alta ao enviar a mensagem para um milhão de dispositivos do que ao enviar para um único dispositivo. Para obter uma métrica estável, emitimos uma métrica de temporizador na primeira tentativa de publicar a mensagem para o primeiro assinante daquele tópico. Depois, temos outras métricas para medir o progresso do sistema em relação à publicação das mensagens restantes.

A métrica AgeOfFirstAttempt serve como um aviso inicial para um problema do sistema inteiro, em grande parte porque filtra o ruído dos dispositivos que estão escolhendo ler as mensagens com mais lentidão. É importante ressaltar que os sistemas como o AWS IoT são equipados com outras métricas além dessas. Mas com todas as métricas sobre latência disponíveis, a estratégia de categorizar a latência de primeiras tentativas separadamente da latência das tentativas repetidas é comumente usada na Amazon.

Medir a latência e a disponibilidade dos sistemas assíncronos é desafiador, e a depuração de erros também pode ser complicada, porque as solicitações saltam de um servidor a outro e podem ser atrasadas em locais fora de cada sistema. Para ajudar com o rastreamento distribuído, propagamos um ID de solicitação em nossas mensagens enfileiradas para que possamos organizar as informações. Geralmente, usamos sistemas como o X-Ray para ajudar com isso também.

Os backlogs são sistemas assíncronos multilocatários

Muitos sistemas assíncronos são multilocatários, e lidam com tarefas em nome de muitos clientes diferentes. Isso acrescenta uma dimensão de complexidade ao gerenciamento de latência e disponibilidade. O benefício de multilocatários é que economiza os custos operacionais de ter que operar separadamente várias frotas, e também permite a execução de cargas de trabalho combinadas com utilização de recursos muito mais elevada. No entanto, os clientes esperam que esse tipo de sistema se comporte como o seu sistema de único locatário, com latência previsível e alta disponibilidade, sem levar em conta as cargas de trabalho de outros clientes.

Os produtos da AWS não expõem as filas internas diretamente para os chamadores inserirem mensagens. Em vez disso, eles implementam APIs leves para autenticar chamadores e anexar informações do chamador em cada mensagem antes do enfileiramento. Isso é semelhante à arquitetura do Lambda descrita anteriormente: quando você invoca uma função de modo assíncrono, o Lambda coloca sua mensagem em uma fila de propriedade do Lambda e devolve imediatamente, em vez de expor as filas internas do Lambda diretamente a você.

Essas APIs leves também permitem a adição de justiça no controle de utilização. A justiça em um sistema multilocatário é importante para que nenhuma carga de trabalho do cliente cause impacto em outro cliente. Uma forma comum de implementação de justiça da AWS é por meio da definição de limites baseados em taxa por cliente, com alguma flexibilidade para bursting. Em vários de nossos sistemas, por exemplo, no próprio SQS, aumentamos os limites por cliente conforme os clientes crescem de modo orgânico. Os limites servem de barreira para picos inesperados, permitindo que tenhamos tempo de fazer ajustes de provisionamento nos bastidores.

De certo modo, a justiça em sistemas assíncronos funciona como o controle de utilização em sistemas síncronos. No entanto, consideramos que seja ainda mais importante pensar em sistemas assíncronos devido aos grandes backlogs que podem se formar rapidamente.

Para ilustrar, considere o que aconteceria se um sistema assíncrono não tivesse proteções contra vizinhos ruidosos incorporadas. Se um cliente do sistema apresentasse um pico de tráfego de súbito que não fosse controlado, e gerasse um backlog em todo o sistema, poderia levar cerca de 30 minutos para um operador se envolver, descobrir o que está acontecendo e mitigar o problema. Durante estes 30 minutos, o lado do produtor do sistema poderia ter feito uma boa escala e enfileirado todas as mensagens. Mas se o volume de mensagens enfileiradas fosse 10 vezes maior que a capacidade escalada do lado do consumidor, isso significaria que levaria 300 minutos para o sistema lidar com o backlog e se recuperar. Até mesmo picos breves de carga podem resultar em tempos de recuperação de várias horas e, portanto, causar interrupções de muitas horas.

Na prática, os sistemas na AWS têm diversos fatores de compensação para minimizar ou evitar os impactos negativos de backlogs de fila. Por exemplo, a escalabilidade automática ajuda a mitigar problemas quando a carga aumenta. Mas é útil observar somente os efeitos do enfileiramento, sem considerar os fatores de compensação, pois isso ajuda os designers de sistema que dependem de várias camadas. Aqui estão alguns padrões de design que descobrimos que ajudam a evitar grandes backlogs de fila e longos tempos de recuperação:

Proteção em todas as camadas é importante em sistemas assíncronos. Como sistemas síncronos não tendem a criar backlogs, nós os protegemos com controle de utilização de porta da frente e controle de admissão. Em sistemas assíncronos, cada componente de nossos sistemas precisam se proteger contra sobrecarga, e evitar que uma carga de trabalho consuma uma fatia injusta dos recursos. Sempre haverá alguma carga de trabalho que passa pelo controle de admissão da porta da frente, por isso precisamos de um cinto, de suspensórios e de um bolso de proteção para impedir que ocorra sobrecarga nos serviços.
Usar mais de uma fila ajuda a definir o tráfego. De algumas formas, uma fila única e sistema de multilocatários estão em conflito. Quando o trabalho é enfileirado em uma fila compartilhada, fica difícil isolar uma carga de trabalho das demais.
Os sistemas em tempo real, muitas vezes, são implementados com filas parecidas com FIFO, mas têm preferência por comportamento parecido com LIFO. O que os clientes nos contam é que, ao enfrentar um backlog, eles preferem ver seus dados novos processados imediatamente. Quaisquer dados acumulados durante uma interrupção ou um aumento podem ser processados conforme a capacidade se tornar disponível.

Estratégias da Amazon para criar sistemas assíncronos multilocatários resilientes

Existem vários padrões que os sistemas da Amazon usam para tornar os sistemas assíncronos multilocatários dela resilientes às mudanças nas cargas de trabalho. Eles são compostos por técnicas, mas também existem muitos sistemas usados na Amazon, cada um com seu próprio conjunto de atividades e requisitos de durabilidade. Na seção a seguir, descrevo alguns dos padrões que utilizamos e como os clientes da AWS relatam o uso nos sistemas deles.

Separação de cargas de trabalho em filas separadas

Em vez de compartilhar uma fila em todos os clientes, em alguns sistemas, cada cliente recebe sua própria fila. Adicionar uma fila para cada cliente ou carga de trabalho nem sempre é economicamente viável, porque os serviços precisarão gastar recursos para sondar todas as filas. Mas em sistemas com um grupo de clientes ou sistemas adjacentes, esta solução simples pode ser útil. Por outro lado, se um sistema tiver dezenas ou centenas de clientes, filas separadas poderão começar a ficar difíceis de gerenciar. Por exemplo, o AWS IoT não usa uma fila separada para cada dispositivo IoT no universo. Os custos de sondagem não seriam escalados muito bem neste caso.

Ordem aleatória de fragmentação

O AWS Lambda é um exemplo de um sistema onde a sondagem de uma fila separada para cada cliente do Lambda teria custos muitos altos. No entanto, ter uma única fila poderia resultar em alguns dos problemas descritos neste artigo. Então, em vez de usar uma fila, o AWS Lambda provisiona um número fixo de filas e atribui cada cliente a um número reduzido de filas. Antes de enfileirar uma mensagem, ele verifica qual dessas filas pretendidas contêm menos mensagens e enfileira nela. Quando a carga de trabalho de um cliente aumenta, isso causará um backlog nas filas mapeadas, mas as outras cargas de trabalho serão direcionadas automaticamente para longe dessas filas. Não é preciso um número tão grande de filas para desenvolver um isolamento mágico de recursos. Essa é apenas uma das muitas proteções desenvolvidas no Lambda, mas é uma técnica usada também em outros serviços da Amazon.

Redirecionamento de tráfego excedente para uma fila separada

Em certos sentidos, quando ocorre um backlog em uma fila, é tarde demais para priorizar o tráfego. No entanto, se o processamento da mensagem for relativamente caro ou demorado, ainda pode ser vantajoso poder movimentar mensagens a uma fila separada, de propagação. Em alguns sistemas na Amazon, o serviço do consumidor implementa o controle de utilização distribuído, e quando as mensagens são removidas da fila para um cliente que ultrapassou a taxa configurada, eles enfileiram as mensagens excedentes em filas de propagação separadas e excluem as mensagens da fila primária. O sistema ainda trabalha nas mensagens presentes nas filas de propagação assim que novos recursos estiverem disponíveis. Em resumo, isso funciona quase como uma fila de prioridade. A lógica semelhante é, às vezes, implementada no lado do produtor. Desse modo, se um sistema aceita um grande volume de solicitações de uma única carga de trabalho, essa carga de trabalho não substitui outras cargas de trabalho na fila de afunilamento.

Redirecionamento de tráfego antigo para uma fila separada

Assim como no redirecionamento de tráfego excedente, podemos redirecionar o tráfego antigo. Quando removemos uma mensagem de uma fila, podemos verificar sua data. Em vez de fazer o registro em log por tempo, podemos usar a informação para decidir se devemos mover a mensagem para uma fila de backlog que será vista somente depois que a fila em tempo real for resolvida. Se houver um pico de carga onde há ingestão de muitos dados, e ficarmos para trás, podemos desviar essa onda de tráfego para uma fila diferente o mais rápido possível, como remover e recolocar o tráfego na fila. Isso libera recursos do consumidor para trabalhar em mensagens mais recentes com mais. rapidez, do que se simplesmente tivéssemos alterado a ordem do backlog. Essa é uma forma de aproximar-se de solicitações de LIFO.

Descartando mensagens antigas (vida útil das mensagens)

Alguns sistemas toleram o descarte de mensagens muito antigas. Por exemplo, alguns sistemas processam deltas para sistemas rapidamente, mas também realizam sincronização completa periodicamente. Com frequência, nos referimos a estes sistemas de sincronização periódica como de varredura antientropia. Nesses casos, em vez de redirecionar o tráfego de uma fila antiga, podemos descartá-la se ela entrou antes da varredura mais recente.

Threads de limites (e outros recursos) por carga de trabalho

Assim como em muitos de nossos serviços síncronos, projetamos nossos sistemas assíncronos para evitar que uma carga de trabalho utilize mais do que sua parcela justa de threads. Um aspecto do AWS IoT sobre o qual não falamos muito ainda é o mecanismo de regras. Os clientes podem configurar o AWS IoT para encaminhar mensagens de seus dispositivos para um cluster Amazon Elasticsearch do cliente, Kinesis Stream e assim por diante. Se a latência desses recursos do cliente tornar-se lenta, mas a taxa de mensagens de entrada permanecer constante, o volume de simultaneidade no sistema aumentará. E como o volume de simultaneidade que um sistema pode gerenciar é limitado a qualquer instante no tempo, o mecanismo de regras evita que uma carga de trabalho consuma mais do que sua parcela justa de recursos relacionados à simultaneidade.

A força em questão é descrita pela Lei de Little, que afirma que a simultaneidade em um sistema é igual à taxa de entrada multiplicada pela latência média de cada solicitação. Por exemplo, se um servidor estivesse processando 100 mensagens/segundo a 100 ms em média, ele consumiria 10 threads em média. Se a latência subisse de repente para 10 segundos, ele usaria de súbito 1.000 threads (em média, então, poderia ser mais que isso na prática), o que poderia facilmente esgotar um grupo de threads.

O mecanismo de regras usa várias técnicas para evitar que isso aconteça. Ele usa E/S sem bloqueio para evitar o esgotamento de thread, embora ainda existam outros limites para quanto trabalho tem um determinado servidor (por exemplo, memória e descritores de arquivo quando o cliente está fazendo rotatividade entre conexões e a dependência está chegando no limite de tempo). Um segunda barreira de simultaneidade que pode ser usada é um semáforo que mede e limita o volume de simultaneidade que pode ser usada para qualquer carga de trabalho a qualquer momento. O mecanismo de regras também usa limitação de justiça com base em taxa. No entanto, como é perfeitamente normal que cargas de trabalho mudem ao longo do tempo, o mecanismo de regras também escala automaticamente os limites ao longo do tempo para adaptar-se às mudanças nas cargas de trabalho. E como o mecanismo de regras é baseado em fila, ele atua como um buffer entre os dispositivos IoT e a escalabilidade automática de recursos e proteção dos limites nos bastidores.

Nos serviços na Amazon, usamos grupos de threads separados para cada carga de trabalho para evitar que uma carga de trabalho consuma todos os threads disponíveis. Também usamos um AtomicInteger para cada carga de trabalho para limitar a simultaneidade permitida para cada uma delas, e uma abordagem de controle de utilização com base em taxa para isolar recursos com base em taxa.

Envio de pressão de retorno upstream

Se uma carga de trabalho está gerando um backlog excessivo que o consumidor não consegue acompanhar, muitos dos sistemas começam automaticamente a recusar trabalho mais agressivamente no produtor. É fácil construir um backlog de dia inteiro para uma carga de trabalho. Mesmo se a carga de trabalho for isolada, ela pode ser acidental, e cara para fazer rotatividade. Uma implementação desta abordagem poderia ser tão simples quanto medir ocasionalmente a profundidade da fila de uma carga de trabalho (presumindo que uma carga de trabalho está em sua própria fila) e escalar um limite de controle de utilização de entrada (inversamente) proporcionalmente ao tamanho do backlog.

Em casos onde compartilhamos uma fila de SQS para várias cargas de trabalho, esta abordagem torna-se complexa. Embora haja uma API do SQS que devolve o número de mensagens na fila, não há uma API que possa devolver o número de mensagens na fila com um atributo específico. Poderíamos medir a profundidade da fila e aplicar pressão de retorno de acordo, mas seria injusto colocar pressão de retorno em cargas de trabalho inocentes que podem estar compartilhando a mesma fila. Outros sistemas como Amazon MQ apresentam visibilidade mais detalhada de backlog.

A pressão de retorno não é adequada para todos os sistemas na Amazon. Por exemplo, em sistemas que realizam o processamento de pedidos para amazon.com, preferimos aceitar pedidos mesmo se houver um backlog, em vez de impedir o aceite de novos pedidos. Mas claro que isso é acompanhado de muita priorização nos bastidores para que os pedidos mais urgentes sejam atendidos primeiro.

Usar filas de atraso para adiar o trabalho para mais tarde

Quando os sistemas têm uma percepção de que o throughput para uma carga de trabalho específica precisa ser reduzida, tentamos usar uma estratégia de restrição naquela carga de trabalho. Para implementar isso, usamos, muitas vezes, um recurso do SQS que atrasa a entrega de uma mensagem para mais tarde. Quando processamos uma mensagem e decidimos salvá-la para mais tarde, estamos redirecionando aquela mensagem para uma outra fila de pico, mas definimos o parâmetro de atraso de modo que nossa mensagem fique oculta na fila de atraso por vários minutos. Isso dá ao sistema uma chance de trabalhar em dados novos.

Evitar muitas mensagens em trânsito

Alguns serviços de fila, como SQS, têm limites referentes a como muitas mensagens em trânsito podem ser entregues ao consumidor da fila. Isso é diferente do número de mensagens que podem estar na fila (para as quais não há limites práticos). Isso é, na verdade, o número de mensagens que a frota do consumidor está processando de uma vez só. Este número pode ser elevado se um sistema remove as mensagens da fila, mas depois não as exclui. Por exemplo, vimos erros onde o código falha na detecção de uma exceção durante o processamento de uma mensagem e esquece de excluir a mensagem. Nesses casos, a mensagem permanece em trânsito da perspectiva do SQS para o VisibilityTimeout da mensagem. Quando projetamos nossa estratégia de como lidar com erros e com sobrecarga, mantivemos estes limites em mente e a tendência de movimentar as mensagens excedentes a uma fila diferente em vez de deixá-las visíveis.

Filas de FIFO do SQS têm um limite semelhante, mas sutil. Com o FIFO do SQS, os sistemas consomem suas mensagens em ordem para um grupo determinado de mensagens, mas as mensagens de outros grupos são processadas em qualquer ordem. Então, se desenvolvermos um pequeno backlog em um grupo de mensagens, continuaremos a processar as mensagens em outros grupos. No entanto, o FIFO do SQS só faz a sondagem das 20 mil mensagens não processadas mais recentes. Então, se houver mais de 20 mil mensagens não processadas em um subconjunto de grupos de mensagens, outros grupos de mensagens com mensagens mais recentes ficarão impossibilitados.

Usar filas de mensagens de inatividade para mensagens que não podem ser processadas

As mensagens que não podem ser processadas podem contribuir para a sobrecarga do sistema. Se um sistema enfileira uma mensagem que não pode ser processada (talvez porque aciona um caso de validação de extremidade de entrada de borda), o SQS pode ajudar a movimentar essas mensagens automaticamente para uma fila separada com o recurso de fila de mensagens de inatividade (DLQ). Emitimos um alarme se há mensagens nessa fila, porque isso quer dizer que temos um erro que precisa ser consertado. O benefício do recurso de DLQ é que ele permite o reprocessamento de mensagens depois que o erro for consertado.

Garantir buffer adicional ao sondar threads por carga de trabalho

Se uma carga de trabalho estiver direcionando throughput suficiente para um ponto onde as threads de sondagem estejam ocupadas todo o tempo mesmo durante o estado estável, o sistema pode ter atingido um ponto onde não há buffer para absorver um pico no tráfego. Neste estado, um pequeno pico no tráfego de entrada pode levar a um volume sustentado de backlog não processado, resultando em latência mais alta. Fazemos um planejamento de buffer adicional nas threads de sondagem para absorver tais surtos. Uma medida é rastrear o número de tentativas de sondagem que resultam em respostas vazias. Se toda a tentativa de sondagem estiver recuperando uma mensagem ou mais, então temos somente o número certo de threads de sondagem ou possivelmente um número insuficiente para acompanhar o tráfego de entrada.

Pulsação de mensagens de longa duração

Quando um sistema processa uma mensagem do SQS, o SQS fornece a esse sistema uma quantia específica de tempo para concluir o processamento da mensagem antes de presumir que o sistema travou, e para entregar a mensagem a outro consumidor para repetir a tentativa. Se o código continua sendo executado e se esquece do prazo, a mesma mensagem pode ser entregue várias vezes em paralelo. Enquanto o primeiro processador ainda está fazendo a rotatividade de uma mensagem após atingir o tempo limite, um segundo processador vai selecioná-la e, de modo parecido, fazer a rotatividade após o tempo limite, e depois um terceiro, e assim por diante. Esse potencial de cascading de quedas de energia é o motivo de implementarmos nossa lógica de processamento de mensagem para interromper o trabalho quando uma mensagem expirar, ou para continuar a pulsação da mensagem para lembrar ao SQS que ainda estamos trabalhando nela. Esse conceito é semelhante a concessões na seleção de líder.

Este é um problema traiçoeiro, porque vemos que a latência de um sistema provavelmente aumentará durante uma sobrecarga, talvez devido a consultas a um banco de dados demorar mais, ou devido aos servidores simplesmente estarem com mais trabalho do que conseguem aguentar. Quando a latência do sistema cruza esse limite de VisibilityTimeout, faz com que o serviço já sobrecarregado, essencialmente, faça uma fork-bomb em si mesmo.

Planejamento para depuração entre hosts

Normalmente, já é difícil compreender as falhas em um sistema distribuído. O artigo relacionado sobre instrumentação descreve várias de nossas abordagens para a instrumentação de sistemas assíncronos, desde o registro de profundidades de fila periodicamente, até a propagação de “IDs de rastreamento” e integração com o X-Ray. Ou, quando nossos sistemas apresentam um fluxo de trabalho assíncrono complicado além de um fila de SQS trivial, com frequência usamos um serviço de fluxo de trabalho assíncrono diferente, como o Step Functions, que fornece visibilidade ao fluxo de trabalho e simplifica a depuração distribuída.

Conclusão

Em um sistema assíncrono, é fácil esquecer-se de como é importante pensar sobre a latência. Afinal, todos os sistemas assíncronos devem ocasionalmente demorar um pouco mais, já que têm uma fila em sua frente para realizar novas tentativas confiáveis. No entanto, cenários de sobrecarga e falhas podem desenvolver volumes imensos e incontornáveis de backlogs dos quais um serviço não se recuperará por um horizonte de tempo razoável. Estes backlogs podem vir de uma carga de trabalho ou cliente enfileirados em uma taxa inesperadamente alta, de cargas de trabalho que se tornam mais cara do que o previsto para o processamento, ou da latência ou de falhas em sua dependência.

Ao desenvolver um sistema assíncrono, precisamos nos concentrar nesses cenários de backlog e antecipá-los, além de minimizá-los ao usar técnicas como priorização, redirecionamento e pressão de retorno.

Leituras complementares

Teoria das filas
Lei de Little
Lei de Amdahl
• Little A Proof for the Queuing Formula: L = λW, Case Western, 1961
• McKenney, Stochastic Fairness Queuing, IBM, 1990
• Nichols e Jacobson, Controlling Queue Delay, PARC, 2011

Sobre o autor

David Yanacek é o engenheiro-chefe sênior responsável pelo AWS Lambda. David trabalha como desenvolvedor de software na Amazon desde 2006, e anteriormente trabalhou no Amazon DynamoDB e no AWS IoT, além de em estruturas de trabalho internas de web service e sistemas de automação de operações de frota. Uma das atividades preferidas de David é a análise de logs e o exame de métricas operacionais para descobrir maneiras de tornar a execução de sistemas mais eficientes com o passar do tempo.

Eleição de líder em sistemas distribuídos Como instrumentar sistemas distribuídos para visibilidade operacional