.NET'te Message Queue ile Çalışmaya Giriş: RabbitMQ, Azure Service Bus ve Kafka Client'ları, Hangi Durumda Hangisi
Esnek yönlendirme ve mesaj bazında ack/retry için RabbitMQ, Azure'da yönetilen servis için Azure Service Bus, yüksek hacim ve replay için Kafka; seri raw client ile başlayıp abstraction'lara geçiyor. Üç broker'ın modeli, pratik karşılaştırma tablosu, aynı OrderPlaced'ı üç client ile gönderip okuyan program, Docker Compose ortamı ve Amazon SQS/SNS notu.
Sürüm notu: Bu yazıdaki kodlar .NET 10, RabbitMQ 4.4 ve RabbitMQ.Client 7.2.2, Apache Kafka 4.2.1 ve Confluent.Kafka 2.16.0, Azure Service Bus emulator'ü ve Azure.Messaging.ServiceBus 7.21.0 ile derlenip denenmiştir.
Önce Tahmin Et
Aynı makinede üç broker çalışıyor: RabbitMQ, Azure Service Bus emulator'ü ve Kafka. Küçük bir konsol uygulaması her birine aynı 355 byte'lık OrderPlaced mesajını gönderiyor, hemen ardından aynı mesajı bir consumer ile geri okuyor ve geçen süreyi ölçüyor. Üçünden hangisi en yavaş çıkar ve neden? Broker'ın kendisi mi yavaş, yoksa ölçtüğümüz şey başka bir şey mi?
Cevabı yazının ortasında, gerçek çıktıyla. İpucu: en yavaş çıkan broker, ikinci mesajda en hızlılardan biri oluyor.
Kavramlar Başka Seride, Burada Kod
Queue ile topic arasındaki fark, at-least-once teslimat, idempotent consumer, outbox ve saga gibi kavramlar Yazılım Mimarisi kategorisindeki Messaging ve Event-Driven Architecture serisinde anlatıldı. Bu seri o kavramları yeniden anlatmıyor; her yazıda ilgili yazıya adıyla gönderiyor. Burada sorulan soru başka: "Bu kavramları biliyorum, peki .NET'te doğru kod nasıl yazılır?"
Bu sorunun cevabı broker'a göre değişen ayrıntılarda saklı. RabbitMQ'da connection'ı singleton tutup channel'ı thread'ler arasında paylaşmamak, Azure Service Bus'ta lock süresiyle işin süresini dengelemek, Kafka'da offset'i işlemden sonra işaretlemek. Kavram aynı, hatanın yeri farklı. Serinin ilk bloğu (2–6) üç broker'ın .NET client'larını ve mesaj contract'larını, ikinci bloğu (7–13) MassTransit gibi abstraction'ları, retry, dead-letter, idempotent consumer, outbox, observability ve testi, son yazı da client sürüm geçişlerini ele alıyor.
Raw Client mı, Abstraction mı?
.NET'te bir broker'la konuşmanın iki yolu var. Birincisi broker'ın kendi client'ı: RabbitMQ için RabbitMQ.Client, Azure Service Bus için Azure.Messaging.ServiceBus, Kafka için Confluent.Kafka. Bunlar broker'ın her özelliğine doğrudan erişim verir, ama bağlantı yönetimi, consumer döngüsü, serialization, hata yönetimi ve topology senin işin. İkincisi bu altyapıyı üstlenen bir kütüphane: MassTransit, Wolverine, Rebus, NServiceBus, Brighter. Sen bir handler yazarsın, gerisini kütüphane yapar.
Seri bilinçli olarak raw client ile başlıyor. Sebebi basit: abstraction'ın sakladığı şeyi bilmeden onun hatalarını ayıklayamazsın. Prefetch'i olmayan bir consumer'ın bütün queue'yu belleğe çektiğini, auto commit'in yarım kalan mesajı atladığını, lock süresi dolan mesajın ikinci kez işlendiğini bir kez kendi gözünle gördüğünde, MassTransit'in ya da Wolverine'in o ayarı neden öyle yaptığını da anlarsın. Abstraction'lara serinin yedinci yazısında geçiyoruz; MassTransit v9'un Ocak 2026'da ticari lisansa geçmesiyle bu seçim artık bir maliyet kararı da.
Üç Broker, Üç Model
Bookstore'un örnek akışı serinin tamamında aynı: Order API (Application Programming Interface) bir sipariş alıyor ve OrderPlaced event'ini yayınlıyor; Billing fatura kesiyor, Notification müşteriye e-posta atıyor. Aynı akışın üç broker'daki hâli:

RabbitMQ'da producer bir exchange'e yazar; exchange routing key'e bakarak mesajın kopyasını bağlı queue'lara koyar. Her consumer kendi queue'sunu okur, işlediği mesajı ack'ler ve mesaj silinir. Yönlendirme esnekliği en yüksek olan model bu: topic, direct, fanout ve headers exchange'leriyle mesajı hemen her kurala göre dağıtabilirsin.
Azure Service Bus'ta producer bir topic'e yazar; her subscription topic'in mesajlarının kendine ait bir kopyasını tutar ve rule'larla (correlation ya da SQL (Structured Query Language) filter) hangi mesajları alacağını seçer. Consumer mesajı kilitleyerek alır, işi bitince complete eder ve mesaj silinir. Session'lar, scheduled message, duplicate detection ve dead-letter queue hazır gelir; altyapıyı Microsoft işletir.
Kafka'da producer bir topic'e yazar, ama topic bir queue değil, partition'lara bölünmüş bir log'dur. Mesaj okunduğunda silinmez, retention süresi boyunca kalır. Her consumer group kendi offset'ini tutar; isteyen geçmişi baştan okuyabilir. Sıralama partition içinde, paralellik partition sayısı kadar.
Pratik Karşılaştırma
Hangi broker'ı seçeceğini çoğu zaman özellik listesinden çok mevcut altyapı, ekip deneyimi ve işletme maliyeti belirler. Yine de .NET geliştiricisinin gündelik sorularında farklar şöyle:
| Soru | RabbitMQ | Azure Service Bus | Kafka |
|---|---|---|---|
| Mesaj işlenince | Ack ile silinir | Complete ile silinir | Kalır, offset ilerler |
| Yönlendirme | Çok esnek (exchange tipleri, binding) | Topic + subscription filter | Topic ve key; filtre consumer'da |
| Sıralama | Queue içinde, tek consumer'la | Session içinde | Partition içinde (key bazında) |
| Replay | Stream queue ile | Yok (DLQ'dan yeniden gönderim) | Doğal: offset'i geri al |
| Retry ve dead-letter | Nack, DLX (Dead Letter Exchange), delivery-limit | MaxDeliveryCount, hazır DLQ (Dead Letter Queue) | Yok; retry/DLT topic'lerini sen kurarsın |
| Yönetilen servis | Kendi kurulumun ya da üçüncü taraf hizmetler | Evet, yalnızca Azure | Confluent Cloud, Amazon MSK (Managed Streaming for Apache Kafka), Azure Event Hubs'ın Kafka uyumlu endpoint'i |
| Yerel geliştirme | Tek container | Emulator: SQL Server ile iki container | Tek container (KRaft) |
Kabaca: iş mesajlarını esnek yönlendirmeyle dağıtmak ve mesaj bazında ack/retry istiyorsan RabbitMQ; Azure'dasın ve altyapı işletmek istemiyorsan Azure Service Bus; yüksek hacimli event akışı, replay ve birden çok bağımsız okuyucu istiyorsan Kafka. Bu ayrım kesin değil; RabbitMQ stream'leri replay sunuyor, Kafka da iş mesajları için yaygın kullanılıyor. Ama seçtiğin broker'ın modeline karşı kod yazmak, onunla birlikte yazmaktan her zaman daha pahalı.
Aynı Mesaj, Üç Client
Üç client'ın şeklini görmek için aynı OrderPlaced'ı üç broker'a gönderip geri okuyan küçük bir program. Bu kod deneme amaçlı; her şeyi tek method'da yapıyor. Doğru yaşam döngüsü ve hata yönetimi sonraki üç yazının konusu:
OrderPlaced order = new(Guid.CreateVersion7(), Guid.NewGuid(), 1220.50m, "TRY", DateTimeOffset.UtcNow,
[new OrderPlacedItem(Guid.NewGuid(), 2, 450.00m), new OrderPlacedItem(Guid.NewGuid(), 1, 320.50m)]);
byte[] body = JsonSerializer.SerializeToUtf8Bytes(order, MessageJson.Options);
Console.WriteLine($"OrderPlaced {order.OrderId}, {body.Length} bytes of JSON");
// RabbitMQ: exchange + routing key, broker mesajı consumer'a push eder
{
ConnectionFactory factory = new() { Uri = new Uri("amqp://guest:guest@localhost:17201/") };
await using IConnection connection = await factory.CreateConnectionAsync();
await using IChannel channel = await connection.CreateChannelAsync(new CreateChannelOptions(
publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true));
await channel.ExchangeDeclareAsync("bookstore.ordering", ExchangeType.Topic, durable: true);
await channel.QueueDeclareAsync("billing.order-placed", durable: true, exclusive: false, autoDelete: false,
arguments: new Dictionary<string, object?> { ["x-queue-type"] = "quorum" });
await channel.QueueBindAsync("billing.order-placed", "bookstore.ordering", "order.placed");
TaskCompletionSource<string> received = new();
AsyncEventingBasicConsumer consumer = new(channel);
consumer.ReceivedAsync += async (_, ea) =>
{
await channel.BasicAckAsync(ea.DeliveryTag, false);
received.TrySetResult(ea.BasicProperties.Type ?? "");
};
await channel.BasicConsumeAsync("billing.order-placed", autoAck: false, consumer);
Stopwatch sw = Stopwatch.StartNew();
await channel.BasicPublishAsync("bookstore.ordering", "order.placed", mandatory: true,
new BasicProperties { Type = OrderPlaced.MessageType, MessageId = Guid.NewGuid().ToString() }, body);
string type = await received.Task;
Console.WriteLine($"RabbitMQ : published + confirmed + consumed ({type}) in {sw.ElapsedMilliseconds} ms");
}
// Azure Service Bus: topic + subscription, processor/receiver mesajı lock ile alır
{
await using ServiceBusClient client = new(
"Endpoint=sb://localhost:17220;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;");
await using ServiceBusSender sender = client.CreateSender("bookstore-orders");
await using ServiceBusReceiver receiver = client.CreateReceiver("bookstore-orders", "billing");
Stopwatch sw = Stopwatch.StartNew();
await sender.SendMessageAsync(new ServiceBusMessage(body) { Subject = "OrderPlaced", ContentType = "application/json" });
ServiceBusReceivedMessage message = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(10));
await receiver.CompleteMessageAsync(message);
Console.WriteLine($"Service Bus : sent + received + completed (seq {message.SequenceNumber}) in {sw.ElapsedMilliseconds} ms");
}
// Kafka: topic + partition, consumer offset'ini kendisi yönetir
{
using IProducer<string, byte[]> producer = new ProducerBuilder<string, byte[]>(
new ProducerConfig { BootstrapServers = "localhost:17210", Acks = Acks.All, EnableIdempotence = true }).Build();
using IConsumer<string, byte[]> consumer = new ConsumerBuilder<string, byte[]>(new ConsumerConfig
{
BootstrapServers = "localhost:17210", GroupId = "billing-intro", AutoOffsetReset = AutoOffsetReset.Earliest
}).Build();
consumer.Subscribe("bookstore.orders");
Stopwatch sw = Stopwatch.StartNew();
DeliveryResult<string, byte[]> delivery = await producer.ProduceAsync("bookstore.orders",
new Message<string, byte[]> { Key = order.OrderId.ToString(), Value = body });
ConsumeResult<string, byte[]> result;
do
{
result = consumer.Consume(TimeSpan.FromSeconds(10));
} while (result.Message.Key != order.OrderId.ToString());
Console.WriteLine($"Kafka : produced to partition {delivery.Partition.Value} offset {delivery.Offset.Value}, consumed in {sw.ElapsedMilliseconds} ms (includes joining the group)");
// group'a katıldıktan sonra ikinci mesaj
sw.Restart();
await producer.ProduceAsync("bookstore.orders", new Message<string, byte[]> { Key = order.OrderId.ToString(), Value = body });
consumer.Consume(TimeSpan.FromSeconds(10));
Console.WriteLine($"Kafka : second message produced + consumed in {sw.ElapsedMilliseconds} ms");
consumer.Close();
}
OrderPlaced 01a1b90d-afb1-721e-8a7d-e95a9c60a58b, 355 bytes of JSON
RabbitMQ : published + confirmed + consumed (bookstore.order-placed.v1) in 5 ms
Service Bus : sent + received + completed (seq 5) in 133 ms
Kafka : produced to partition 2 offset 2, consumed in 3081 ms (includes joining the group)
Kafka : second message produced + consumed in 9 ms
İlk sorunun cevabı: Kafka 3 saniye ile açık ara en yavaş, ama bu sürenin neredeyse tamamı mesajla ilgili değil. Consumer Subscribe çağrısından sonra group'a katılıyor, group coordinator partition'ları dağıtıyor ve ancak ondan sonra okumaya başlıyor. Group'a katıldıktan sonraki ikinci mesaj 9 ms sürdü. Service Bus'taki 133 ms ise emulator'ün kendi maliyeti; Azure'daki gerçek gecikme ağa, tier'a ve bölgeye bağlı, bu sayıdan bir sonuç çıkarma. Bu tür mikro ölçümlerin öğrettiği şey broker'ların hızı değil, modellerin farkı: RabbitMQ consumer'ı abone olur olmaz mesaj alır, Kafka consumer'ı önce bir group'un üyesi olmak zorunda. Bu fark, deploy sırasında her instance'ın yeniden başlamasının Kafka'da neden bir rebalance fırtınasına dönüşebildiğini de açıklıyor.
Kodun şekline de bakmaya değer. RabbitMQ'da exchange, queue ve binding'i kodda declare ettik; Service Bus'ta topic ve subscription önceden tanımlıydı (emulator'ün config dosyasında); Kafka'da topic'i bir kez komut satırından oluşturduk ve consumer'ın group'u ilk bağlantıda kendiliğinden doğdu. Topology'nin kimin sorumluluğunda olduğu, broker seçiminin görünmeyen bir parçası.
Örnek Uygulama ve Yerel Ortam
Seri boyunca kullanacağımız projeler: Bookstore.Contracts (yalnızca mesaj record'ları), Bookstore.OrderApi (sipariş alan minimal API), Bookstore.Billing ve Bookstore.Notification (consumer'lar). Ortak contract:
namespace Bookstore.Contracts;
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Total,
string Currency,
DateTimeOffset PlacedAt,
IReadOnlyList<OrderPlacedItem> Items)
{
public const string MessageType = "bookstore.order-placed.v1";
}
public sealed record OrderPlacedItem(Guid BookId, int Quantity, decimal UnitPrice);
Yerel ortam tek bir Docker Compose dosyası. Portları bilinçli olarak varsayılanlardan uzak seçtik ki makinende çalışan başka bir RabbitMQ ya da Kafka ile çakışmasın: RabbitMQ AMQP (Advanced Message Queuing Protocol) için 17201, yönetim arayüzü için 17202; Kafka 17210; Service Bus emulator'ü AMQP için 17220, yönetim için 17221.
name: bookstore-messaging
services:
rabbitmq:
image: rabbitmq:4.4-management
ports:
- "17201:5672"
- "17202:15672"
kafka:
image: apache/kafka:4.2.1
ports:
- "17210:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,INTERNAL://:19092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:17210,INTERNAL://kafka:19092
KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,INTERNAL:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_NUM_PARTITIONS: 3
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: ${SQL_PASSWORD}
servicebus:
image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest
ports:
- "17220:5672"
- "17221:5300"
environment:
SQL_SERVER: mssql
MSSQL_SA_PASSWORD: ${SQL_PASSWORD}
ACCEPT_EULA: "Y"
volumes:
- ./sbemu/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json
depends_on:
- mssql
Kafka'nın iki listener'ı var: PLAINTEXT makinedeki .NET uygulamaları için localhost:17210'u, INTERNAL ise container'lar arası trafik (ve ileride Schema Registry gibi yan servisler) için kafka:19092'yi duyuruyor. Tek listener ile kurulan Kafka container'ları, container içinden çalıştırılan kafka-topics.sh gibi araçlar dışarıya duyurulan adrese ulaşamadığı için sıkça "Node may not be available" uyarısıyla takılır; biz de ilk denemede bunu yaşadık. ZooKeeper yok: Kafka 4 yalnızca KRaft modunda çalışıyor. Service Bus emulator'ü verisini bir SQL Server container'ında tutuyor; şifreyi dosyaya yazmak yerine SQL_PASSWORD ortam değişkeniyle veriyoruz. docker compose up -d sonrası:
mqwork1-kafka-1 apache/kafka:4.2.1 Up Less than a second
mqwork1-mssql-1 mcr.microsoft.com/mssql/server:2022-latest Up Less than a second
mqwork1-rabbitmq-1 rabbitmq:4.4-management Up Less than a second
mqwork1-servicebus-1 mcr.microsoft.com/azure-messaging/servicebus-emulator:latest Up Less than a second
Emulator'ün kullanıma hazır olması container'ın "Up" olmasından bir süre sonra; logda Emulator Service is Successfully Up! satırını bekle. Apple Silicon bir Mac'te SQL Server imajı amd64 olduğu için emülasyonla çalışıyor ve açılışı yavaş.
Amazon SQS/SNS ve Diğerleri
Seride Amazon SQS (Simple Queue Service) ve SNS (Simple Notification Service) için ayrı yazı yok, ama modelleri tanıdık gelecek. SQS bir queue servisi: standard queue'lar at-least-once teslim eder ve sırayı garanti etmez, FIFO (First In, First Out) queue'lar message group bazında sıra ve deduplication sunar. SNS bir pub/sub topic'i ve tipik kurulumda her abone SNS topic'ine bağlı kendi SQS queue'sunu okur; bu, Azure Service Bus'taki topic ve subscription ikilisinin karşılığı. Visibility timeout, Service Bus'taki lock süresinin; SQS'in redrive policy'si de MaxDeliveryCount ve DLQ'nun karşılığı. Bu serideki consumer kuralları (manual onay, idempotent işleme, lock süresine dikkat) SQS için de aynen geçerli. NATS (hafif, açık kaynak bir mesajlaşma sistemi), Redis Streams ve Apache Pulsar da seçenekler arasında; bu seri ise .NET dünyasında yaygın olan üçlüye, RabbitMQ, Azure Service Bus ve Kafka'ya odaklanıyor.
Seçim Yaparken Dikkat
Broker'ı tek bir özellik için seçme. "Replay lazım olabilir" diye Kafka'yı seçen bir ekip, mesaj bazında retry ve delayed redelivery'yi kendisi yazmak zorunda kalır. "Yönlendirme esnek olsun" diye RabbitMQ seçen ekip, cluster'ı işletmeyi de üstlenir. Gerçek ihtiyaçlarını (sıralama, hacim, replay, ekip deneyimi, bulut sağlayıcısı) yaz ve broker'ı ona göre seç.
Birden fazla broker'ı aynı sistemde kullanmak mümkün ve bazen doğru: iş mesajları RabbitMQ ya da Service Bus'ta, yüksek hacimli analitik event'ler Kafka'da. Ama her broker ayrı bir işletme yükü, ayrı bir izleme paneli ve ayrı bir hata modeli demek.
Yerel ortamın production'ı ne kadar temsil ettiğini bil. Tek node'lu RabbitMQ'da quorum queue replikasyon yapmaz; Service Bus emulator'ü partitioned entity, büyük mesaj ve kalıcılık desteklemez; tek broker'lı Kafka'da replication factor 1'dir. Davranışı yerelde, ölçeği ve dayanıklılığı gerçek ortamda test et.
Sık Düşülen Tuzaklar
Broker'ı soyutlayan bir interface'i baştan yazmak. IMessageBus.PublishAsync gibi tek bir method üç broker'ın farklı modellerini (ack, lock, offset) gizleyemez; ya en zayıf ortak paydaya iner ya da sızdırır. Önce bir broker'ı iyi kullan; abstraction gerekiyorsa olgun bir kütüphane seç.
Kafka'yı queue gibi kullanmak. Mesaj bazında ack, delayed redelivery ve "işlenince sil" yok; bunları istiyorsan yanlış araçtasın.
Emulator'de ölçülen gecikmeyi production beklentisi sanmak. Yukarıdaki 133 ms, Azure Service Bus'ın performansı hakkında hiçbir şey söylemiyor.
Ölçüm yaparken consumer'ın başlangıç maliyetini mesaj gecikmesi sanmak. Kafka'daki 3 saniye bunun tipik örneği.
Contract'ı broker'a göre değiştirmek. OrderPlaced üç broker'da da aynı; değişen yalnızca metadata'nın taşındığı yer (property, application property, header).
Küçük Bir Deneme
★☆☆ Compose dosyasını ayağa kaldır ve RabbitMQ yönetim arayüzünü (http://localhost:17202) aç. Yukarıdaki programı çalıştırdıktan sonra bookstore.ordering exchange'ini ve billing.order-placed queue'sunu arayüzde bul.
★★☆ Programı Kafka consumer'ının GroupId'sini her çalıştırmada değiştirecek şekilde düzenle ve "group'a katılma" süresinin değişip değişmediğini ölç. Sonra GroupProtocol = GroupProtocol.Consumer ile aynı ölçümü tekrarla.
★★★ Notification için ikinci bir consumer ekle: RabbitMQ'da notification.order-placed queue'su, Service Bus'ta notification subscription'ı, Kafka'da notification group'u. Aynı mesajın her iki consumer'a da ulaştığını üç broker'da ayrı ayrı göster.
Üç broker'ı yan yana gördük; şimdi tek tek derine iniyoruz. Bir sonraki yazıda RabbitMQ.Client 7 ile ilk gerçek producer ve consumer'ı yazıyoruz: async API, connection ve channel'ın yaşam döngüsü, publisher confirmation, prefetch, manual ack ve bağlantı koptuğunda ne olduğu.