3 Horas investigando latência no sistema de notificação
- #PostgreSQL
- #Kubernetes
- #Elixir
O cenário
Mantemos um sistema de notificações em Elixir responsável por entregar webhooks em tempo real para clientes da plataforma.
A arquitetura utiliza Oban para processamento assíncrono, com filas separadas por criticidade, algo como:
realtime— eventos com expectativa de entrega imediatahigh_webhooks— webhooks de clientes ativoslow_webhooks— webhooks de clientes em estado degradadoretry_active_webhooks— retentativas de clientes ativosretry_degraded_webhooks— retentativas de clientes degradados
No papel, a separação fazia sentido. Cada fila representava uma prioridade de negócio diferente e permitia ajustar concorrência e comportamento de forma independente.
O problema é que intenção arquitetural e comportamento real nem sempre são a mesma coisa.
O incidente
Em uma manhã aparentemente normal, começaram a surgir alertas relacionados à latência de entrega dos webhooks mais críticos.
- Os eventos continuavam sendo entregues.
- Nenhum job estava falhando.
- Nenhum serviço estava indisponível.
- Nenhuma exception aparecia no Sentry.
Mas algo estava diferente.
Clientes que normalmente recebiam notificações em poucos milissegundos passaram a observar atrasos perceptíveis.
Para um produto que promete comportamento próximo de tempo real, isso era suficiente para iniciar uma investigação.
O que vimos primeiro
A primeira impressão era de que havia algum gargalo operacional.
Começamos pelo básico:
- Pool de conexões do PostgreSQL.
- Saturação de CPU ou memória.
- Workers travados.
- Jobs excessivamente lentos.
- Problemas nos sistemas que recebem os webhooks.
Nada.
O banco estava saudável, os tempos médios dos jobs permaneciam estáveis, não existiam filas crescendo indefinidamente, os endpoints dos clientes respondiam normalmente, tudo parecia funcionar. Só que mais devagar.
A investigação
Nossa stack de observabilidade já possuía uma boa base:
- PromEx
- OpenTelemetry
- Grafana
- Prometheus
- Logs estruturados
Conseguíamos responder perguntas como:
- Quantos jobs existem por fila?
- Quantos jobs estão executando?
- Qual o throughput geral?
- Quantos jobs falharam?
Mas não conseguíamos responder uma pergunta muito mais importante:
Quanto tempo um evento demora para sair da aplicação e chegar ao cliente?
Nem:
Qual fila está impactando diretamente o SLA do negócio?
Ou ainda:
Quanto tempo um job permanece aguardando antes de começar a executar?
Essas perguntas parecem simples depois que o incidente acontece. Mas eram justamente as métricas que não tínhamos.
Onde encontramos o problema
Durante a análise percebemos que o comportamento observado não estava relacionado a falhas de processamento, mas sim à capacidade disponível para execução dos jobs.
Os workers estavam saudáveis, os tempos médios de execução permaneciam estáveis e não existiam erros relevantes nos endpoints dos clientes. Ainda assim, os eventos mais sensíveis ao SLA estavam acumulando tempo de espera antes de serem executados.
Ao aprofundar a investigação identificamos que a combinação entre o volume de jobs, a distribuição de concorrência entre as filas e a limitação do pool de conexões utilizado pelo Oban estava criando contenção no processamento.
O efeito era sutil: nada deixava de funcionar, mas determinados eventos passavam mais tempo aguardando recursos antes de iniciar sua execução.
Esse tipo de degradação é particularmente difícil de identificar porque os indicadores tradicionais de saúde continuam aparentemente normais.
O ajuste imediato
A mitigação ocorreu em duas frentes.
Primeiro, ampliamos a capacidade disponível para o Oban aumentando o pool de conexões com o PostgreSQL de 50 para 100 conexões.
Considerando que o ambiente executava sete pods simultaneamente, o volume de workers concorrendo por recursos já estava próximo do limite que a configuração anterior conseguia atender com eficiência.
Também redistribuímos a concorrência das filas para refletir melhor os requisitos de negócio:
config :webhooks, Oban,
queues: [
realtime: 60,
high_webhooks: 50,
low_webhooks: 15,
retry_active_webhooks: 20,
retry_degraded_webhooks: 10
]
A ideia foi direcionar mais capacidade para as filas associadas ao SLA crítico e reduzir recursos destinados às filas de menor prioridade operacional.
Poucos minutos após o deploy, a latência retornou aos níveis esperados.
Uma discussão arquitetural que surgiu durante o incidente
Durante a investigação também revisitamos uma decisão de modelagem das filas.
Historicamente utilizávamos filas distintas para representar diferentes níveis de criticidade:
realtimehigh_webhookslow_webhooksretry_active_webhooksretry_degraded_webhooks
Essa abordagem facilita o isolamento operacional, mas também aumenta a complexidade de configuração e ajuste de concorrência.
Por isso iniciamos testes com uma estratégia diferente: consolidar os eventos de maior criticidade em uma única fila e utilizar o mecanismo nativo de prioridade do Oban para ordenar a execução dos jobs.
Por exemplo:
%{
queue: :webhooks,
priority: 0 # realtime
}
%{
queue: :webhooks,
priority: 1 # high priority
}
Nesse modelo, a prioridade passa a ter efeito direto na ordem de execução porque os jobs competem dentro da mesma fila.
Ainda estamos avaliando os impactos dessa abordagem em produção, mas ela tem potencial para simplificar a arquitetura e reduzir a necessidade de calibrar múltiplas filas independentes.
O aprendizado
1. Capacidade é tão importante quanto organização
Separar workloads em múltiplas filas ajuda a expressar prioridades de negócio, mas não cria capacidade adicional.
Quando os recursos compartilhados, especialmente conexões com banco e concorrência dos workers, tornam-se insuficientes, a degradação pode aparecer mesmo que a arquitetura das filas pareça correta.
2. Prioridade só funciona quando o mecanismo consegue aplicá-la
Durante a investigação revisamos o recurso de prioridades do Oban.
Foi importante entender que a prioridade atua dentro da mesma fila, ordenando quais jobs serão executados primeiro.
Ela não substitui o controle de concorrência entre filas distintas e não foi a causa nem a correção do incidente observado.
Por outro lado, tornou-se uma ferramenta interessante para uma possível simplificação futura da arquitetura através da consolidação de filas.
3. Observabilidade de negócio é mais importante do que observabilidade de infraestrutura
CPU, memória, throughput, tamanho das filas e quantidade de workers ativos são métricas importantes.
Mas elas não respondem à pergunta que realmente importa durante um incidente:
O cliente está recebendo o evento dentro do SLA esperado?
Nós sabíamos quantos jobs estavam sendo executados por minuto.
Sabíamos quantos jobs falhavam.
Sabíamos quantos workers estavam ativos.
Mas não sabíamos quanto tempo um webhook demorava para sair da nossa aplicação e chegar ao cliente.
Essa diferença parece pequena até o dia em que você precisa investigar um problema real.
Foi justamente a ausência dessas métricas que transformou um ajuste relativamente simples em uma investigação de quase três horas.
4. Sistemas raramente falham de forma óbvia
O incidente não foi causado por uma exception.
Não houve outage.
Não houve banco indisponível.
Não houve crash.
O sistema continuou funcionando durante todo o período.
Os webhooks continuavam sendo entregues.
Os jobs continuavam sendo processados.
Os clientes continuavam recebendo os eventos.
A única diferença era que tudo estava acontecendo mais devagar do que o negócio conseguia tolerar. Esse é um dos tipos mais difíceis de degradação para diagnosticar porque, sob a ótica da infraestrutura, quase tudo parece saudável.
O que estamos implementando agora
A principal ação pós-incidente não foi criar novas filas nem alterar prioridades.
Foi melhorar a capacidade de observação do sistema.
Estamos instrumentando métricas diretamente no fluxo de entrega dos webhooks utilizando :telemetry.span/3, permitindo medir:
- Latência fim a fim da entrega.
- Tempo de espera em fila.
- P95 e P99 por tipo de evento.
- SLA por categoria de webhook.
- Taxa de eventos enviados para dead letter.
O objetivo é simples: quando houver uma degradação semelhante no futuro, queremos que ela apareça primeiro em um dashboard ou alerta, e não em uma investigação manual.
Conclusão
O ajuste técnico que resolveu o incidente levou poucos minutos.
O difícil foi descobrir onde procurar.
No fim, a principal lição não foi sobre Oban, PostgreSQL, filas ou prioridades.
Foi sobre visibilidade.
Durante muito tempo observamos a saúde dos componentes da plataforma e assumimos que isso era suficiente para entender a saúde do produto.
Não era.
Um sistema pode ter CPU sobrando, banco saudável, workers ativos e nenhum erro aparente, enquanto seus clientes já estão percebendo degradação.
A diferença entre um war room de três horas e um incidente resolvido em cinco minutos normalmente não está na correção.
Está na capacidade de enxergar o problema rapidamente.
E isso quase sempre começa pelas métricas que representam o negócio, não pela infraestrutura.



