Backpressure é um contrato que todo chamador precisa honrar, não uma configuração
Uma fila limitada em um serviço não faz nada se o chamador acima dele continuar empurrando. Estas são minhas notas sobre backpressure como um contrato ponta a ponta: o que o chamado deve, o que todo chamador deve de volta, e por que o incidente de amplificação de retries do Azure OpenAI é a cara de uma cláusula quebrada em hiperescala.
Em 29 de maio deste ano, o Azure OpenAI teve o tipo de dia que merece entrar em um currículo de sistemas. O histórico público de status da Microsoft descreve o caso de forma direta: um rollout upstream mudou a maneira como certas falhas relacionadas a capacidade eram expostas, o tráfego interno de retry cresceu rapidamente em resposta, e os retries amplificados sobrecarregaram um componente compartilhado de balanceamento de carga de inferência. Falhas e latência se espalharam por regiões e modelos. O impacto para clientes começou às 09:20 UTC. A mitigação foi confirmada às 17:05 UTC — quase oito horas depois — após a Microsoft isolar a carga de trabalho interna problemática em infraestrutura dedicada e reverter a mudança que disparou tudo.
Tire o contexto de IA e o formato é antigo. Um componente sob pressão avisou seus chamadores de que estava falhando. Um chamador — o Microsoft 365, first-party, rodando dentro da mesma empresa — respondeu a esse sinal com mais tráfego. A sobrecarga não ficou onde o limite era imposto. Ela se mudou para cima, para a infraestrutura compartilhada, e dali atingiu terceiros em várias regiões. O relato da própria Microsoft nomeia a lacuna com precisão: aquela carga interna não estava sujeita aos controles de rate limiting e de sobrecarga aplicados ao tráfego externo, e chegava à camada de roteamento compartilhada por um caminho mais direto que o do tráfego externo. Daí o reparo — mover as grandes cargas first-party para infraestrutura de roteamento dedicada, para que parem de compartilhar uma camada de balanceamento com todo mundo.
Em código após código que eu leio, backpressure é uma configuração. Uma capacidade de fila aqui, um semáforo ali, uma dependência de Reactive Streams no arquivo de build, e o item está marcado. O incidente me soa diferente, e bate com o que eu fico reaprendendo nos meus próprios testes de carga: backpressure é um contrato com obrigações dos dois lados de cada salto. O serviço sob carga deve aos seus chamadores um sinal de desaceleração que seja barato de produzir e cedo o bastante para agir. Cada chamador deve uma reação real — menos requisições em voo, retries orçados, ou descarte deliberado do próprio tráfego. Um limite imposto em um serviço e ignorado pelos seus chamadores não protege o sistema. Ele escolhe o próximo local do desastre.
Uma dependência lenta, rastreada para fora
A menor cadeia que mostra a mecânica tem quatro partes: um serviço de borda A, dois serviços internos B e C, e um banco de dados sob C. C normalmente atende 2.000 requisições por segundo. Um plano de query ruim entra em cena, e o teto de C cai para 400.
O que C faz em seguida é a primeira cláusula do contrato. Se C enfileirar trabalho recebido sem limite, sua latência sobe conforme o backlog cresce. Os chamadores dão timeout muito antes de o backlog drenar, então C gasta suas 400 requisições por segundo restantes respondendo a chamadores que já desligaram, enquanto a memória cresce até o processo morrer. O capítulo sobre sobrecarga do livro de SRE descreve exatamente esse estado final: o trabalho se acumula, as tarefas ficam sem memória e caem, e a falha se espalha para os vizinhos.
Então C limita sua fila e rejeita o excesso rapidamente. Essa é a parte que todo mundo entrega, e o ponto onde a maioria dos sistemas para. C agora sobrevive: atende 400 requisições por segundo de trabalho real e devolve erros rápidos para o resto. Mas essas rejeições caem em B, e a reação de B — não a fila de C — decide se o incidente acabou.
B tem dois caminhos. Se B honrar o sinal, ele limita suas requisições em voo em direção a C, para de fazer retry sobre as rejeições, falha ou degrada as requisições que não consegue atender, e passa o mesmo sinal para cima, para A. A carga acima do teto de C é convertida em erros rápidos e explícitos na borda. Os usuários veem uma funcionalidade degradada. O sistema continua de pé.
Se B ignorar o sinal — mantendo sua concorrência em malha aberta e fazendo retry de cada falha três vezes — a carga oferecida a C triplica exatamente quando C está mais fraco. C continua rejeitando, então as threads e os pools de conexão de B enchem de tentativas em andamento, a latência do próprio B sobe, e B se torna o serviço sobrecarregado. O colapso andou um salto para mais perto do usuário. Agora A enfrenta a mesma escolha em que B acabou de falhar, com menos tempo e mais tráfego.
O diagrama abaixo é o formato que eu vivo rascunhando nas minhas notas: o sinal de desaceleração sai da dependência sobrecarregada, atravessa os saltos que o honram, e morre no primeiro que não honra — e é nesse salto que o transbordo da fila e o colapso reaparecem.
Os retries definem a velocidade dessa mudança de endereço. O livro de SRE do Google trabalha com um orçamento de três tentativas por requisição. Composto ao longo de uma cadeia, esse número deixa de ser pequeno: se três camadas fizerem retry três vezes cada de uma chamada que falha, uma requisição de usuário vira até 3³ = 27 tentativas contra a dependência no fundo, e o retry do próprio usuário adiciona mais um fator de três por cima. O post-incident review da Azure coloca um número medido ao lado dessa aritmética: em alguns casos uma única requisição que falhou gerou até 48 tentativas adicionais, porque várias camadas da pilha de chamada fizeram retry imediatamente e sem backoff ou jitter suficientes. Essa aritmética é o motivo de a regra do livro de SRE ser estrita: uma requisição que falhou deve sofrer retry em exatamente uma camada — a imediatamente acima da rejeição — e toda camada acima dessa deve ver um erro ou uma resposta degradada, não uma tentativa nova.
O que o chamado deve
O lado do contrato que cabe ao serviço sobrecarregado se resume a três obrigações, e nenhuma delas é "ter uma fila".
Um limite dimensionado em tempo, não em itens. Um limite de fila é uma promessa de latência. O tempo de espera que uma fila adiciona é igual à sua profundidade dividida pela taxa de serviço, então uma fila de 5.000 itens na frente de um serviço fazendo 400 requisições por segundo promete ao último chamador uma espera de 12,5 segundos. Se os chamadores desistem em 2 segundos, cada slot além do 800 guarda uma requisição que já está morta — o serviço vai processá-la, e ninguém vai ler a resposta. A regra de dimensionamento que eu mantenho nas minhas notas: limite = taxa de serviço × o deadline que os chamadores de fato usam, menos o tempo típico de processamento. Um número maior não compra resiliência; compra trabalho morto.
Rejeição mais barata que atendimento. Dizer não precisa custar quase nada, porque sob sobrecarga real vira a operação mais frequente que o serviço executa. O livro de SRE é direto sobre esse modo de falha: rejeitar uma requisição ainda queima CPU, e um backend pode acabar sobrecarregado puramente por produzir rejeições. Isso defende rejeitar o mais cedo possível no caminho da requisição — antes de parsing e lookups caros — e estabelece a régua de qualidade do exercício inteiro: uma tarefa provisionada para uma dada taxa deve continuar atendendo essa taxa não importa quanto excesso chegue. O Google projeta para estabilidade de duas a dez vezes a carga provisionada. Goodput — a fatia da vazão cujos resultados os chamadores de fato consomem — é o número a defender; vazão total sob sobrecarga é vaidade.
Um sinal que diz o que fazer em seguida. Nem toda rejeição significa a mesma coisa, e o chamado é a única parte que sabe qual delas se aplica. Três mensagens cobrem os casos das minhas notas:
- Tente em outro lugar: esta réplica está ocupada, mas o pool provavelmente não está. O significado padrão de um erro rápido e passível de retry.
- Vá mais devagar, tente depois: o pool está saturado. Carregue uma dica — o header
Retry-Afterdo HTTP, ou o metadadogrpc-retry-pushback-msdo gRPC, que diz ao cliente exatamente quantos milissegundos esperar. - Pare: retry não vai ajudar. O gRPC codifica isso como um valor de pushback negativo; o livro de SRE usa um erro explícito de "sobrecarregado; não faça retry" para que pilhas profundas parem de martelar.
Medido contra esses três, um timeout é o pior sinal que um chamado pode emitir: chega no último momento possível, custa um deadline inteiro de recursos do lado do chamador para ser recebido, e não diz nada sobre o que fazer em seguida. Um serviço que só sinaliza sobrecarga dando timeout, em termos de contrato, ficou mudo.
O que o chamador deve
O lado do chamador é onde o contrato geralmente quebra, porque os incentivos do chamador apontam para o lado errado: de dentro de um serviço, tanto fazer mais retry quanto enfileirar mais parecem diligência.
Um teto de requisições em voo. A reação mínima honesta é uma malha fechada: um limite fixo de requisições concorrentes em direção a cada dependência. A lei de Little faz o resto — concorrência é igual a vazão vezes latência, então quando a dependência fica mais lenta, um teto fixo de concorrência força a vazão do chamador para baixo automaticamente. O teto traduz a latência crescente do chamado exatamente na resposta de que ele precisa: menos requisições. Um chamador em malha aberta que dispara a uma taxa fixa independentemente das respostas não tem esse acoplamento; ele é a ferramenta de teste de carga que encontra o colapso, rodando em produção.
Um orçamento de retry, não uma contagem de retries. Uma contagem de retries é uma afirmação sobre uma requisição. Um orçamento é uma afirmação sobre o cliente inteiro, e só o orçamento interrompe a amplificação. As implementações que comparei escrevendo isto convergem para a mesma faixa:
- O livro de SRE coloca uma razão de retry de 10% por cliente por cima do teto de três tentativas; na sua análise de pior caso, isso leva a amplificação de retry de aproximadamente 3× para 1,1×.
- O design de retry do gRPC mantém um token bucket por nome de servidor: cada falha custa um token, cada sucesso devolve
tokenRatio(0,1 no exemplo da spec), e todos os retries param enquanto o bucket estiver em ou abaixo da metade demaxTokens. A biblioteca cliente também limitamaxAttemptsem 5 no máximo, não importa o que a configuração do serviço peça. - O
RetryBudgetpadrão do Finagle permite cerca de 20% do total de requisições como retries, mais um piso de 10 retries por segundo para que clientes de baixo tráfego não fiquem sem retries, rastreado com um leaky token bucket cujos tokens depositados expiram por TTL. A documentação traz um aviso que merece moldura: compartilhe um único orçamento entre todas as camadas de retry do cliente, ou as camadas se multiplicam.
A propriedade comum é a inversão. Retries ingênuos são mais agressivos exatamente quando o sistema está mais doente. Retries orçados são abundantes quando as falhas são raras e somem quando as falhas são comuns — a própria lógica de retry honra o backpressure.
Contexto que permita ao chamado descartar. Um chamador que propaga seu deadline permite ao chamado pular trabalho que já não pode ser consumido; o gRPC aplica um único deadline de chamada em todas as tentativas de um RPC, então retries não podem ressuscitar uma requisição cujo chamador já foi embora. Criticidade faz o mesmo pela importância: o Google propaga um de quatro valores de criticidade automaticamente pelos metadados de RPC, então uma tarefa no fundo da pilha pode rejeitar trabalho em lote enquanto protege trabalho interativo sem precisar perguntar a ninguém. Quais requisições descartar primeiro é um assunto à parte; a cláusula do contrato aqui é apenas que o rótulo precisa viajar junto com a chamada, porque o chamado não consegue inventá-lo.
Auto-regulação antes do fio. Para rejeição no estilo de quota existe um movimento mais forte do lado do chamador: parar de enviar localmente. O adaptive throttling do livro de SRE faz cada cliente acompanhar seus requests e accepts em uma janela de dois minutos e rejeitar novas chamadas localmente com probabilidade (requests − K·accepts)/(requests + 1). Com o padrão K = 2, o cliente começa a se auto-limitar assim que o backend rejeita metade do seu tráfego; apertar K para 1,1 significa que o backend só precisa rejeitar uma requisição a cada dez que aceita. A ressalva vem do mesmo capítulo: clientes que chamam raramente carregam uma visão desatualizada do backend, então isso nunca substitui os limites do próprio chamado — apenas tira a maior parte do custo de rejeição do fio para os chamadores que importam.
Demanda concedida, onde o runtime suportar. Tudo acima é reativo — o chamador envia, o chamado empurra de volta. Sistemas de crédito invertem o padrão: nada se move até o receptor conceder capacidade. O request(n) do Reactive Streams é essa cláusula expressa como interface; o HTTP/2 a impõe por stream com janelas de controle de fluxo que começam em 65.535 bytes até o receptor aumentá-las; consumidores de broker fazem isso com prefetch ou contagens de permits. Dentro de um processo ou de uma única conexão, demanda concedida é a forma mais forte do contrato, porque o runtime a impõe. Atravessando fronteiras de serviço e fan-outs, ela decai para a forma reativa — que é o motivo de as obrigações reativas acima continuarem valendo em todo lugar.
Onde o contrato quebra em silêncio
Toda falha de backpressure que eu estudei cai em uma de quatro lacunas, e nenhuma delas se parece com uma configuração faltando.
Um broker esconde a contraparte. Coloque uma fila entre dois serviços e a visão do produtor muda: o broker sempre aceita, então o produtor vê uma dependência saudável enquanto o consumidor se afoga. O sinal de desaceleração ainda existe — chama-se lag do consumidor — mas mora em um dashboard que o código do produtor nunca lê. O ensaio de Fred Hébert, Queues Don't Fix Overload, apontou isso lá em 2014: com um buffer maior, "você está tornando as falhas mais raras, mas está tornando a magnitude delas pior". O contrato sobrevive a um broker apenas se alguém o restabelecer: limite a fila em tempo (idade máxima da mensagem, TTL), ou conecte o lag de volta aos produtores como quotas ou limites rígidos. Fire-and-forget é amputação de sinal — uma escolha adequada quando ninguém está esperando o resultado, e uma dívida silenciosa quando alguém está.
Retries invertem o sinal. Uma rejeição significa vá mais devagar. Um loop de retry ingênuo traduz isso como envie de novo, agora. Essa é exatamente a cláusula que o incidente da Azure quebrou: a mudança que disparou tudo não removeu sinais de capacidade, ela mudou como eles apareciam, e a lógica de retry do lado do chamador os transformou em tráfego multiplicado. Qualquer salto cuja política de retry não tem orçamento guarda essa inversão na reserva, esperando o dia ruim certo.
Balanceadores de carga diluem o sinal. Fazer retry contra uma réplica diferente é legítimo — o livro de SRE conta com isso como balanceamento de carga orgânico quando uma única tarefa esquenta. Mas quando o pool inteiro está saturado, rejeições por réplica com retry espalhado pelo pool convertem o sinal de um nó na carga de todos. É por isso que os designs mais fortes tornam o sinal coletivo: o gRPC faz throttling de retries por nome de servidor em vez de por conexão, e os backends do Google acompanham quantas tentativas cada requisição recebida já sobreviveu, mudando para um sinal de parada quando o histograma diz que o problema é o pool, não a tarefa.
Autoscaling corre atrás do sinal. Escalar horizontalmente responde à sobrecarga com capacidade — minutos depois, após provisionamento, pull de imagem, aquecimento e preenchimento de cache. O sinal de desaceleração opera em milissegundos. Sem o contrato segurando a linha nesse meio-tempo, a tempestade de retries chega muito antes da capacidade, e a frota escala para dentro de um sistema que já está colapsando. Autoscaling é a segunda linha de defesa; não pode ser a primeira.
Bloquear ou descartar
Hébert enquadra o estado final como uma escolha que não pode ser delegada: quando a entrada excede a capacidade por tempo suficiente, ou você bloqueia os produtores ou descarta o excesso. Ambas honram o contrato; diferem em quem absorve a dor.
Bloquear — filas limitadas que empurram tempo de espera para cima, sistemas de crédito, tetos de requisições em voo — preserva trabalho e serve chamadores cooperativos: serviços internos, pipelines em lote, qualquer coisa que consiga de fato ir mais devagar. Tem um lado afiado: threads bloqueadas são elas mesmas um recurso, e uma cadeia de bloqueio com um ciclo dentro pode dar deadlock do mesmo jeito que qualquer ciclo de lock.
Descartar — load shedding, respostas degradadas — serve para a borda, porque a internet pública não vai mais devagar quando alguém pede. Usuários fazem retry, navegadores fazem retry, e SDKs mobile fazem retry nos seus próprios horários; o único movimento defensável nessa fronteira é tornar a rejeição quase gratuita e o caminho degradado genuinamente útil.
Cadeias reais precisam das duas coisas: descarte na borda, bloqueie entre serviços, e garanta que as duas se encontrem em um salto que sabe de que lado está.
O que eu levo de volta para os meus próprios designs:
- Dimensione toda fila em tempo: profundidade ÷ taxa de serviço precisa caber dentro do deadline que os chamadores de fato usam. Além dessa linha, a fila guarda trabalho morto, não resiliência.
- Faça da rejeição a operação mais barata que o serviço executa, e faça-a dizer uma de três coisas: tente em outro lugar, tente depois (com uma dica), ou pare.
- Limite as requisições em voo em direção a toda dependência. O teto é o que transforma um chamado lento em um chamador mais lento em vez de um chamador morto.
- Substitua contagens de retry por um orçamento compartilhado na faixa de 10–20% — a faixa em que a prática de SRE, o gRPC e o Finagle aterrissaram — e faça retry em exatamente uma camada.
- Propague deadlines e criticidade em toda chamada; um chamado só consegue descartar bem aquilo que seus chamadores rotulam.
- Trate todo broker como um lugar onde o sinal desaparece. Limite-o em tempo, ou conecte o lag do consumidor de volta a um limite visível ao produtor.
Recorra a backpressure quando os chamadores podem cooperar — caminhos serviço a serviço, pipelines, qualquer coisa interna onde um teto de concorrência e um orçamento de retry estejam ao alcance. Prefira descarte onde não podem — a borda pública, clientes esporádicos, fronteiras entre organizações. Em qualquer um dos casos, gaste o tempo de revisão nos chamadores, não só no serviço: o limite de fila configurado nesta sprint só decide onde o próximo incidente vai cair. Se ele vai cair ou não é decidido por cada chamador acima dele.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Concorrência Estruturada Parece Igual em Quatro Runtimes — Até um Filho Falhar
Escrevi o mesmo fan-out quatro vezes — Java 25 StructuredTaskScope, Kotlin coroutineScope, Swift withThrowingTaskGroup, Python asyncio.TaskGroup — e a API de superfície é quase intercambiável. As semânticas de cancelamento e agregação de exceções não são. Estas são minhas notas sobre o que diverge no caminho de falha e por que apenas Python te entrega todas as falhas por padrão.
Descarte Primeiro as Requisições Certas: Load Shedding Baseado em Prioridade sob Sobrecarga
Limites estáticos de RPS descartam o tráfego errado. Concorrência é o que satura um serviço, não a taxa de requisições. A partir das minhas anotações após ler o artigo do InfoQ sobre proteção contra sobrecarga, o post de janeiro da Uber sobre o Cinnamon e a palestra da Netflix no QCon SF sobre load shedding priorizado em nível de serviço, eis por que latência é o sinal de controle correto — e como uma pequena taxonomia de prioridades aliada a um limite adaptativo de concorrência mantém o tráfego mais barato sendo descartado primeiro.
Auditando um serviço Scala contra as quatro restrições regenerativas de Chad Fowler
Levei um serviço Scala de processamento de pedidos das minhas anotações pelas quatro restrições regenerativas de Chad Fowler. Duas passaram de graça, duas forçariam um redesign de verdade. Aqui está o que aprendi sobre onde "módulo fracamente acoplado" termina e "componente regenerativo" começa, e quais partes do redesign eu de fato pagaria.