Artigo

Dá pra fazer fila sem RabbitMQ? | Protokol

6 min de leitura

Eu tenho um produto chamado Protokol que tem uma regra que parece boba de tão simples: quando acontece uma coisa importante, o cliente TEM que ficar sabendo.

Existem muitos jeitos de fazer isso, e como ele ainda tava em MVP... eu queria o jeito mais fácil: em email por SMTP.

Mas depois de um tempo, eu fui expandir pra push e aí precisei centralizar em algum serviço de notificação.

E como eu estou sendo mão de vaca aqui, eu preferi não subir um RMQ logo de cara e trouxe uma solução que a maioria não conhece, mas provavelmente já paga por ela.

Só pra explicar, o meu cenário de backend é: um app monolítico em .NET + Postgres.

E o que eeu quero mostrar aqui é a solução que eu trouxe, por que ela cabe num monolito sem subir mais nada. E ainda te deixar claro quando que uma fila de verdade passa a valer a pena pra você.

O jeito que quase todo mundo faz

O primeiro instinto é esse aqui quando não vai usar alguma queue.

1await _db.SaveChangesAsync(); // grava o dado 2await _smtp.SendAsync(email); // manda o e-mail

Mandar assim funciona, e por um tempo eu ia levar assim mesmo.

O incômodo é que tu tá escrevendo em dois lugares que não conversam.

O banco commitou, mas o e-mail depende de um serviço lá fora que pode estar lento, fora do ar, ou simplesmente falhar naquele segundo.

E não tem transação cobrindo os dois: se cair no meio, o dado existe e a notificação nunca sai. Sem barulho.

Agora junta isso com o detalhe chato: o SMTP tá dentro do request. Servidor de e-mail lento vira endpoint lento.

Pra um MVP dá pra ignorar, mas pra um push e um serviço de notificação centralizado, já não dava mais.

Repara no que o problema pede de verdade: gravar o dado e registrar o "preciso avisar" como uma coisa só.

E alguém tentando entregar de novo até conseguir. Nenhuma das duas coisas exige um broker.

A solução: anotar a intenção junto com o dado

A ideia tem duas partes, e a primeira é a mais importante.

Parte 1: a intenção de notificar entra na MESMA transação do dado.

1await using var tx = await _db.Database.BeginTransactionAsync(); 2 3_db.Registros.Add(registro); 4_db.Outbox.Add(new OutboxMessage { 5 Type = "RegistroCriado", 6 Payload = JsonSerializer.Serialize(new { registro.Id }), 7 CreatedAt = DateTime.UtcNow, 8 Status = OutboxStatus.Pending 9}); 10 11await _db.SaveChangesAsync(); 12await tx.CommitAsync(); // dado E intenção: atômicos

Ou os dois entram, ou nenhum entra.

Nada de dado sem notificação, nada de notificação sem dado. A sacada é perceber que "o que enviar" também é só uma linha de tabela.

Esse padrão tem nome: transactional outbox.

Caixa de saída, igual a de e-mail: tu não manda na hora, tu deixa anotado o que precisa sair.

Parte 2: um worker drena a caixa, com retry.

Um BackgroundService (no .NET; um loop equivalente em qualquer stack) acorda de tempos em tempos, pega o que tá pendente e tenta entregar:

1var pending = await _db.Outbox 2 .Where(m => m.Status == OutboxStatus.Pending && m.NextAttemptAt <= now) 3 .OrderBy(m => m.CreatedAt) 4 .Take(20) 5 .ToListAsync(); 6 7foreach (var msg in pending) 8{ 9 try 10 { 11 await _sender.SendAsync(msg); 12 msg.Status = OutboxStatus.Sent; 13 } 14 catch 15 { 16 msg.Attempts++; 17 msg.NextAttemptAt = now.Add(Backoff(msg.Attempts)); // exponencial 18 if (msg.Attempts >= MaxAttempts) msg.Status = OutboxStatus.Dead; 19 } 20} 21await _db.SaveChangesAsync();

SMTP fora do ar por dez minutos? As mensagens esperam na tabela e saem quando ele voltar.

A aplicação reiniciou? O estado tá no banco, o worker retoma de onde parou.

O backoff exponencial evita marretar um serviço que já tá sofrendo. E o status Dead depois de N tentativas é a tua dead-letter queue.

E aqui entra a parte que eu mais gosto: a tua fila responde SQL.

Quantas pendentes? Quais morreram? Por quê? SELECT * FROM outbox WHERE status = 'Dead' e acabou a investigação.

Um requisito continua valendo: idempotência.

Retry significa que a entrega pode acontecer mais de uma vez, então o efeito (e-mail, push) precisa tolerar repetição.

Chave de deduplicação no provedor, ou um "já enviei esse MessageId?" antes de mandar.

Os limites (sem vender milagre)

Antes que tu ache que eu tô te vendendo bala de prata, deixa eu te falar onde isso aperta.

Uma instância é o cenário simples.

Com uma instância só, o worker é único e não tem corrida. Ninguém disputa nada.

Agora sobe pra duas instâncias e o problema aparece: dois workers pegam a mesma mensagem e o cliente recebe o e-mail em dobro.

A saída clássica no Postgres é o FOR UPDATE SKIP LOCKED no select do lote. Traduzindo: a linha que um worker travou, os outros pulam e vão pegar outra.

Funciona bem. Mas é um degrau de complexidade que tu só sobe quando for escalar de verdade. Enquanto tu tiver numa instância, nem pensa nisso.

A latência é a do polling.

Teu worker acorda de 5 em 5 segundos? Então a notificação pode demorar até 5 segundos pra sair.

Pra avisar um usuário, isso é invisível. Ninguém nota.

Agora pra dois serviços conversando em tempo real, onde cada milissegundo conta? Aí não serve, e tu vai querer outra coisa.

Quando uma fila de verdade passa a valer

Chega uma hora que o broker deixa de ser overkill e vira a ferramenta certa. Mas repara: é quando tu precisa de algo que SÓ ele faz.

Fan-out de verdade. Vários consumidores independentes ouvindo o mesmo evento, cada um no seu ritmo, cada um com seu cursor. É o mundo do Kafka, e a tabela não foi feita pra isso.

Throughput absurdo. Se tu tá falando de dezenas de milhares de mensagens por segundo, a tabela vira gargalo. Aí o broker paga o próprio custo.

Latência sub-segundo entre serviços. Onde ficar perguntando "tem coisa nova?" de tempos em tempos já é lento demais.

Agora, se o teu requisito é "quando X acontecer, Y tem que sair, mesmo que algo caia no meio", tu tá no caso do outbox. Não inventa.

E olha a melhor parte: se um dia tu graduar pro broker, o outbox não vira lixo. Ele vira o produtor confiável que publica no broker. É literalmente o papel que ele já tem nas arquiteturas grandes. O padrão nasceu aí, tu só chegou nele mais cedo.

Conclusao

Recapitulando o que importa: tu provavelmente não precisa de um broker. Tu precisa de uma tabela e um loop com retry.

O outbox te dá a garantia que interessa, o dado e a intenção saindo juntos ou não saindo, a entrega insistindo até conseguir, e os mortos visíveis num SELECT. Tudo isso com uma peça de infra a MENOS pra operar. Numa madrugada de plantão, isso vale ouro.

Então começa pela tabela. Gradua pro broker no dia que o fan-out ou o throughput baterem na tua porta.

E quando esse dia chegar, teu outbox já vai tá lá, pronto pra ser o produtor confiável dele. Tu não vai jogar nada fora.

Racoelho

Conteúdo sobre desenvolvimento, tecnologia e desafios de programação para impulsionar sua carreira em tech.

Conecte-se

© 2024- 2026 Racoelho. Todos os direitos reservados.

v3.0.19 • Build: 2026-04-17