Sua Fila Não Vai Esvaziar: A Aritmética da Recuperação de Backlog
Uma frota de consumidores aparentemente saudável pode ficar sentada sobre um backlog de 3 milhões de mensagens que nunca diminui. Estas são minhas anotações sobre a aritmética por trás disso: excedente = capacidade − chegada, drenagem = backlog ÷ excedente, e por que uma frota dimensionada para o regime estacionário tem capacidade de recuperação zero. Construí um pequeno simulador em TypeScript para observar a amplificação de retries estacionar uma frota corretamente dimensionada em uma falha metaestável, e deduzi quando descartar (shedding) supera drenar.
O alerta dispara às 3 da manhã: profundidade da fila em 3 milhões e subindo. Abro o dashboard dos consumidores esperando um massacre. Em vez disso, todo consumidor está verde, a CPU está estável, a taxa de erros é quase zero e a linha de lag está completamente plana. Nada está quebrado. E nada está se recuperando também. Essa contradição é o problema inteiro, e não é um mistério — é uma aritmética que deixei de fazer com antecedência.
Passei algumas noites transformando aquele pânico em fórmulas depois de ler um artigo da InfoQ de maio de 2026 que expôs a matemática de forma clara, e então persegui a parte perigosa até a literatura sobre falhas metaestáveis. Estas são minhas anotações: os três números que decidem se um backlog algum dia drena, o penhasco que esconde o risco, o loop de retries que pode derrotar uma frota corretamente dimensionada e um pequeno modelo em TypeScript que me permitiu ver os três acontecendo. A matemática independe do broker; uso o AWS SQS para o enquadramento concreto porque sua reentrega por visibility timeout torna o loop de retries fácil de enxergar.
Três números, e o único que drena um backlog
Uma fila tem três entradas que você já observa, mesmo que nunca as tenha nomeado. Taxa de chegada (λ): mensagens entrando por segundo. Taxa de processamento por consumidor (μ): mensagens que um consumidor processa por segundo. Número de consumidores (c). A capacidade total é c × μ. Se a capacidade supera a chegada, a fila permanece pequena. Se não, ela cresce. Tudo daí para frente é consequência dessa única comparação.
O número que importa durante a recuperação é a diferença entre eles:
surplus = c × μ − λ
drain_time = backlog ÷ surplus
O excedente (surplus) é a única capacidade disponível para consumir um backlog, porque as chegadas em regime estacionário já estão consumindo o resto. É aqui que mora a surpresa das 3 da manhã. Uma frota provisionada para lidar com o tráfego de regime estacionário — o padrão racional e consciente de custos — tem um excedente de exatamente zero. Seu tempo de drenagem para qualquer backlog é infinito. A linha de lag está plana não porque o sistema está travado em um bug, mas porque backlog ÷ 0 não tem resposta finita.
Concretamente: 10.000 msg/seg chegando, 400 msg/seg por consumidor, 25 consumidores. A capacidade é 10.000. O excedente é 0. Um backlog de 3,6 milhões de mensagens vindo de um incidente de 10 minutos nunca se resolve. Adicione sete consumidores para um total de 32 e o excedente salta para 2.800/seg; o mesmo backlog drena em cerca de 21 minutos. Essas sete instâncias são toda a diferença entre "se recupera antes do café da manhã" e "nunca se recupera sem um humano".
Isso dá a fórmula de folga (headroom) diretamente. Para limpar um backlog de pior caso dentro de um objetivo de tempo de recuperação (RTO, em segundos) por cima da demanda de regime estacionário:
consumers = λ/μ + max_backlog ÷ (μ × RTO)
Para um backlog de pior caso de 5M e um RTO de 30 minutos: 10000/400 + 5000000/(400 × 1800) = 25 + 7 = 32. Um overhead de 28% acima do regime estacionário. O valor da fórmula não é o número — é que o planejamento de capacidade deixa de ser uma negociação baseada em sensações ("um pouco de folga a mais") e se torna um item de linha que você pode defender ou cortar.
O penhasco que esconde o risco
A razão pela qual os backlogs parecem surgir do nada é que a relação entre utilização e crescimento da fila é não-linear. A utilização é λ ÷ (c × μ). Observe o que um único aumento de 10% no tráfego faz em dois pontos de partida.
Com 80% de utilização em uma frota de 10.000/seg, o excedente é 2.000/seg. Um pico de 10% te leva a 88%; o excedente cai para 1.200. Irritante, mas sobrevivível. Com 90% de utilização, o excedente é 1.000/seg. O mesmo pico de 10% te empurra para 99%; o excedente despenca para 100/seg. A fila agora cresce cerca de dez vezes mais rápido do que crescia a 80% para um pico idêntico. O sistema não mudou da noite para o dia. A margem simplesmente era mais fina do que o dashboard sugeria, e margem fina é invisível até que algo se apoie sobre ela.
A Lei de Little te dá a tradução para impacto no cliente de graça, e ela vale igualmente para SQS, Kafka, RabbitMQ ou uma lista do Redis:
queue_depth = arrival_rate × time_in_queue
Se 600.000 mensagens estão à frente de uma taxa de chegada de 5.000/seg, a mensagem que chega agora espera cerca de 120 segundos antes mesmo de o processamento começar. Inverta: se seu SLA é de 10 segundos a 5.000/seg, sua profundidade máxima tolerável é 50.000. Esse segundo número merece estar em um dashboard com um alarme conectado a ele, porque é a linha em que você está violando por definição, e não por julgamento.
O loop de retries que derrota uma frota correta
Aqui está a parte que transforma um backlog em uma queda de serviço, e a parte que eu mais quero que meu eu do futuro lembre. Dimensionar o excedente corretamente é necessário, mas não suficiente, porque a taxa de chegada não é uma constante. Ela responde à fila.
Quando a fila está profunda, as mensagens esperam mais. Produtores aguardando respostas sofrem timeout e fazem retry. Com o SQS, a mesma dinâmica aparece sem nenhum código do cliente: uma mensagem cujo visibility timeout expira antes de o consumidor dar o ack é reentregue, então um consumidor lento fabrica suas próprias chegadas extras. Cada retry é mais uma mensagem. A chegada efetiva sobe em função de quão congestionado você já está:
effective_arrival = base_arrival × (1 + retries_per_timeout × timeout_probability)
A probabilidade de timeout sobe com a profundidade, o que aumenta a chegada efetiva, o que aumenta a profundidade. Esse loop de realimentação é um efeito sustentador, e pode manter a chegada efetiva acima da capacidade mesmo depois que o gatilho original desapareceu. O artigo de Bronson et al. no HotOS '21 chama isso de falha metaestável: um estado estável-mas-inútil que persiste depois que seu gatilho é removido, sustentado por uma otimização pensada para o caso comum. O comentário de Marc Brooker sobre a versão em fila ficou comigo — o sistema está "no ar, mas fora do ar" ("Up, but down"). O throughput parece ótimo; o goodput é zero, porque quem chamou parou de esperar pelas respostas.
Os números dizem que isso não é um caso de canto. No estudo empírico de falhas metaestáveis em onze organizações, a amplificação de retries foi o efeito sustentador em mais da metade dos incidentes, e as quedas documentadas variaram de 1,5 a 73 horas. O próprio exemplo do autor da InfoQ é mundano e absolutamente pertinente: um pipeline de pedidos no SQS perdeu um serviço de pagamento downstream por oito minutos, os produtores fizeram retry o tempo todo, a chegada efetiva voltou a 2,5x da base, e a fila continuou crescendo por mais 40 minutos com todos os consumidores saudáveis. Um gatilho de 8 minutos se tornou uma hora de dor voltada para o cliente, e os segundos 52 minutos foram causados inteiramente pela dinâmica de recuperação, não pela falha original.
Eu queria sentir a diferença em vez de confiar na fórmula, então construí um simulador de um único arquivo que avança um backlog um segundo de cada vez. Fila mais profunda, maior probabilidade de timeout, mais trabalho re-enfileirado.
// queue-drain.ts — matemática de tempo de drenagem + uma pequena simulação de consumidor estilo SQS.
// Rodar: npx tsx queue-drain.ts
type Fleet = {
arrivalRate: number; // mensagens/seg base entrando na fila (lambda)
perConsumer: number; // mensagens/seg que um consumidor drena (mu)
consumers: number; // tamanho da frota (c)
};
// O excedente é a ÚNICA capacidade disponível para drenar um backlog.
export const surplus = (f: Fleet): number =>
f.consumers * f.perConsumer - f.arrivalRate;
// Tempo de drenagem estático (segundos), ignorando retries.
// Infinity quando surplus <= 0: o backlog nunca se resolve sozinho.
export const drainSeconds = (f: Fleet, backlog: number): number => {
const s = surplus(f);
return s <= 0 ? Infinity : backlog / s;
};
// Consumidores necessários para limpar maxBacklog dentro de rto segundos, mais o regime estacionário.
export const headroom = (
arrivalRate: number, perConsumer: number, maxBacklog: number, rto: number,
): number => Math.ceil(arrivalRate / perConsumer + maxBacklog / (perConsumer * rto));
// Avança o backlog um segundo de cada vez. Uma fila mais profunda aumenta a
// probabilidade de timeout, então produtores em timeout re-enfileiram e as chegadas sobem.
export function simulate(
f: Fleet, backlog: number, retriesPerTimeout: number, maxSteps = 36_000,
): number[] {
const capacity = f.consumers * f.perConsumer;
const depths: number[] = [];
let depth = backlog;
for (let t = 0; t < maxSteps; t++) {
depths.push(depth);
if (depth <= 0) break;
const timeoutProb = Math.min(1, depth / (f.arrivalRate * 30));
const effectiveArrival = f.arrivalRate * (1 + retriesPerTimeout * timeoutProb);
depth = Math.max(0, depth + effectiveArrival - capacity);
}
return depths;
}
function report(label: string, f: Fleet, backlog: number, retries: number): void {
const depths = simulate(f, backlog, retries);
const drained = depths[depths.length - 1] <= 0;
const mins = drained ? (depths.length / 60).toFixed(1) + " min" : "never";
console.log(`${label}: surplus=${surplus(f)}/s drain=${mins} peak=${Math.max(...depths).toLocaleString()}`);
}
const steady: Fleet = { arrivalRate: 10_000, perConsumer: 400, consumers: 25 };
const withHeadroom: Fleet = { ...steady, consumers: 32 };
const backlog = 3_600_000;
console.log(`headroom (5M backlog, 30-min RTO): ${headroom(10_000, 400, 5_000_000, 1800)} consumers`);
report("steady-state fleet, no retries", steady, backlog, 0);
report("with headroom, no retries ", withHeadroom, backlog, 0);
report("with headroom, retry storm ", withHeadroom, backlog, 1.5);Rode com npx tsx queue-drain.ts. A saída é o argumento:
headroom (5M backlog, 30-min RTO): 32 consumers
steady-state fleet, no retries: surplus=0/s drain=never peak=3,600,000
with headroom, no retries : surplus=2800/s drain=21.4 min peak=3,600,000
with headroom, retry storm : surplus=2800/s drain=never peak=442,787,800
A linha não-óbvia é a terceira. A frota de 32 consumidores que limpa o backlog em 21 minutos sem retries nunca se recupera quando retriesPerTimeout é 1,5 — o pico dispara para centenas de milhões. A única coisa que mudou é que as chegadas agora respondem à profundidade. A única linha de sustentação em simulate é timeoutProb = Math.min(1, depth / (f.arrivalRate * 30)): ela amarra a taxa de chegada a quão profunda a fila está, que é o que converte um sistema que drena em um sistema autossustentável. O provisionamento não falhou. O loop de realimentação falhou.
O diagrama acima traça esse loop; a aresta a observar é a que o fecha — as chegadas subindo porque a profundidade subiu.
Descartar ou drenar, e disparar na inclinação
Uma vez que você aceita que as chegadas respondem à profundidade, duas regras operacionais decorrem disso.
A primeira é que drenar nem sempre é a jogada certa. Se o tempo estimado de drenagem excede o TTL da mensagem, a maior parte do backlog já é lixo — quem chamou sofreu timeout e foi embora. Processá-lo queima computação em trabalho que não ajuda ninguém enquanto requisições novas esperam atrás dele. A regra é direta: if drain_time > message_ttl, shed. Descarte mensagens que passaram do TTL, tire a prioridade do tráfego em lote (batch) em favor do tempo real, e entregue respostas degradadas onde existe um fallback. O descarte (shedding) tem um retorno mais silencioso para o planejamento também: se o trabalho obsoleto é descartado, o backlog de pior caso é limitado pela janela do TTL em vez da duração do incidente, o que reduz a folga que você precisa reservar. Para sistemas grandes, um controle de admissão inteligente costuma ser mais barato do que manter consumidores ociosos. Este é o complemento, do lado do backlog, ao load shedding por admissão de requisições; as alavancas diferem, mas o objetivo — descartar primeiro o trabalho mais barato — é o mesmo.
A segunda regra é sobre quando escalar. Disparar na profundidade absoluta da fila é tarde demais por construção: quando a profundidade alarma, você já está fundo no penhasco. Dispare, em vez disso, na taxa de variação — rate(queue_depth[5m]) no Prometheus, metric math no CloudWatch — e projete para frente pelo seu atraso de provisionamento, porque um novo consumidor que leva três minutos para baixar uma imagem e iniciar deveria ser dimensionado para onde o backlog estará quando ele chegar, não para onde está agora. Uma inclinação crescente sob consumidores saudáveis é também seu sinal metaestável mais precoce, bem antes de a profundidade se tornar assustadora.
Há um custo real em corrigir demais aqui, e vale nomeá-lo. Comportamento modal — trocar de política sob sobrecarga — é difícil de raciocinar e fácil de errar; um caminho de descarte que raramente roda é um caminho de descarte que apodrece. Esquemas de prioridade assumem que sua carga de trabalho tem uma ordem de prioridade limpa, o que muitas não têm. E em um pipeline de múltiplos estágios, escalar com base em um sinal de profundidade local pode te levar a escalar o estágio completamente errado: o throughput é limitado pelo estágio mais lento, então adicionar consumidores a montante do gargalo real compra exatamente zero de recuperação e uma conta maior. Monitore a profundidade em cada estágio, e conserte o gargalo antes de escalar qualquer coisa à frente dele.
Quando recorrer a isso, e quando não
Faça a aritmética de tempo de drenagem sempre que um backlog puder se formar mais rápido do que você consegue adicionar capacidade — pipelines baseados em fila, workers assíncronos, qualquer coisa com um RTO. Pule o maquinário pesado quando a fila é rasa por design, quando as chegadas genuinamente não respondem à latência (sem retries de produtor, sem reentrega, fire-and-forget), ou quando a resposta correta mais barata é simplesmente mais excedente permanente.
O que eu mudei depois deste estudo, concretamente:
- Dimensione frotas com
consumers = λ/μ + max_backlog/(μ × RTO), não com o regime estacionário. Excedente zero significa drenagem infinita. - Coloque a profundidade de SLA da Lei de Little (
λ × SLA_seconds) em um dashboard com um alarme. É a linha que você viola por definição. - Faça auto-scaling na inclinação da profundidade, projetada pelo atraso de provisionamento — nunca na profundidade absoluta.
- Observe a chegada efetiva vs. a base durante a recuperação. Profundidade estável ou crescente sob consumidores saudáveis significa amplificação; adicione backoff, jitter e uma DLQ, não consumidores.
- Defina
if drain_time > TTL, shed. Limite o backlog de pior caso pelo TTL em vez da duração do incidente.
Recorra à matemática às 3 da manhã e você divide dois números e sabe onde está. Pule-a, e você fica atualizando uma linha de lag plana se perguntando por que uma frota saudável não se mexe — que é a armadilha metaestável usando um rosto calmo.
Leitura adicional: o artigo de Bronson et al. no HotOS '21 sobre falhas metaestáveis para o modelo subjacente, e os textos de Marc Brooker sobre metaestabilidade para a intuição de sistemas de controle.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Cache Stampedes São um Problema de Capacidade, Não de Locking
Um lock distribuído em cada cache miss é a correção que a maioria dos posts recorre; nos meus próprios testes é a que transforma um soluço de 400 ms num colapso de fila. Estas são minhas notas tratando stampedes como um orçamento de capacidade upstream — por que 10% de jitter no TTL é um número de cargo cult, o que o XFetch realmente compra, e o critério que uso para decidir quando um lock vale a pena.
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.
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.