Voltar para o blog
MensageriaRabbitMQBackendArquitetura

At-least-once vs exactly-once: qual garantia de entrega escolher

30 de maio de 20268 min de leiturapor Sávio Araújo

As três garantias de entrega

Sistemas de mensageria garantem entrega em diferentes graus:

  • At-most-once: a mensagem é entregue no máximo uma vez: pode ser perdida, nunca duplicada
  • At-least-once: a mensagem é entregue pelo menos uma vez: pode ser duplicada, nunca perdida
  • Exactly-once: a mensagem é entregue exatamente uma vez: sem perda, sem duplicata

Na teoria, exactly-once parece o melhor. Na prática, é caro, difícil, e muitas vezes desnecessário.

At-most-once: fire and forget

// RabbitMQ sem ack
const channel = await connection.createChannel();
channel.sendToQueue("notifications", Buffer.from(JSON.stringify(message)), {
  persistent: false, // não persiste em disco
});
// Se o consumer morrer antes de processar, a mensagem é perdida

Quando usar:

  • Métricas e logs (perder alguns é aceitável)
  • Notificações não-críticas (push notification)
  • Cache invalidation (a próxima leitura vai buscar dado atualizado de qualquer forma)

At-least-once: acks explícitos

// Consumer com ack manual
channel.consume("orders", async (msg) => {
  if (!msg) return;
 
  try {
    await processOrder(JSON.parse(msg.content.toString()));
    channel.ack(msg); // confirma processamento bem-sucedido
  } catch (error) {
    // Mensagem volta para a fila (pode ser processada de novo)
    channel.nack(msg, false, true);
  }
});

E no producer, confirmar que o broker recebeu:

// Publisher confirms: aguarda confirmação do broker
await channel.waitForConfirms(); // ou confirmSelect() + event

Com essas duas garantias combinadas (publisher confirms + consumer acks), a mensagem é entregue pelo menos uma vez. O "pelo menos" existe porque um consumer pode processar e morrer antes de enviar o ack: o broker vai reentregá-la.

O problema do exactly-once

Exactly-once verdadeiro requer coordenação distribuída em dois sistemas: o broker e o consumer. Para garantir que uma mensagem é processada exatamente uma vez, você precisa de uma transação distribuída que:

  1. Remove a mensagem da fila
  2. Executa o efeito (salva no banco, chama API)

Essas duas operações precisam ser atômicas. Se o sistema cair entre elas, o estado fica inconsistente.

O Kafka tem suporte a transações exactly-once, mas com custo de performance e complexidade significativos. O RabbitMQ não oferece isso nativamente.

Effectively-once: a solução prática

A alternativa pragmática é at-least-once + consumer idempotente. O resultado percebido é "exactly-once": nenhum efeito duplicado: sem o custo de transações distribuídas.

@Injectable()
export class OrderConsumer {
  @RabbitSubscribe({ queue: "orders.processing" })
  async processOrder(@Payload() message: OrderMessage) {
    // Verifica se já foi processado
    const alreadyProcessed = await this.idempotencyStore.exists(message.messageId);
    if (alreadyProcessed) {
      this.logger.log(`Mensagem ${message.messageId} já processada, ignorando`);
      return; // ack implícito, sem reprocessar
    }
 
    // Processa dentro de uma transação que inclui a marcação de "processado"
    await this.db.transaction(async (trx) => {
      await this.orderService.process(message, trx);
      await this.idempotencyStore.mark(message.messageId, trx);
    });
  }
}

A chave: a marcação de "já processado" acontece na mesma transação que o efeito real. Se a transação falhar, nenhum dos dois persiste. Se o consumer morrer após a transação mas antes do ack, o broker reenvia a mensagem: mas o consumer identifica a duplicata e a ignora.

Gerando messageId no producer

Para isso funcionar, o producer precisa gerar IDs únicos e estáveis:

async publishOrder(order: Order): Promise<void> {
  const messageId = `order:${order.id}:created`;
  // ID determinístico: se publicar duas vezes, o consumer só processa uma
 
  await this.channel.publish(
    "orders",
    "orders.created",
    Buffer.from(JSON.stringify(order)),
    {
      messageId,
      persistent: true,
      contentType: "application/json",
    }
  );
}

Use IDs determinísticos (baseados no conteúdo ou no ID do recurso) quando possível: assim, mesmo que o producer publique a mesma mensagem duas vezes (por retry), o consumer processa apenas uma.

Armazenamento de idempotência

Opção 1: Redis (mais rápido)

@Injectable()
export class RedisIdempotencyStore {
  constructor(private readonly redis: Redis) {}
 
  async exists(messageId: string): Promise<boolean> {
    return (await this.redis.exists(`idempotency:${messageId}`)) === 1;
  }
 
  async mark(messageId: string): Promise<void> {
    // TTL de 7 dias: suficiente para qualquer retry razoável
    await this.redis.setex(`idempotency:${messageId}`, 7 * 24 * 3600, "1");
  }
}

Opção 2: banco de dados (mais consistente)

// processed_messages table:
// id, message_id (unique), processed_at
 
async exists(messageId: string, trx?: Transaction): Promise<boolean> {
  const db = trx ?? this.knex;
  const result = await db("processed_messages").where({ messageId }).first();
  return !!result;
}
 
async mark(messageId: string, trx?: Transaction): Promise<void> {
  const db = trx ?? this.knex;
  await db("processed_messages").insert({ messageId, processedAt: new Date() });
}

Com banco de dados, a verificação e a marcação podem ser parte da mesma transação que o efeito: garantindo consistência absoluta.

Quando usar cada garantia

| Caso | Garantia ideal | |---|---| | Logs, métricas | At-most-once | | Notificações não-críticas | At-most-once | | Emails, SMS | At-least-once + idempotência | | Transações financeiras | At-least-once + idempotência forte | | Processamento de pedidos | At-least-once + idempotência | | Eventos de auditoria | At-least-once |

Conclusão

Exactly-once verdadeiro é caro e complexo. Para a grande maioria dos sistemas, at-least-once com consumidores idempotentes (effectively-once) oferece as mesmas garantias práticas com implementação muito mais simples.

O ponto crítico é: o messageId precisa existir e ser único, e a marcação de "processado" precisa ser transacional com o efeito real. Com isso, duplicatas são tratadas de forma silenciosa e transparente.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech