Kafka ile RabbitMQ’yu karşılaştırırken genelde bir özellik tablosuyla başlanır: throughput, replay, routing, ordering, retry. Tablo faydalıdır ama tek başına seçim yapmayı pek kolaylaştırmaz, çünkü maddeler birbirinden bağımsız görünür.

Bu farkları başka bir yerden okumayı daha kullanışlı buluyorum:

Bir mesaj broker’a girdikten sonra ne oluyor?

RabbitMQ mesajın teslim durumunu takip eder ve ack geldikten sonra mesajı kuyruktan çıkarır. Kafka ise mesajı bir log’da tutar; nerede kalındığı bilgisini consumer kendi offset’iyle taşır.

İki sistem arasındaki önemli farkların çoğu bu noktadan başlıyor: mesajın broker’daki yaşam döngüsü, consumer’ın konumunu kimin tuttuğu ve mesaj işlendikten sonra broker’ın ne yaptığı. Replay, consumer group, retry ve routing konularına da buradan geçebiliriz.

Önce: broker’a neden ihtiyaç duyuyoruz?

Message broker, iki uygulama arasındaki iletişimi asenkron hale getiren aracı katmandır. Üretici (producer) mesajı broker’a bırakır, tüketici (consumer) uygun olduğunda alır. İki taraf birbirini tanımak ve aynı anda ayakta olmak zorunda değildir.

Sipariş servisinin e-posta, fatura, stok ve kargo servislerini doğrudan HTTP ile çağırdığı bir sistem düşünelim:

@Transactional
public Order createOrder(OrderRequest request) {
    Order order = orderRepository.save(Order.from(request));

    emailService.sendConfirmation(order);      // yavaşsa sipariş yavaşlar
    invoiceService.create(order);               // hata verirse sipariş geri döner
    stockService.reserve(order);
    shippingService.schedule(order);

    return order;
}
@Transactional
public Order createOrder(OrderRequest request) {
    Order order = orderRepository.save(Order.from(request));

    // Sipariş servisi kimin dinlediğini bilmiyor
    broker.publish(new OrderCreated(order.getId(), order.getTotal()));

    return order;
}

Broker kullanmanın temel faydaları birkaç başlıkta toplanabilir:

  1. Decoupling

    Sipariş servisi OrderCreated olayını yayınlar ve gerisini bilmez. Yarın altıncı bir servis eklendiğinde sipariş kodu değişmez.

  2. Tamponlama

    Trafik arttığında mesajlar kuyrukta birikebilir; consumer’lar yetişebildikleri hızda işler.

  3. Dayanıklılık

    Fatura servisi bir süre ayakta olmasa da mesajlar broker’da bekler; servis geri geldiğinde kaldığı yerden devam eder.

  4. Yatay ölçekleme

    Aynı kuyruğu tüketen consumer sayısını artırarak işleme kapasitesini büyütebilirsiniz.

  5. Fan-out

    Tek bir olayı birden çok bağımsız servise ulaştırmak — üretici tarafında bir değişiklik yapmadan.

Her yere broker koymak da doğru değil

  • Anında cevap gereken akışlarda uygun değil. “Bu ürünün fiyatı kaç?” sorusunun cevabı bir kuyruktan gelmez; orada HTTP veya gRPC daha doğrudur.

  • Küçük ve düşük trafikli bir uygulamada maliyeti faydasını geçebilir. Broker kendi operasyonel yükünü getirir: cluster, izleme, alarm, sürüm yükseltme.

  • Basit background job için önce veritabanını değerlendirin. Tek tabloya yazılan bir job kuyruğu belirli bir ölçeğe kadar iş görür.

Broker’a ihtiyacınız olduğuna karar verdiyseniz asıl karar başlıyor: hangisi?

İki model: postane ve arşiv

RabbitMQ — postane

Bir postane gibi düşünülebilir: mesajı adresine göre doğru kuyruğa yönlendirir ve consumer’ın teslim durumunu takip eder.

Mesaj acklendiğinde kuyruktan çıkar.

Kafka — arşiv

Append-only bir arşiv gibi düşünülebilir: mesajlar log’a sırayla yazılır ve retention süresi boyunca orada kalır.

Consumer nerede kaldığını kendi offset’iyle takip eder.

Aynı şeyin akış hali:

graph LR
    P1["Producer"] --> EX{{"Exchange"}}
    EX -->|"routing key"| Q1[("Queue")]
    Q1 -->|"push"| C1["Consumer"]
    C1 -.->|"ack → mesaj kuyruktan çıkar"| Q1

RabbitMQ’da akış tek yönlü ve sonlu: broker mesajı bir consumer’a iter, ack geldiğinde kuyruktan çıkarır. Geri dönen tek şey teslim bilgisidir, mesajın kendisi değil.

graph LR
    P2["Producer"] --> T["Topic partition<br/>append-only log"]
    T -->|"offset 512"| C2["Consumer A"]
    T -->|"offset 40"| C3["Consumer B"]
    T -->|"offset 0'dan itibaren"| C4["Consumer C<br/>yeni katıldı"]

Kafka’da log ortada durur ve her consumer kendi konumundan okur. Consumer B geriden geliyor, Consumer C yeni bağlandı ve geçmişi baştan okuyor. Hiçbiri diğerini etkilemiyor, log’a dokunulmuyor.

Bundan sonraki bölümlerde bu iki modelin nelere yol açtığına bakacağız.

Mesaj log’da kalınca: replay

Mesaj okunduktan sonra da yerinde duruyorsa geri dönüp tekrar okunabilir. Kafka’nın replay yeteneği buradan geliyor.

Bunun nasıl çalıştığını görmek için Kafka’nın dört kavramı yeterli.

Topic ve partition

Topic, mesajların yazıldığı mantıksal kategoridir: orders, user-signups, payment-events.

Bir topic partition denen parçalara bölünür. Her partition, yalnızca sonuna ekleme yapılan sıralı bir log dosyasıdır:

orders topic'i, 3 partition

partition-0:  [0][1][2][3][4][5] ← yeni mesajlar buraya
partition-1:  [0][1][2][3]
partition-2:  [0][1][2][3][4][5][6][7]

Partition sadece bir depolama detayı değil, aynı zamanda Kafka’nın paralellik birimidir. Bunun sonuçlarına birazdan geleceğiz.

Bir mesajın hangi partition’a gideceğini partitioner belirler. Varsayılan davranışta:

  • Mesajın key‘i varsa: key’in hash’ine göre bir partition seçilir; aynı key aynı partition’a gider.
  • Key yoksa: mesajlar partition’lara dengeli dağıtılır.

“Key yoksa dengeli dağıtılır” uzun vadede doğru, ama mesaj mesaj değil: Kafka 2.4’ten beri varsayılan davranış sticky partitioning. Producer, bir batch dolana ya da linger.ms süresi bitene kadar aynı partition’a yapışır, sonra bir sonrakine geçer — batch’ler büyür, gönderim ucuzlar, dağılım da zaman içinde dengelenir. Sonraki sürümlerde bu davranış broker gecikmesini de hesaba katacak şekilde iyileştirildi.

Offset

Partition içindeki her mesajın sıra numarasıdır. Consumer “4523’e kadar okudum” bilgisini offset olarak commit eder.

Replay de bundan ibaret: offset geri alınır, aynı mesajlar tekrar akar.

mevcut durum:      offset = 4523
offset geri alınır: offset = 4100
sonuç:             423 mesaj yeniden işlenir

E-posta servisinde bir hata yüzünden son iki saatte yanlış içerik gittiyse, kodu düzeltip offset’i geri almak yeterli olur. RabbitMQ’da o mesajlar acklendiği anda kuyruktan çıkmıştı.

Consumer group

Aynı group.id‘ye sahip consumer’lar bir grup oluşturur ve iki kuralı vardır:

Grup içinde: iş bölüşülür

Bir partition, grup içinde yalnızca bir consumer’a atanır. Böylece aynı mesaj grup içinde iki kez işlenmez.

Gruplar arasında: iş kopyalanır

Farklı gruplar aynı topic’i birbirinden bağımsız okur; her grubun kendi offset’i vardır. Fan-out böyle yapılır.

graph LR
    T["orders topic<br/>3 partition"]

    T --> G1["Grup: fatura-servisi<br/>offset 4523"]
    T --> G2["Grup: analitik<br/>offset 4523"]
    T --> G3["Grup: arama-indeksi<br/>offset 120 — geriden geliyor"]

Bunun pratik bir sonucu var: yarın arama indeksini sıfırdan kurmanız gerekirse yeni bir consumer group açıp offset’i başa alırsınız. Topic’teki geçmiş yeni servisi besler ve mevcut servislerin bir şey yapması gerekmez.

Retention

Mesajlar okunduktan sonra silinmediğine göre bir yerde durmaları gerekir. Retention politikası bunu yönetir: süre bazlı (örneğin 7 gün), boyut bazlı ya da log compaction ile — her key’in yalnızca son değeri saklanır.

Log compaction, bir topic’i “olay akışı” olmaktan çıkarıp “durum tablosu"na yaklaştırır. user-profiles topic’inde her kullanıcı id’si bir key ise, compaction sonrası her kullanıcının en güncel hali kalır. Yeni bir servis topic’i baştan okuyup kendi yerel kopyasını kurabilir.

Broker teslimi takip edince: mesajı reddedebilmek

Şimdi ters yöne bakalım. RabbitMQ’da mesaj bir consumer’a teslim edildiğinde broker bunu bilir ve mesajı “uçuşta” sayar. Bu bilgi, Kafka’da doğrudan karşılığı olmayan bir şeyi mümkün kılıyor: mesajı reddetmek.

Ack, nack, requeue

Consumer mesajı işleyince ack gönderir ve mesaj kuyruktan çıkar. İşleyemezse nack ile ya tekrar kuyruğa koydurur (requeue) ya da düşürtür.

@RabbitListener(queues = "email-jobs")
public void handle(EmailJob job, Channel channel,
                   @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
    try {
        emailSender.send(job);
        channel.basicAck(tag, false);              // işlendi
    } catch (TransientFailure e) {
        channel.basicNack(tag, false, true);       // geçici hata, kuyruğa geri koy
    } catch (PermanentFailure e) {
        channel.basicNack(tag, false, false);      // kalıcı hata, DLX'e yönlendir
    }
}

Kafka’da bu üç satırın doğrudan karşılığı yok. Orada mesaj bazında bir reddetme mekanizması değil, offset vardır:

  • Başarısız mesajın offset’ini commit ederseniz o mesaj atlanmış olur.
  • Commit etmezseniz o partition’daki sıra ilerlemez ve arkadaki mesajlar bekler.

İkincisine head-of-line blocking deniyor ve task queue senaryolarında en sık karşılaşılan zorluklardan biri.

Dead Letter Exchange

Reddedilen, TTL’i dolan veya kuyruk limitini aşan mesajlar bir DLX’e yönlendirilir. Hatalı mesajları karantinaya almanın ve gecikmeli retry kurmanın yerleşik yolu budur:

graph LR
    Q["email-jobs"] -->|"nack (requeue=false)"| DLX{{"email-dlx"}}
    DLX --> DLQ[("email-dlq<br/>karantina")]
    DLQ -.->|"incele, düzelt,<br/>elle geri gönder"| Q

RabbitMQ bunu yerleşik olarak sunarken Kafka’da genellikle retry topic’leri ve ayrı bir DLQ akışı tasarlamanız gerekir:

email-jobs → (hata) → email-retry-5m → (hata) → email-retry-30m → (hata) → email-dlq

Yaygın ve işleyen bir pattern, ama yazılması ve işletilmesi gereken ek bir katman.

Prefetch

Broker’ın bir consumer’a ack beklemeden aynı anda kaç mesaj göndereceğini belirler:

spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1
        acknowledge-mode: manual

Süresi öngörülemeyen işlerde prefetch değerini düşük tutmak işe yarar. Yüksek bir değerde worker’a peşin verilen işler, o worker yavaş bir işe takıldığında onunla birlikte bekler; düşük değerde her worker müsait oldukça sıradaki işi alır.

RabbitMQ’nun mesaj bazında kontrol sunan başka özellikleri de var:

  • TTL — mesaja veya kuyruğa yaşam süresi verme
  • Priority queue — öncelikli mesajları öne alma
  • Delayed messages — plugin ile, mesajı belirli süre sonra teslim etme
  • Quorum queues — Raft tabanlı replikasyon; modern kurulumlarda klasik mirrored queue yerine tercih edilir

Bunların Kafka’da yerleşik karşılığı yok; benzer davranışlar uygulama tarafında kurulur.

Peki RabbitMQ Streams? O da replay yapmıyor mu?

Yeni RabbitMQ sürümlerinde Streams adında, append-only bir log tipi var: mesaj okunduğunda silinmiyor ve offset ile geri okunabiliyor.

Yani “replay yalnızca Kafka’da var” demek doğru olmaz. Ama iki şeyi ayırmakta fayda var:

  • Özellik olarak replay RabbitMQ tarafında da mevcut.
  • Ekosistem olarak fark sürüyor: Kafka Connect, Kafka Streams, ksqlDB, Debezium, şema kayıt defteri ve olgun izleme araçları Kafka tarafında.

Elinizde zaten RabbitMQ varsa ve ihtiyacınız sınırlı bir replay ise Streams’e bakmaya değer. Bir event backbone kuruyorsanız ekosistem farkı belirleyici olabilir.

Yönlendirmeyi kim yapıyor?

Mesaj ack sonrası kuyruktan çıkıyorsa, doğru kuyruğa gitmesi broker’ın sorumluluğundadır. Mesaj log’da duruyorsa, kimin neyi okuyacağı okuyan tarafın kararıdır.

Bu yüzden iki sistemin routing modeli birbirine hiç benzemiyor.

RabbitMQ: exchange’ler

Producer mesajı doğrudan kuyruğa değil, bir exchange’e gönderir. Exchange, binding kurallarına ve mesajın routing key‘ine bakarak hangi kuyruklara kopyalayacağına karar verir.

Producer → Exchange → (Binding + Routing Key) → Queue → Consumer

Dört exchange tipi var:

TipNasıl karar verirÖrnek
DirectRouting key tam eşleşirsepayment.failed → yalnızca bu key ile bind edilmiş kuyruk
FanoutKey’e bakmaz, bağlı tüm kuyruklara kopyalarBroadcast
TopicPattern eşleşmesiorder.*order.created, order.cancelled
HeadersRouting key yerine mesaj header’larına bakar{"format": "pdf", "priority": "high"}

Topic exchange’te * tek kelime, # sıfır veya daha çok kelime anlamına gelir. order.# deseni order.eu.created gibi çok seviyeli key’leri de yakalar.

RabbitMQ burada daha esnek bir routing modeli sunuyor: mesajın hangi kuyruklara gideceği broker konfigürasyonuyla belirlenir, üretici veya tüketici kodunu değiştirmeden.

Kafka: topic, partition, key

Kafka’da RabbitMQ’daki exchange/binding modelinin karşılığı yok. Producer bir topic’e yazar, partition seçimi key ve partitioner üzerinden yapılır. Consumer hangi topic’i okuyacağını kendisi bilir.

Filtreleme gerekiyorsa iki yol var: ya konuları ayrı topic’lere bölersiniz, ya da consumer tarafında ilgilenmediğiniz mesajları atlarsınız. İkisi de exchange’lerin verdiği esnekliğin yerini tam olarak tutmaz.

Paralellik neyle sınırlı?

Partition, Kafka’nın paralellik birimi olduğuna göre aynı consumer group’ta partition sayısından fazla aktif consumer olamaz.

orders topic'i, 4 partition, tek consumer group

4 consumer  → her biri 1 partition alır       ✅
6 consumer  → 4'ü çalışır, 2'si boş bekler    ⚠️

RabbitMQ’da partition sayısına bağlı böyle bir consumer-group sınırı yok; bir kuyruğa istediğiniz kadar consumer bağlayabilirsiniz. Elbette burada da throughput broker, ağ ve consumer kaynaklarıyla sınırlıdır — ama sınır yapısal bir tavan değil, kapasite meselesidir.

Kafka:     consumer group paralelliği ≤ partition sayısı
RabbitMQ:  bir kuyruğun consumer sayısı partition sayısıyla sınırlı değil

Partition sayısı geri alınması kolay bir karar değil

Varsayılan partitioning davranışında partition sayısını değiştirmek, key’in partition’a eşlenmesini de değiştirir. Yani aynı key geçiş sonrası farklı bir partition’a düşebilir ve o key için sıralama garantisi geçiş anında kırılır.

Partition sayısını azaltmak ise desteklenmez.

Bu yüzden partition sayısı yalnızca kapasite kararı değil, key bazlı sıralama açısından da düşünülmesi gereken bir karardır.

Sıcak key, tek partition'a yığılmaya yol açar

Key seçimi hem sıralamayı hem yük dağılımını belirler. tenantId‘yi key yaparsanız ve müşterilerinizden biri trafiğin büyük kısmını üretiyorsa, o trafik tek bir partition’a — dolayısıyla tek bir consumer’a — düşer. Diğer partition’lar boş dururken o partition birikmeye başlar.

Ölçüt şu: key’in kardinalitesi partition sayısından belirgin şekilde büyük ve dağılımı görece dengeli olmalı.

Sıralama

Kafka’nın sıralama garantisi topic genelinde değil, partition içindedir. Bu ayrım önemli, çünkü genellikle “tüm olaylar sırayla işlensin” değil, “aynı entity’nin olayları sırayla işlensin” ihtiyacına cevap verir.

Key mekanizması bu ihtiyacı karşılar:

// key = orderId → bu siparişin tüm olayları aynı partition'da, sırayla
kafkaTemplate.send("order-events", order.getId(), new OrderCreated(...));
kafkaTemplate.send("order-events", order.getId(), new OrderPaid(...));
kafkaTemplate.send("order-events", order.getId(), new OrderShipped(...));
graph LR
    A["orderId = 1042"] --> P1["partition-2<br/>created → paid → shipped"]
    B["orderId = 1043"] --> P2["partition-0<br/>created → paid"]
    C["orderId = 1044"] --> P1

Topic genelinde global sıra gerekiyorsa tek partition kullanmak gerekir; bu da paralelliği ortadan kaldırır ve nadiren tercih edilir.

RabbitMQ tarafında ise tek kuyruk ve tek consumer varsa FIFO sıra korunur. Ölçek için birden çok consumer bağlandığında mesajlar farklı worker’lara dağılır ve işlenme sırası garanti edilmez. Requeue de sırayı etkiler: nack ile geri konan mesaj kuyruğun sonuna değil, mümkün olduğunca eski konumuna geri konur — yani neredeyse anında tekrar teslim edilir.

Kafka’daki key→partition modeline benzer bir davranış istiyorsanız Consistent Hash Exchange plugin’i ile mesajları key’e göre ayrı kuyruklara dağıtabilirsiniz; ancak bu, Kafka’daki kadar yerleşik bir mekanizma değil.

Entity bazlı sıralama gereksiniminiz varsa — event sourcing, durum makineleri, sipariş yaşam döngüsü — Kafka’nın modeli bu ihtiyaca daha doğrudan cevap veriyor.

İki sistemde de aynı olan konu: delivery garantileri

Buraya kadar farklara baktık. Delivery garantileri ise iki sistemde de aynı üç seviyede tanımlanır:

GarantiAnlamıRiski
At-most-onceEn fazla bir kez teslim edilirMesaj kaybolabilir
At-least-onceEn az bir kez teslim edilirMesaj tekrarlanabilir
Exactly-onceTam bir kez işlenirEn zor ve en maliyetli

Kafka’da producer tarafında acks=all ve min.insync.replicas ile mesajın yeterli sayıda replica’ya yazılması beklenir; enable.idempotence=true producer retry’larının duplicate üretmesini engeller — bu ayar Kafka 3.0’dan beri zaten varsayılan olarak açık, yani çoğu kurulumda açmanız gereken değil, kapatmamanız gereken bir şey. Consumer tarafında garantiyi belirleyen şey offset’i ne zaman commit ettiğinizdir:

// Önce işle, sonra commit et
process(record);
consumer.commitSync();

// İşlemeden sonra, commit'ten önce çökerse
// → aynı mesaj tekrar gelir
// Önce commit et, sonra işle
consumer.commitSync();
process(record);

// Commit'ten sonra, işlemeden önce çökerse
// → mesaj işlenmemiş olur

RabbitMQ’da producer tarafında publisher confirms broker’ın mesajı aldığını doğrular; mesajın persistent ve kuyruğun durable olması diske yazılmasını sağlar. Consumer tarafında manual ack at-least-once, auto-ack ise at-most-once anlamına gelir.

Kafka’nın transactions özelliği exactly-once sunar, ancak kapsamı esas olarak “Kafka’dan oku → işle → Kafka’ya yaz” akışlarıdır. Dış bir sisteme yazarken — veritabanı, HTTP API, e-posta sağlayıcısı — bu garanti geçerli değildir. RabbitMQ zaten exactly-once iddiasında bulunmaz.

Her iki broker’da da pratik yaklaşım genellikle at-least-once teslimat ve idempotent consumer kullanmaktır. Bu, çoğu zaman mesajın taşıdığı bir id’yi kaydedip tekrar kontrolü yapmaktan ibarettir:

if (!processedEvents.markIfAbsent(event.id())) {
    return;   // zaten işlenmiş
}
process(event);

Consumer mesajı ne zaman alıyor?

Buraya kadar mesajın broker’da ne olduğuna baktık. Peki consumer tarafına ne zaman geçiyor? Boşta CPU kaldığında mı, bellek müsait olduğunda mı?

Cevap: ikisi de değil. Broker’ın sizin CPU’nuzdan ve belleğinizden haberi yok. İki sistem de akışı bambaşka bir şeyle sınırlıyor — ve sık sorulan “bir anda çok sayıda event gelirse uygulama çöker mi?” sorusunun cevabı tam olarak burada.

Kafka: consumer ister, broker verir

Kafka pull modeliyle çalışır. Broker kimseye bir şey itmez; consumer düzenli olarak poll() çağırır ve o çağrının döndürdüğü kadar kayıt işler.

while (running) {
    ConsumerRecords<String, Report> records = consumer.poll(Duration.ofMillis(500));

    for (ConsumerRecord<String, Report> record : records) {
        process(record);          // burası ne kadar sürerse, döngü o kadar yavaş döner
    }

    consumer.commitSync();
}

Bu döngünün hızını belirleyen tek şey process()‘in hızı. Yavaşsa döngü yavaş döner, poll() daha seyrek çağrılır ve broker’dan daha az kayıt gelir. Yani backpressure Kafka’da modelin kendisinden geliyor: istemediğiniz sürece size bir şey gelmez.

Bir poll() çağrısının ne kadar veri getireceğini birkaç ayar belirliyor:

AyarVarsayılanNe yapar
max.poll.records500Tek poll() çağrısının döndüreceği maksimum kayıt sayısı
max.partition.fetch.bytes1 MiBPartition başına tek seferde çekilecek en fazla veri
fetch.min.bytes1Broker en az bu kadar veri birikene kadar bekleyebilir
fetch.max.wait.ms500fetch.min.bytes dolmazsa en fazla bu kadar bekler

Pratikte ayarlayacağınız tek şey genelde max.poll.records. Bir iş 30 saniye sürüyorsa 500 kayıt almanın anlamı yok:

spring:
  kafka:
    consumer:
      max-poll-records: 1     # bir poll = bir iş

Kafka istemcisi arka planda bir miktar ön çekim (prefetch) yapar; ağ gidiş-dönüşünü beklememek için birkaç batch’i bellekte tutar. Ama uygulama kodunuzun gördüğü kayıt sayısını max.poll.records sınırlar. Yani “aynı anda elimde kaç iş var” sorusunun cevabı ön çekim değil, bu ayardır.

RabbitMQ: broker iter, prefetch frenler

RabbitMQ push modeliyle çalışır. basic.consume ile abone olduğunuz anda broker mesajları size akıtmaya başlar — gelir gelmez, siz istemeden.

Buradaki tehlike de gerçek. Freni koymazsanız broker kuyruktaki her şeyi bağlantı hızının elverdiği kadar hızlı gönderir, istemci kütüphanesi de hepsini belleğe alır. Klasik OutOfMemoryError sebebi budur.

Fren, daha önce değindiğimiz prefetch ayarı:

prefetch = 10

broker → consumer'a en fazla 10 ADET UNACKED mesaj gönderir
consumer 1 tanesini ack'ler → broker 1 tane daha gönderir

Yani prefetch, kredi bazlı kayan bir penceredir: elinizdeki işlenmemiş mesaj sayısı tavana dayandığında broker susar.

prefetch = 0 sınırsız demektir

Ham AMQP istemcisinde basicQos çağırmazsanız prefetch sınırsızdır — broker kuyruktaki milyonlarca mesajı size göndermeye çalışır.

Spring AMQP bu tuzağı varsayılan olarak kapatır (prefetch: 250), ama değerin sizin iş sürenize göre doğru olduğunu yine de kontrol etmek gerekir.

Doğru değeri seçmek de basit bir kurala bağlı:

kısa ve birbirine yakın süreli işler  → yüksek prefetch (100–250), gidiş-dönüş maliyeti amorti olur
uzun ve değişken süreli işler         → prefetch: 1, her worker müsait oldukça sıradakini alır

İkisini yan yana koyunca

KafkaRabbitMQ
ModelPull — consumer isterPush — broker gönderir
Akışı ne sınırlarmax.poll.records ve döngünüzün hızıprefetch (unacked mesaj tavanı)
“Sınırsız” ayarı var mıYokpoll() çağırmazsanız hiçbir şey gelmezVarprefetch=0, klasik OOM sebebi
Consumer yavaşlarsaLag büyür; broker etkilenmezBroker teslimi durdurur; kuyruk şişerse flow control publisher’a yansır
Backpressure nereden gelirModelin kendisindenBir ayardan

Bölümün başındaki sorunun cevabı da bu son satırda:

Kafka’da uygulamayı boğmak için özel çaba göstermeniz gerekir. RabbitMQ’da boğmamak için prefetch‘i ayarlamış olmanız gerekir.

Uzun süren işler: 30 dakikalık rapor

Şimdi somut soruya gelelim: bir rapor 30 dakika sürüyor ve iş bitince mesajı ackliyoruz. Bu güvenli mi?

RabbitMQ’da büyük ölçüde evet, ama bir sınırı var. Kafka’da hayır — ve varsayılan ayarlarla sonuç sessiz bir hata değil, çok daha gürültülü bir şey: aynı rapor tekrar tekrar üretilir.

Kafka’da ne oluyor?

İki ayrı zaman aşımı var ve sık sık birbirine karıştırılıyor:

AyarVarsayılanNeyi ölçer
session.timeout.ms45 snHeartbeat’ler kesildi mi — yani süreç hayatta mı
max.poll.interval.ms5 dakikaİki poll() çağrısı arasında ne kadar geçti — yani işleme ne kadar sürüyor

Heartbeat’leri ayrı bir arka plan thread’i attığı için 30 dakika süren bir iş session.timeout.ms‘i tetiklemez; süreç canlı görünmeye devam eder. Sizi vuran ikincisi:

00:00  poll() → 1 kayıt: "aylık mutabakat raporu"
00:00  rapor üretimi başlar
05:00  max.poll.interval.ms aşıldı
       → broker consumer'ı ölü sayar, gruptan atar
       → rebalance: partition başka bir worker'a geçer
       → yeni worker son commit'lenmiş offset'ten başlar
       → AYNI RAPORU baştan üretmeye başlar
30:00  ilk worker raporu bitirir, commitSync() çağırır
       → CommitFailedException: artık o partition'ın sahibi değil
       → 30 dakikalık iş çöpe gitti, offset ilerlemedi
35:00  ikinci worker da 5. dakikada atılmıştı... döngü sürer

Yani evet: varsayılan ayarlarla Kafka aynı raporu sonsuza kadar üretir ve hiçbir zaman commit edemez. Üstelik her denemede rapor gerçekten üretildiği için sistem meşgul görünür ve sorun geç fark edilir.

RabbitMQ’da ne oluyor?

RabbitMQ’da max.poll.interval.ms benzeri bir kavram yok; bir mesajı istediğiniz kadar unacked tutabilirsiniz. Bağlantı heartbeat’i (varsayılan 60 sn) ayrı bir I/O thread’i tarafından atıldığı için uzun işlemeden etkilenmez.

Ama bir sınır var ve tam da bu senaryoya denk geliyor:

consumer_timeout — varsayılanı 30 dakika

RabbitMQ 3.8.15’ten beri broker tarafında bir teslimat zaman aşımı var: bir mesaj varsayılan olarak 30 dakika içinde acklenmezse kanal kapatılır ve mesaj requeue edilir.

Yani 30 dakikalık rapor örneği, RabbitMQ’nun varsayılan sınırının tam üzerinde duruyor. Uzun işler çalıştıracaksanız rabbitmq.conf içinde yükseltmeniz gerekir:

# 2 saat
consumer_timeout = 7200000

Sınır aşıldığında kapatılan şey mesaj değil kanal olduğu için, yalnızca süresi dolan mesaj değil, o kanalda tutulan bütün unacked mesajlar requeue edilir. prefetch yüksekse bu, tek bir yavaş iş yüzünden onlarca mesajın yeniden teslim edilmesi demektir.

Karşılaştırma:

KafkaRabbitMQ
Bir işi ne kadar tutabilirimmax.poll.interval.ms — varsayılan 5 dakikaconsumer_timeout — varsayılan 30 dakika
Ayar neredeConsumer tarafındaBroker tarafında (tüm kuyrukları etkiler)
AşılırsaGruptan atılma + rebalance + commit hatasıKanal kapanır, mesaj requeue olur
Etki alanıTüm partition — arkadaki işler de dururKanal kapandığı için o kanaldaki tüm unacked mesajlar

Peki nasıl kurgulanmalı?

İlk akla gelen çözüm — “mesajı alır almaz ackleyip arkada async işlesek?” — sezgisel olarak doğru yerde duruyor ama düz haliyle iki şeyi birden bozuyor. Dört yaklaşımı yan yana koyalım:

YaklaşımGarantiBackpressureNe zaman
1. Zaman aşımını yükseltat-least-onceKorunurSüre öngörülebilir ve üst sınırı biliniyorsa
2. pause() + async + boş poll()at-least-onceKorunurSüre değişken, hızlı çökme tespiti isteniyorsa
3. Hemen ackle, arkada işleat-most-onceYokNeredeyse hiç — aşağıya bakın
4. İş tablosu (claim + reaper)at-least-once, kendi veritabanınızdaKorunurSaatler süren, durum ve iptal gereken işler

1. Zaman aşımını yükseltmek

En basit ve çoğu zaman yeterli olan çözüm:

spring:
  kafka:
    consumer:
      max-poll-records: 1          # bir poll = bir rapor
      properties:
        max.poll.interval.ms: 2700000   # 45 dakika

Bedeli şu: gerçekten çöken bir worker’ın partition’ı artık 45 dakika boyunca sahipsiz kalır. Yani hızlı hata tespitini uzun iş süresine feda etmiş olursunuz. İş süresinin bilinen bir üst sınırı varsa gayet makul bir takas.

2. pause() + async + boş poll()

Kafka’nın bu iş için tasarlanmış mekanizması. İşi arka plana atarsınız ama poll() çağırmayı sürdürürsünüz — sadece önce partition’ı duraklatarak, ki yeni kayıt gelmesin:

ConsumerRecords<String, Report> records = consumer.poll(Duration.ofMillis(500));

for (ConsumerRecord<String, Report> record : records) {
    consumer.pause(consumer.assignment());              // yeni kayıt isteme
    Future<?> job = executor.submit(() -> generate(record.value()));

    while (!job.isDone()) {
        consumer.poll(Duration.ofSeconds(1));           // boş döner ama sayaç sıfırlanır
    }

    consumer.commitSync();                              // iş gerçekten bitti
    consumer.resume(consumer.assignment());
}

Duraklatılmış bir consumer’da poll() kayıt döndürmez, ama hem heartbeat’i hem max.poll.interval.ms sayacını canlı tutar. Böylece iş 30 dakika sürse de consumer gruptan atılmaz, üstelik gerçekten çökerse saniyeler içinde fark edilir.

Spring Kafka kullanıyorsanız aynı davranışa container’ın pause() / resume() metotlarıyla ulaşabilirsiniz; ama süre öngörülebilirse 1. yaklaşım daha az koda mal olur.

3. Hemen ackleyip arkada işlemek — neden tuzak?

Bu, iki ayrı problemi aynı anda yaratıyor:

  1. At-most-once’a düşersiniz

    Offset commit edildi, mesaj gitti. Worker 12. dakikada çökerse o rapor sessizce kaybolur — kimse istemediği hâlde beklemeye devam eder.

  2. Backpressure’ı kaparsınız

    poll() artık hiçbir şey beklemediği için kayıt akmaya devam eder ve hepsi executor’ın kuyruğunda birikir. Sınırsız bir ThreadPoolExecutor kuyruğu, bir önceki bölümdeki prefetch=0 senaryosunun aynısına — OutOfMemoryError‘a — çıkar.

    Bunu engellemek için kuyruğu sınırlayıp doluyken poll()‘ü durdurmanız gerekir — ki o noktada 2. yaklaşımı, daha kötü bir biçimde yeniden yazmış olursunuz.

Yani: raporu kaybetmek kabul edilebilir değilse bu yolu seçmeyin.

4. İş tablosu: mesajı tetikleyiciye indirgemek

Saatler süren, ilerleme gösteren, iptal edilebilen işler için doğru cevap genelde şu: işin dayanıklılığını broker’dan alıp kendi veritabanınıza koymak. Mesaj artık işin kendisi değil, sadece bir tetikleyici.

graph TD
    M["report-jobs mesajı<br/>sadece bir job_id taşır"] --> C["Consumer"]
    C --> CL["report_jobs satırını<br/>RUNNING'e çek<br/>(idempotent claim + lease)"]
    CL --> OK["offset'i HEMEN commit et"]
    OK --> W["işi arka planda yürüt,<br/>ilerlemeyi satıra yaz"]
    W --> D["DONE"]
    W --> F["FAILED + attempt++"]
    RP["Reaper<br/>lease'i dolmuş RUNNING satırları"] -.->|"tekrar kuyruğa koy"| M

Buradaki kilit nokta üçüncü kutu: offset hemen commit ediliyor ve bu 3. yaklaşımdaki gibi görünüyor — ama at-most-once değil. Çünkü dayanıklılık artık offset’te değil, veritabanı satırında. Broker’ın işi “tetikleyiciyi bir kez teslim et"e indi; işin gerçekten yapıldığının kaydını siz tutuyorsunuz.

Karşılığında elinize şunlar geçiyor:

  • İşin durumunu ve ilerlemesini kullanıcıya gösterebilirsiniz
  • Deneme sayısı, hata mesajı ve süre gibi bilgiler sorgulanabilir bir yerde durur
  • Takılı kalan işler lease süresi dolduğunda reaper tarafından toplanır
  • İptal etmek, satırı CANCELLED‘a çekmek kadar basit

Bu tasarımın ayrıntıları — idempotent claim, lease, stuck-job recovery, retry ve retention — Asenkron e-posta gönderim sistemi yazısında stack’ten bağımsız olarak anlatılıyor. Oradaki e-posta kuyruğu ile buradaki rapor kuyruğu aynı desenin iki örneği.

Not: bu yaklaşıma geçtiğinizde broker seçimi de büyük ölçüde önemsizleşir — iş yönetimi artık broker’ın değil, sizin tablonuzun işi.

Özet: uzun işler için kural

Kafka’da uzun işi tutmak bir ayar gerektirirmax.poll.interval.ms varsayılanı 5 dakika ve aşıldığında sonuç sessiz bir kayıp değil, sonsuz bir tekrar döngüsüdür. RabbitMQ’da tutmak daha doğaldır ama consumer_timeout‘un 30 dakikalık varsayılanını bilmek gerekir. Birkaç dakikayı geçen işlerde ikisinde de doğru cevap aynı: mesajı bir tetikleyiciye indirip işin durumunu kendi veritabanınızda tutmak.

Asıl ayrım: mesaj bir “iş” mi, bir “olgu” mu?

Buraya kadar iki modelin farklarına baktık. Şimdi asıl soruya gelelim: elinizde somut bir ihtiyaç varken hangisini seçeceksiniz?

Özellik listesi bu soruyu tek başına cevaplamıyor, çünkü listedeki maddelerin çoğunu ikisi de yapabiliyor. Daha ayırt edici bir soru şu:

Bu mesaj ne anlatıyor: yapılması gereken bir iş mi, yoksa olmuş bitmiş bir olgu mu?

"Bu raporu üret"            → iş    — yapılmalı, yapılınca değeri biter
"Sipariş 1042 oluşturuldu"  → olgu  — kim okursa okusun doğru, silinmesi bilgi kaybı

Bu ayrım geri kalan her şeyi belirliyor:

İş (task)Olgu (event)
Kim ilgilenirTam olarak bir workerBilinmeyen sayıda okuyucu
İş bitince mesajDeğersizdir, silinirHâlâ doğrudur, saklanır
İkinci kez işlemekZarar — ikinci fatura, ikinci e-postaFayda — projeksiyonu yeniden kurar
Başarısız olursaTekrar denenmeli; sonunda birinin yapması lazımOlay yine de olmuştur, sorun tüketicidedir
SıraGenelde önemsizEntity bazında çoğu zaman önemli
Doğal veri yapısıKuyrukLog

Sağ sütun Kafka’nın, sol sütun RabbitMQ’nun tarif ettiği dünya. En çok dikkat çeken satır üçüncüsü:

Kafka’nın en güçlü özelliği — mesajın durması ve yeniden okunabilmesi — task queue tarafında bir kazanç değil, yönetilmesi gereken bir risktir. Yanlışlıkla geri alınan bir offset event akışında “projeksiyonu yeniden kur” demektir; e-posta kuyruğunda 40.000 kişiye ikinci kez e-posta demektir.

Bu soyut kaldığı sürece ikna edici olmuyor. O yüzden aynı işi iki broker’da da kuralım ve nerede zorlandığına bakalım.

Rapor üretimini Kafka ile kursak ne olurdu?

Somut bir senaryo:

İş          : müşteri panelinden istenen raporlar
Hacim       : günde ~12.000 istek
Süre        : 2 saniye (günlük özet) – 8 dakika (aylık mutabakat)
Ay sonu     : üç gün boyunca normalin ~20 katı istek
Gereksinim  : başarısız rapor tekrar denenmeli, kurumsal müşteri öne alınmalı

Kafka kurulumu gayet makul görünüyor: report-jobs topic’i, 12 partition, report-workers consumer group’unda 12 worker. Ve çalışır — bu senaryoda Kafka’nın “yapamayacağı” bir şey yok. Sorunlar ilk gün değil, üçüncü ayda çıkıyor.

1. Uzun bir iş, arkasındaki bütün işleri bekletir

partition-7‘ye 8 dakikalık bir aylık mutabakat raporu düştü. O partition’ı grup içinde yalnızca bir worker işler ve mesajları sırayla alır. Arkadaki 40 tane iki saniyelik rapor, diğer 11 worker boşta beklerken 8 dakika sıra bekler.

graph LR
    subgraph K["Kafka — atama yazma anında yapılır"]
        KP7["partition-7<br/>8 dk · 2 sn · 2 sn · 2 sn"] --> KW7["worker-7<br/>meşgul, sıra ilerlemiyor"]
        KP3["partition-3<br/>boş"] --> KW3["worker-3<br/>boşta"]
    end
graph LR
    subgraph R["RabbitMQ — atama teslim anında yapılır"]
        RQ[("report-jobs<br/>8 dk · 2 sn · 2 sn · 2 sn")] --> RW1["worker-1<br/>8 dk'lık işi aldı"]
        RQ --> RW2["worker-2<br/>sıradakini aldı"]
        RQ --> RW3["worker-3<br/>sıradakini aldı"]
    end

Fark tek bir cümlede toplanabiliyor ve task queue tartışmasının büyük kısmını da bu cümle açıklıyor:

Kafka:     mesaj → partition → o partition'ın worker'ı     (atama yazma anında)
RabbitMQ:  mesaj → kuyruk    → o an boşta olan worker      (atama teslim anında)

İşlerin süresi birbirine yakınsa bu fark hissedilmez. Süreler değişkense — ki rapor, PDF ve resim işleme senaryolarında tipik olarak değişkendir — doğrudan kuyruk gecikmesine yansır.

2. Başarısız bir rapor

Bir rapor, S3’e yüklenirken timeout aldı. Kafka’da elinizde iki seçenek var, ikisi de rahatsız edici:

  • Offset’i commit etmezseniz mesaj tekrar gelir, ama o partition ilerlemez. Hata kalıcıysa (bozuk parametre, silinmiş müşteri) partition sonsuza kadar aynı mesajda takılır ve arkasındaki her şey durur. Buna poison message deniyor.
  • Commit edip mesajı bir retry topic’ine yazarsanız iş çözülür, ama Kafka’da “bunu beş dakika sonra teslim et” diye bir şey yok. Retry topic’inin consumer’ı mesajın timestamp’ine bakıp bekler — yani pause()/resume() ile kendi partition’ını bilerek bekletir.

Yazmanız gereken katman şuna benziyor:

report-jobs → report-retry-5m → report-retry-30m → report-retry-2h → report-dlq

  + her seviye için ayrı bir consumer
  + timestamp'e bakıp bekleten pause/resume mantığı
  + orijinal topic, partition, offset ve hata bilgisini header'da taşıma
  + DLQ'dan seçerek geri gönderme aracı
  + izlenecek dört ek consumer lag metriği

RabbitMQ’da aynı davranışın karşılığı zaten kutudan çıkıyor:

channel.basicNack(tag, false, false);   // → DLX → gecikmeli kuyruk → tekrar dene

Bunu Kafka aleyhine fazla büyütmemek gerekir: Spring Kafka’da @RetryableTopic bu topic’leri, consumer’ları ve DLQ akışını sizin yerinize kurar. Yine de altta duran yapı aynı kalır — n tane ek topic, n tane ek consumer ve izlenecek n tane ek lag metriği. Fark “mümkün mü” değil, “kaç hareketli parça” farkı.

3. Ay sonu: yirmi kat yük

Kuyruk birikti, worker sayısını artırmak istiyorsunuz.

Kafka
  12 partition, tek consumer group
  40 worker deploy edilir  →  12'si çalışır, 28'i boşta bekler

  Partition'ı 12 → 48 çıkarmak:
    - key varsa aynı key farklı partition'a düşer, sıra garantisi geçişte kırılır
    - sonradan azaltmak desteklenmez
    - yani bu, ay sonunda alelacele verilecek bir karar değil

RabbitMQ
  replicas: 12 → 40
  bitti

Kafka’da partition sayısı bir kapasite tavanıdır ve önceden, kalıcı olarak seçilir. Task queue’larda yük genelde tam olarak öngörülemediği için bu tavan can sıkıcı bir yerde durur: fazla partition açarsanız gereksiz overhead, az açarsanız ölçekleme tavanı.

4. Kurumsal müşteriyi öne almak

Kafka’da log sıralıdır; “bu mesajı öne al” diye bir kavram yok. Yapabileceğiniz şey ayrı bir report-jobs-priority topic’i açıp worker’ın iki topic’i ağırlıklı okumasını sağlamak — yani öncelik mantığını, iki topic arasındaki adil payı ve açlık (starvation) korumasını siz yazarsınız.

RabbitMQ’da bu bir kuyruk argümanı:

// x-max-priority ile açılmış bir kuyruğa
rabbitTemplate.convertAndSend("report-jobs", job, m -> {
    m.getMessageProperties().setPriority(customer.isEnterprise() ? 9 : 1);
    return m;
});

Öncelikli kuyruk desteği klasik kuyruklarda uzun süredir mevcut; quorum queue kullanıyorsanız kendi RabbitMQ sürümünüzde önceliğin nasıl desteklendiğini doğrulamakta fayda var — model klasik kuyruktakinden daha sadedir.

5. “Bu raporu gece 03:00’te üret”

Kafka’da gecikmeli teslim yerleşik değil. Mesajı erken teslim alıp uygulama tarafında bekletmek de partition’ı bloklar; pratikte iş bir scheduler’a veya veritabanına taşınır.

RabbitMQ’da iki yerleşik yol var: mesaja TTL verip DLX üzerinden hedef kuyruğa düşürmek ya da delayed message plugin’ini kullanmak.

Peki Kafka’yı seçmek ne kazandırırdı?

Listenin tek taraflı görünmemesi için tersini de yazalım — Kafka’nın bu senaryoda gerçekten getirdikleri:

Hacim tavanı

Günde 12.000 iş Kafka için hiçbir şey. Aynı akış günde 12 milyona çıksaydı Kafka’nın diske dayalı tamponu ve sıralı I/O’su belirleyici olurdu.

Zaten kurulu olması

Kafka event backbone olarak çalışıyorsa, birkaç job türü için ikinci bir broker’ı işletmek, izlemek ve yükseltmek gerçek bir maliyettir. Retry-topic pattern’i bu maliyetin altında kalabilir.

İsteklerin geçmişi

Rapor istekleri log’da kaldığı için “hangi rapor ne sıklıkla isteniyor, hangi müşteri neyi çekiyor” sorusu topic’i baştan okuyarak cevaplanır. RabbitMQ’da bunun için ayrıca bir yere yazmanız gerekir.

Aynı isteği birden çok taraf tüketecekse

Rapor isteği hem işlenecek, hem kullanım kotasından düşülecek, hem audit log’a yazılacaksa: tek topic, üç consumer group.

Özet: “neden RabbitMQ” sorusunun cevabı

Yukarıdaki kazançlara bakın: hepsi gerçek, ama hiçbiri rapor üretme senaryosunun günlük ihtiyacı değil. Hacim düşük, tüketici tek, geçmişin analitik değeri varsa da bunu zaten veritabanından alıyorsunuz. Kaybettikleriniz ise — retry, öncelik, gecikme, esnek worker ölçeği, uzun işin arkasını bekletmemesi — tam olarak bu senaryonun her gün karşılaştığı şeyler.

Yani cevap şu:

RabbitMQ’yu seçme sebebi Kafka’nın bu işi yapamaması değil. Bu senaryoda Kafka’nın güçlü olduğu şeylere ihtiyacınız yok, zayıf olduğu şeyler ise tam da günlük ihtiyacınız.

Aynı cümleyi tersine çevirdiğinizde Kafka’yı seçme sebebini de elde ediyorsunuz — bir sonraki bölüm bunun için.

Ters yön: event akışını RabbitMQ ile kursak ne olurdu?

Bu sefer senaryo şu: OrderCreated olayı yayınlanıyor ve beş servis bunu tüketiyor — fatura, stok, e-posta, analitik, sadakat puanı.

RabbitMQ ile kurulumu zor değil: bir fanout exchange, ona bağlı beş durable kuyruk. Aylarca da sorunsuz çalışır. Zorluk, sistem yaşlandıkça çıkıyor.

1. Altıncı tüketici geliyor

Arama indeksi eklendi. Kuyruğunu bind ettiği andan sonraki mesajları görür; iki yıllık sipariş geçmişi orada yok.

Yapmanız gereken: veritabanından geçmişi tarayan ayrı bir backfill scripti. Ve bu script kendi problemlerini getiriyor — backfill ile canlı akışın çakıştığı yerde aynı sipariş iki kez işlenebilir ya da tam geçiş anındakiler kaçabilir; üstelik bu, üretimdeki asıl kod yolundan farklı, daha az test edilmiş ikinci bir yol.

Kafka’da aynı iş üç satırlık bir konfigürasyon:

spring:
  kafka:
    consumer:
      group-id: search-indexer     # yeni grup → kendi offset'i
      auto-offset-reset: earliest  # log'un başından oku

2. Tüketici altı saat bozuk çalıştı

Fatura servisi hatalı bir KDV hesabıyla deploy edildi ve altı saat boyunca mesajları işleyip ackledi. Mesajlar kuyruktan çıktı, geri dönüşü yok. Düzeltmek yine veritabanı taramasına kalıyor.

Kafka’da bu, consumer group’un offset’ini altı saat geri almaktan ibaret — mesajlar hâlâ log’da.

3. Geride kalan bir tüketici bütün broker’ı yavaşlatabilir

Analitik servisi üç saat düştü ve kuyruğunda milyonlarca mesaj birikti. RabbitMQ mesajları öncelikle bellekte tutmaya çalışır; bellek eşiği aşıldığında broker flow control uygular ve bu yalnızca o kuyruğu değil, publisher’ları yavaşlatır. Yani analitik servisinin sorunu sipariş servisine geri yansır.

Kafka’da geride kalan bir consumer için özel bir durum yok: mesajlar zaten diskte, o sadece daha eski bir offset’ten okur. Page cache dışına düşen okumalar disk I/O yaratır ama producer’lara yansımaz.

RabbitMQ:  birikme broker'ın problemidir   → yayılabilir
Kafka:     birikme tüketicinin problemidir → izole kalır

İki model arasındaki en az konuşulan, ama operasyonda en çok hissedilen farklardan biri bu.

Lazy queue ve quorum queue davranışları bu tabloyu yumuşatır: mesajlar daha erken diske alınır ve bellek baskısı azalır. Ancak sürekli diske yazma da bedelsiz değildir ve flow control’ün publisher’a yansıyabilmesi modelin bir parçası olmaya devam eder.

4. Her tüketici için bir kopya

Fanout exchange mesajı beş kuyruğa kopyalar. Beş milyon olay, beş kuyrukta beş milyon kopya demek. Kafka’da ise bir log ve beş offset var; tüketici eklemek depolama maliyetini artırmaz.

5. Entity bazlı sıra

Sipariş 1042’nin created, paid, shipped olayları tek kuyruk ve tek consumer’da sırayla işlenir. Ölçek için ikinci consumer’ı bağladığınız anda bu üç mesaj üç farklı worker’a düşebilir ve shipped, paid‘den önce işlenebilir.

Kafka’da key = orderId demek yeterli: aynı siparişin olayları aynı partition’a, dolayısıyla aynı consumer’a, sırayla gider.

6. Etrafındaki araçlar

Debezium ile CDC, Kafka Connect ile hedeflere yazma, Kafka Streams veya Flink ile akış üzerinde işlem, şema kayıt defteri ile şema evrimi — bu ekosistem Kafka’nın etrafında kurulu. Event backbone kuruyorsanız bir süre sonra bunlardan en az birine ihtiyaç duyuyorsunuz.

Özet: “neden Kafka” sorusunun cevabı

Yukarıdaki maddelerin hiçbiri “RabbitMQ bunu yapamaz” demiyor; her birinin bir çözümü var. Ama dikkat edin, hepsinin çözümü sizin yazacağınız ve işleteceğiniz bir şey: backfill scripti, düzeltme taraması, ayrı bir analitik deposu, kopya kuyrukların maliyeti, consistent hash plugin’i.

Kafka’da bunlar modelin kendisinden çıkıyor — çünkü mesaj zaten duruyor ve konumu zaten tüketicide.

Kafka’yı seçme sebebi throughput değil. Mesajın işlendikten sonra da değerli olması — yarın yeni bir tüketicinin, düzeltilmiş bir kodun ya da yeni bir sorunun aynı geçmişe ihtiyaç duyması.

“İkisinde de var” itirazı: aynı özellik, iki farklı implementasyon

Bu tartışmanın en sık takıldığı yer burası. “RabbitMQ’da da fanout var”, “dead letter queue ikisinde de var”, “retry ikisinde de yapılıyor” — üçü de doğru. Özellik listesi seviyesinde bakınca fark kalmıyor gibi görünüyor.

Fark listede değil, mekanizmanın kime ait olduğunda. RabbitMQ’da teslim durumunu broker tutar; dolayısıyla retry sayacı, gecikme, dead-lettering ve mesajı reddetme broker özelliğidir, konfigüre edersiniz. Kafka’da konumu consumer tutar; dolayısıyla aynı şeyler uygulama kodu ve ek topic’e dönüşür.

Bu farkın üç somut yerde nasıl göründüğüne bakalım.

Fan-out: kopyalanan şey mesaj mı, konum mu?

RabbitMQ’nun fanout exchange’i de, Kafka’nın consumer group’ları da aynı sonucu üretir: bir olay beş servise ulaşır. Ama üretme biçimleri farklı:

RabbitMQ fanout:  broker mesajı 5 kuyruğa KOPYALAR     → 5 mesaj
Kafka groups:     tek log, 5 grup kendi OFFSET'ini tutar → 1 mesaj, 5 sayı

Aşağıdaki satırların hepsi bu tek farktan çıkıyor:

RabbitMQ fanoutKafka consumer group
Topolojiyi kim belirlerBroker — exchange ve binding’lerConsumer — kendi subscribe eder
Yeni tüketici ne görürYalnızca bind ettiği andan sonrasınıauto-offset-reset=earliest ile retention boyunca her şeyi
Depolama maliyetiTüketici sayısı kadar kopyaSabit; tüketici eklemek maliyeti artırmaz
Yavaş tüketiciKendi kuyruğu şişer; bellek eşiği aşılırsa flow control publisher’a yansırLag’i büyür; diskten okur, producer etkilenmez
Unutulmuş tüketiciKimse tüketmeyen kuyruk sonsuza kadar büyür — klasik bir üretim kazasıOffset eskir, retention deposu sınırlar
Geri okumaMümkün değil, mesaj ack ile gittiOffset’i geri al
FiltrelemeBroker tarafında — routing key, header, patternConsumer tarafında ya da ayrı topic

Yani “RabbitMQ’da da fanout var” cümlesi doğru ama eksik: fanout bugünden itibaren dağıtır. Kafka’nın consumer group’u ise dağıtmaz — herkesin aynı geçmişe kendi hızında bakmasına izin verir.

Pratik ayırt edici soru şu: “Yarın bu olaya yeni bir tüketici eklersem, geçmişe ihtiyacı olur mu?”

Hayırsa fanout yeterlidir ve tartışmayı orada kapatabilirsiniz. Evetse RabbitMQ tarafında cevap “backfill scripti yazarsınız” olur; Kafka tarafında cevap bir satır konfigürasyondur.

Retry ve backoff: kim bekletiyor?

“Retry ikisinde de var” da doğru. Ama retry’ın asıl sorusu şu: bekleme süresi boyunca ne bloklanıyor?

RabbitMQ’da

En basit yol nack(requeue=true) — ve buradaki tuzağı bilmekte fayda var: requeue edilen mesaj kuyruğun sonuna değil, mümkün olduğunca kendi eski konumuna geri konur. Yani hemen tekrar teslim edilir. Kalıcı bir hatada bu, saniyede binlerce denemeye çıkan sıcak bir döngüdür.

Gerçek backoff için gecikmeli bir kuyruk merdiveni kurulur:

work-queue ──nack(requeue=false)──▶ work-dlx ──▶ retry-5s   (TTL=5s, consumer yok)
                                                     │ TTL dolar → dead-letter
                                                work-exchange ──▶ work-queue

retry-5s, retry-30s, retry-5m diye üç kuyruk açar ve x-death başlığındaki deneme sayısına göre hangisine göndereceğinize karar verirsiniz. Deneme sayacını broker tutuyor — siz taşımıyorsunuz:

List<Map<String, ?>> death = (List<Map<String, ?>>)
        message.getMessageProperties().getHeaders().get("x-death");
long attempts = death == null ? 0 : (Long) death.get(0).get("count");

Alternatif olarak rabbitmq_delayed_message_exchange plugin’i mesaj başına serbest gecikme verir ve merdiveni tamamen ortadan kaldırır.

Mesaj başına TTL'de baş-blokajı

Gecikmeyi mesaj başına TTL ile kurmak cazip görünür ama kuyrukta mesajlar yalnızca baştan süre aşımına uğrar. 30 saniyelik TTL’i olan bir mesajın arkasındaki 5 saniyelik mesaj, öndeki mesaj kuyruğun başından çıkana kadar dead-letter edilmez.

Bu yüzden gecikme merdiveni kuyruk başına sabit TTL ile kurulur: retry-5s, retry-30s, retry-5m.

Kafka’da

İki seçenek var ve seçim doğrudan bir ödünleşme:

// Spring Kafka — partition'ı duraklatıp aynı yerde tekrar dener
new DefaultErrorHandler(recoverer, new ExponentialBackOff(1_000L, 2.0));
  • Sıra korunur — mesaj hâlâ kendi partition’ında
  • Ama o partition ilerlemez; arkadaki her şey bekler
  • Uzun backoff max.poll.interval.ms‘i zorlar; aşılırsa consumer gruptan atılır ve rebalance başlar
@RetryableTopic(attempts = "4",
                backoff = @Backoff(delay = 1_000, multiplier = 2.0))
@KafkaListener(topics = "report-jobs")
public void handle(ReportJob job) { ... }

Arka planda report-jobs-retry-0, -retry-1, -retry-2 ve report-jobs-dlt topic’lerini açar.

  • Partition ilerler, uzun backoff mümkün
  • Ama mesaj artık başka bir topic’te: aynı key için sıra garantisi kırıldı

Ödünleşmeyi tabloya dökersek:

Sıra korunurPartition ilerlerUzun backoff
Kafka — blocking retry
Kafka — retry topic’leri
RabbitMQ — nack(requeue=true)⚠️ sıcak döngü
RabbitMQ — TTL + DLX merdiveni

RabbitMQ satırlarında “sıra” sütununun boş olması tesadüf değil: orada zaten çok consumer’lı bir kuyrukta sıra garantisi yok, dolayısıyla retry bir şey bozmuyor. Kafka’da ise retry, kaybedecek bir garantiyi var olduğu için ödünleşmeye dönüşüyor.

Asıl fark bu: RabbitMQ’da retry, mesajın kendi problemidir. Kafka’da retry, ya partition’ın ilerlemesini ya da sıralama garantisini feda etmenizi gerektirir.

DLQ: broker özelliği mi, sizin yazdığınız topic mi?

“Dead letter queue ikisinde de var” cümlesi burada en çok yanıltan cümle. RabbitMQ’da DLX bir broker mekanizması; Kafka’da DLT sadece başka bir topic — oraya yazan da sizin consumer’ınız.

RabbitMQ (DLX)Kafka (DLT)
Broker özelliği miEvet — kuyruk argümanıHayır — sıradan bir topic
Neyle tetiklenirnack(requeue=false), mesaj/kuyruk TTL’i, kuyruk uzunluk limiti, quorum queue’da x-delivery-limitYalnızca consumer kodunun oraya yazmasıyla
Consumer ayaktayken değilseTTL ve uzunluk limiti yine çalışır, mesaj yine DLX’e düşerHiçbir şey olmaz; kimse yazmaz
Deneme sayısını kim tutarBroker — x-death.countSiz — header’da taşırsınız
Ne kaydedilirSebep, kaynak exchange, routing key, kuyruk, sayı, zamanNe koyarsanız; Spring Kafka orijinal topic/partition/offset ve exception bilgisini ekler
Zehirli mesajın etki alanıYalnızca o mesajBlocking retry’da tüm partition
Geri göndermeManagement UI veya shovel ile kuyruklar arası taşımaHeader’ları temizleyip orijinal topic’e yeniden yazan bir araç yazarsınız

Bu tablodaki en pratik satır dördüncüsü. Kafka’da bir mesajın kaç kez denendiğini bilmek istiyorsanız bu bilgiyi header’a siz koyar, retry topic’leri arasında siz taşır ve DLT’ye siz yazarsınız. Zincirin herhangi bir halkasında bunu unutan bir kod, sonsuz retry döngüsü üretir.

İki ince nokta:

  • RabbitMQ’da klasik dead-lettering at-most-once’tır: mesaj DLX’e giderken kaybolabilir. Quorum queue’larda dead-letter-strategy ayarıyla at-least-once’a çekilebilir; karşılığında ek yük gelir.

  • Kafka’da DLT’ye yazan taraf consumer olduğu için, DLT’ye yazma işleminin kendisi de başarısız olabilir. Bu durumda ne yapacağınıza — atla, blokla, yerel diske düş — karar vermeniz gerekir.

Bonus: consumer çökerse ne kadarı tekrar işlenir?

Retry tartışmasının az konuşulan tarafı bu ve implementasyon farkı burada çok net:

RabbitMQ:  consumer düşer → kanal kapanır
           → yalnızca o consumer'ın unacked mesajları geri konur
           → tanecik: mesaj

Kafka:     consumer düşer → rebalance
           → yeni sahip son commit'lenmiş offset'ten başlar
           → tanecik: son commit'ten bu yana gelen her şey

enable.auto.commit=true ve 5 saniyelik commit aralığıyla çalışan bir Kafka consumer’ının çökmesi, beş saniyelik trafiğin tamamının yeniden işlenmesi demektir. RabbitMQ’da aynı olay, o an elde tutulan prefetch kadar mesajın geri konmasıdır.

İkisi de at-least-once; ama “en az bir kez"in pratikte kaç mesaj ettiği farklı.

Toparlarsak

Üç başlıkta da aynı cümle çıkıyor karşımıza:

RabbitMQ teslim durumunu takip ettiği için retry, gecikme, dead-lettering ve reddetme birer broker ayarıdır. Kafka konumu takip ettiği için aynı davranışlar birer uygulama kararı, ek topic ve ek header’dır.

“İkisinde de var” doğru. Ama birinde konfigürasyon, ötekinde kod — ve işletilecek olan da o kod.

Karar için altı soru

İki senaryoyu da gördükten sonra seçimi altı soruya indirgeyebiliriz. Cevaplar genelde aynı tarafa yığılır; yığılmıyorsa muhtemelen tek bir broker’la çözülecek bir problem değildir.

SoruKafka’ya işaret ederRabbitMQ’ya işaret eder
1. Mesaj ne anlatıyor?“Şu oldu” — bir olgu“Şunu yap” — bir iş
2. Kaç taraf tüketecek?Bilinmeyen sayıda, bağımsızTam olarak biri
3. Yarın yeni bir tüketici geçmişe ihtiyaç duyar mı?EvetHayır, dünkü iş dünde kaldı
4. Aynı mesajı ikinci kez işlemek?Faydalı — durumu yeniden kurarZararlı — çift gönderim, çift ücret
5. İşlerin süresi nasıl?Birbirine yakın, kısaDeğişken — saniyeler ile dakikalar arası
6. Mesaj başına kontrol gerekiyor mu?HayırEvet — öncelik, gecikme, reddetme, retry

Aynı akışın karar ağacı hali:

graph TD
    A["Bu mesaj ne anlatıyor?"] -->|"'Şu oldu' — olgu"| C{"Geçmişe ihtiyaç var mı?<br/>replay, yeni tüketici,<br/>projeksiyon yeniden kurma"}
    A -->|"'Şunu yap' — iş"| B{"Kaç taraf işleyecek?"}
    B -->|"Birden çok bağımsız taraf"| C
    B -->|"Tek worker"| D{"Mesaj başına kontrol?<br/>öncelik, gecikme,<br/>reddetme, değişken süre"}
    D -->|"Evet"| R["RabbitMQ"]
    D -->|"Hayır, ama hacim çok yüksek"| K["Kafka"]
    C -->|"Evet"| K
    C -->|"Hayır"| R

Yedinci bir soru daha var ve pratikte ilk altısından daha belirleyici olabiliyor: hangisi zaten kurulu ve ekip hangisini işletmeyi biliyor? İkinci bir broker; cluster, izleme, alarm, yedekleme ve sürüm yükseltme demek. Marjinal bir kazanç için bu yükü almak çoğu zaman doğru karar değildir — bu da meşru bir mühendislik gerekçesidir.

Bu hesabı değiştiren iki güncel gelişme var:

  • KRaft. Kafka artık ZooKeeper’sız çalışıyor; metadata’yı kendi Raft tabanlı quorum’unda tutuyor. KRaft 3.3’ten beri üretime hazır, 4.0’dan itibaren de tek seçenek. Bu, “Kafka işletmek iki ayrı dağıtık sistem işletmek demek” argümanını ortadan kaldırdı. Geri kalan yük — partition ve replication planlama, consumer lag izleme — yerinde duruyor.

  • Yönetilen servisler. MSK, Confluent Cloud, Redpanda Cloud ya da RabbitMQ tarafında CloudAMQP gibi seçenekler operasyonel yükün büyük bölümünü devralıyor. O noktada soru “hangisini işletebiliriz"den “hangisinin faturasını ve bağımlılığını kabul ederiz"e kayıyor.

Broker seçiminden önce cevaplanması gereken bir soru daha var: mesajın kuyruğa girdiğini garanti edebiliyor musunuz? Veritabanına yazdıktan sonra broker’a publish edene kadar süreç çökerse, sipariş kaydedilmiş ama e-posta hiç gönderilmemiş olur.

Bu problem broker seçiminden bağımsızdır ve çözümü outbox pattern’idir. Asenkron e-posta gönderim sistemi yazısında bu tasarımı stack’ten bağımsız olarak ele almıştım.

Genel karar

Altı soruyu somut ihtiyaç başlıklarına indirgersek:

Kafka daha uygun olabilir

  • Event streaming / event sourcing yapıyorsanız; olaylar sistemin kalıcı kaydıysa
  • Çok yüksek throughput gerekiyorsa — log toplama, clickstream, telemetri, IoT
  • Aynı veriyi birden çok bağımsız tüketici okuyacaksa
  • Entity bazlı sıralama gerekiyorsa
  • Stream processing yapacaksanız — Kafka Streams, Flink, ksqlDB
  • Yeni servisleri geçmiş veriyle besleyerek devreye almak istiyorsanız
  • CDC kuruyorsanız — Debezium ile veritabanı değişikliklerini yayınlamak

RabbitMQ daha uygun olabilir

  • Klasik task queue / background job ihtiyacınız varsa
  • Karmaşık routing gerekiyorsa — içeriğe veya etikete göre dağıtım
  • Mesaj başına iş takibi istiyorsanız — ack, nack, retry, DLX
  • Request/reply (RPC) deseni kuracaksanız
  • Öncelik, TTL, gecikmeli mesaj gibi kontroller gerekiyorsa
  • Mesaj işlendikten sonra saklanmasına gerek yoksa
  • Daha basit operasyon ve düşük kaynak tüketimi istiyorsanız
  • AMQP, MQTT, STOMP gibi farklı protokoller gerekiyorsa

Nereden başlamalı sorusuna da şöyle bakılabilir: klasik bir task queue ihtiyacınız varsa RabbitMQ daha doğal bir başlangıç noktasıdır. Event streaming, replay veya yüksek hacimli akış işleme ihtiyacı varsa Kafka’yı değerlendirmek daha anlamlı olur.

İkisi rakip olmaktan çok tamamlayıcı; büyük sistemlerde ikisi bir arada kullanılır — event backbone için Kafka, iş kuyrukları için RabbitMQ.

Hangisini seçerseniz seçin, izlenecek metrik farklı: Kafka’da consumer lag (grubun kaç mesaj geride olduğu), RabbitMQ’da kuyruk derinliği ve unacked mesaj sayısı. İkisi de “consumer’lar yetişemiyor” durumunun ilk göstergesidir.

Hızlı karşılaştırma

ÖzellikKafkaRabbitMQ
ModelDağıtık log / event streamingMessage queue
Mesaj saklamaRetention süresince log’da kalırAck sonrası kuyruktan çıkar
ReplayOffset ileStreams dışında yok
RoutingTopic, partition, keyDört exchange tipi
SıralamaPartition içinde garantiliTek consumer’da FIFO
DeliveryAt-most / at-least-once, sınırlı exactly-onceAt-most / at-least-once
Retry & hataRetry topic + DLQ tasarlanırYerleşik — nack, requeue, DLX, TTL
Push / pullPullPush, prefetch ile kontrollü
BackpressureModelin kendisinde — poll() çağırmazsanız gelmezprefetch ayarında — 0 sınırsız demek
Uzun süren işmax.poll.interval.ms (varsayılan 5 dk) yükseltilmeliconsumer_timeout (varsayılan 30 dk) yükseltilmeli
Grup paralelliğiPartition sayısıyla sınırlıPartition sayısıyla sınırlı değil
ThroughputÇok yüksekYüksek
GecikmeDüşük, batch ayarına bağlıÇok düşük
Öncelikli mesajUygulama tarafındaYerleşik
Gecikmeli mesajUygulama tarafındaPlugin ile
RPC deseniUygun değilDoğal destek
Operasyonel yükDaha yüksek — KRaft ile azaldı ama duruyorDaha düşük
EkosistemConnect, Streams, ksqlDB, DebeziumGeniş dil desteği, yönetim UI’ı, plugin’ler

Toparlarsak

Kafka ve RabbitMQ aynı probleme farklı modellerle yaklaşıyor.

Kafka’da mesajlar log’da kalıyor ve consumer’lar kendi konumlarını takip ediyor. Bu model replay, bağımsız consumer grupları ve event streaming gibi ihtiyaçlarda güçlü.

RabbitMQ’da ise broker mesajın teslim durumunu takip ediyor. Ack, requeue, routing ve DLX gibi özellikler task queue senaryolarında daha doğal bir model sunuyor.

Seçim yaparken “hangisi daha hızlı?” sorusu pek yol göstermiyor; iki tarafın da somut senaryolarını kurunca ortaya çıkan soru şu:

Bu mesaj işlendikten sonra hâlâ değerli mi?

Değerliyse — yarın yeni bir tüketici, düzeltilmiş bir kod ya da yeni bir soru aynı geçmişe ihtiyaç duyacaksa — mesaj bir olgudur ve log’da durması gerekir: Kafka.

Değilse — iş yapıldığında bitiyorsa, önemli olan onun bir kez ve sonunda mutlaka yapılması, gerekirse önceliklendirilmesi, ertelenmesi ve reddedilebilmesiyse — mesaj bir iştir ve kuyrukta durması gerekir: RabbitMQ.

İki sistemi aynı mimaride birlikte kullanmak da gayet normaldir; zaten çoğu sistemde her iki tür mesaj da var.