[CS] CARLOS SEIDL EN
Artigos

Kafka vs RabbitMQ em .NET: Mensageria na Prática

Kafka vs RabbitMQ em .NET 10: fila de tarefas contra log de eventos, com o mesmo contrato rodando nos dois brokers. Veja quando escolher cada um.

Por 21 min de leitura
  • Kafka
  • RabbitMQ
  • Mensageria
  • .NET 10
Capa do artigo: Kafka vs RabbitMQ em .NET: Mensageria na Prática

Kafka ou RabbitMQ: como os serviços conversam

O problema: como os serviços conversam sem esperar uns pelos outros
O problema: como os serviços conversam sem esperar uns pelos outros

Quando um pedido é criado, o serviço de Pedidos precisa avisar Pagamento, Estoque, Notificação e Analytics. Se ele chamar cada um por HTTP, fica acoplado a todos: se um estiver fora do ar, o pedido falha ou atrasa. A saída é a comunicação assíncrona por mensageria, e é aí que surge a pergunta de toda equipe: Kafka ou RabbitMQ?

Os dois movem mensagens entre serviços, mas partem de modelos diferentes. O RabbitMQ é um message broker baseado em filas; o Kafka é uma plataforma de event streaming baseada em um log. Escolher pelo nome mais famoso é um erro caro. Neste artigo, que continua a série depois do Saga Pattern (que usou mensagens para coordenar transações), vemos os dois lado a lado em .NET: do ambiente ao código rodando, e depois quando cada um vale a pena.

Fila no RabbitMQ, log de eventos no Kafka

Conceitos: fila que entrega e some, log que guarda e relê
Conceitos: fila que entrega e some, log que guarda e relê

RabbitMQ: fila de mensagens

  • O produtor publica em uma exchange, que roteia para filas por regras (direct, topic, fanout, headers).
  • O broker entrega a mensagem ao consumidor e, após o ack, remove a mensagem da fila.
  • Vários consumidores na mesma fila dividem o trabalho (competing consumers).
  • O broker é "esperto": cuida de roteamento, reentrega, prioridade, TTL e dead-letter.

Kafka: log de eventos

  • O produtor grava eventos em um tópico, dividido em partições (logs ordenados, só de acréscimo).
  • O evento não é removido ao ser lido: fica guardado pelo período de retenção configurado.
  • Cada consumidor guarda o seu offset (posição de leitura). Cada consumer group lê o tópico inteiro, de forma independente.
  • O broker é "simples" e o consumidor é "esperto": quem lê decide de onde ler e quando confirmar.

A diferença em uma frase: no RabbitMQ a mensagem é uma tarefa a ser entregue e descartada; no Kafka o evento é um fato registrado que pode ser relido.

Em .NET, os clientes mais usados são o pacote RabbitMQ.Client (a partir da versão 7, com API assíncrona) e o Confluent.Kafka. Ambos podem ser usados diretamente, como neste artigo, ou por trás de uma biblioteca como MassTransit, Wolverine ou NServiceBus.

A chave que o Kafka usa para manter a ordem é só uma propriedade da mensagem:

C#
// Mesma chave -> mesma partição -> ordem preservada


var message = new Message<string, byte[]>


{


    Key = order.CustomerId,


    Value = payload


};

E, no RabbitMQ, a confirmação é uma escolha explícita do consumidor:

C#
// autoAck: false -> a mensagem só sai da fila após o BasicAck


await channel.BasicConsumeAsync(


    "orders.process", autoAck: false, consumer);

Os dois trechos são explicados no exemplo completo, na seção 4.

Exchange e filas no RabbitMQ, partições no Kafka

Arquitetura: exchange e filas no RabbitMQ; tópico, partições e grupos no Kafka
Arquitetura: exchange e filas no RabbitMQ; tópico, partições e grupos no Kafka

RabbitMQ

Texto
Producer ─► Exchange ──(routing key)──► Fila A ─┬─► Consumer 1


                  │                             └─► Consumer 2


                  └──(routing key)──► Fila B ───► Consumer 3





Consumer 1 e 2 dividem a Fila A; o 3 recebe a sua própria cópia.

A exchange decide para quais filas a mensagem vai. Cada fila entrega cada mensagem a um consumidor. Para vários sistemas receberem o mesmo evento, cria-se uma fila por sistema ligada à mesma exchange. No exemplo, são duas ligações com a mesma chave (trecho de RabbitMqDemo.Topology.cs):

C#
await channel.QueueBindAsync(


    Process, Exchange, RoutingKey, cancellationToken: ct);


await channel.QueueBindAsync(


    Audit, Exchange, RoutingKey, cancellationToken: ct);

Cada QueueBindAsync liga uma fila à exchange: orders.process e orders.audit recebem, cada uma, uma cópia de toda mensagem publicada com order.placed.

Kafka

Texto
Producer ──(chave)──► Tópico: Partição 0 | 1 | 2


                          ├─► Grupo "billing"


                          │   (1 consumidor por partição)


                          └─► Grupo "analytics"


                              (lê tudo de novo, no seu ritmo)

A partição é a unidade de paralelismo e de ordem: a ordem só é garantida dentro de uma partição, e dentro de um grupo cada partição é lida por um consumidor. Logo, o número de partições é o teto de paralelismo do grupo. No exemplo, o tópico nasce com três partições (trecho de KafkaDemo.cs):

C#
new TopicSpecification


{


    Name = topic,


    NumPartitions = 3,


    ReplicationFactor = 1


}

NumPartitions = 3 limita o grupo a três consumidores ativos; ReplicationFactor = 1 serve só ao ambiente local.

Comparando as duas abordagens, por dimensão

  • Modelo de consumo: RabbitMQ empurra a mensagem ao consumidor (push) e a descarta após o ack; no Kafka, o consumidor puxa (pull) a partir de um offset e a mensagem continua lá.
  • Reler o passado (replay): no Kafka é nativo (basta posicionar o offset ou usar um grupo novo). No RabbitMQ não existe em filas comuns; o recurso mais próximo são as Streams do RabbitMQ, que também são um log com leitura não destrutiva.
  • Roteamento: RabbitMQ tem exchanges e várias regras prontas. O Kafka roteia por tópico e chave; filtros ficam no consumidor.
  • Ordem: RabbitMQ preserva a ordem em uma fila com um consumidor; com consumidores concorrentes e reentregas, a ordem se perde. Kafka garante ordem por partição.
  • Falhas e reentrega: RabbitMQ tem nack, requeue e dead-letter exchange nativos. No Kafka não há ack por mensagem: avança-se o offset, e retry e dead-letter topic são padrões que você implementa.
  • Escala: o Kafka escala por partições e brokers e é feito para altíssimo volume; o RabbitMQ atende muito bem volumes de moderados a altos e cresce com filas e nós, mas o seu ponto forte não é reter histórico grande.
  • Operação: o RabbitMQ é mais simples de subir e entender. O Kafka exige pensar em partições, replicação, retenção e rebalanceamento. A partir do Kafka 4.0 o ZooKeeper deixou de existir e o cluster roda em modo KRaft, o que simplificou a operação.

Em .NET, o caminho é escolher entre usar o cliente direto (mais controle, mais código) ou uma biblioteca com abstrações para retry, outbox e sagas. O exemplo a seguir usa o cliente direto, para a mecânica ficar visível.

Kafka e RabbitMQ em C#: o mesmo fluxo nos dois

Fluxo do exemplo: o mesmo conjunto de pedidos nos dois brokers
Fluxo do exemplo: o mesmo conjunto de pedidos nos dois brokers

O exemplo publica os mesmos seis pedidos (um deles inválido) nos dois brokers e mostra a diferença no que acontece depois: no RabbitMQ a fila esvazia e o pedido inválido vai para a dead-letter; no Kafka dois grupos leem os mesmos eventos, com ordem por cliente.

Código completo, com instruções para rodar (com e sem Docker): github.com/ceseidl/kafka-vs-rabbitmq-dotnet. Todo ele também está neste artigo, arquivo por arquivo.

4.1. Ambiente

Pré-requisitos: .NET 10 SDK e Docker. Os dois brokers sobem com um docker-compose.yml; o Kafka usa a imagem oficial apache/kafka em modo KRaft (sem ZooKeeper).

Arquivo docker-compose.yml. As portas ficam presas ao loopback (127.0.0.1 e ::1), para que os brokers (o RabbitMQ usa guest/guest) não fiquem acessíveis pela rede, e os healthcheck permitem docker compose up -d --wait.

yaml
services:


  rabbitmq:


    image: rabbitmq:4.3-management


    ports:


      # EN: Loopback only (IPv4 and IPv6: "localhost" may be


      # EN: ::1): the broker (guest/guest) is not reachable


      # EN: from the network.


      # PT: Só no loopback (IPv4 e IPv6: "localhost" pode ser


      # PT: ::1): o broker (guest/guest) não fica acessível


      # PT: pela rede.


      - "127.0.0.1:5672:5672" # AMQP


      - "[::1]:5672:5672"


      # EN: management UI (guest/guest).


      # PT: painel de administração (guest/guest).


      - "127.0.0.1:15672:15672"


      - "[::1]:15672:15672"


    healthcheck:


      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]


      interval: 5s


      timeout: 10s


      retries: 20





  kafka:


    # EN: KRaft mode: no ZooKeeper.


    # PT: modo KRaft: sem ZooKeeper.


    image: apache/kafka:4.0.0


    ports:


      - "127.0.0.1:9092:9092"


      - "[::1]:9092:9092"


    healthcheck:


      test:


        - CMD-SHELL


        - >-


          /opt/kafka/bin/kafka-broker-api-versions.sh


          --bootstrap-server localhost:9092


          > /dev/null 2>&1


      interval: 5s


      timeout: 15s


      retries: 30


      start_period: 10s

O projeto (MensageriaDemo.csproj) é um console com os dois pacotes de cliente:

xml
<Project Sdk="Microsoft.NET.Sdk">





  <PropertyGroup>


    <OutputType>Exe</OutputType>


    <TargetFramework>net10.0</TargetFramework>


    <ImplicitUsings>enable</ImplicitUsings>


    <Nullable>enable</Nullable>


  </PropertyGroup>





  <ItemGroup>


    <PackageReference Include="Confluent.Kafka"


                      Version="2.15.1" />


    <PackageReference Include="RabbitMQ.Client"


                      Version="7.2.2" />


  </ItemGroup>





</Project>
Texto
docker compose up -d --wait


dotnet run -- rabbit


dotnet run -- kafka

Sem Docker

O exemplo só precisa de um RabbitMQ em localhost:5672 (usuário e senha guest) e de um Kafka em localhost:9092. Como eles sobem não importa para o código. Se você não usa Docker, há três caminhos:

  1. Instalação nativa do RabbitMQ. Instale o Erlang/OTP e depois o RabbitMQ (instaladores em rabbitmq.com/docs/install-windows, ou pelos pacotes do seu Linux/macOS). Ative o painel de gerenciamento e confira o serviço:
Bash
rabbitmq-plugins enable rabbitmq_management


rabbitmqctl status
  1. Instalação nativa do Kafka (modo KRaft). Instale o Java 17 ou superior, baixe o .tgz em kafka.apache.org/downloads, extraia e, dentro da pasta, formate o armazenamento uma vez e inicie o broker. No Windows, use o WSL 2 (Ubuntu), que é o caminho mais simples para rodar os scripts .sh:
Bash
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"


bin/kafka-storage.sh format --standalone \


  -t $KAFKA_CLUSTER_ID -c config/server.properties


bin/kafka-server-start.sh config/server.properties
  1. Serviço gerenciado. Um plano gratuito de um RabbitMQ gerenciado (como o CloudAMQP) e um cluster Kafka gerenciado (como o Confluent Cloud) dispensam instalar qualquer coisa. Nesse caso troque o host, a porta e as credenciais no código (HostName/Uri no RabbitMQ e BootstrapServers no Kafka) e acrescente os parâmetros de TLS e de autenticação que o provedor indicar.

Com o ambiente de pé, o dotnet run é o mesmo. Se o Kafka estiver em outra máquina ou porta, ajuste o BootstrapServers; no RabbitMQ, o host e a credencial.

4.2. Contrato e ponto de entrada

Os blocos desta seção são os arquivos do repositório, sem cortes; por isso as mensagens e os comentários aparecem em inglês e português. Primeiro Contracts.cs, depois Program.cs.

C#
using System.Text.Json;





namespace MensageriaDemo;





public sealed record OrderPlaced(


    Guid OrderId, string CustomerId, decimal Total);





public static class Wire


{


    public static byte[] Serialize(OrderPlaced order) =>


        JsonSerializer.SerializeToUtf8Bytes(order);





    public static OrderPlaced Deserialize(


        ReadOnlySpan<byte> body) =>


        JsonSerializer.Deserialize<OrderPlaced>(body)


        ?? throw new InvalidOperationException(


            "Empty message / Mensagem vazia");





    // EN: Sample orders: two customers, plus one invalid order


    // (Total <= 0).


    // PT: Pedidos de exemplo: dois clientes, e um pedido


    // inválido (Total <= 0).


    public static IReadOnlyList<OrderPlaced> Sample() =>


    [


        new(Guid.NewGuid(), "cli-1", 100m),


        new(Guid.NewGuid(), "cli-2", 250m),


        new(Guid.NewGuid(), "cli-1", 0m),


        new(Guid.NewGuid(), "cli-2", 80m),


        new(Guid.NewGuid(), "cli-1", 40m),


        new(Guid.NewGuid(), "cli-2", 15m),


    ];


}
C#
using MensageriaDemo;





using var cts = new CancellationTokenSource(


    TimeSpan.FromSeconds(60));





switch (args.FirstOrDefault())


{


    case "rabbit":


        await RabbitMqDemo.RunAsync(cts.Token);


        break;


    case "kafka":


        await KafkaDemo.RunAsync(cts.Token);


        break;


    default:


        Console.WriteLine(


            "usage / uso: dotnet run -- rabbit|kafka");


        break;


}

O contrato é o mesmo nos dois brokers; o que muda é como cada um o transporta.

4.3. RabbitMQ: topologia, publicação e consumo

Primeiro a topologia, em RabbitMqDemo.Topology.cs: uma exchange direct, a fila principal como quorum queue (a fila replicada e durável recomendada), uma fila de auditoria ligada à mesma chave e uma dead-letter exchange para o que for rejeitado.

C#
using RabbitMQ.Client;


using RabbitMQ.Client.Events;





namespace MensageriaDemo;





public static partial class RabbitMqDemo


{


    private const string Exchange = "orders";


    private const string RoutingKey = "order.placed";


    private const string Process = "orders.process";


    private const string Audit = "orders.audit";


    private const string Dead = "orders.dead";





    private static async Task DeclareTopologyAsync(


        IChannel channel, CancellationToken ct)


    {


        await channel.ExchangeDeclareAsync(


            Exchange, ExchangeType.Direct,


            durable: true, cancellationToken: ct);


        await channel.ExchangeDeclareAsync(


            "orders.dlx", ExchangeType.Fanout,


            durable: true, cancellationToken: ct);





        // EN: Main queue: quorum queue, with dead-letter for


        // rejected messages.


        // PT: Fila principal: quorum queue, com dead-letter


        // para mensagens rejeitadas.


        var args = new Dictionary<string, object?>


        {


            ["x-queue-type"] = "quorum",


            ["x-dead-letter-exchange"] = "orders.dlx",


        };


        await channel.QueueDeclareAsync(


            Process, durable: true, exclusive: false,


            autoDelete: false, args, cancellationToken: ct);


        await channel.QueueDeclareAsync(


            Audit, durable: true, exclusive: false,


            autoDelete: false, cancellationToken: ct);


        await channel.QueueDeclareAsync(


            Dead, durable: true, exclusive: false,


            autoDelete: false, cancellationToken: ct);





        // EN: Two queues bound to the same key: each one


        // receives a copy of the message.


        // PT: Duas filas ligadas à mesma chave: cada uma


        // recebe uma cópia da mensagem.


        await channel.QueueBindAsync(


            Process, Exchange, RoutingKey, cancellationToken: ct);


        await channel.QueueBindAsync(


            Audit, Exchange, RoutingKey, cancellationToken: ct);


        await channel.QueueBindAsync(


            Dead, "orders.dlx", "", cancellationToken: ct);





        foreach (var queue in new[] { Process, Audit, Dead })


            // EN: Repeatable demo.


            // PT: Demo repetível.


            await channel.QueuePurgeAsync(queue, ct);


    }





    private static async Task<uint> Count(


        IChannel channel, string queue, CancellationToken ct) =>


        (await channel.QueueDeclarePassiveAsync(queue, ct))


            .MessageCount;


}

Depois, em RabbitMqDemo.cs, o fluxo principal: publicar com confirmação do broker e consumir com dois workers concorrentes, ack manual e rejeição sem reenfileirar.

C#
using RabbitMQ.Client;


using RabbitMQ.Client.Events;





namespace MensageriaDemo;





public static partial class RabbitMqDemo


{


    public static async Task RunAsync(CancellationToken ct)


    {


        // EN: Default credentials: guest/guest.


        // PT: Credenciais padrão: guest/guest.


        var factory = new ConnectionFactory


        {


            HostName = "localhost"


        };


        await using var connection =


            await factory.CreateConnectionAsync(ct);





        // EN: Publisher confirms: publish only returns after the


        // broker has accepted the message.


        // PT: Confirmações do broker: o publish só retorna


        // depois que o broker aceitou a mensagem.


        var options = new CreateChannelOptions(


            publisherConfirmationsEnabled: true,


            publisherConfirmationTrackingEnabled: true);


        await using var channel =


            await connection.CreateChannelAsync(options, ct);





        await DeclareTopologyAsync(channel, ct);





        var orders = Wire.Sample();


        var handled = 0;


        var done = new TaskCompletionSource();





        // EN: Prefetch 1: each worker gets one message at a time


        // (fair dispatch).


        // PT: Prefetch 1: cada worker recebe uma mensagem por


        // vez (distribuição justa).


        await channel.BasicQosAsync(


            0, prefetchCount: 1, global: false, ct);





        async Task StartWorker(string name)


        {


            var consumer =


                new AsyncEventingBasicConsumer(channel);


            consumer.ReceivedAsync += async (_, ea) =>


            {


                try


                {


                    var order = Wire.Deserialize(ea.Body.Span);


                    if (order.Total <= 0)


                        throw new InvalidOperationException(


                            "Invalid total / Total inválido");





                    Console.WriteLine(


                        $"[{name}] processed / processou "


                        + $"{order.CustomerId} R$ {order.Total}");


                    await channel.BasicAckAsync(


                        ea.DeliveryTag, multiple: false);


                }


                catch (Exception ex)


                {


                    Console.WriteLine(


                        $"[{name}] failed / falhou "


                        + $"({ex.Message})");


                    await channel.BasicNackAsync(


                        ea.DeliveryTag,


                        multiple: false,


                        requeue: false);


                }


                finally


                {


                    if (Interlocked.Increment(ref handled)


                        == orders.Count)


                        done.TrySetResult();


                }


            };





            // EN: autoAck: false -> the message only leaves the


            // queue after BasicAck.


            // PT: autoAck: false -> a mensagem só sai da fila


            // após o BasicAck.


            await channel.BasicConsumeAsync(


                Process, autoAck: false, consumer, ct);


        }





        await StartWorker("worker-1");


        await StartWorker("worker-2");





        foreach (var order in orders)


        {


            var props = new BasicProperties


            {


                Persistent = true,


                MessageId = order.OrderId.ToString(),


            };


            await channel.BasicPublishAsync(


                Exchange, RoutingKey, mandatory: true, props,


                Wire.Serialize(order), ct);


        }





        await done.Task.WaitAsync(ct);


        // EN: Lets the broker update its counters.


        // PT: Deixa o broker atualizar os contadores.


        await Task.Delay(500, ct);





        var process = await Count(channel, Process, ct);


        var audit = await Count(channel, Audit, ct);


        var dead = await Count(channel, Dead, ct);


        Console.WriteLine(


            "orders.process (after consumption / "


            + $"após consumo): {process} msgs");


        Console.WriteLine(


            "orders.audit (copy, no consumer / "


            + $"cópia sem consumidor): {audit} msgs");


        Console.WriteLine(


            $"orders.dead (dead-letter): {dead} msgs");


    }


}

Saída da execução (a ordem entre os workers pode variar):

Texto
[worker-1] processed / processou cli-1 R$ 100


[worker-2] processed / processou cli-2 R$ 250


[worker-1] failed / falhou (Invalid total / Total inválido)


[worker-2] processed / processou cli-2 R$ 80


[worker-1] processed / processou cli-1 R$ 40


[worker-2] processed / processou cli-2 R$ 15


orders.process (after consumption / após consumo): 0 msgs


orders.audit (copy, no consumer / cópia sem consumidor): 6 msgs


orders.dead (dead-letter): 1 msgs

Pontos importantes do código (RabbitMQ)

  • Publisher confirms: com a confirmação habilitada no canal, BasicPublishAsync só conclui quando o broker aceita a mensagem; se ele rejeitar, a chamada lança exceção. Sem isso, "publicou" não significa "chegou".
  • Ack manual (autoAck: false): a mensagem só sai da fila depois do BasicAckAsync. Se o worker cair no meio, o broker reentrega.
  • BasicNackAsync com requeue: false manda a mensagem para a dead-letter exchange. Reenfileirar uma mensagem que sempre falha cria um laço infinito.
  • prefetchCount: 1** evita que um worker acumule mensagens enquanto o outro fica ocioso.
  • A fila orders.audit recebeu as 6 mensagens sem ninguém consumir: é o roteamento por exchange entregando uma cópia por fila.
  • A fila principal terminou vazia. A mensagem processada não existe mais, e esta é a principal diferença para o Kafka.
  • A ordem entre cli-1 e cli-2 na saída não é garantida, porque dois workers disputam a mesma fila.

4.4. Kafka: produtor com chave e grupos de consumo

O Kafka está em três arquivos da mesma classe parcial. Primeiro KafkaDemo.cs, que cria o tópico e coordena a execução:

C#
using Confluent.Kafka;


using Confluent.Kafka.Admin;





namespace MensageriaDemo;





public static partial class KafkaDemo


{


    private const string Bootstrap = "localhost:9092";





    public static async Task RunAsync(CancellationToken ct)


    {


        // EN: Fresh topic on every run: no dependence on old


        // offsets.


        // PT: Tópico novo a cada execução: sem depender de


        // offsets antigos.


        var topic = $"orders-{Guid.NewGuid():N}"[..14];


        await CreateTopicAsync(topic);





        var orders = Wire.Sample();


        await ProduceAsync(topic, orders, ct);





        // EN: Two groups read the SAME events, each at its own


        // pace.


        // PT: Dois grupos leem os MESMOS eventos, cada um no seu


        // ritmo.


        Consume("billing", topic, orders.Count, ct);


        Consume("analytics", topic, orders.Count, ct);


    }





    private static async Task CreateTopicAsync(string topic)


    {


        using var admin = new AdminClientBuilder(


            new AdminClientConfig


            {


                BootstrapServers = Bootstrap


            }).Build();





        await admin.CreateTopicsAsync(


        [


            new TopicSpecification


            {


                Name = topic,


                NumPartitions = 3,


                ReplicationFactor = 1


            }


        ]);


    }


}

Em KafkaDemo.Producer.cs, o produtor com chave:

C#
using Confluent.Kafka;





namespace MensageriaDemo;





public static partial class KafkaDemo


{


    private static async Task ProduceAsync(


        string topic,


        IReadOnlyList<OrderPlaced> orders,


        CancellationToken ct)


    {


        var config = new ProducerConfig


        {


            BootstrapServers = Bootstrap,


            // EN: Waits for the in-sync replicas.


            // PT: Espera as réplicas em sincronia.


            Acks = Acks.All,


            // EN: Retries without duplicating.


            // PT: Retry sem duplicar.


            EnableIdempotence = true,


        };


        using var producer =


            new ProducerBuilder<string, byte[]>(config).Build();





        foreach (var order in orders)


        {


            // EN: The key defines the partition: same customer


            // -> same partition -> order preserved.


            // PT: A chave define a partição: mesmo cliente ->


            // mesma partição -> ordem preservada.


            var message = new Message<string, byte[]>


            {


                Key = order.CustomerId,


                Value = Wire.Serialize(order),


            };


            var result =


                await producer.ProduceAsync(topic, message, ct);





            Console.WriteLine(


                $"produced / produziu {order.CustomerId} -> "


                + "partition / partição "


                + $"{result.Partition.Value}, "


                + $"offset {result.Offset.Value}");


        }


    }


}

E em KafkaDemo.Consumer.cs, os grupos de consumo:

C#
using Confluent.Kafka;





namespace MensageriaDemo;





public static partial class KafkaDemo


{


    private static void Consume(


        string group, string topic, int expected,


        CancellationToken ct)


    {


        var config = new ConsumerConfig


        {


            BootstrapServers = Bootstrap,


            GroupId = group,


            // EN: A new group reads from the beginning.


            // PT: Grupo novo lê desde o início.


            AutoOffsetReset = AutoOffsetReset.Earliest,


            // EN: Commit only after processing.


            // PT: Commit só após processar.


            EnableAutoCommit = false,


        };


        using var consumer =


            new ConsumerBuilder<string, byte[]>(config).Build();


        consumer.Subscribe(topic);





        // EN: The first poll on a fresh broker can wait for the


        // group join and the offsets topic, so keep polling


        // until a deadline instead of giving up on the first


        // empty poll.


        // PT: O primeiro poll em um broker novo pode esperar o


        // group join e o tópico de offsets; por isso continua


        // tentando até um prazo, em vez de desistir no primeiro


        // poll vazio.


        var deadline = DateTime.UtcNow.AddSeconds(25);


        var seen = 0;


        while (seen < expected


               && DateTime.UtcNow < deadline


               && !ct.IsCancellationRequested)


        {


            var result =


                consumer.Consume(TimeSpan.FromSeconds(2));


            if (result is null) continue;





            var order = Wire.Deserialize(result.Message.Value);


            Console.WriteLine(


                $"[{group}] {order.CustomerId} R$ {order.Total} "


                + "(partition / partição "


                + $"{result.Partition.Value}, "


                + $"offset {result.Offset.Value})");





            // EN: At-least-once: confirm after the side effect.


            // PT: At-least-once: confirma depois do efeito.


            consumer.Commit(result);


            seen++;


        }





        Console.WriteLine(


            $"[{group}] read {seen} of {expected} events / "


            + $"leu {seen} de {expected} eventos");


        consumer.Close();


    }


}

Saída resumida (os números de partição variam de execução para execução):

Texto
produced / produziu cli-1 -> partition / partição 2, offset 0


produced / produziu cli-2 -> partition / partição 1, offset 0


produced / produziu cli-1 -> partition / partição 2, offset 1


...


[billing] cli-1 R$ 100 (partition / partição 2, offset 0)


[billing] cli-1 R$ 0 (partition / partição 2, offset 1)


[billing] cli-1 R$ 40 (partition / partição 2, offset 2)


[billing] cli-2 R$ 250 (partition / partição 1, offset 0)


...


[billing] read 6 of 6 events / leu 6 de 6 eventos


[analytics] cli-1 R$ 100 (partition / partição 2, offset 0)


...


[analytics] read 6 of 6 events / leu 6 de 6 eventos

Pontos importantes do código (Kafka)

  • A chave decide a partição: os três pedidos de cli-1 caíram na mesma partição, com offsets 0, 1 e 2, e foram lidos nessa ordem. É a garantia de ordem por entidade.
  • Acks.All + EnableIdempotence: o produtor espera a confirmação das réplicas em sincronia e, se precisar repetir o envio, o broker descarta a duplicata dentro daquela sessão do produtor.
  • Dois grupos, mesmo conteúdo: billing e analytics leram os 6 eventos, cada um com seu próprio offset. O evento não sumiu quando o primeiro grupo terminou. Esse é o ponto que o RabbitMQ não oferece em filas comuns.
  • EnableAutoCommit = false e Commit depois do processamento: dá semântica at-least-once. Se o processo cair antes do commit, o evento é lido de novo, então o consumidor deve ser idempotente.
  • O pedido inválido (R$ 0) foi lido normalmente: o Kafka não tem nack nem dead-letter nativos. Quem trata falha é o seu código (por exemplo, publicando em um tópico orders.dead).
  • A demo faz commit a cada mensagem para ficar clara. Em produção, faça commit em lotes, para não pagar uma ida ao broker por evento.
  • Tópico com ReplicationFactor = 1 serve só para o ambiente local; em produção, use fator 3 (e min.insync.replicas coerente com Acks.All).

Erros comuns e boas práticas com Kafka e RabbitMQ

Boas práticas e pontos de atenção em cada broker
Boas práticas e pontos de atenção em cada broker

5.1. Erros comuns

  • Ack automático no RabbitMQ: com autoAck: true, o broker considera a mensagem entregue no instante do envio; se o consumidor cair, ela se perde.
C#
// Erro comum: perde a mensagem se o processo cair no meio


await channel.BasicConsumeAsync(


    "orders.process", autoAck: true, consumer);





// Correção: confirmar só depois de processar


// (BasicAckAsync no handler)


await channel.BasicConsumeAsync(


    "orders.process", autoAck: false, consumer);
  • Requeue infinito: nack com requeue: true de uma mensagem "venenosa" a reenfileira para sempre. Use dead-letter e, se precisar de nova tentativa, uma fila com atraso.
  • Auto-commit no Kafka: o padrão do cliente confirma offsets em segundo plano, antes de o seu código terminar o trabalho. Falha no meio significa evento perdido sem aviso.
C#
// Erro comum: o offset é confirmado em segundo plano,


// sem esperar o seu processamento


// (EnableAutoCommit padrão = true)


var perigoso = new ConsumerConfig { GroupId = "billing" };





// Correção: commit explícito depois do efeito de negócio


var seguro = new ConsumerConfig


{


    GroupId = "billing",


    EnableAutoCommit = false


};
  • Poucas partições: o paralelismo de um grupo nunca passa do número de partições, e aumentar depois muda o mapeamento chave → partição, quebrando a ordem de chaves já existentes. Planeje com folga.
  • Chave ruim: uma chave com pouca variedade (ou um cliente muito dominante) cria partições "quentes", em que um consumidor trabalha e os outros esperam.
  • Mensagem sem versão: tanto fila quanto tópico precisam evoluir o contrato sem quebrar consumidores antigos. Inclua versão no evento e só acrescente campos opcionais.
  • Assumir "exatamente uma vez": nenhum dos dois entrega isso de ponta a ponta quando há efeito externo (banco, e-mail, cobrança). O que existe é entrega pelo menos uma vez e consumidor idempotente.

5.2. Dicas de produção

  • Idempotência no consumidor nos dois casos: guarde a chave da mensagem processada (tabela com restrição de unicidade, gravada na mesma transação do efeito). O padrão Inbox e o Transactional Outbox para publicar com segurança estão no artigo de Saga Pattern.
  • RabbitMQ: use quorum queues para dados que não podem se perder, publisher confirms, ack manual, prefetch ajustado e dead-letter configurada. Monitore tamanho de fila e mensagens não confirmadas.
  • Kafka: Acks.All, idempotência no produtor, min.insync.replicas coerente, commit manual, e monitoramento do consumer lag (a distância entre o fim do log e o offset do grupo). Lag crescente é o alarme principal.
  • Falhas no Kafka: defina antes de ir para produção o que acontece com o evento que falha (retentar quantas vezes, para qual tópico de erro enviar, quem olha).
  • Serialização: JSON simplifica o começo; quando muitos times consomem o mesmo tópico, considere um formato com esquema (Avro, Protobuf) e um schema registry.
  • Observabilidade: propague o identificador de correlação nos cabeçalhos da mensagem e use OpenTelemetry para ligar produtor e consumidor no mesmo trace.
  • Teste com o broker real: o que diferencia os dois está no comportamento (reentrega, rebalanceamento, ordem), e isso não aparece em testes com mocks. Suba o broker em contêiner no teste de integração.
  • Valide o código e a configuração no seu ambiente antes de copiar: versões de cliente e de broker mudam nomes de parâmetros e valores padrão.

5.3. No dia a dia: rodando local e usando de verdade

Subir o broker é a parte fácil. O valor aparece quando você consegue olhar dentro dele durante o desenvolvimento e sabe em que tipo de funcionalidade cada um entra. Os comandos abaixo usam o docker compose exec do exemplo. Sem Docker, rode a mesma ferramenta direto: rabbitmqctl ... para o RabbitMQ, e bin/kafka-topics.sh, bin/kafka-console-consumer.sh e bin/kafka-consumer-groups.sh (dentro da pasta do Kafka) com os mesmos parâmetros.

5.3.1. Olhando o RabbitMQ

O painel de gerenciamento fica em http://localhost:15672 (usuário e senha guest). Na aba Queues você vê, para cada fila, quantas mensagens estão prontas, quantas foram entregues e ainda não confirmadas, e quantos consumidores estão ligados. Na própria tela dá para publicar uma mensagem manualmente e ler (Get messages) o que está parado na orders.dead. Pelo terminal:

Bash
# filas, mensagens e consumidores


docker compose exec rabbitmq \


  rabbitmqctl list_queues name messages consumers





# esvaziar uma fila de teste


docker compose exec rabbitmq \


  rabbitmqctl purge_queue orders.dead

5.3.2. Olhando o Kafka

O Kafka não traz painel. O dia a dia é feito com as ferramentas de linha de comando que já vêm na imagem (em /opt/kafka/bin):

Bash
# listar tópicos


docker compose exec kafka \


  /opt/kafka/bin/kafka-topics.sh \


  --bootstrap-server localhost:9092 --list





# ler um tópico desde o início, mostrando a chave


docker compose exec kafka \


  /opt/kafka/bin/kafka-console-consumer.sh \


  --bootstrap-server localhost:9092 --topic <nome-do-topico> \


  --from-beginning --property print.key=true





# situação de um grupo: offset, fim do log e lag por partição


docker compose exec kafka \


  /opt/kafka/bin/kafka-consumer-groups.sh \


  --bootstrap-server localhost:9092 --describe --group billing

Se preferir uma interface, existem painéis de código aberto que rodam em contêiner e se ligam ao mesmo localhost:9092 (por exemplo, Kafka UI ou AKHQ). A coluna LAG do --describe é a que você vai olhar mais: se o número só cresce, o consumidor não está dando conta.

5.3.3. Roteiro de uma tarde de desenvolvimento

  1. docker compose up -d e abra o painel do RabbitMQ.
  2. Rode o produtor e veja a mensagem aparecer na fila; derrube o consumidor no meio do processamento e confirme que, com ack manual, ela volta para a fila.
  3. Publique de propósito uma mensagem inválida e confira que ela caiu na orders.dead, com o motivo, em vez de ficar em loop.
  4. No Kafka, rode dois consumidores no mesmo grupo e veja as partições se dividirem; rode um terceiro grupo e veja que ele recebe tudo de novo.
  5. Zere o ambiente quando quiser recomeçar: docker compose down -v.

5.3.4. Onde cada um aparece nos sistemas reais

Pense em uma loja online. Um mesmo pedido gera os dois tipos de trabalho:

  • Tarefas (RabbitMQ): enviar o e-mail de confirmação, gerar a nota fiscal em PDF, redimensionar as fotos do produto, chamar o gateway de pagamento com nova tentativa se ele estiver fora. Cada uma tem um dono, deve acontecer uma vez, e se falhar vai para a dead-letter para alguém olhar.
  • Fatos (Kafka): "pedido criado", "pagamento aprovado", "item enviado". Quem precisa deles é o faturamento, o painel de vendas, o motor de recomendação, a auditoria, e outro time daqui a seis meses que ainda nem existe. Como o evento fica no log, esse novo consumidor lê o histórico sem pedir nada ao time de pedidos.

Mais exemplos do cotidiano:

  • Disparar e-mail ou SMS: RabbitMQ — tarefa única, com nova tentativa e fila de falhas.
  • Gerar relatório pesado a pedido do usuário: RabbitMQ — o usuário não espera; o worker processa no seu ritmo.
  • Enviar webhook para um parceiro: RabbitMQ — reentrega com atraso quando o parceiro estiver fora.
  • Rastrear cliques e telemetria: Kafka — volume alto, leitura por vários consumidores.
  • Alimentar um data lake ou busca (Elasticsearch): Kafka — cada destino lê o fluxo no próprio ritmo e pode reler.
  • Sincronizar dados entre serviços (CDC): Kafka — o log é a fonte; um serviço novo parte do começo.
  • Trilha de auditoria: Kafka — retenção longa e leitura não destrutiva.

5.3.5. Um padrão comum: os dois juntos

O serviço de pedidos publica o fato "pedido criado" no Kafka. Um consumidor desse tópico, no serviço de notificações, transforma o fato em tarefas e as coloca no RabbitMQ (e-mail, nota, etiqueta). Assim o Kafka guarda a história e o RabbitMQ cuida da execução, cada um no que faz melhor.

5.3.6. Hábitos que poupam dor de cabeça

  • Dê nomes previsíveis a filas, tópicos e grupos (servico.entidade.acao), porque você vai procurá-los no painel ou na linha de comando.
  • Mantenha o docker-compose.yml no repositório, para que qualquer pessoa suba o ambiente com um comando.
  • Para testar o tratamento de falha, faça o consumidor falhar de propósito (um pedido com valor zero, como na demo) em vez de esperar que aconteça em produção.
  • Valide os comandos acima com as versões de imagem que você usa: caminhos e parâmetros das ferramentas mudam entre versões.

Quando escolher Kafka e quando escolher RabbitMQ

Quando escolher cada um
Quando escolher cada um

Escolha o RabbitMQ quando

  • A mensagem é uma tarefa: enviar e-mail, gerar relatório, processar imagem, cobrar pedido.
  • Você precisa de roteamento flexível (por tipo, por tópico, por cabeçalho) sem escrever código de roteamento.
  • Precisa de reentrega, prioridade, TTL e dead-letter prontos, e de padrões de requisição/resposta.
  • O volume é de moderado a alto, e a simplicidade operacional pesa mais do que o histórico.
  • A equipe é pequena e quer algo em produção rápido.

Escolha o Kafka quando

  • O dado é um fluxo de eventos que vários sistemas consomem e que precisa ser relido (nova funcionalidade, correção de bug, novo consumidor que precisa do histórico).
  • O volume é muito alto e o crescimento exige escala horizontal por partições.
  • Há processamento de streams (agregações, janelas, junções) ou integração com ferramentas de dados (Kafka Streams, Flink, Spark, Kafka Connect).
  • Você quer o log como fonte de verdade (auditoria, event sourcing, CDC).
  • Ordem por chave com alto volume é requisito.

Evite o Kafka quando

  • O problema é uma fila de tarefas simples: você pagaria a complexidade de partições, rebalanceamento e retry manual para nada.
  • Precisa de roteamento complexo, prioridade de mensagem ou atraso por mensagem, que o RabbitMQ entrega pronto.
  • A equipe não tem capacidade de operar um cluster (ou de contratar um serviço gerenciado).

Evite o RabbitMQ quando

  • Os consumidores precisam reler eventos antigos ou ser adicionados depois com histórico (considere as Streams do RabbitMQ ou o Kafka).
  • Você precisa de um volume de streaming de dados muito alto, com retenção longa.
  • A ordem global por entidade com muitos consumidores concorrentes é essencial.

Alternativas

  • RabbitMQ Streams: log de leitura não destrutiva dentro do próprio RabbitMQ, uma ponte quando você quer um pouco de replay sem trocar de broker.
  • Azure Service Bus, Amazon SQS/SNS: serviços gerenciados no modelo de fila e tópico, com dead-letter e sessões prontas; menos operação, em troca de acoplar-se à nuvem.
  • Azure Event Hubs, Amazon Kinesis ou Kafka gerenciado: o modelo de log sem operar o cluster.
  • Bibliotecas de mensageria (MassTransit, Wolverine, NServiceBus): abstraem o transporte e trazem retry, outbox e sagas, e várias suportam os dois brokers. Confira a licença antes de adotar: segundo a documentação oficial, o MassTransit v9 é comercial.
  • Os dois juntos: é comum usar o Kafka como espinha dorsal de eventos e o RabbitMQ para as filas de trabalho dos serviços. Não é obrigatório escolher um só.
  • Nenhum dos dois: um monólito modular com chamada em processo, ou uma tabela de outbox com um processador simples, pode resolver sem broker. Adote mensageria quando houver um problema real de acoplamento ou de escala.

Kafka vs RabbitMQ: tarefa ou fato, qual escolher

Conclusão: Kafka vs RabbitMQ em um olhar
Conclusão: Kafka vs RabbitMQ em um olhar

RabbitMQ e Kafka não são concorrentes diretos: um entrega tarefas a quem vai executá-las; o outro registra fatos que qualquer um pode ler, inclusive depois. No exemplo, a mesma carga de pedidos deixou a fila do RabbitMQ vazia e mandou o pedido inválido para a dead-letter; no Kafka, os eventos continuaram no tópico, lidos por dois grupos, com a ordem de cada cliente preservada.

Em qualquer dos dois, o que sustenta o sistema em produção é parecido: confirmação explícita, consumidor idempotente, tratamento de falha desenhado, contrato versionado e monitoramento (tamanho da fila ou lag).

Reflexão prática: antes de escolher, responda a três perguntas sobre o seu fluxo. A mensagem é uma tarefa ou um fato? Alguém vai precisar relê-la depois? Qual o volume previsto em dois anos? Se as respostas forem "tarefa", "não" e "moderado", o RabbitMQ sai na frente com muito menos custo. Se forem "fato", "sim" e "alto", o log do Kafka se paga.

Referências de Kafka e RabbitMQ

Leia também

Precisa de APIs .NET de alta disponibilidade? Conheça o serviço de backend e arquitetura .NET.

← todos os artigos