Fila é um daqueles assuntos que todo mundo já usou sem saber o nome, e que quando chega a hora de escolher a ferramenta vira uma discussão infinita. Esse post não é sobre framework nenhum: é sobre o problema que a fila resolve, o mecanismo que faz ela funcionar em qualquer linguagem, e o que você ganha e perde em cada lugar onde pode guardar as mensagens.
#O problema
Sua aplicação recebe uma requisição. Nela, você precisa: salvar um registro, gerar um PDF, enviar um e-mail, notificar um sistema externo e atualizar um índice de busca. O usuário fica olhando o spinner por oito segundos.
Pior: o serviço de e-mail está fora do ar. A requisição quebra. O registro já foi salvo, o PDF já foi gerado, mas o usuário vê um erro 500 e clica de novo. Agora você tem dois registros e dois PDFs.
Os dois problemas aqui são o mesmo problema: você está executando de forma síncrona um trabalho que não precisa acontecer agora, e acoplando o sucesso da requisição ao sucesso de algo que você não controla.
A fila separa isso em dois tempos. A requisição faz só o que é essencial e registra a intenção do resto: "precisa gerar um PDF pro pedido 42", "precisa enviar e-mail pro usuário 7". Devolve a resposta. Em outro processo, em outra hora, em outra máquina se você quiser, alguém pega essas intenções e executa.
O que você ganha com isso:
- Latência: a resposta sai em 200ms em vez de 8 segundos.
- Resiliência: se o serviço de e-mail está fora, a mensagem continua na fila e é tentada de novo depois. A requisição do usuário não quebra por causa disso.
- Controle de carga: se chegarem 10.000 pedidos de uma vez, eles entram na fila e são consumidos no ritmo que a sua infraestrutura aguenta. Sem fila, os 10.000 batem ao mesmo tempo no banco e no serviço externo.
- Desacoplamento: quem publica não precisa saber quem consome, nem se existe alguém consumindo agora.
#O mecanismo, sem ferramenta nenhuma
Independente da tecnologia, toda fila tem as mesmas quatro peças.
Produtor: o código que escreve a mensagem. Normalmente é o seu request handler. Ele serializa o que precisa ser feito (um identificador do tipo de trabalho + os dados mínimos para executá-lo) e entrega para o broker.
Broker: onde a mensagem fica guardada até alguém consumir. É a peça que você escolhe — Redis, RabbitMQ, uma tabela no Postgres, uma collection no Mongo.
Consumidor (worker): um processo separado, rodando em loop, que pede a próxima mensagem, executa o trabalho e avisa o broker que terminou. Esse processo é totalmente independente da sua aplicação web — pode ser outro container, outra máquina, e você pode subir dez deles.
Acknowledgement (ack): o aviso de "terminei". É a peça mais importante e a mais ignorada. Enquanto o worker não dá o ack, a mensagem não é considerada entregue.
O loop do worker, em pseudocódigo que funciona igual em qualquer linguagem:
1loop:
2 msg = broker.pegar_proxima(timeout=30s)
3 if msg == null:
4 continue
5
6 try:
7 executar(msg.tipo, msg.dados)
8 broker.ack(msg)
9 catch erro:
10 if msg.tentativas < max_tentativas:
11 broker.devolver(msg, atraso=backoff(msg.tentativas))
12 else:
13 broker.mover_para_dead_letter(msg)
Cinco coisas nesse loop merecem atenção antes de você escolher qualquer ferramenta:
Visibilidade. Quando o worker pega a mensagem, ela não pode sumir do broker nem continuar visível para os outros workers. Ela precisa entrar num estado intermediário: "reservada por alguém, invisível por N segundos". Se o worker morrer no meio, o prazo expira e a mensagem volta para a fila. Sem isso, um worker que trava leva o trabalho junto.
Entrega ao menos uma vez. Como consequência direta do ponto acima, o mesmo trabalho pode ser executado duas vezes: o worker termina, o processo morre antes do ack, a mensagem volta. Praticamente todo broker garante at-least-once, não exactly-once.
Idempotência. Por causa disso, o trabalho precisa ser seguro para rodar duas vezes. Isso é responsabilidade do seu código, não do broker. Na prática: use uma chave de idempotência (o ID do pedido, por exemplo), cheque se o efeito já aconteceu antes de aplicá-lo, prefira UPDATE ... WHERE status = 'pendente' a um UPDATE cego.
Retentativa com backoff. Falhou, tenta de novo — mas não imediatamente, e não para sempre. Espere 1s, depois 5s, depois 30s. Se o serviço externo caiu, martelar ele a cada milissegundo só piora.
Dead letter queue. Depois de N tentativas, a mensagem sai da fila principal e vai para uma fila de quarentena. Isso evita que uma mensagem envenenada (um payload que sempre quebra) trave o consumo para sempre, e te dá um lugar concreto para olhar quando algo deu errado.
Regra prática que vale mais que qualquer ferramenta: nunca coloque na mensagem o objeto inteiro. Coloque o identificador. Se você serializa o usuário completo e o worker consome dez minutos depois, ele vai trabalhar com dados velhos. Mande {"tipo": "enviar_boas_vindas", "usuario_id": 7} e deixe o worker buscar o estado atual.
#Onde guardar as mensagens
Agora a parte que gera discussão. Todas as opções abaixo funcionam. A escolha certa depende muito mais do seu volume e da sua equipe do que de qual é "a melhor".
#Banco relacional (Postgres, MySQL, SQL Server)
Uma tabela com as colunas id, payload, status, disponivel_em, tentativas, reservado_em. O produtor faz INSERT. O worker pega a próxima com algo nessa linha:
1SELECT * FROM fila
2WHERE status = 'pendente' AND disponivel_em <= now()
3ORDER BY id
4LIMIT 1
5FOR UPDATE SKIP LOCKED;
O FOR UPDATE SKIP LOCKED é o detalhe que faz isso funcionar de verdade: ele trava a linha escolhida e faz os outros workers pularem as linhas travadas em vez de esperar por elas. Sem isso, seus workers viram uma fila de espera em cima do banco. Postgres tem isso desde a 9.5, MySQL desde a 8.0.
Vantagens:
- Você já tem o banco. Zero infraestrutura nova, zero componente novo para monitorar, fazer backup e atualizar.
- Transacionalidade real: você insere o pedido e enfileira o job na mesma transação. Ou os dois acontecem, ou nenhum. Isso é algo que nenhum broker externo te dá de graça — com Redis ou RabbitMQ você sempre corre o risco de gravar no banco e falhar ao publicar, ou publicar e falhar ao gravar.
- Durabilidade e visibilidade: a mensagem está em disco, com as mesmas garantias do resto dos seus dados. E você inspeciona a fila com
SELECT, o que é infinitamente mais confortável do que qualquer CLI de broker às 3 da manhã. - Fácil de entender e de depurar. O estado é uma tabela.
Desvantagens:
- Throughput limitado. Isso vai muito bem até alguns milhares de mensagens por segundo; acima disso o banco começa a sofrer com a contenção.
- Cada mensagem gera escritas: insert, update de reserva, delete ou update final. Isso é I/O e é vacuum (no Postgres, uma tabela de fila com muito giro precisa de atenção com autovacuum e bloat).
- Você compete com a carga transacional da aplicação pelo mesmo recurso. Um pico na fila pode degradar suas requisições normais.
- Sem polling inteligente por padrão: o worker fica perguntando "tem algo novo?" de tempos em tempos. Dá para melhorar com
LISTEN/NOTIFYno Postgres, mas é mais peça para montar. - Roteamento sofisticado (fan-out, topics, prioridades complexas) você implementa na unha.
Quando usar: quase sempre no começo. É a escolha mais subestimada do mercado. Se você não tem certeza de que precisa de mais, não precisa de mais.
#Banco não relacional (MongoDB, DynamoDB e afins)
A ideia é a mesma do relacional, com a collection no lugar da tabela. No Mongo a operação-chave é findAndModify (findOneAndUpdate), que encontra e atualiza o documento atomicamente:
1db.fila.findOneAndUpdate(
2 { status: "pendente", disponivel_em: { $lte: agora } },
3 { $set: { status: "processando", reservado_em: agora },
4 $inc: { tentativas: 1 } },
5 { sort: { prioridade: -1, criado_em: 1 } }
6)
Essa operação é atômica em nível de documento, o que resolve o problema de dois workers pegarem a mesma mensagem.
Vantagens:
- Mesmo argumento do relacional: se o Mongo já é seu banco principal, você não adiciona infraestrutura.
- Payload flexível: a mensagem é um documento, sem migração de schema quando você muda o formato do job.
- TTL index resolve a limpeza de mensagens antigas sozinho.
- Escala horizontal por sharding é mais natural do que num relacional.
Desvantagens:
- Todas as desvantagens de throughput e polling do banco relacional, geralmente piores.
- A garantia transacional é mais fraca. Mongo tem transações multi-documento desde a 4.0, mas elas custam caro e exigem replica set — não é o caminho leve que o
FOR UPDATE SKIP LOCKEDé no Postgres. - Fica fácil errar a modelagem: sem os índices certos em
statusedisponivel_em, ofindOneAndUpdatevira collection scan e a fila derrete sob carga. - Ordenação e prioridade dependem de índices que você precisa desenhar corretamente desde o início.
Quando usar: quando o Mongo já é sua fonte de verdade e o volume é moderado. Se você está escolhendo o Mongo só para fazer fila, escolheu errado.
#Redis
Redis é memória. Isso define tudo — as vantagens e as desvantagens.
Historicamente se fazia fila com listas: LPUSH para publicar, BRPOP para consumir bloqueando até chegar algo. Simples e rápido, mas com um buraco sério: BRPOP remove a mensagem no ato. Se o worker morre em seguida, a mensagem evaporou. A correção clássica é BRPOPLPUSH (ou BLMOVE), que move o item para uma lista de "em processamento" no mesmo passo atômico — assim existe um rastro para recuperar.
Hoje o caminho recomendado é Redis Streams com consumer groups: XADD publica, XREADGROUP consome, XACK confirma. O Stream mantém a lista de mensagens pendentes por consumidor (o PEL), e XAUTOCLAIM permite que outro worker assuma as mensagens de um worker que morreu. Isso é um modelo de fila completo, com ack de verdade.
Vantagens:
- Muito rápido. Dezenas ou centenas de milhares de operações por segundo com latência submilissegundo.
- Pop bloqueante nativo: o worker fica esperando a mensagem chegar em vez de ficar perguntando. Sem polling, sem latência ociosa.
- Simples de operar. Um processo, um arquivo de configuração.
- Estruturas prontas para além da fila simples: sorted set para jobs agendados e prioridade, pub/sub para broadcast, contadores para rate limit.
- Provavelmente você já tem Redis no stack por causa de cache ou sessão.
Desvantagens:
- Durabilidade é opcional e imperfeita. A persistência padrão (RDB/AOF com
appendfsync everysec) pode perder o último segundo de escritas num crash. Para uma fila de e-mails, tudo bem. Para uma fila de cobranças, pense duas vezes. - Limitado pela RAM. Se os consumidores caírem e a fila crescer, você não está enchendo um disco barato — está enchendo memória cara, e a
maxmemory-policyerrada pode literalmente descartar suas mensagens. - Sem transação com o seu banco: gravar no Postgres e publicar no Redis são duas operações que podem divergir.
- Roteamento é básico. Não existe exchange, binding, nem roteamento por padrão de tópico. O que existe você constrói.
- Streams não fazem limpeza sozinhos: sem
XTRIMouMAXLEN, o stream cresce para sempre. E o PEL de um consumer group que ninguém monitora acumula mensagens fantasma silenciosamente.
Quando usar: volume alto, latência baixa, e tolerância a perder uma fração de segundo de mensagens num cenário de crash. É o padrão de fato para jobs de background em aplicações web.
#RabbitMQ
Aqui a diferença é de categoria: os anteriores são bancos que você usa como fila; o RabbitMQ é um broker de mensagens de verdade, feito só para isso.
O modelo central é exchange → binding → queue. O produtor nunca publica direto numa fila: ele publica numa exchange com uma routing key, e a exchange decide para quais filas aquilo vai. Isso muda o que você consegue montar:
- Uma mensagem para uma fila (direct).
- Uma mensagem para várias filas ao mesmo tempo (fanout) — o pedido criado dispara faturamento, e-mail e analytics de forma independente.
- Roteamento por padrão (topic):
pedido.*.criado,*.brasil.*.
Além disso o Rabbit traz, prontos, os itens que você implementaria na mão em qualquer das opções acima: ack manual e basic.reject, dead letter exchange nativa, TTL por mensagem e por fila, prefetch (quantas mensagens não confirmadas cada worker pode segurar — o botão que faz o balanceamento entre workers realmente funcionar), prioridade de fila, publisher confirms, filas quorum replicadas entre nós.
Vantagens:
- Roteamento poderoso, sem nada para inventar.
- Durabilidade séria: filas duráveis, mensagens persistentes, publisher confirms, replicação com quorum queues.
- Push em vez de pull: o broker empurra a mensagem para o consumidor. Latência baixa sem polling.
- Dead letter, TTL, retry e prioridade são configuração, não código seu.
- Interface de administração boa: você vê profundidade de fila, taxa de consumo e mensagens presas sem precisar construir nada.
- Protocolo padrão (AMQP) com cliente maduro em praticamente toda linguagem.
Desvantagens:
- É mais um sistema para rodar, monitorar, atualizar e aprender — com conceitos próprios (vhost, exchange, binding, prefetch, política de fila) que a equipe inteira vai precisar entender.
- Throughput bruto menor que o Redis, principalmente com mensagens persistentes e confirms ligados. O custo da garantia é real.
- Filas longas degradam o desempenho: o Rabbit foi feito para mensagens que passam, não para mensagens que se acumulam. Fila crescendo é sinal de alarme, não estado normal.
- Sem replay: consumiu e deu ack, acabou. Se você precisa reprocessar o histórico de eventos do último mês, o modelo é outro (log distribuído, tipo Kafka).
- Configuração errada de cluster e política de fila causa problemas sutis que só aparecem sob carga.
Quando usar: quando o roteamento importa, quando várias aplicações consomem os mesmos eventos, quando perder mensagem é inaceitável, ou quando a fila é infraestrutura compartilhada entre times.
#Resumo da escolha
- Já tem banco relacional e volume moderado? Use uma tabela. Sério.
FOR UPDATE SKIP LOCKED, transação junto com a escrita do domínio, e você resolve 80% dos casos sem adicionar nada ao stack. - Já tem Mongo como banco principal? Mesma lógica, com
findOneAndUpdatee os índices certos. - Precisa de throughput alto e latência baixa, e a perda de uma fração de segundo em caso de crash é aceitável? Redis, com Streams e consumer groups, não com listas.
- Precisa de roteamento, fan-out para vários consumidores, ou garantia forte de entrega? RabbitMQ.
- Precisa de replay do histórico, retenção longa e ordenação por partição? Nenhuma das quatro — você está descrevendo um log distribuído, e a conversa passa a ser Kafka, Pulsar ou Redpanda. É outro modelo: o consumidor guarda um offset e o dado continua lá depois de lido.
#O que realmente importa
Trocar de broker é, na maioria dos casos, um dia de trabalho. Se você desenhou os jobs com identificadores em vez de objetos serializados, e escreveu o trabalho de forma idempotente, a migração de uma tabela no Postgres para o Redis ou para o Rabbit é razoavelmente mecânica.
O que não é mecânico é consertar um consumidor que não é idempotente depois que ele cobrou o cliente duas vezes, ou descobrir que não existe dead letter queue no dia em que uma mensagem envenenada travou a fila inteira.
Então: comece pelo mais simples que a sua infraestrutura já suporta, e gaste o esforço no desenho do job, não na escolha do broker.