Blog

Fila morta que ninguém lê: transformar mensagem descartada em correção de verdade

A fila morta tinha quarenta e um mil mensagens e a mais antiga era de catorze meses atrás. Ninguém tinha desligado o alerta: nunca existiu alerta. O painel mostrava a fila principal saudável, o consumidor com lag zero e a taxa de erro em zero vírgula três por cento, porque toda mensagem que falhava três vezes saía do numerador e ia parar num lugar que nenhum gráfico observava. Este artigo mostra por que a fila morta é um registro de defeitos e não um depósito, qual campo precisa acompanhar a mensagem para que o descarte seja diagnosticável meses depois, por que a taxa de erro que o time acompanha exclui exatamente as falhas que importam, como agrupar quarenta e um mil mensagens em cinco causas em vez de tratar uma a uma, por que reprocessar tudo de uma vez costuma derrubar o serviço que acabou de se recuperar, e qual regra decide o que é reprocessável e o que precisa ser encerrado sem retorno.

2026-09-01 / Arquitetura / 17 min

01

A fila morta não é um depósito, é um relatório de defeitos

O nome atrapalha. Fila de mensagens mortas soa como o lugar onde vão as coisas que já não têm conserto, e essa leitura autoriza o comportamento que produz quarenta e um mil mensagens paradas: se está morto, não precisa de ninguém olhando. Mas nenhuma mensagem chega ali por decisão de negócio. Ela chega porque o consumidor tentou processá-la, falhou o número configurado de vezes e o broker seguiu a política que alguém definiu no dia em que a fila foi criada. Cada mensagem naquele lugar é um caso em que o sistema afirmou uma coisa e entregou outra, e a fila morta é o único inventário completo dessas afirmações quebradas.

A consequência prática é que a fila morta contém informação que não existe em nenhum outro lugar do sistema. O log tem a exceção mas raramente tem o payload completo, e quando tem, ele já foi rotacionado. A métrica tem o contador mas perdeu a identidade da mensagem. O rastreamento distribuído tem o caminho mas quase nunca sobrevive à amostragem, porque justamente as requisições com erro são as que a amostragem por taxa fixa descarta com a mesma probabilidade das outras. A mensagem morta é o único artefato que preserva ao mesmo tempo o dado de entrada e o fato de que o processamento falhou, e é por isso que descartá-la sem análise é apagar a evidência do defeito junto com o sintoma.

Há um segundo efeito que é mais silencioso e mais caro. A mensagem que vai para a fila morta some do cálculo de sucesso do consumidor. O consumidor que tentou três vezes, falhou e roteou a mensagem terminou o trabalho dele com êxito do ponto de vista do broker: ele confirmou o recebimento e seguiu para a próxima. Do ponto de vista do painel, aquela unidade de trabalho não é um erro, é uma conclusão. É assim que um sistema com dois por cento de falha real reporta zero vírgula três por cento e passa quatorze meses sendo considerado saudável por todo mundo que olha o gráfico.

O QUE O PAINEL MEDE

  entrada 100k --> [consumidor] --+--> sucesso 99.7k  --> "99.7% de sucesso"
                                  |
                                  +--> erro 300       --> taxa de erro 0.3%
                                  |
                                  +--> 3 falhas -> DLQ 2.0k   (nao entra em nenhum dos dois)
                                                    ^
                                                    |
                                        invisivel para o SLO,
                                        visivel para o cliente

O QUE O CLIENTE MEDE

  entrada 100k --> resultado esperado 97.7k
                   resultado ausente   2.3k  --> 2.3% de falha real
                                              (300 erros + 2000 na DLQ)

A correção de instrumentação é de uma linha e vale mais do que qualquer painel novo: a taxa de sucesso do consumidor precisa contar a mensagem roteada para a fila morta como falha, e não como conclusão. Isso normalmente significa emitir a métrica de resultado no ponto em que a decisão de roteamento é tomada, e não no ponto em que o broker recebe a confirmação. Enquanto essa contagem não mudar, todo o resto deste artigo é opcional, porque ninguém vai priorizar a análise de uma fila que o painel afirma não existir.

02

A mensagem morta precisa carregar o motivo, não só o corpo

A maioria das filas mortas é inútil não por estar cheia, mas porque cada mensagem guardada ali é idêntica à mensagem original. Um payload de pedido com trezentos campos e nenhuma indicação do que aconteceu. Para descobrir por que aquela mensagem falhou, alguém precisa reconstruir o contexto: achar o log daquele instante, se ainda existir, correlacionar por identificador, se ele estiver no log, e adivinhar qual versão do consumidor estava rodando. Multiplicado por quarenta e um mil, esse trabalho não é caro, é impossível, e é exatamente por isso que a fila nunca é lida.

A alternativa é envelopar a mensagem no momento do descarte. O corpo original fica intacto num campo, para que o reprocessamento seja literal, e ao redor dele entram os metadados que respondem às perguntas que alguém vai fazer meses depois. Cinco campos resolvem quase todos os casos: a classe do erro, que é o que permite agrupar; a mensagem do erro truncada, que é o que permite diferenciar dentro do grupo; a versão do consumidor, que é o que responde se a falha ainda existe no código atual; o identificador de rastreamento, que é o que liga o descarte ao restante da requisição; e o carimbo de tempo da primeira tentativa, não da última, porque é ele que localiza o evento que causou o começo do problema.

// queue/dead-letter.js
// Envelopa a mensagem no momento do descarte.
//
// O corpo original fica intacto em 'payload' para que o reprocessamento
// seja literal. Os metadados existem para responder, meses depois, as
// perguntas que ninguem consegue responder pelo log rotacionado.

const MAX_ERROR_MESSAGE = 500;

// A classe do erro e o que permite agrupar 41k mensagens em 5 causas.
// Sem normalizacao, 'timeout after 30012ms' e 'timeout after 30044ms'
// viram dois grupos distintos e o agrupamento perde a utilidade.
const classifyError = (error) => {
  if (error.name === 'ValidationError') return 'validation';
  if (error.name === 'TimeoutError') return 'dependency_timeout';
  if (error.status === 404) return 'reference_not_found';
  if (error.status >= 500) return 'dependency_unavailable';
  return 'unknown';
};

export const buildDeadLetter = ({ message, error, attempts, consumerVersion }) => ({
  // Corpo original, byte a byte. Nada de reserializar aqui: qualquer
  // normalizacao aplicada agora muda o que sera reprocessado depois.
  payload: message.body,

  errorClass: classifyError(error),
  errorMessage: String(error.message ?? '').slice(0, MAX_ERROR_MESSAGE),

  // Responde 'essa falha ainda existe no codigo atual?' sem arqueologia
  // de git. E o campo que autoriza descartar um grupo inteiro.
  consumerVersion,

  // Liga o descarte ao restante da requisicao enquanto o trace existir.
  traceId: message.headers['x-trace-id'] ?? null,

  attempts,
  // Primeira tentativa, nao a ultima: e ela que localiza o deploy ou o
  // incidente que iniciou o problema. A ultima so registra o backoff.
  firstAttemptAt: message.headers['x-first-attempt-at'] ?? message.receivedAt,
  deadLetteredAt: new Date().toISOString(),
});

Há uma armadilha de conformidade nesse envelope que vale antecipar. A mensagem morta é frequentemente a cópia mais duradoura de um dado pessoal dentro do sistema, porque a fila principal expira em dias e a fila morta costuma ser configurada com retenção máxima. Se o pipeline tem uma política de retenção, ela precisa valer também ali, e o mesmo processo que apaga o dado do banco tem que alcançar a fila morta. Um envelope que guarda o payload completo por catorze meses é uma exposição que ninguém decidiu criar e que nenhum inventário registra.

03

Agrupar por causa transforma quarenta e um mil casos em cinco decisões

A razão pela qual ninguém começa a análise é que o volume sugere um esforço proporcional ao número de mensagens, e ele não é. Filas mortas reais têm uma distribuição fortemente concentrada: um punhado de causas responde por quase todo o volume, e a cauda são casos isolados. Quarenta e um mil mensagens costumam se resolver em cinco ou seis decisões, e o trabalho difícil é chegar a esse agrupamento sem carregar as quarenta e um mil para a memória de alguém.

O agrupamento útil não é por classe de erro sozinha, porque ela é grossa demais: dependency_timeout junta a fila inteira de um incidente de duas horas com falhas esporádicas de meses diferentes, que exigem tratamentos opostos. A chave que funciona é a combinação de classe do erro com versão do consumidor e uma assinatura do erro com os números removidos. Essa terceira parte é a que costuma ser esquecida, e sem ela cada milissegundo diferente num timeout vira um grupo próprio. Com os três campos, o relatório sai em uma passada e cabe numa tela.

// scripts/triage-dead-letter.js
// Agrupa a fila morta por causa e produz um relatorio que cabe numa tela.
//
// Roda em uma passada, sem carregar tudo na memoria: o consumo e por
// lote e o acumulador so guarda um resumo por grupo.

// Remove numeros, UUIDs e aspas: 'timeout after 30012ms' e
// 'timeout after 30044ms' precisam cair no mesmo grupo.
const signatureOf = (errorMessage) =>
  errorMessage
    .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<id>')
    .replace(/\d+/g, '<n>')
    .replace(/'[^']*'|"[^"]*"/g, '<v>')
    .slice(0, 120);

const groupKey = (entry) =>
  `${entry.errorClass}|${entry.consumerVersion}|${signatureOf(entry.errorMessage)}`;

export const triage = (entries) => {
  const groups = new Map();

  for (const entry of entries) {
    const key = groupKey(entry);
    const group = groups.get(key) ?? {
      errorClass: entry.errorClass,
      consumerVersion: entry.consumerVersion,
      signature: signatureOf(entry.errorMessage),
      count: 0,
      firstSeen: entry.firstAttemptAt,
      lastSeen: entry.firstAttemptAt,
      // Uma amostra por grupo: suficiente para investigar, e evita
      // guardar 41k payloads no relatorio.
      sample: entry,
    };

    group.count += 1;
    if (entry.firstAttemptAt < group.firstSeen) group.firstSeen = entry.firstAttemptAt;
    if (entry.firstAttemptAt > group.lastSeen) group.lastSeen = entry.firstAttemptAt;
    groups.set(key, group);
  }

  return [...groups.values()]
    .map((group) => ({
      ...group,
      // Janela concentrada indica incidente pontual e favorece
      // reprocessamento. Janela larga indica defeito estrutural e
      // reprocessar so devolve as mesmas mensagens para a fila morta.
      spanHours:
        (Date.parse(group.lastSeen) - Date.parse(group.firstSeen)) / 3_600_000,
    }))
    .sort((a, b) => b.count - a.count);
};

O campo que decide a ação é o intervalo entre a primeira e a última ocorrência do grupo. Um grupo com nove mil mensagens concentradas em duas horas é o rastro de um incidente que já terminou, e reprocessar resolve. Um grupo com nove mil mensagens espalhadas por onze meses é um defeito que continua acontecendo, e reprocessar apenas devolve as mesmas mensagens à fila morta na semana seguinte. Essa distinção é a única que muda o que o time faz, e ela não aparece em nenhum agrupamento que ignore o tempo.

Perfil do grupoO que significaAção correta
Volume alto, janela de horas, versão antigaRastro de incidente encerrado em código que já mudouReprocessar em lote com limite de vazão
Volume alto, janela de meses, versão atualDefeito estrutural ativo no consumidorCorrigir o código primeiro, reprocessar depois
Classe validation, qualquer janelaProdutor emite payload que o contrato não aceitaCorrigir no produtor, encerrar as mensagens antigas
Classe reference_not_found, janela largaEntidade referenciada foi apagada legitimamenteEncerrar sem retorno e registrar a decisão
Volume baixo, ocorrências isoladasCauda longa de casos únicosAmostrar dois ou três e encerrar o resto

04

Reprocessar sem derrubar o serviço que acabou de se recuperar

O erro clássico depois de uma triagem bem-feita é redirecionar o grupo inteiro de volta para a fila principal de uma vez. Quarenta mil mensagens injetadas em segundos numa fila que processa duzentas por segundo em regime normal produzem três efeitos ao mesmo tempo: o tráfego atual passa a esperar atrás do reprocessamento, a dependência que causou o incidente original recebe uma rajada muito maior do que a que a derrubou, e se o defeito ainda existir, as mesmas mensagens voltam para a fila morta com o contador de tentativas zerado, apagando o histórico que a triagem acabou de construir.

O reprocessamento seguro tem quatro propriedades e nenhuma delas é opcional. Vazão limitada, para que o reprocessamento consuma uma fração da capacidade e o tráfego atual continue tendo prioridade. Lote pequeno de teste antes do volume, tipicamente cinquenta mensagens, com verificação do resultado antes de liberar o resto. Marcação de origem na mensagem, para que uma falha no reprocessamento seja distinguível de uma falha nova. E ponto de parada automático, que interrompe o processo quando a taxa de falha do próprio reprocessamento passa de um limiar, porque continuar depois disso só transforma um problema conhecido em dois.

// scripts/replay-dead-letter.js
// Reprocessa um grupo da fila morta com vazao limitada e parada automatica.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export const replay = async ({
  entries,
  publish,
  ratePerSecond = 20,
  canaryBatch = 50,
  abortFailureRate = 0.2,
}) => {
  const interval = 1000 / ratePerSecond;
  const stats = { published: 0, failed: 0, aborted: false };

  for (const [index, entry] of entries.entries()) {
    try {
      await publish({
        ...entry.payload,
        headers: {
          // Marca a origem: uma falha aqui e uma reincidencia conhecida,
          // nao um caso novo. Sem isso o proximo relatorio conta o mesmo
          // defeito duas vezes.
          'x-replay-of': entry.deadLetteredAt,
          'x-replay-reason': entry.errorClass,
        },
      });
      stats.published += 1;
    } catch (error) {
      stats.failed += 1;
    }

    const processed = stats.published + stats.failed;

    // Canario: para depois do primeiro lote e devolve o controle para
    // quem chamou verificar o efeito antes de liberar o volume.
    if (processed === canaryBatch && index + 1 < entries.length) {
      return { ...stats, paused: true, remaining: entries.length - processed };
    }

    // Parada automatica: se o proprio reprocessamento esta falhando,
    // continuar transforma um problema conhecido em dois.
    if (processed >= canaryBatch && stats.failed / processed > abortFailureRate) {
      return { ...stats, aborted: true, remaining: entries.length - processed };
    }

    await sleep(interval);
  }

  return { ...stats, paused: false, aborted: false, remaining: 0 };
};

Falta uma verificação que costuma ser descoberta tarde demais: a mensagem antiga ainda faz sentido? Um evento de reserva de estoque de catorze meses atrás, reprocessado hoje, vai debitar um estoque que já foi vendido. Um evento de notificação vai mandar ao cliente uma mensagem sobre um pedido que ele já recebeu. Antes de reprocessar, cada grupo precisa passar por uma pergunta explícita sobre validade temporal, e a resposta muitas vezes é que o dado precisa ser corrigido no banco e não reinjetado no fluxo. Reprocessar é a ação padrão para incidentes recentes, não para tudo que estiver na fila.

05

Encerrar sem retorno é uma decisão, e ela precisa ficar registrada

Boa parte da fila morta não deve ser reprocessada, e admitir isso é o que destrava a limpeza. Mensagens cuja entidade de referência já foi apagada, eventos cuja janela de validade expirou, payloads gerados por uma versão do produtor que não existe mais, duplicatas de algo que já foi processado por outro caminho. O caminho correto para esses casos não é apagar em silêncio: é encerrar registrando o motivo, porque a diferença entre uma fila limpa e uma fila esvaziada é justamente a existência desse registro.

O registro de encerramento é barato e resolve dois problemas futuros. O primeiro é a auditoria: quando alguém perguntar por que o pedido tal nunca foi processado, a resposta existe e tem data, autor e justificativa, em vez de um silêncio que obriga a reconstruir tudo. O segundo é a reincidência: com o motivo registrado, o próximo relatório da fila morta consegue distinguir o grupo que já foi analisado e encerrado do grupo novo, e a triagem seguinte não recomeça do zero. Sem esse registro, cada análise da fila morta é a primeira análise da fila morta.

// queue/dead-letter-disposition.js
// Encerrar sem retorno e uma decisao registrada, nao um delete.

const VALID_REASONS = new Set([
  'entity_deleted',       // a entidade referenciada nao existe mais
  'window_expired',       // o evento perdeu validade temporal
  'producer_retired',     // formato de uma versao do produtor que nao existe
  'already_processed',    // outro caminho ja tratou o mesmo efeito
  'fixed_in_code',        // defeito corrigido, mensagens antigas irrelevantes
]);

export const closeGroup = async ({ group, reason, decidedBy, store }) => {
  if (!VALID_REASONS.has(reason)) {
    // Motivo livre vira 'outros' em tres meses e o registro perde a
    // funcao. A lista fechada e o que mantem o relatorio comparavel.
    throw new Error(`motivo de encerramento invalido: ${reason}`);
  }

  return store.recordDisposition({
    signature: group.signature,
    errorClass: group.errorClass,
    consumerVersion: group.consumerVersion,
    affectedCount: group.count,
    firstSeen: group.firstSeen,
    lastSeen: group.lastSeen,
    reason,
    decidedBy,
    decidedAt: new Date().toISOString(),
  });
};

// A triagem seguinte consulta as decisoes anteriores e separa o que ja
// foi analisado do que e novo. Sem isso, toda analise da fila morta e a
// primeira analise da fila morta.
export const splitAgainstHistory = (groups, dispositions) => {
  const closed = new Map(dispositions.map((d) => [d.signature, d]));

  return {
    // Grupo ja encerrado que voltou a aparecer: o motivo registrado nao
    // se sustentou, e isso e um achado por si so.
    recurring: groups
      .filter((group) => closed.has(group.signature))
      .map((group) => ({ ...group, previousDisposition: closed.get(group.signature) })),
    fresh: groups.filter((group) => !closed.has(group.signature)),
  };
};

O grupo reincidente é o achado mais valioso desse desenho e ele não existe sem o histórico. Uma assinatura encerrada como corrigida no código que reaparece três meses depois significa que a correção não pegou o caso todo, e essa informação chega antes de o volume crescer o suficiente para virar incidente. É o mesmo raciocínio de um teste de regressão, com a diferença de que o gatilho é a produção e não a suíte, e por isso ele encontra a classe de defeito que nenhum teste escrito antecipou.

06

O alerta que impede a fila de acumular catorze meses de novo

Depois da limpeza vem a parte que determina se o esforço se sustenta. A fila morta acumulou catorze meses porque nada avisava, e o instinto é criar um alerta de profundidade da fila com um limiar. Esse alerta funciona mal nos dois extremos: com limiar baixo, ele dispara em qualquer incidente e o time aprende a ignorá-lo em duas semanas; com limiar alto, ele nunca dispara em um vazamento lento de dez mensagens por dia, que é exatamente o padrão que produz quarenta e um mil ao longo de um ano.

Três sinais cobrem os casos que importam sem gerar ruído. A taxa de entrada na fila morta, medida por minuto, pega o incidente enquanto ele acontece e é o único desses sinais que merece acordar alguém. O aparecimento de uma assinatura nova, que nunca foi vista antes, pega o defeito recém-introduzido no dia do deploy que o criou e vira uma tarefa, não um chamado. E a idade da mensagem mais antiga não encerrada pega o vazamento lento, porque ela cresce monotonicamente enquanto ninguém olha e não depende de volume nenhum para disparar.

SinalDetectaFalha se usado sozinhoDestino
Entrada por minuto acima do normalIncidente em cursoNão vê acúmulo lento de dez por diaChamado imediato
Assinatura de erro inéditaDefeito introduzido no deploySilencioso quando o defeito é antigoTarefa no dia
Idade da mensagem mais antigaVazamento lento e fila esquecidaSó acusa depois que já acumulouRevisão semanal
Profundidade total da filaNada que os outros três não peguem antesRuidoso ou inútil, dependendo do limiarPainel apenas

O último ajuste é de processo, não de ferramenta: a fila morta precisa de dono nomeado e de uma revisão recorrente curta, quinze minutos por semana com o relatório de triagem já pronto. A automação entrega o agrupamento, mas a decisão entre reprocessar, corrigir e encerrar é sempre humana, porque depende de contexto de negócio que nenhum classificador tem. Sem esse dono, os alertas passam a ser encaminhados e ignorados como qualquer outro, e a fila volta ao estado inicial em menos de um ano.

FAQ

Perguntas frequentes

Quantas tentativas configurar antes de mandar a mensagem para a fila morta?

O número de tentativas importa menos do que a distinção entre os tipos de erro, e configurar um valor único para todos é o que produz filas mortas cheias de casos que nunca poderiam ter dado certo. Erros permanentes, como payload que viola o contrato, referência a uma entidade inexistente ou falha de autorização, não devem ser tentados nenhuma vez além da primeira: cada nova tentativa consome capacidade e adiciona latência para chegar ao mesmo resultado, e o único efeito prático é atrasar em minutos o momento em que o defeito fica visível. Erros transitórios, como timeout de dependência, indisponibilidade momentânea ou conflito de concorrência, se beneficiam de três a cinco tentativas com espera exponencial e uma componente aleatória, porque a maioria se resolve sozinha em segundos. A implementação que funciona é o consumidor classificar a exceção e decidir o caminho, em vez de delegar tudo à política única do broker. Um detalhe que costuma escapar é que a espera entre tentativas precisa caber dentro do tempo de visibilidade da mensagem: se o backoff ultrapassa esse limite, o broker devolve a mensagem para outro consumidor no meio da espera e o sistema processa a mesma mensagem em paralelo, o que gera efeito duplicado sem nenhum erro registrado.

Vale a pena manter uma fila morta separada por tipo de consumidor?

Vale, e o critério é a diferença de dono, não a diferença técnica. Uma fila morta única para dez consumidores diferentes produz um relatório em que ninguém se reconhece, e o resultado previsível é que nenhum time assume a análise, que é justamente o mecanismo que leva ao acúmulo. Filas mortas separadas por domínio dão a cada time um inventário que é claramente responsabilidade dele, com volume pequeno o suficiente para caber numa revisão semanal. O custo é operacional: mais filas para monitorar e mais painéis para manter, o que só compensa quando a separação corresponde a times reais. A configuração que costuma dar o melhor equilíbrio é uma fila morta por serviço, não por tipo de mensagem, com o relatório de triagem agregando todas elas numa visão única para quem precisa enxergar o conjunto. Assim a responsabilidade fica clara na operação diária e a visão transversal continua existindo para identificar defeitos que atravessam serviços, que são justamente os que nenhum time isolado enxerga.

Como lidar com a fila morta em um sistema que precisa preservar ordem?

Esse é o caso em que a fila morta padrão quebra uma garantia sem avisar, e ele exige um desenho diferente. Quando o processamento depende de ordem por entidade, mandar a terceira mensagem de uma sequência para a fila morta e continuar com a quarta significa aplicar uma atualização sobre um estado que nunca chegou, e o resultado é uma inconsistência silenciosa que aparece dias depois sem nenhum erro correspondente. A solução é parar o fluxo daquela entidade específica, não da fila inteira: ao falhar definitivamente, o consumidor marca a chave como bloqueada e passa a rotear diretamente para a fila morta toda mensagem subsequente da mesma chave, sem tentar processá-la. O restante do tráfego segue normal e a ordem daquela entidade fica preservada, ainda que interrompida. O reprocessamento, nesse desenho, tem uma exigência adicional: as mensagens precisam voltar na ordem original e antes de qualquer mensagem nova da mesma chave, o que na prática significa reprocessar com o bloqueio ainda ativo e liberá-lo apenas depois que a última mensagem pendente tiver sido concluída com sucesso.

Uma fila morta ignorada é um defeito conhecido que ninguém está contando

Quarenta e um mil mensagens paradas há catorze meses não são um problema de armazenamento, são o inventário completo de tudo que o sistema prometeu e não entregou, mantido fora do cálculo de sucesso que o time acompanha. Posso revisar o tratamento da sua fila morta e definir o envelope de descarte que torna a mensagem diagnosticável, a correção da métrica que faz o descarte contar como falha, o relatório de triagem que agrupa o volume em poucas decisões, o reprocessamento com vazão limitada e parada automática, o registro de encerramento que impede a próxima análise de recomeçar do zero e os alertas que pegam tanto o incidente quanto o vazamento lento.